From 63b3170c6270350f68d1662da7b051b171549d42 Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Thu, 23 Oct 2025 14:29:40 -0700 Subject: [PATCH 01/10] docs(select): add Select Primitive PDR with purpose, patterns, accessibility, and examples --- docs/pdrs/select-primitive.mdx | 225 +++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/pdrs/select-primitive.mdx diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx new file mode 100644 index 0000000..437b01e --- /dev/null +++ b/docs/pdrs/select-primitive.mdx @@ -0,0 +1,225 @@ +# Select Primitive PDR + +## Purpose + +The Select primitive provides an accessible, customizable dropdown that overcomes native ``**: Maintains form submission and [autofill](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) compatibility +- **Custom value rendering**: Allows different display format than option (e.g., "USA" in dropdown, "🇺🇸 United States" when selected) +- **Typeahead navigation**: Essential UX for long lists without complexity of full search + +## Primitive Composition + +**Components**: `Select` (context), `.Trigger` (Pressable-based), `.Value`, `.Content` (portal + [Floating UI](https://floating-ui.com/docs/computePosition)), `.Option` (extends ListBox), `.Group`/`.Label` + +**Hooks**: [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useButton`](https://react-spectrum.adobe.com/react-aria/useButton.html), `useProps`, `useDataAttributes`, `withSlots` + +This is a **UI component** that combines behavioral hooks with compound component composition. + +## Implementation Patterns + +| Concept | Implementation | +| ------------------ | ------------------------------------------------------------------------- | +| React Aria | useSelect, useSelectState, useButton, useListBox | +| Slots | withSlots on all sub-components, slot prop for targeting | +| Render Props | value/option renderers with state/props access | +| data-\* Attributes | data-open, data-disabled, data-invalid, data-selected, data-focus-visible | +| data-override | Applied when slots/props override default behavior | +| data-version | Dev-only component version tracking | +| Composability | Compound components, children/items support | + +## Internal Structure & Reuse Potential + +Behavioral hooks separated from rendering for reusability: + +- **SelectContext**: Manages state across compound components (avoids prop drilling) +- **useSelectTrigger/Value/Popover**: Modular hooks reusable in Combobox, Autocomplete, DatePicker +- **SelectListBox**: Extends ListBox with select-specific behavior + +## React Aria & External Integration + +**React Aria provides core behavior** ([`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useButton`](https://react-spectrum.adobe.com/react-aria/useButton.html))—handling ARIA attributes, keyboard nav, and state. We extend with: + +- **Slots/render props**: Enable deep customization without forking +- **Data attributes**: Expose state for CSS (avoiding [ARIA for styling](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/#do-not-use-aria-to-style-content)) +- **[Floating UI](https://floating-ui.com/docs/computePosition)**: Smart positioning with [`flip`](https://floating-ui.com/docs/flip)/[`shift`](https://floating-ui.com/docs/shift) collision detection + +## Architecture & Features + +### Component Structure + +```jsx + +``` + +### Rendered DOM Structure + +```html + + + + + + + +
+
+
Option 1
+
+ Group Label +
Option 2
+
+
+
+``` + +### Key Props & State + +| Prop | Type | Purpose | +| --------------------------------- | ----------------- | ------------------------------------------- | +| `value`/`defaultValue` | `string` | Controlled/uncontrolled selection | +| `open`/`defaultOpen` | `boolean` | Controlled/uncontrolled dropdown visibility | +| `onValueChange` | `(value) => void` | Selection callback | +| `onOpenChange` | `(open) => void` | Visibility callback | +| `disabled`, `required`, `invalid` | `boolean` | Form states | +| `placeholder` | `string` | Empty state text | +| `name` | `string` | Form submission field name | +| `items` | `Iterable` | Dynamic collections | +| `disabledKeys` | `Set` | Disable specific options | + +**State management**: Supports controlled/uncontrolled modes, syncs with hidden ` + + + + + + Fruits + Apple + Orange (Out of Stock) + + + + +// Customization via slots and render props + + +// Dynamic items API +const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }]; + +``` + +## Implementation Rationale + +**Architecture decisions**: + +- **Compound components**: Maximum flexibility vs monolithic API that limits customization +- **Build on ListBox**: Reuse [ARIA listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) patterns rather than duplicate +- **Portal rendering**: Solve [overflow clipping](https://github.com/floating-ui/floating-ui/discussions/1965) and z-index issues +- **Hidden ` - - - - - - - Option 1 - - Group Label - Option 2 - - - + + + + + Option 1 + + Group Label + Option 2 + + ``` @@ -120,6 +216,13 @@ Behavioral hooks separated from rendering for reusability: **State management**: Supports controlled/uncontrolled modes, syncs with hidden ` - - - - - - Fruits - Apple - Orange (Out of Stock) - - + + + + + + Fruits + Apple + Orange (Out of Stock) + + // Customization via slots and render props @@ -188,20 +301,20 @@ const [value, setValue] = useState('apple'); ) }} > - - + isFocused ? 'focused' : ''} > Custom Option - - + + // Dynamic items API const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }]; ``` From 3b65f5251bc47ca1840c7f5c3a367d1d5db7afad Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Mon, 27 Oct 2025 09:15:09 -0700 Subject: [PATCH 03/10] docs(select): clarify implementation pattern and update core concepts in Select PDR --- docs/pdrs/select-primitive.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx index 2f12e59..6621cb8 100644 --- a/docs/pdrs/select-primitive.mdx +++ b/docs/pdrs/select-primitive.mdx @@ -24,7 +24,7 @@ This is a **UI component** that combines behavioral hooks with compound componen ## Implementation Pattern -Each component follows Bento's Sacred Trinity (withSlots/useProps/useDataAttributes): +Each component uses withSlots, useProps, and useDataAttributes: ```tsx // SelectTrigger - Button that opens dropdown @@ -79,11 +79,13 @@ export const SelectContent = withSlots('BentoSelectContent', function SelectCont }); ``` +> **Note:** This is a simplified example. For the full implementation including dismiss and focus handling, see the [Portal & Overlay Strategy](#portal--overlay-strategy) section. + **Context provides:** `state` (useSelectState), `triggerRef`, `popoverRef`, ARIA props **Hooks used:** useSelect, useSelectState, useButton, useOption, useOverlayPosition **See:** ListBox implementation for detailed option/group patterns -## Implementation Patterns +## Core Concepts Summary | Concept | Implementation | | ------------------ | ------------------------------------------------------------------------- | @@ -243,7 +245,7 @@ Uses React Aria's `HiddenSelect` component for native form compatibility: **Data attributes** for CSS ([not ARIA](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/#do-not-use-aria-to-style-content)): - Trigger: `data-open`, `data-disabled`, `data-invalid`, `data-required`, `data-placeholder` -- Option: `data-selected`, `data-disabled`, `data-highlighted` +- Option: `data-selected`, `data-disabled`, `data-focused` - Interactive: `data-focus-visible`, `data-hovered`, `data-pressed` **Slots** for composition: From 00b872a667b69731a0eb00df469ef62eee333819 Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Wed, 29 Oct 2025 15:07:32 -0700 Subject: [PATCH 04/10] docs(select): update Select primitive pdr --- docs/pdrs/select-primitive.mdx | 203 ++++++++++++++++++++++++++------- 1 file changed, 163 insertions(+), 40 deletions(-) diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx index 6621cb8..4de1991 100644 --- a/docs/pdrs/select-primitive.mdx +++ b/docs/pdrs/select-primitive.mdx @@ -9,16 +9,16 @@ The Select primitive provides an accessible, customizable dropdown that overcome - **Compound components** (`Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectOption`, `SelectGroup`, `SelectLabel`): Enables flexible composition vs monolithic component that would limit customization - **Builds on ListBox**: Reuses proven selection patterns rather than duplicating accessibility/keyboard logic - **Portal rendering**: Solves [dropdown clipping issues](https://github.com/floating-ui/floating-ui/discussions/1965) common with overflow:hidden containers -- **[Floating UI](https://floating-ui.com/) positioning**: Chosen over Popper for [smaller bundle](https://bundlephobia.com/package/@floating-ui/dom) and better tree-shaking +- **React Aria positioning**: Uses [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) for smart positioning with flip/shift collision detection and RTL support, integrated with React Aria's accessibility system - **Hidden native ``**: Maintain form submission and [autofill](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) compatibility -- **Floating UI vs Popper**: [Smaller bundle](https://bundlephobia.com/package/@floating-ui/dom), better tree-shaking +- **React Aria positioning**: Use [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) for consistency with existing Bento primitives (e.g., Drawer), seamless integration with React Aria's accessibility hooks, and proven positioning logic that handles edge cases (RTL, viewport boundaries, visual viewport on mobile) **Future optimizations** (large lists): From 29cd16d852a2122ab7110ce59946c5f67c6f691a Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Wed, 29 Oct 2025 15:13:08 -0700 Subject: [PATCH 05/10] docs(select): refine Select primitive documentation for clarity and accuracy --- docs/pdrs/select-primitive.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx index 4de1991..46219e3 100644 --- a/docs/pdrs/select-primitive.mdx +++ b/docs/pdrs/select-primitive.mdx @@ -173,7 +173,7 @@ export const SelectLabel = ListBoxSection.Label; | Concept | Implementation | | ------------------ | ------------------------------------------------------------------------- | -| React Aria | useSelect (includes useMenuTrigger), useSelectState, useOverlay, useOverlayPosition, useListBox (via ListBox component) | +| React Aria | useSelect (includes useMenuTrigger internally), useSelectState, useOverlay, useOverlayPosition; ListBox component internally uses useListBox | | Slots | withSlots on all sub-components, slot prop for targeting | | Render Props | value/option renderers with state/props access | | data-\* Attributes | data-open, data-disabled, data-invalid, data-selected, data-focus-visible | @@ -195,7 +195,7 @@ export const SelectLabel = ListBoxSection.Label; ## React Aria & External Integration -**React Aria provides core behavior** ([`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html) (which internally uses `useMenuTrigger`), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html))—handling ARIA attributes, keyboard nav, and state. We extend with: +**React Aria provides core behavior**—[`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html) (which internally uses `useMenuTrigger`) and [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html) handle ARIA attributes, keyboard nav, and state. We extend with: - **Slots/render props**: Enable deep customization without forking - **Data attributes**: Expose state for CSS (avoiding [ARIA for styling](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/#do-not-use-aria-to-style-content)) @@ -355,7 +355,7 @@ Uses React Aria's `HiddenSelect` component for native form compatibility. As sho - Roles: `combobox`, `listbox`, `option`, `group` - Attributes: `aria-expanded`, `aria-selected`, `aria-invalid`, `aria-required` - Keyboard: Arrow nav, `Enter`/`Space` select, `Escape` close, typeahead -- Focus: [Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex), returns to trigger, `data-focus-visible` +- Focus: [Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex), returns to the trigger, `data-focus-visible` **Platform**: @@ -439,7 +439,7 @@ const [value, setValue] = useState('apple'); // Dynamic items API const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }]; ``` From f9af7ed489f2584198a40dbaeaea04e973cf6d1d Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Tue, 4 Nov 2025 11:04:19 -0700 Subject: [PATCH 06/10] docs(select): align PDR with orchestration pattern and slot composition --- docs/pdrs/select-primitive.mdx | 612 +++++++++++++++++++++++---------- 1 file changed, 426 insertions(+), 186 deletions(-) diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx index 46219e3..ca87684 100644 --- a/docs/pdrs/select-primitive.mdx +++ b/docs/pdrs/select-primitive.mdx @@ -6,40 +6,114 @@ The Select primitive provides an accessible, customizable dropdown that overcome ### Unique Attributes -- **Compound components** (`Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectOption`, `SelectGroup`, `SelectLabel`): Enables flexible composition vs monolithic component that would limit customization -- **Builds on ListBox**: Reuses proven selection patterns rather than duplicating accessibility/keyboard logic -- **Portal rendering**: Solves [dropdown clipping issues](https://github.com/floating-ui/floating-ui/discussions/1965) common with overflow:hidden containers +- **Slot-based composition**: Select acts as coordinator, applying necessary props to slotted children. Users can slot custom components (Button, Popover, ListBox, etc.) for maximum flexibility +- **Extended slot system**: Supports both customization pattern (`slots` prop) and composition pattern (`slot` attribute) for finding and applying props to children +- **Builds on ListBox**: Reuses proven selection patterns rather than duplicating accessibility/keyboard logic, following [React Aria's pattern](https://react-spectrum.adobe.com/react-aria/Select.html#example) +- **Coordinator pattern**: Select internally calls `useOverlay`/`useOverlayPosition` and merges overlay props onto slotted popover component - **React Aria positioning**: Uses [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) for smart positioning with flip/shift collision detection and RTL support, integrated with React Aria's accessibility system - **Hidden native ` + + + + Option 1 + Option 2 + + + + + +``` + +**With SelectOption convenience (value → id mapping):** + ```tsx -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectOption, - SelectGroup, - SelectLabel -} from '@bento/select'; +import { Select, SelectOption } from '@bento/select'; +import { Button, Text, Popover, ListBox } from '@bento/components'; + + +``` +**With custom components:** + +```tsx ``` @@ -354,6 +495,7 @@ Uses React Aria's `HiddenSelect` component for native form compatibility. As sho - Roles: `combobox`, `listbox`, `option`, `group` - Attributes: `aria-expanded`, `aria-selected`, `aria-invalid`, `aria-required` +- **Validation**: External (consumer passes `isInvalid` prop) → Select forwards `aria-invalid` to trigger and adds `data-invalid` for styling. Error content rendered via `slot="errorMessage"` or `render` prop. - Keyboard: Arrow nav, `Enter`/`Space` select, `Escape` close, typeahead - Focus: [Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex), returns to the trigger, `data-focus-visible` @@ -371,12 +513,68 @@ Uses React Aria's `HiddenSelect` component for native form compatibility. As sho - Option: `data-selected`, `data-disabled`, `data-focused` - Interactive: `data-focus-visible`, `data-hovered`, `data-pressed` -**Slots** for composition: +**Slot-based composition** (using `slot` attribute): + +- `slot="trigger"` - Button-like component that receives triggerProps +- `slot="value"` or `slot="trigger.value"` - Value display component +- `slot="popover"` - Popover/overlay component that receives overlay props +- `slot="list"` or `slot="listbox"` - ListBox component that receives menuProps +- `slot="description"` - Description component +- `slot="errorMessage"` - Error message component -- `trigger`, `trigger.value`, `trigger.icon` - Button customization -- `content`, `content.viewport` - Dropdown container -- `option`, `option.label`, `option.icon` - Option rendering -- `group`, `group.label` - Grouped sections +**Slot customization** (using `slots` prop): + +- `slots.trigger`, `slots.trigger.value` - Customize trigger/value rendering +- `slots.popover` - Customize popover rendering +- `slots.list`, `slots.listbox` - Customize list rendering + +**Typed slot interfaces** allow TypeScript-aware consumers to create custom components with proper types + +### TypeScript Slot Safety + +Select exports typed interfaces for each slot and helpers for consumer type safety: + +```ts +// Slot type map +export type SelectSlots = { + trigger: SelectTriggerSlotProps; + 'trigger.value': SelectValueSlotProps; + value: SelectValueSlotProps; + popover: SelectPopoverSlotProps; + list: SelectListSlotProps; + listbox: SelectListSlotProps; + description: React.HTMLAttributes; + errorMessage: React.HTMLAttributes; +}; + +// Helper to extract slot prop types (inspired by MUI's PropsFromSlot) +export type PropsFromSelectSlot = SelectSlots[S]; +``` + +**Usage with custom components:** + +```tsx +// Type-safe custom trigger with additional props +function MyTrigger(props: PropsFromSelectSlot<'trigger'> & { tone?: 'neutral' | 'danger' }) { + const { triggerProps, tone = 'neutral', ...rest } = props; + return - - - Option 1 - Option 2 - - - - - -``` - -**With SelectOption convenience (value → id mapping):** - ```tsx import { Select, SelectOption } from '@bento/select'; -import { Button, Text, Popover, ListBox } from '@bento/components'; +import { Button } from '@bento/button'; +import { Text } from '@bento/text'; +import { ListBox, ListBoxItem } from '@bento/listbox'; + +// Custom value component (Text doesn't have placeholder prop) +function SelectValue({ placeholder, selectedItem, ...props }) { + const displayText = selectedItem ? selectedItem.textValue : placeholder; + return {displayText}; +} -``` - -**With custom components:** - -```tsx - ``` @@ -437,14 +114,8 @@ import { Button, Text, Popover, ListBox } from '@bento/components'; ```html - @@ -457,104 +128,70 @@ import { Button, Text, Popover, ListBox } from '@bento/components';
Option 1
-
- Group Label -
Option 2
-
+
Option 2
``` -### Key Props & State - -| Prop | Type | Purpose | -| --------------------------------- | ----------------- | ------------------------------------------- | -| `value`/`defaultValue` | `string` | Controlled/uncontrolled selection | -| `open`/`defaultOpen` | `boolean` | Controlled/uncontrolled dropdown visibility | -| `onValueChange` | `(value) => void` | Selection callback | -| `onOpenChange` | `(open) => void` | Visibility callback | -| `disabled`, `required`, `invalid` | `boolean` | Form states | -| `placeholder` | `string` | Empty state text | -| `name` | `string` | Form submission field name | -| `items` | `Iterable` | Dynamic collections | -| `disabledKeys` | `Set` | Disable specific options | - -**State management**: Supports controlled/uncontrolled modes, syncs with hidden ` - - {/* ... */} - ``` -**Note on type safety:** Similar to [MUI X's slot system](https://mui.com/x/common-concepts/custom-components/), `slotProps` types aren't dynamically linked to custom `slots` types. Use `satisfies` to ensure compile-time validation of slot names and casting for `slotProps` when passing additional props. +**Note**: Similar to [MUI X's slot system](https://mui.com/x/common-concepts/custom-components/), `slotProps` types aren't dynamically linked to custom `slots` types. Use `satisfies` for compile-time validation. ## Prior Art -- **[React Aria](https://react-spectrum.adobe.com/react-aria/Select.html)**: Our accessibility foundation (hooks only, no components) -- **[Radix UI](https://www.radix-ui.com/primitives/docs/components/select)**: Good compound pattern (but limited slot customization) -- **[Headless UI](https://headlessui.com/react/listbox)**: Clean API (but rigid structure) -- **[Arco](https://arco.design/react/en-US/components/select)/[Ant](https://ant.design/components/select)**: Virtual scrolling, search (future scope) +- **[React Aria](https://react-spectrum.adobe.com/react-aria/Select.html)**: Accessibility foundation (hooks only) +- **[Radix UI](https://www.radix-ui.com/primitives/docs/components/select)**: Good compound pattern (limited slot customization) +- **[Headless UI](https://headlessui.com/react/listbox)**: Clean API (rigid structure) -**Why Bento**: Combines React Aria's accessibility with slot-based composition, allowing users to bring their own components (Button, Popover, ListBox, etc.) while Select acts as coordinator applying necessary props. Extended slot system supports both customization (`slots` prop) and composition (`slot` attribute) for maximum flexibility—avoiding the "all or nothing" approach of other libraries. +**Why Bento**: Combines React Aria's accessibility with slot-based composition, allowing users to bring their own components while Select acts as coordinator. ## Usage Examples ```tsx import { Select, SelectOption } from '@bento/select'; -import { Button, Text, Popover, ListBox, ListBoxItem } from '@bento/components'; +import { Button } from '@bento/button'; +import { Text } from '@bento/text'; +import { ListBox, ListBoxItem } from '@bento/listbox'; -// Basic slot-based composition -const [value, setValue] = useState('apple'); - +function SelectValue({ placeholder, selectedItem, ...props }) { + return {selectedItem ? selectedItem.textValue : placeholder}; +} -// Using ListBoxItem directly (no SelectOption convenience) +// Basic usage -// Using render prop for conditional error messages - - - - Apple - Orange - - - Choose your favorite fruit + + Apple + Orange + -// Custom components with typed slot interfaces -interface MyCustomTriggerProps extends SelectTriggerSlotProps { - customProp?: string; -} - -function MyCustomTrigger({ triggerProps, customProp, ...props }: MyCustomTriggerProps) { - return ; -} - - - -// Dynamic items API +// Dynamic items const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }]; ``` ## Implementation Rationale -**Architecture decisions**: - -- **Slot-based composition**: Select acts as coordinator, finding children by `slot` attribute and applying props. Users can bring custom components (Button, Popover, ListBox, etc.) for maximum flexibility, similar to [React Aria's pattern](https://react-spectrum.adobe.com/react-aria/Select.html#example) -- **Dual slot system**: Slot system supports both patterns—`slot` attribute for composition (Select finds and applies props) and `slots` prop for customization (inherited via Box context with namespacing). This provides maximum flexibility. -- **Coordinator pattern**: Select internally calls `useOverlay`/`useOverlayPosition` and applies merged overlay props to popover slot. **Slot children are stateless**; validation is external (via `isInvalid` prop). Users don't need to handle overlay positioning logic. -- **Build on ListBox**: Reuse [ARIA listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) patterns rather than duplicate. Collection is built at Select root level and shared with ListBox via `ListStateContext`, so ListBox doesn't rebuild the collection. -- **Collection building pattern**: Follows React Aria's pattern—collection built at top level via `CollectionBuilder`, then state created after collection is built. **Items must stay in `children`** for React Aria to discover them. No leaf creation inside Select components. -- **Render props**: Optional `render` prop exposes state for dynamic chrome (e.g., conditional errors) without disrupting collection building. -- **SelectOption convenience**: Optional wrapper that maps `value` → `id` prop for better API. Users can also use `ListBoxItem` directly with `id` prop if they prefer. -- **Typed slot interfaces**: Provides TypeScript interfaces that extend component interfaces (e.g., trigger slot extends `PressableProps`, list slot extends `ListBoxProps`). Includes `SelectSlots` map and `PropsFromSelectSlot` helper. Uses `satisfies` for compile-time validation (inspired by [MUI X](https://mui.com/x/common-concepts/custom-components/)). -- **React Aria utils**: Uses `mergeRefs` from `@react-aria/utils` for ref merging consistency with React Aria patterns. -- **Box as context provider**: Select uses `withSlots` which wraps components with Box context provider. Box manages environment (window/document) and slot inheritance. Container can optionally be used as a visible wrapper element but isn't required. -- **Portal rendering**: Popover slot handles portal rendering (users provide their own Popover component). This solves [overflow clipping](https://github.com/floating-ui/floating-ui/discussions/1965) and z-index issues. -- **Hidden `` limitations. - **Slot-based composition**: Coordinator applies props to slotted children (Button, ListBox, etc.) - **Builds on ListBox**: Reuses selection patterns, following [React Aria's pattern](https://react-spectrum.adobe.com/react-aria/Select.html#example) - **Coordinator pattern**: Internally handles overlay positioning, portal rendering, and scroll locking -- **Scroll locking**: Uses `usePreventScroll` to lock body scroll when open (floating elements should scroll lock) +- **Scroll locking**: Uses [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) to lock body scroll when open (floating elements should scroll lock) - **Hidden native `` limitations. **Note on description/error slots**: Provided for convenience and accessibility. They enforce a specific rendering order similar to `ControlGroup`. Users who prefer more flexibility can handle description/error messages themselves. -**Hooks**: `useSelect`, `useSelectState`, `useOverlay`, `useOverlayPosition`, `usePreventScroll`, `useProps`, `useDataAttributes`, `withSlots` +**Hooks**: [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html), `useProps`, `useDataAttributes`, `withSlots` -**Utilities**: `mergeRefs` from `@react-aria/utils` +**Utilities**: [`mergeRefs`](https://www.npmjs.com/package/@react-aria/utils) from `@react-aria/utils` (already used in Bento's Radio, Checkbox, and Pressable primitives) ## Implementation Pattern @@ -38,8 +38,8 @@ Select acts as **coordinator** that builds collection, creates state, and applie **Key patterns**: - Collection building at `Select` root via `CollectionBuilder` (items must stay in `children`) - State created after collection is built, shared with ListBox via `ListStateContext` -- Overlay container created internally with portal rendering, positioned via `useOverlayPosition` -- Scroll locking via `usePreventScroll` when dropdown is open +- Overlay container created internally with portal rendering, positioned via [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) +- Scroll locking via [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) when dropdown is open - Value slot receives `placeholder` prop—component should display it when `selectedItem` is null (Text doesn't have `placeholder` prop) **Props per slot**: @@ -54,29 +54,29 @@ Select acts as **coordinator** that builds collection, creates state, and applie | Concept | Implementation | |---------|----------------| -| React Aria | useSelect, useSelectState, useOverlay, useOverlayPosition, usePreventScroll | +| React Aria | [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) | | Coordinator | Select manages state/refs/overlay internally; slot children are stateless | | Slot Composition | `slot` attribute + `slots` prop (via Box context) | | Typed Slots | TypeScript interfaces extend component interfaces | | data-\* Attributes | data-open, data-disabled, data-invalid, data-selected, data-focus-visible | -| Scroll Locking | usePreventScroll locks body scroll when open | +| Scroll Locking | [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) locks body scroll when open | ## React Aria Integration - **Collection building**: `CollectionBuilder` at root level, shared via state - **State creation**: After collection built, shared with ListBox via `ListStateContext` -- **Overlay management**: Select internally creates overlay container, uses `useOverlay` and `useOverlayPosition` for positioning with flip/shift collision detection, RTL support +- **Overlay management**: Select internally creates overlay container, uses [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html) and [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) for positioning with flip/shift collision detection, RTL support - **Portal rendering**: Overlay rendered via `createPortal` to document.body -- **Scroll locking**: `usePreventScroll` locks body scroll when open +- **Scroll locking**: [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) locks body scroll when open ### Overlay Implementation Select internally creates and manages the overlay container that wraps the listbox. This includes: - **Portal rendering**: Overlay is rendered via `createPortal` to `document.body` to avoid z-index issues -- **Positioning**: Uses `useOverlayPosition` with flip/shift collision detection and RTL support -- **Click-outside handling**: Uses `useOverlay` to handle dismiss on outside click -- **Scroll locking**: Uses `usePreventScroll` to lock body scroll when open +- **Positioning**: Uses [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) with flip/shift collision detection and RTL support +- **Click-outside handling**: Uses [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html) to handle dismiss on outside click +- **Scroll locking**: Uses [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) to lock body scroll when open - **Focus management**: Returns focus to trigger when closed The overlay container is always mounted but hidden via `display: none` when closed. Users only need to provide the `ListBox` via the `list` or `listbox` slot. @@ -266,7 +266,7 @@ const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }] - **Slot-based composition**: Coordinator finds children by `slot`, applies props. Users bring custom components. - **Build on ListBox**: Reuse ARIA listbox patterns, share state via `ListStateContext` - **Coordinator pattern**: Select manages overlay positioning, portal rendering, and scroll locking internally; slot children are stateless -- **Scroll locking**: `usePreventScroll` locks body scroll when open +- **Scroll locking**: [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) locks body scroll when open - **SelectOption convenience**: Maps `value` → `id` (users can use `ListBoxItem` directly) - **Typed slots**: Interfaces extend component interfaces for type safety - **Description/error slots**: Optional, enforce rendering order (users can handle themselves) From fcdca3fe1bcbfdf828dd8ec305a66151686c0015 Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Thu, 6 Nov 2025 14:40:39 -0700 Subject: [PATCH 09/10] docs(select-primitive): update docs for container-based composition and clarify props usage --- docs/pdrs/select-primitive.mdx | 58 ++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx index 3a5cda5..1365c0f 100644 --- a/docs/pdrs/select-primitive.mdx +++ b/docs/pdrs/select-primitive.mdx @@ -6,11 +6,10 @@ Accessible, customizable dropdown that overcomes native ``**: Maintains form submission and autofill compatibility +- **Coordinator pattern**: Internally handles overlay positioning, form integration, and state management +- **Hidden native `` limitations. **Note on description/error slots**: Provided for convenience and accessibility. They enforce a specific rendering order similar to `ControlGroup`. Users who prefer more flexibility can handle description/error messages themselves. -**Hooks**: [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html), `useProps`, `useDataAttributes`, `withSlots` +**Hooks**: [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), `useProps`, `useDataAttributes` -**Utilities**: [`mergeRefs`](https://www.npmjs.com/package/@react-aria/utils) from `@react-aria/utils` (already used in Bento's Radio, Checkbox, and Pressable primitives) +**HOC**: `withSlots` + +**Primitives**: `Container` from `@bento/container` (handles slot composition) ## Implementation Pattern -Select acts as **coordinator** that builds collection, creates state, and applies props to slotted children. **Slot children are stateless**—they receive props but don't manage state. **Validation is external**—consumers pass `isInvalid`. +Select acts as **coordinator** that builds collection, creates state, and applies props to slotted children using **Container-based slot composition**. **Slot children are stateless**—they receive props but don't manage state. **Validation is external**—consumers pass `isInvalid`. **Key patterns**: - Collection building at `Select` root via `CollectionBuilder` (items must stay in `children`) - State created after collection is built, shared with ListBox via `ListStateContext` -- Overlay container created internally with portal rendering, positioned via [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) -- Scroll locking via [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) when dropdown is open +- **Container-based composition**: Uses `@bento/container` with `slots` prop to map props to slotted children +- Overlay positioning via [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html) and [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) - Value slot receives `placeholder` prop—component should display it when `selectedItem` is null (Text doesn't have `placeholder` prop) +**Container-based slot mapping**: +```tsx + + {children} + +``` + +**Note**: The overlay/popover container is managed internally by Select and is not exposed as a slot. + **Props per slot**: -- `trigger` - `triggerProps`, merged ref, data attributes (`data-open`, `data-disabled`, `data-invalid`) -- `value` - `valueProps`, `selectedItem`, `placeholder` +- `trigger` - `triggerProps`, explicit ARIA attributes (`role="combobox"`, `aria-disabled`, `aria-invalid`, `aria-required`), ref, data attributes +- `value` / `trigger.value` - `valueProps`, `selectedItem`, `placeholder` - `list` / `listbox` - `menuProps` -- `description` / `errorMessage` - Respective props +- `description` / `errorMessage` - Respective props from `useSelect` **SelectOption vs ListBoxItem**: `SelectOption` maps `value` → `id`. Users can use `ListBoxItem` directly with `id` prop. There is no `ListItem` component in Bento. @@ -263,14 +282,21 @@ const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }] ## Implementation Rationale **Key decisions**: -- **Slot-based composition**: Coordinator finds children by `slot`, applies props. Users bring custom components. +- **Container-based composition**: Uses `@bento/container` with `slots` prop for cleaner architecture—no manual child traversal or `cloneElement` recursion - **Build on ListBox**: Reuse ARIA listbox patterns, share state via `ListStateContext` -- **Coordinator pattern**: Select manages overlay positioning, portal rendering, and scroll locking internally; slot children are stateless -- **Scroll locking**: [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) locks body scroll when open +- **Coordinator pattern**: Select manages overlay positioning, form integration, and state internally; slot children are stateless +- **Explicit ARIA attributes**: Ensures correct `role="combobox"` and validation ARIA (`aria-disabled`, `aria-invalid`, `aria-required`) regardless of trigger implementation +- **Form integration**: Uses React Aria's `HiddenSelect` for client-side rendering; custom SSR fallback ensures form submission works server-side +- **Safe style merging**: Converts `CSSStyleDeclaration` to plain objects to avoid React DOM warnings; preserves numeric values for auto-unit conversion - **SelectOption convenience**: Maps `value` → `id` (users can use `ListBoxItem` directly) - **Typed slots**: Interfaces extend component interfaces for type safety - **Description/error slots**: Optional, enforce rendering order (users can handle themselves) +**Architecture benefits**: +- Reduced complexity: ~200 fewer lines vs manual child enhancement +- Cleaner separation: Container handles slot composition; Select handles coordination +- Easier maintenance: Slot logic centralized in Container; Select focuses on state/ARIA + **Future optimizations**: Virtual scrolling, debounced typeahead, lazy section loading, React.memo for options. -**Testing focus**: Keyboard navigation, screen readers, RTL, form submission, positioning edge cases, scroll locking behavior. +**Testing focus**: Keyboard navigation, screen readers, RTL, form submission, positioning edge cases, SSR compatibility. \ No newline at end of file From ef92db3466404a4824a5d002854fde5e7ab0b654 Mon Sep 17 00:00:00 2001 From: Kawika Bader Date: Mon, 10 Nov 2025 14:19:16 -0700 Subject: [PATCH 10/10] docs(select-primitive): improve Select pdr with clearer slot usage, props, and accessibility --- docs/pdrs/select-primitive.mdx | 322 ++++++++++++++++++++------------- 1 file changed, 193 insertions(+), 129 deletions(-) diff --git a/docs/pdrs/select-primitive.mdx b/docs/pdrs/select-primitive.mdx index 1365c0f..f908f83 100644 --- a/docs/pdrs/select-primitive.mdx +++ b/docs/pdrs/select-primitive.mdx @@ -10,130 +10,190 @@ Accessible, customizable dropdown that overcomes native ``**: Maintains form submission and autofill compatibility (client + SSR) -- **SelectOption convenience**: Maps `value` → `id` prop (users can use `ListBoxItem` directly with `id`) - **Typed slot interfaces**: TypeScript interfaces extend component interfaces for type safety ## Primitive Composition -**Components**: `Select` (coordinator), `SelectOption` (optional wrapper, maps `value` → `id`) +**Components**: `Select` (coordinator) **Slots**: -- `slot="trigger"` - Receives `triggerProps` (must implement `PressableProps`) -- `slot="value"` or `slot="trigger.value"` - Receives `valueProps`, `selectedItem`, `placeholder` -- `slot="list"` or `slot="listbox"` - Receives `menuProps` (typically `ListBox`) +- `slot="trigger"` - Receives `triggerProps`, `focusProps`, `hoverProps`, `ref` (should forward ARIA/press props; `@bento/button` already satisfies this) +- `slot="label"` - Receives `labelProps` (optional, for accessible name) +- `slot="value"` - Receives `valueProps`, `selectedItem`, `placeholder` + - In `slots` prop, target nested value with namespaced key `'trigger.value'` +- `slot="popover"` - Receives `overlayProps`, `positionProps`, `ref` (consumer handles portal rendering and structure) + - **Note**: Currently consumers manually handle portal rendering. A future `@bento/popover` or `@bento/overlay` component would consume these props for cleaner composition. +- `slot="listbox"` - Receives `menuProps` (typically `ListBox`) - `slot="description"` - Receives `descriptionProps` (optional) -- `slot="errorMessage"` - Receives `errorMessageProps` (optional) +- `slot="error"` - Receives `errorMessageProps` (optional, follows Bento convention of `slot="error"`) **Note on description/error slots**: Provided for convenience and accessibility. They enforce a specific rendering order similar to `ControlGroup`. Users who prefer more flexibility can handle description/error messages themselves. -**Hooks**: [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), `useProps`, `useDataAttributes` +**Hooks**: [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), [`useFocusRing`](https://react-spectrum.adobe.com/react-aria/useFocusRing.html), [`useHover`](https://react-spectrum.adobe.com/react-aria/useHover.html), `useProps`, `useDataAttributes` + +**Contexts**: `OverlayTriggerStateContext`, `PopoverContext` (from `react-aria-components`), `ListStateContext` (from `@bento/listbox`) + +**Note**: The RAC contexts (`OverlayTriggerStateContext`, `PopoverContext`) are used in the [select-poc implementation](https://github.com/godaddy/bento/blob/select-poc/packages/select/src/select.tsx) and are key to eliminating manual coordination complexity. These contexts handle trigger-overlay coordination automatically, eliminating the need for custom `shouldCloseOnInteractOutside` logic or `useButton` wrappers that were required in proof-of-concept implementations without contexts. **HOC**: `withSlots` **Primitives**: `Container` from `@bento/container` (handles slot composition) -## Implementation Pattern +**Future dependencies**: A `@bento/popover` or `@bento/overlay` component would provide cleaner overlay composition, consuming the `overlayProps` and `positionProps` from the popover slot. Currently, consumers manually handle portal rendering and overlay structure. + +## Design Constraints -Select acts as **coordinator** that builds collection, creates state, and applies props to slotted children using **Container-based slot composition**. **Slot children are stateless**—they receive props but don't manage state. **Validation is external**—consumers pass `isInvalid`. +### Single Selection Only -**Key patterns**: -- Collection building at `Select` root via `CollectionBuilder` (items must stay in `children`) -- State created after collection is built, shared with ListBox via `ListStateContext` -- **Container-based composition**: Uses `@bento/container` with `slots` prop to map props to slotted children -- Overlay positioning via [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html) and [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) -- Value slot receives `placeholder` prop—component should display it when `selectedItem` is null (Text doesn't have `placeholder` prop) +**This primitive only supports single selection.** React Aria's [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html) hook is specifically designed for single-selection scenarios and does not support multiple selection. + +- ✅ Use `@bento/select` for: Dropdown menus, form fields, single-choice pickers +- ❌ Don't use for: Multi-select, tag inputs, checkbox lists + +**For multiple selection**, use [`@bento/listbox`](../../packages/listbox) directly with `selectionMode="multiple"`: -**Container-based slot mapping**: ```tsx - - {children} - +// Multi-select use case - use ListBox instead + + Option 1 + Option 2 + ``` -**Note**: The overlay/popover container is managed internally by Select and is not exposed as a slot. +### Manual Portal Management -**Props per slot**: -- `trigger` - `triggerProps`, explicit ARIA attributes (`role="combobox"`, `aria-disabled`, `aria-invalid`, `aria-required`), ref, data attributes -- `value` / `trigger.value` - `valueProps`, `selectedItem`, `placeholder` -- `list` / `listbox` - `menuProps` -- `description` / `errorMessage` - Respective props from `useSelect` +Consumers must manually handle portal rendering for the popover overlay: -**SelectOption vs ListBoxItem**: `SelectOption` maps `value` → `id`. Users can use `ListBoxItem` directly with `id` prop. There is no `ListItem` component in Bento. +```tsx +{state.isOpen && ReactDOM.createPortal( + +
+ + ... + +
+
, + document.body +)} +``` -## Core Concepts +**Rationale**: Provides maximum flexibility for portal placement and overlay structure. A future `@bento/popover` primitive will simplify this for common use cases while preserving the low-level escape hatch. -| Concept | Implementation | -|---------|----------------| -| React Aria | [`useSelect`](https://react-spectrum.adobe.com/react-aria/useSelect.html), [`useSelectState`](https://react-spectrum.adobe.com/react-stately/useSelectState.html), [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html), [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html), [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) | -| Coordinator | Select manages state/refs/overlay internally; slot children are stateless | -| Slot Composition | `slot` attribute + `slots` prop (via Box context) | -| Typed Slots | TypeScript interfaces extend component interfaces | -| data-\* Attributes | data-open, data-disabled, data-invalid, data-selected, data-focus-visible | -| Scroll Locking | [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) locks body scroll when open | +### Value Component Boilerplate + +The `@bento/text` component doesn't have a `placeholder` prop, so consumers must create a wrapper component for the trigger value: + +```tsx +function SelectValue({ placeholder, selectedItem, ...props }) { + return {selectedItem ? selectedItem.textValue : placeholder}; +} +``` -## React Aria Integration +**Future improvement**: A built-in `@bento/select-value` component could reduce this boilerplate while preserving flexibility for custom implementations. -- **Collection building**: `CollectionBuilder` at root level, shared via state -- **State creation**: After collection built, shared with ListBox via `ListStateContext` -- **Overlay management**: Select internally creates overlay container, uses [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html) and [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) for positioning with flip/shift collision detection, RTL support -- **Portal rendering**: Overlay rendered via `createPortal` to document.body -- **Scroll locking**: [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) locks body scroll when open +### Fixed Positioning Defaults -### Overlay Implementation +Overlay positioning uses sensible defaults that cannot currently be customized via props: -Select internally creates and manages the overlay container that wraps the listbox. This includes: +- `placement: 'bottom start'` - Opens below trigger, aligned to start edge +- `offset: 4` - 4px spacing between trigger and overlay +- Auto-flip and collision detection enabled -- **Portal rendering**: Overlay is rendered via `createPortal` to `document.body` to avoid z-index issues -- **Positioning**: Uses [`useOverlayPosition`](https://react-spectrum.adobe.com/react-aria/useOverlayPosition.html) with flip/shift collision detection and RTL support -- **Click-outside handling**: Uses [`useOverlay`](https://react-spectrum.adobe.com/react-aria/useOverlay.html) to handle dismiss on outside click -- **Scroll locking**: Uses [`usePreventScroll`](https://react-spectrum.adobe.com/react-aria/usePreventScroll.html) to lock body scroll when open -- **Focus management**: Returns focus to trigger when closed +**Future improvement**: Expose `placement`, `offset`, `crossOffset`, and other positioning options as optional props. -The overlay container is always mounted but hidden via `display: none` when closed. Users only need to provide the `ListBox` via the `list` or `listbox` slot. +## Implementation Pattern -## Architecture & Features +Select acts as **coordinator** using `CollectionBuilder` → `useSelectState` → Context Providers → `Container` slots pattern. **Slot children are stateless**, receiving props but not managing state. **Validation is external** via `isInvalid` prop. -### Component Structure +**Architecture flow**: +1. `CollectionBuilder` at root builds collection from children +2. State created via `useSelectState({ ...props, collection, children: undefined })` +3. Three React Aria Components contexts eliminate manual coordination: + - `OverlayTriggerStateContext` - Open/close state (consumed by `DismissButton`) + - `PopoverContext` - Trigger ref, scroll ref, placement (for positioning) + - `ListStateContext` - Selection state (consumed by `ListBox`/`ListBoxItem`) +4. `Container` maps props to slots via `slots` prop +5. Consumers handle portal rendering with `FocusScope` wrapper (see [Manual Portal Management](#manual-portal-management)) +**Context benefits**: Eliminates `shouldCloseOnInteractOutside`, race condition guards (`isTogglingRef`), `e.stopPropagation()`, and custom event coordination (~100 lines saved vs manual approach) + +**Slot mapping implementation**: ```tsx -import { Select, SelectOption } from '@bento/select'; -import { Button } from '@bento/button'; -import { Text } from '@bento/text'; -import { ListBox, ListBoxItem } from '@bento/listbox'; +// Hook setup +const state = useSelectState({ ...props, collection, children: undefined }); +const { labelProps, triggerProps, valueProps, menuProps, descriptionProps, errorMessageProps } = useSelect(props, state, triggerRef); +const { focusProps, isFocused, isFocusVisible } = useFocusRing(); +const { hoverProps, isHovered } = useHover({ isDisabled: props.isDisabled }); +const buttonProps = mergeProps(triggerProps, focusProps, hoverProps); + +usePreventScroll({ isDisabled: !state.isOpen }); +const { overlayProps } = useOverlay({ onClose: () => state.close(), isOpen: state.isOpen, isDismissable: true }, popoverRef); +const { overlayProps: positionProps } = useOverlayPosition({ targetRef: triggerRef, overlayRef: popoverRef, placement: 'bottom start', offset: 4, isOpen: state.isOpen }); + +// Context + Container composition + + + + + {children} + + + + +``` -// Custom value component (Text doesn't have placeholder prop) -function SelectValue({ placeholder, selectedItem, ...props }) { - const displayText = selectedItem ? selectedItem.textValue : placeholder; - return {displayText}; -} +**Key implementation notes**: +- `triggerProps` from `useSelect` works directly—no `useButton` wrapper needed +- `state.selectedItem` (singular) for single-selection—not `selectedItems[0]` +- Contexts handle coordination—no `shouldCloseOnInteractOutside` or race condition guards +- Select orchestrates hooks/state; consumers compose overlay structure (portals, animations, wrappers) - +**Props per slot**: +- `trigger` - `triggerProps`, `focusProps`, `hoverProps`, `ref`, ARIA attributes (`aria-disabled`, `aria-invalid`, `aria-required`, `aria-expanded`, `aria-haspopup`), data attributes (should forward all props; native ` @@ -156,23 +216,26 @@ function SelectValue({ placeholder, selectedItem, ...props }) { | Prop | Type | Purpose | |------|------|---------| -| `value`/`defaultValue` | `string` | Controlled/uncontrolled selection | +| `value`/`defaultValue` | `Key` | Controlled/uncontrolled selection (React Aria `Key` type) | | `open`/`defaultOpen` | `boolean` | Dropdown visibility | -| `onValueChange` | `(value) => void` | Selection callback | -| `onOpenChange` | `(open) => void` | Visibility callback | +| `onValueChange` | `(value: Key) => void` | Selection callback | +| `onOpenChange` | `(open: boolean) => void` | Visibility callback | | `isDisabled`, `isRequired`, `isInvalid` | `boolean` | Form states | | `placeholder` | `string` | Empty state text (passed to value slot) | | `name` | `string` | Form submission field name | +| `aria-label` | `string` | Accessible name when no visible label slot is provided | | `items` | `Iterable` | Dynamic collections | -| `disabledKeys` | `Set` | Disable specific options | +| `disabledKeys` | `Iterable` | Disable specific options | + +**React Aria mapping**: `value` ↔ `selectedKey`, `defaultValue` ↔ `defaultSelectedKey`, `onValueChange` ↔ `onSelectionChange`. **Form integration**: Uses React Aria's `HiddenSelect` when `name` prop provided—enables form submission, syncs with state, handles autofill. ## Accessibility & Platform Support **ARIA** ([combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#keyboard-interaction-12)): -- Roles: `combobox`, `listbox`, `option`, `group` -- Attributes: `aria-expanded`, `aria-selected`, `aria-invalid`, `aria-required` +- Roles/attributes: Consistent with select-only combobox pattern (e.g., trigger exposes appropriate ARIA for a listbox popup; list exposes `role="listbox"`, options expose `role="option"`) +- Common attributes: `aria-expanded`, `aria-selected`, `aria-invalid`, `aria-required` - Keyboard: Arrow nav, `Enter`/`Space` select, `Escape` close, typeahead - Focus: Roving tabindex, returns to trigger @@ -185,30 +248,34 @@ function SelectValue({ placeholder, selectedItem, ...props }) { **Data attributes** (not ARIA): - Trigger: `data-open`, `data-disabled`, `data-invalid`, `data-required` -- Overlay container: `data-open` (internal, not exposed to users) +- Popover: `data-open` (provided via popover slot, consumer applies to their container) - Option: `data-selected`, `data-disabled`, `data-focused` - Interactive: `data-focus-visible`, `data-hovered`, `data-pressed` **Slot customization** (using `slots` prop): -- `slots.trigger`, `slots.trigger.value` - Customize trigger/value -- `slots.list`, `slots.listbox` - Customize list +- `slots.trigger`, `slots['trigger.value']` — Customize trigger/value (namespaced keys apply to nested slots) +- `slots.popover` — Customize overlay container (consumer handles portal rendering) +- `slots.listbox` — Customize list ### TypeScript Slot Safety ```ts export type SelectSlots = { trigger: SelectTriggerSlotProps; + label: React.HTMLAttributes; 'trigger.value': SelectValueSlotProps; - value: SelectValueSlotProps; - list: SelectListSlotProps; + value: SelectValueSlotProps; // Standalone value slot for advanced layouts; typically use 'trigger.value' + popover: SelectPopoverSlotProps; listbox: SelectListSlotProps; description: React.HTMLAttributes; - errorMessage: React.HTMLAttributes; + error: React.HTMLAttributes; }; export type PropsFromSelectSlot = SelectSlots[S]; ``` +**Note on `value` slot**: The `value` slot is provided for advanced layouts where the value is rendered outside the trigger. Most consumers should use `'trigger.value'` for nested customization within the trigger button. + **Usage**: ```tsx function MyTrigger(props: PropsFromSelectSlot<'trigger'> & { tone?: 'neutral' | 'danger' }) { @@ -219,7 +286,7 @@ function MyTrigger(props: PropsFromSelectSlot<'trigger'> & { tone?: 'neutral' | // Use `satisfies` (TypeScript 4.9+) for compile-time slot validation const mySlots = { trigger: MyTrigger, - list: MyListBox + listbox: MyListBox } satisfies Partial>>; ``` @@ -236,67 +303,64 @@ const mySlots = { ## Usage Examples ```tsx -import { Select, SelectOption } from '@bento/select'; +import { Select } from '@bento/select'; import { Button } from '@bento/button'; import { Text } from '@bento/text'; import { ListBox, ListBoxItem } from '@bento/listbox'; +import { Item } from '@react-stately/collections'; function SelectValue({ placeholder, selectedItem, ...props }) { return {selectedItem ? selectedItem.textValue : placeholder}; } -// Basic usage +// Basic usage with ListBoxItem -// Using ListBoxItem directly - - - Apple - Orange + + {item => {item.name}} -// Dynamic items -const items = [{ id: 'apple', name: 'Apple' }, { id: 'orange', name: 'Orange' }]; - - - {item => {item.name}} + + Option 1 + Choose an option + This field is required ``` ## Implementation Rationale -**Key decisions**: -- **Container-based composition**: Uses `@bento/container` with `slots` prop for cleaner architecture—no manual child traversal or `cloneElement` recursion -- **Build on ListBox**: Reuse ARIA listbox patterns, share state via `ListStateContext` -- **Coordinator pattern**: Select manages overlay positioning, form integration, and state internally; slot children are stateless -- **Explicit ARIA attributes**: Ensures correct `role="combobox"` and validation ARIA (`aria-disabled`, `aria-invalid`, `aria-required`) regardless of trigger implementation -- **Form integration**: Uses React Aria's `HiddenSelect` for client-side rendering; custom SSR fallback ensures form submission works server-side -- **Safe style merging**: Converts `CSSStyleDeclaration` to plain objects to avoid React DOM warnings; preserves numeric values for auto-unit conversion -- **SelectOption convenience**: Maps `value` → `id` (users can use `ListBoxItem` directly) -- **Typed slots**: Interfaces extend component interfaces for type safety -- **Description/error slots**: Optional, enforce rendering order (users can handle themselves) - -**Architecture benefits**: -- Reduced complexity: ~200 fewer lines vs manual child enhancement -- Cleaner separation: Container handles slot composition; Select handles coordination -- Easier maintenance: Slot logic centralized in Container; Select focuses on state/ARIA - -**Future optimizations**: Virtual scrolling, debounced typeahead, lazy section loading, React.memo for options. - -**Testing focus**: Keyboard navigation, screen readers, RTL, form submission, positioning edge cases, SSR compatibility. \ No newline at end of file +**Why this architecture**: +- **RAC contexts over manual coordination**: Eliminates ~100 LOC of race condition guards, `shouldCloseOnInteractOutside`, and `e.stopPropagation()` calls +- **Container-based slots**: No `cloneElement` recursion or manual child traversal—cleaner and more maintainable +- **Build on existing primitives**: Reuses `@bento/listbox` patterns via `ListStateContext` instead of reimplementing +- **Single-selection focus**: React Aria's `useSelect` is single-only; multi-select users should use `ListBox` directly +- **Flexible overlay composition**: Like React Aria's `DialogTrigger`/`TooltipTrigger`—Select orchestrates, consumers compose portals/animations +- **Form integration**: `HiddenSelect` component when `name` provided—enables native form submission and autofill + +**Trade-offs accepted**: +- Manual portal management (flexibility over convenience—future `@bento/popover` will simplify) +- Value component boilerplate (precision over magic—future `@bento/select-value` could help) +- Fixed positioning defaults (simplicity over customization—future props could expose options) + +**Future work**: Virtual scrolling, debounced typeahead, `@bento/popover` primitive, `@bento/select-value` component, customizable positioning props, lazy section loading. \ No newline at end of file