Skip to content

Commit e7a1177

Browse files
authored
Dropdown components (menu, select, combobox, autocomplete) (#85)
* docs(table): clarify className vs containerClassName targets in JSDoc * Format * Add Menu component * Add autocomplete, combobox, and select * Format * Update interface, add tests with snapshots * Format * fix: improve API consistency for dropdown components - Narrow Select renderValue type based on multiple discriminant - Add value/defaultValue props to Autocomplete.Async - Export AsyncFetcher type from public API - Add JSDoc explaining Combobox.Creatable T extends object constraint * fix: use strict undefined check for Autocomplete.Async controlled value Use !== undefined instead of ?? so that value="" is correctly treated as controlled mode. * docs: update add-component skill with Pattern D and Base UI wrapping rules - Add Pattern D (Standalone) for pre-assembled + Parts components - Clarify Pick<> rule: Root uses Pick<>, leaf sub-components use ComponentProps - Note Pattern D can combine with Pattern C (directory split) * Update components * Support async in select and refactor types * Add changeset * Run fetcher on open dropdown in Select component * Consistency among exported types * Merge creatable * Simplify creatable API * Disable component in creating a new item * Fix lint and format * Fix by API review * Update docs and types * Update changeset * docs: add JSDoc warning about ItemGroup structural matching constraint Document that items are identified as groups by the presence of label and items fields. Consumers should avoid item types whose shape coincidentally matches this structure. * fix: align default emptyText to "No results." for both Autocomplete variants * Add missing displayName to ComboboxChips, AutocompleteStandalone, and AutocompleteAsyncStandalone * Fix misleading JSDoc on onValueChange in AutocompleteUseAsyncReturn Remove incorrect claim about filtering item-press events, which does not happen in the Autocomplete useAsync implementation. * Format
1 parent 8a19347 commit e7a1177

33 files changed

+7931
-18
lines changed

.agents/skills/add-component/SKILL.md

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,119 @@ export type { ComponentNameProps } from "./types";
115115
// DO NOT export internal types, type guards, or enums
116116
```
117117

118+
### Pattern D — Standalone (Pre-assembled + Parts)
119+
120+
Use when a compound component (Pattern B) has many sub-components that make typical usage verbose. The standalone pattern provides a **pre-assembled component** that covers 80% of use cases with a simple `items` prop, while still exposing low-level **Parts** for custom composition.
121+
122+
Pattern D can be combined with Pattern C (directory split) when the standalone or parts files grow large enough to warrant separate internal types or helper files.
123+
124+
**When to choose Pattern D over Pattern B:**
125+
126+
- Pattern B (Compound): The consumer always needs to choose and arrange sub-components (e.g., `Menu`, `Dialog` — content varies per usage)
127+
- Pattern D (Standalone): There is a dominant usage pattern where passing `items` is sufficient, but some consumers need full control (e.g., `Select`, `Combobox`)
128+
129+
**Files:**
130+
131+
```
132+
components/
133+
component-name.tsx # Internal: Base UI wrappers (Parts primitives)
134+
component-name-standalone.tsx # Public: Standalone + Parts re-export
135+
```
136+
137+
**component-name.tsx** — Internal compound parts (Pattern B style, not exported from `index.ts`):
138+
139+
```tsx
140+
import * as React from "react";
141+
import { ComponentName as BaseComponentName } from "@base-ui/react/component-name";
142+
import { cn } from "@/lib/utils";
143+
144+
function ComponentNameRoot<Value>({
145+
...props
146+
}: React.ComponentProps<typeof BaseComponentName.Root<Value>>) {
147+
return <BaseComponentName.Root data-slot="component-name" {...props} />;
148+
}
149+
150+
function ComponentNameItem({
151+
className,
152+
...props
153+
}: React.ComponentProps<typeof BaseComponentName.Item>) {
154+
return (
155+
<BaseComponentName.Item
156+
data-slot="component-name-item"
157+
className={cn("astw:...", className)}
158+
{...props}
159+
/>
160+
);
161+
}
162+
163+
// Assemble into Parts object
164+
const ComponentNameParts = {
165+
Root: ComponentNameRoot,
166+
Item: ComponentNameItem,
167+
// ...other sub-components
168+
};
169+
170+
export { ComponentNameRoot, ComponentNameItem, ComponentNameParts };
171+
```
172+
173+
**component-name-standalone.tsx** — Public standalone + Parts:
174+
175+
```tsx
176+
import { ComponentNameRoot, ComponentNameItem, ComponentNameParts } from "./component-name";
177+
import type { MappedItem } from "./select-standalone"; // shared type if applicable
178+
179+
interface ComponentNameStandaloneProps<I> {
180+
items: I[];
181+
placeholder?: string;
182+
mapItem?: (item: ExtractItem<I>) => MappedItem;
183+
className?: string;
184+
value?: ExtractItem<I> | null;
185+
onValueChange?: (value: ExtractItem<I> | null) => void;
186+
}
187+
188+
function ComponentNameStandalone<I>(props: ComponentNameStandaloneProps<I>) {
189+
// Pre-assembled composition using internal parts
190+
return (
191+
<div className={className}>
192+
<ComponentNameRoot items={items} value={value} onValueChange={onValueChange}>
193+
{/* pre-wired sub-components */}
194+
</ComponentNameRoot>
195+
</div>
196+
);
197+
}
198+
199+
// Use Object.assign to keep the standalone callable as a component
200+
// while attaching Parts and variant sub-components
201+
const ComponentName = Object.assign(ComponentNameStandalone, {
202+
Parts: ComponentNameParts,
203+
// Optional variant sub-components (e.g., Async, Creatable)
204+
});
205+
206+
export { ComponentName };
207+
```
208+
209+
**Consumer usage:**
210+
211+
```tsx
212+
// Standalone — simple usage (80% case)
213+
<ComponentName items={["A", "B", "C"]} onValueChange={handleChange} />
214+
215+
// Parts — full control for custom layouts
216+
<ComponentName.Parts.Root>
217+
<ComponentName.Parts.Trigger>...</ComponentName.Parts.Trigger>
218+
<ComponentName.Parts.Content>
219+
<ComponentName.Parts.Item value="a">Alpha</ComponentName.Parts.Item>
220+
</ComponentName.Parts.Content>
221+
</ComponentName.Parts.Root>
222+
```
223+
224+
**Conventions:**
225+
226+
- The standalone file (`-standalone.tsx`) is the public entry point exported from `index.ts`
227+
- The internal parts file (without `-standalone`) is NOT exported from `index.ts`
228+
- Use `Object.assign` to attach `Parts` and variant sub-components (e.g., `Async`, `Creatable`) to the standalone function
229+
- Variant sub-components should support the same `mapItem`, `className`, `disabled` base props as the standalone
230+
118231
## Step 2: Styling Rules
119232

120233
All Tailwind classes MUST use the `astw:` prefix. This is a Tailwind v4 scoped prefix for AppShell.
@@ -182,8 +295,37 @@ When wrapping a Base UI component, fetch https://base-ui.com/llms.txt to find th
182295

183296
When wrapping Base UI components:
184297

185-
- Use `Pick<>` to select only stable, consumer-relevant props from Base UI types
186-
- Do NOT spread all Base UI props — this prevents upstream changes from becoming breaking changes
298+
- **Root / Provider components**: Use `Pick<>` to select only stable, consumer-relevant props from Base UI types. Root components often expose internal state-management props that should not leak to consumers, so explicitly pick `open`, `defaultOpen`, `onOpenChange`, `children`, etc.
299+
- **Leaf sub-components** (Trigger, Content, Item, etc.): Use `React.ComponentProps<typeof Base*.SubComponent>` directly. These components have a narrow, stable prop surface (mostly `className`, `children`, DOM attributes) and benefit from automatic compatibility with Base UI updates.
300+
- **Composited Leaf sub-components** (wrapping multiple Base UI primitives, e.g. Portal + Positioner + Popup): When a single wrapper component combines props from multiple Base UI primitives, group each primitive's props under a namespaced prop object to prevent name collisions between primitives and keep prop ownership clear. The primary primitive's props (typically the one rendered as the outermost DOM element) stay at the top level; secondary primitives get a nested prop.
301+
302+
```tsx
303+
// Example: Content wraps both Positioner and Popup.
304+
// Popup props stay top-level (it's the primary element consumers style).
305+
// Positioner props are grouped under `position`.
306+
function Content({
307+
className,
308+
position,
309+
children,
310+
...popupProps
311+
}: React.ComponentProps<typeof Base*.Popup> & {
312+
position?: { side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; sideOffset?: number };
313+
}) {
314+
const { side = "bottom", align = "start", sideOffset = 4 } = position ?? {};
315+
return (
316+
<Base*.Portal>
317+
<Base*.Positioner sideOffset={sideOffset} side={side} align={align}>
318+
<Base*.Popup className={cn("astw:...", className)} {...popupProps}>
319+
{children}
320+
</Base*.Popup>
321+
</Base*.Positioner>
322+
</Base*.Portal>
323+
);
324+
}
325+
```
326+
327+
If the same nested shape is reused across multiple components, extract a shared internal type (e.g. `PositionProps` in `@/lib/position`) — but the principle itself is general: **always use prop hierarchy to separate concerns when compositing multiple primitives**.
328+
187329
- Set `displayName` on every sub-component (e.g., `Root.displayName = "Dialog.Root"`)
188330
- For components needing portals, use the Base UI `Portal` component
189331

.changeset/bright-waves-dance.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
Add `Menu`, `Select`, `Combobox`, and `Autocomplete` components.
6+
7+
## New components
8+
9+
```tsx
10+
import { Menu, Select, Combobox, Autocomplete } from "@tailor-platform/app-shell";
11+
```
12+
13+
### Menu
14+
15+
Dropdown menu with compound component API (`Menu.Root`, `Menu.Trigger`, `Menu.Content`, `Menu.Item`, etc.). Supports checkbox/radio items, grouped items, separators, and nested sub-menus via `Menu.SubmenuRoot` / `Menu.SubmenuTrigger`.
16+
17+
```tsx
18+
<Menu.Root>
19+
<Menu.Trigger>Open menu</Menu.Trigger>
20+
<Menu.Content>
21+
<Menu.Item>Edit</Menu.Item>
22+
<Menu.Item>Duplicate</Menu.Item>
23+
<Menu.Separator />
24+
<Menu.Item>Delete</Menu.Item>
25+
</Menu.Content>
26+
</Menu.Root>
27+
```
28+
29+
### Select
30+
31+
Single or multi-select dropdown. Pass `items` and get a fully assembled select out of the box. Also supports async data fetching via `Select.Async`.
32+
33+
```tsx
34+
<Select
35+
items={["Apple", "Banana", "Cherry"]}
36+
placeholder="Pick a fruit"
37+
onValueChange={(value) => console.log(value)}
38+
/>
39+
```
40+
41+
### Combobox
42+
43+
Searchable combobox with single/multi selection. Pass `items` and get a fully assembled combobox with built-in filtering. Supports async data fetching via `Combobox.Async` and user-created items via `onCreateItem` prop.
44+
45+
```tsx
46+
<Combobox
47+
items={["Apple", "Banana", "Cherry"]}
48+
placeholder="Search fruits..."
49+
onValueChange={(value) => console.log(value)}
50+
/>
51+
```
52+
53+
### Autocomplete
54+
55+
Text input with a suggestion list. The value is the raw input string, not a discrete item selection. Also supports async suggestions via `Autocomplete.Async`.
56+
57+
```tsx
58+
<Autocomplete
59+
items={["Apple", "Banana", "Cherry"]}
60+
placeholder="Type a fruit..."
61+
onValueChange={(value) => console.log(value)}
62+
/>
63+
```
64+
65+
### Low-level primitives via `.Parts`
66+
67+
`Select`, `Combobox`, and `Autocomplete` each expose a `.Parts` namespace containing the styled low-level sub-components (e.g. `Root`, `Input`, `Content`, `Item`, `List`, etc.) and hooks (`useFilter`, `useAsync`, `useCreatable`) for building fully custom compositions when the ready-made component doesn't fit your needs.
68+
69+
```tsx
70+
const { Root, Trigger, Content, Item } = Select.Parts;
71+
```

0 commit comments

Comments
 (0)