UNPKG

@loke/ui

Version:
237 lines (185 loc) 7.67 kB
--- name: primitive-and-slot type: core domain: composition requires: [loke-ui] description: > Primitive element system (17 HTML types) with asChild composition via Slot. createSlot, createSlottable, Slottable for compound slot patterns. dispatchDiscreteCustomEvent for React 18 batching. Prop merge order: handlers composed (child first then slot), style merged (child wins), className concatenated, all other props child-wins. Mental model: asChild means "render whatever you give me but behave like [element]." --- # Primitive and Slot ## Setup ```tsx import { Primitive } from "@loke/ui/primitive"; // Basic usage — renders a <button> with all standard button props function SaveButton(props: React.ComponentPropsWithoutRef<typeof Primitive.button>) { return <Primitive.button type="button" {...props} />; } ``` ```tsx // asChild — render the child element but apply Primitive's behavior import { Primitive } from "@loke/ui/primitive"; function Trigger({ asChild, ...props }: { asChild?: boolean } & React.ComponentPropsWithoutRef<typeof Primitive.button>) { return <Primitive.button asChild={asChild} {...props} />; } // Caller passes their own element; Primitive merges props onto it <Trigger asChild> <a href="/settings">Settings</a> </Trigger> // Renders: <a href="/settings" type="button"> — button props merge onto <a> ``` ## Core Patterns ### asChild composition `asChild` tells Primitive to clone the child element rather than render the default HTML tag. The child element's props win for everything except event handlers (composed) and style/className (merged). ```tsx import { Primitive } from "@loke/ui/primitive"; interface ButtonProps extends React.ComponentPropsWithoutRef<typeof Primitive.button> { asChild?: boolean; } function Button({ asChild, ...props }: ButtonProps) { return <Primitive.button asChild={asChild} {...props} />; } // Renders <a> with onClick from both Button and <a> <Button asChild onClick={() => console.log("button handler")}> <a href="/" onClick={() => console.log("link handler")}> Home </a> </Button> ``` ### Slottable for compound slot patterns When a component wraps children and also needs to accept an `asChild` target, use `Slottable` to mark which child is the slot target. Props from the outer component flow onto the element inside `<Slottable>`. ```tsx import { createSlot, createSlottable } from "@loke/ui/slot"; const MySlot = createSlot("MyComponent"); const MySlottable = createSlottable("MyComponent"); function MyComponent({ asChild, children, ...props }: { asChild?: boolean; children: React.ReactNode; } & React.HTMLAttributes<HTMLElement>) { const Comp = asChild ? MySlot : "div"; return ( <Comp {...props}> <span className="icon" /> <MySlottable>{children}</MySlottable> </Comp> ); } // The <button> receives MyComponent's props; the icon stays as a sibling <MyComponent asChild className="wrapper"> <button>Click me</button> </MyComponent> // Renders: <button class="wrapper"><span class="icon" />Click me</button> ``` ### dispatchDiscreteCustomEvent for React 18 batching React 18 batches all event handler updates, including custom events dispatched inside discrete events (click, pointerdown, keydown). Custom events don't carry a React priority, so their updates can be batched unexpectedly. Use `dispatchDiscreteCustomEvent` to flush synchronously. ```tsx import { dispatchDiscreteCustomEvent } from "@loke/ui/primitive"; function SelectItem({ onSelect }: { onSelect?: () => void }) { return ( <div onPointerDown={(event) => { // Dispatching a custom event inside a discrete event — must flush dispatchDiscreteCustomEvent( event.currentTarget, new CustomEvent("select.item", { bubbles: true, cancelable: true }), ); }} /> ); } ``` Do NOT use this utility for: - Known event types (`new Event('click')`) — React already handles priority - Custom events inside non-discrete events (scroll, mousemove) — batching is fine there - Focus/blur events — they can fire implicitly during render ### Prop merge order When `asChild` is active, Slot merges slot props (from Primitive) with child props using these rules: | Prop type | Rule | |---|---| | Event handlers (`on*`) | Both called; child handler runs first, then slot handler | | `style` | Merged as objects; child wins on conflicts | | `className` | Concatenated: `"${slotClass} ${childClass}"` | | Everything else | Child wins | ```tsx // Demonstrating merge behavior <Primitive.button asChild onClick={() => console.log("slot")} className="slot-class" style={{ color: "red" }} data-testid="overridden-by-child" > <a onClick={() => console.log("child")} // fires first className="child-class" // appended after slot-class style={{ color: "blue" }} // wins over slot's red data-testid="child-value" // wins /> </Primitive.button> // Output className: "slot-class child-class" // Output style: { color: "blue" } // Output data-testid: "child-value" // onClick fires: "child" then "slot" ``` ## Available Primitive Elements `a`, `button`, `div`, `form`, `h2`, `h3`, `img`, `input`, `label`, `li`, `nav`, `ol`, `p`, `select`, `span`, `svg`, `ul` ## Common Mistakes ### 1. Multiple children with asChild `asChild` requires exactly one child element. Multiple children cause `React.Children.only` to throw. ```tsx // WRONG — throws at runtime <Primitive.button asChild> <span>Icon</span> <span>Label</span> </Primitive.button> // CORRECT — wrap in a single element <Primitive.button asChild> <span><span>Icon</span><span>Label</span></span> </Primitive.button> ``` Source: `slot.tsx` — `SlotClone` calls `React.Children.only(null)` when count > 1. ### 2. Expecting child props to be overridden by slot props The merge rules mean child props win for non-handler, non-style, non-className props. If you need slot props to take precedence, you must not pass conflicting props from the child. ```tsx // WRONG — expects disabled to come from Primitive, but child's disabled=false wins <Primitive.button asChild disabled> <a disabled={false}>Link</a> </Primitive.button> // Result: disabled=false (child wins) // CORRECT — only pass what you intend on the child <Primitive.button asChild disabled> <a>Link</a> </Primitive.button> ``` Source: `slot.tsx` — `mergeProps` spreads `{ ...slotProps, ...overrideProps }` where `overrideProps` starts as `{ ...childProps }`. ### 3. Custom events without dispatchDiscreteCustomEvent inside discrete handlers Omitting `dispatchDiscreteCustomEvent` when dispatching a custom event inside a click/pointerdown handler can cause state updates to batch unexpectedly, producing stale UI between the event firing and React re-rendering. ```tsx // WRONG — update may batch, trigger may remain "open" for a frame onPointerDown={(e) => { e.currentTarget.dispatchEvent(new CustomEvent("dismiss")); }} // CORRECT onPointerDown={(e) => { dispatchDiscreteCustomEvent(e.currentTarget, new CustomEvent("dismiss")); }} ``` Source: `primitive.tsx` — comment citing React batching PR #1378. ### 4. Confusing asChild with Headless UI's `as` prop `as` in Headless UI replaces the rendered tag but does not merge props — it accepts a component type. `asChild` in `@loke/ui` accepts a child element instance and merges props onto it. They are not equivalent. ```tsx // Headless UI pattern (NOT @loke/ui) <HeadlessButton as={Link} href="/home" /> // @loke/ui pattern <Primitive.button asChild> <Link href="/home" /> </Primitive.button> ```