@loke/ui
Version:
237 lines (185 loc) • 7.67 kB
Markdown
---
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 `/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>
```