React

Collection

Item discovery in document order, independent of how the items are composed.

Behaviour

Items are read in document order, however they are nested
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a")}>A</div>
    <section>
      <div>
        <div {...collectionItemProps("s", "b")}>B</div>
      </div>
    </section>
    <div {...collectionItemProps("s", "c")}>C</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(getCollection(root, "s").map((item) => item.key)).toEqual(["a", "b", "c"]);
A nested collection's items belong to it, not to the one around it
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("outer", "a")}>
      A<div {...collectionItemProps("inner", "a1")}>A1</div>
    </div>
    <div {...collectionItemProps("outer", "b")}>B</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(getCollection(root, "outer").map((item) => item.key)).toEqual(["a", "b"]);
expect(getCollection(root, "inner").map((item) => item.key)).toEqual(["a1"]);
An item reports whether it is disabled
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a", { disabled: true })}>A</div>
    <div {...collectionItemProps("s", "b")}>B</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(getCollection(root, "s").map((item) => item.disabled)).toEqual([true, false]);
An item's text is its textValue, or else its text content
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a", { textValue: "Archive" })}>📦 Archive (3)</div>
    <div {...collectionItemProps("s", "b")}> Bin </div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(getCollection(root, "s").map((item) => item.textValue)).toEqual(["Archive", "Bin"]);
A scope is matched exactly, even when it contains selector characters
const scope = ":r1:";
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps(scope, "a")}>A</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(getCollection(root, scope)).toHaveLength(1);
An item's text is not read until it is asked for
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a")}>A</div>
    <div {...collectionItemProps("s", "b")}>B</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
const texts = vi.spyOn(Node.prototype, "textContent", "get");
const items = getCollection(root, "s");
const before = texts.mock.calls.length;
const text = items[1]!.textValue;
const after = texts.mock.calls.length;
texts.mockRestore();
expect([before, after, text]).toEqual([0, 1, "B"]);
One item is found by its key
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a")}>A</div>
    <div {...collectionItemProps("s", 'b"]')}>B</div>
    <div {...collectionItemProps("other", "c")}>C</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(getCollectionItem(root, "s", 'b"]')?.textValue).toBe("B");
expect(getCollectionItem(root, "s", "c")).toBeNull();
An element inside an item is that item's, when the item is this collection's
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a")}>
      <span>Inside</span>
    </div>
    <span>Between</span>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
const inside = screen.getByText("Inside").element();
expect(closestItemElement(inside, root, "s")?.dataset.key).toBe("a");
expect(closestItemElement(inside, root, "other")).toBeNull();
expect(closestItemElement(screen.getByText("Between").element(), root, "s")).toBeNull();
Keys that more than one item has are told, each once
const screen = await render(
  <div data-testid="root">
    <div {...collectionItemProps("s", "a")}>A</div>
    <div {...collectionItemProps("s", "b")}>B</div>
    <div {...collectionItemProps("s", "a")}>A again</div>
    <div {...collectionItemProps("s", "a")}>A once more</div>
  </div>,
);
const root = screen.getByTestId("root").element() as HTMLElement;
expect(duplicateKeys(getCollection(root, "s"))).toEqual(["a"]);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/collection.json
Source

src/primitives/collection.ts

/**
 * A collection is whatever items its root contains, in document order. Reading
 * it from the DOM, rather than registering items with the root, means any
 * composition — groups, conditionals, items rendered by other components —
 * orders correctly with nothing to keep in sync.
 *
 * `scope` keeps a collection from claiming the items of one nested inside it.
 *
 * A reading is one pass over the items, so an interaction takes one and works
 * from it. An item's text is its whole subtree, and is read only when asked for.
 */

export interface CollectionItem {
  readonly key: string;
  readonly element: HTMLElement;
  readonly disabled: boolean;
  readonly textValue: string;
}

export interface CollectionItemOptions {
  readonly disabled?: boolean;
  /** The text typeahead matches; defaults to the item's text content. */
  readonly textValue?: string | undefined;
}

export function collectionItemProps(
  scope: string,
  key: string,
  { disabled = false, textValue }: CollectionItemOptions = {},
) {
  return {
    "data-collection": scope,
    "data-key": key,
    "data-disabled": disabled ? "" : undefined,
    "data-text-value": textValue,
  } as const;
}

class DomItem implements CollectionItem {
  readonly key: string;
  readonly element: HTMLElement;
  readonly disabled: boolean;

  constructor(element: HTMLElement) {
    this.element = element;
    this.key = element.getAttribute("data-key") ?? "";
    this.disabled = element.hasAttribute("data-disabled");
  }

  get textValue(): string {
    return this.element.getAttribute("data-text-value") ?? this.element.textContent?.trim() ?? "";
  }
}

function selectorOf(scope: string): string {
  return `[data-collection="${CSS.escape(scope)}"]`;
}

export function getCollection(root: HTMLElement | null, scope: string): CollectionItem[] {
  if (root === null) return [];
  return Array.from(root.querySelectorAll<HTMLElement>(selectorOf(scope)), (element) => new DomItem(element));
}

/** The first item with a key, without reading the others. */
export function getCollectionItem(
  root: HTMLElement | null,
  scope: string,
  key: string,
): CollectionItem | null {
  const element = root?.querySelector<HTMLElement>(`${selectorOf(scope)}[data-key="${CSS.escape(key)}"]`);
  return element ? new DomItem(element) : null;
}

/** The item's element that `node` is, or is inside: what a pointer is over, or what has focus. */
export function closestItemElement(
  node: EventTarget | null,
  root: HTMLElement,
  scope: string,
): HTMLElement | null {
  if (!(node instanceof Element)) return null;
  const element = node.closest<HTMLElement>(selectorOf(scope));
  return element !== null && root.contains(element) ? element : null;
}

/** The keys that more than one item has. A key should be one item's own. */
export function duplicateKeys(items: readonly CollectionItem[]): string[] {
  const seen = new Set<string>();
  const twice = new Set<string>();
  for (const { key } of items) (seen.has(key) ? twice : seen).add(key);
  return [...twice];
}

/** Focus an item and bring it into view without the page jumping to centre it. */
export function focusItem(element: HTMLElement): void {
  element.focus({ preventScroll: true });
  element.scrollIntoView({ block: "nearest", inline: "nearest" });
}