UNPKG

@loke/ui

Version:
234 lines (185 loc) 7.94 kB
--- 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` `` ## Core Patterns ### createContext with optional default 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} />; } ``` ### createContextScope for nested composition 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 for DOM-ordered items `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", [createCollectionScope], ); // 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 } ``` ## Common Mistakes ### 1. Using React.createContext directly instead of the library's createContext `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. ### 2. Missing scope threading in nested components 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. ### 3. Assuming insertion order equals DOM order in collections 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.