@loke/ui
Version:
234 lines (185 loc) • 7.94 kB
Markdown
---
name: context-and-collection
type: core
domain: composition
requires: [loke-ui]
description: >
Building compound components with createContext (error-throwing consumer hook)
and createContextScope (nested composition with scope threading).
createCollection for DOM-ordered item tracking via data attributes +
ResizeObserver pattern. Scope threading required for components that may be
nested within themselves (Accordion inside Accordion, nested Menus, etc.).
---
# Context and Collection
## Setup
```tsx
import { createContext } from "@loke/ui/context";
// Returns [Provider, useContext]
const [ToastProvider, useToastContext] = createContext<{
duration: number;
onClose: () => void;
}>("Toast");
function Toast({ duration, onClose, children }: {
duration: number;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<ToastProvider duration={duration} onClose={onClose}>
{children}
</ToastProvider>
);
}
function ToastClose() {
const { onClose } = useToastContext("ToastClose");
return <button onClick={onClose}>Dismiss</button>;
}
```
If `ToastClose` renders outside `ToastProvider`, the hook throws:
> `` `ToastClose` must be used within `Toast` ``
Pass a `defaultContext` as the second argument to make the context optional (hook returns default instead of throwing when no provider is found).
```tsx
import { createContext } from "@loke/ui/context";
const [SizeProvider, useSizeContext] = createContext<{ size: "sm" | "md" | "lg" }>(
"Button",
{ size: "md" }, // default — hook never throws
);
// Can be used without a provider; returns { size: "md" }
function ButtonIcon() {
const { size } = useSizeContext("ButtonIcon");
return <span data-size={size} />;
}
```
Use `createContextScope` when a component may be nested inside itself, or when two components share infrastructure (e.g., Popover + Tooltip both using Popper). Each instance gets its own scope, preventing context bleed between nested copies.
```tsx
import { createContextScope, type Scope } from "@loke/ui/context";
const ACCORDION_NAME = "Accordion";
// Returns [createContext fn for this scope, createScope fn]
const [createAccordionContext, createAccordionScope] =
createContextScope(ACCORDION_NAME);
type AccordionContextValue = {
value: string[];
onItemOpen: (value: string) => void;
};
const [AccordionProvider, useAccordionContext] =
createAccordionContext<AccordionContextValue>(ACCORDION_NAME);
// Export scope creator so consumers can compose it
export { createAccordionScope };
// Scope prop threads isolation through the tree
type ScopedProps<P> = P & { __scopeAccordion?: Scope };
function Accordion(props: ScopedProps<{
value: string[];
onValueChange: (v: string[]) => void;
children: React.ReactNode;
}>) {
const { __scopeAccordion, value, onValueChange, children } = props;
return (
<AccordionProvider
value={value}
onItemOpen={(v) => onValueChange([...value, v])}
scope={__scopeAccordion}
>
{children}
</AccordionProvider>
);
}
function AccordionItem(props: ScopedProps<{ value: string; children: React.ReactNode }>) {
const { __scopeAccordion, value, children } = props;
const { onItemOpen } = useAccordionContext("AccordionItem", __scopeAccordion);
return (
<div onClick={() => onItemOpen(value)}>{children}</div>
);
}
```
`createCollection` tracks registered items in DOM order rather than insertion order. This is critical for roving focus and keyboard navigation where visual order must match traversal order.
```tsx
import { createCollection } from "@loke/ui/collection";
import { createContextScope } from "@loke/ui/context";
// ItemData is extra metadata stored per item
const [Collection, useCollection, createCollectionScope] = createCollection<
HTMLButtonElement,
{ disabled: boolean; value: string }
>("ListBox");
const [createListBoxContext, createListBoxScope] = createContextScope(
"ListBox",
[],
);
// In the root component — wrap with Collection.Provider + Collection.Slot
function ListBox({ children, __scopeListBox }: ScopedProps<{ children: React.ReactNode }>) {
return (
<Collection.Provider scope={__scopeListBox}>
<Collection.Slot scope={__scopeListBox}>
<div role="listbox">{children}</div>
</Collection.Slot>
</Collection.Provider>
);
}
// In each item — wrap with Collection.ItemSlot passing metadata
function ListBoxItem({
value,
disabled = false,
children,
__scopeListBox,
}: ScopedProps<{ value: string; disabled?: boolean; children: React.ReactNode }>) {
return (
<Collection.ItemSlot scope={__scopeListBox} value={value} disabled={disabled}>
<button role="option" disabled={disabled}>
{children}
</button>
</Collection.ItemSlot>
);
}
// Consumer — getItems() returns items sorted by DOM position
function useListBoxItems(__scopeListBox: Scope) {
const getItems = useCollection(__scopeListBox);
return getItems(); // [{ ref, value, disabled }, ...] in DOM order
}
```
`React.createContext` does not provide the error-throwing hook or scope threading. Context created with `React.createContext` will silently return `undefined` when used outside a provider (or whatever default you set), instead of throwing a descriptive error.
```tsx
// WRONG — silent undefined, no helpful error
const MyCtx = React.createContext<{ value: string } | undefined>(undefined);
function useMyContext() {
return React.useContext(MyCtx); // returns undefined silently
}
// CORRECT — throws: "`MyConsumer` must be used within `MyComponent`"
import { createContext } from "@loke/ui/context";
const [MyProvider, useMyContext] = createContext<{ value: string }>("MyComponent");
```
Source: `createContext.tsx` — `useContext` checks for `undefined` and throws with the component name.
When using `createContextScope`, you must pass the `__scope*` prop through every level of the tree. Omitting it means nested instances of the same component share a single context, causing the inner component to read state from the outer one.
```tsx
// WRONG — AccordionItem reads outer Accordion's context when nested
function AccordionItem({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = useAccordionContext("AccordionItem"); // no scope
// ...
}
// CORRECT — scope isolates each Accordion instance
function AccordionItem(props: ScopedProps<{ value: string; children: React.ReactNode }>) {
const { __scopeAccordion, value, children } = props;
const ctx = useAccordionContext("AccordionItem", __scopeAccordion);
// ...
}
```
Source: `createContext.tsx` — `createContextScope` creates per-instance context slots indexed by scope object identity.
Items register in `useEffect`, which fires after paint. The order of effects does not match DOM order when items are conditionally rendered or reordered. `useCollection` sorts by DOM position using `querySelectorAll`, not by registration order.
```tsx
// WRONG — assumes items[0] is the first registered item
const getItems = useCollection(scope);
const firstItem = getItems()[0]; // correct DOM order, not insertion order
// CORRECT mental model: always call getItems() at use time; it queries the DOM
function handleKeyDown() {
const items = getItems(); // fresh DOM-ordered snapshot
const nextItem = items[currentIndex + 1];
nextItem?.ref.current?.focus();
}
```
Source: `collection.tsx` — `getItems` uses `querySelectorAll("[data-squared-collection-item]")` to sort by DOM position at call time.