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.jsonSource
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" });
}