@dxos/react-ui-list 0.8.4-main.d05673bc65 → 0.8.4-main.dfabb4ec29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/lib/browser/index.mjs +778 -121
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +778 -121
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  8. package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
  9. package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  11. package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
  12. package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
  13. package/dist/types/src/components/Combobox/Combobox.d.ts +105 -0
  14. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -0
  15. package/dist/types/src/components/Combobox/Combobox.stories.d.ts +12 -0
  16. package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Combobox/index.d.ts +2 -0
  18. package/dist/types/src/components/Combobox/index.d.ts.map +1 -0
  19. package/dist/types/src/components/List/List.d.ts +15 -4
  20. package/dist/types/src/components/List/List.d.ts.map +1 -1
  21. package/dist/types/src/components/List/List.stories.d.ts +2 -2
  22. package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/List/ListItem.d.ts +7 -7
  24. package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
  25. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  26. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  27. package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
  28. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
  29. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
  30. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Listbox/index.d.ts +2 -0
  32. package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
  33. package/dist/types/src/components/Picker/Picker.d.ts +49 -0
  34. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
  35. package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
  36. package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
  37. package/dist/types/src/components/Picker/context.d.ts +29 -0
  38. package/dist/types/src/components/Picker/context.d.ts.map +1 -0
  39. package/dist/types/src/components/Picker/index.d.ts +3 -0
  40. package/dist/types/src/components/Picker/index.d.ts.map +1 -0
  41. package/dist/types/src/components/RowList/RowList.d.ts +61 -0
  42. package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
  43. package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
  44. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
  45. package/dist/types/src/components/RowList/index.d.ts +3 -0
  46. package/dist/types/src/components/RowList/index.d.ts.map +1 -0
  47. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  48. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  49. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  50. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  51. package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
  52. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  53. package/dist/types/src/components/index.d.ts +4 -0
  54. package/dist/types/src/components/index.d.ts.map +1 -1
  55. package/dist/types/src/util/path.d.ts.map +1 -1
  56. package/dist/types/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +20 -20
  58. package/src/components/Accordion/Accordion.stories.tsx +3 -3
  59. package/src/components/Accordion/AccordionItem.tsx +1 -4
  60. package/src/components/Combobox/Combobox.stories.tsx +60 -0
  61. package/src/components/Combobox/Combobox.tsx +387 -0
  62. package/src/components/Combobox/index.ts +5 -0
  63. package/src/components/List/List.stories.tsx +5 -5
  64. package/src/components/List/List.tsx +12 -8
  65. package/src/components/List/ListItem.tsx +28 -28
  66. package/src/components/List/ListRoot.tsx +1 -1
  67. package/src/components/List/testing.ts +4 -4
  68. package/src/components/Listbox/Listbox.stories.tsx +48 -0
  69. package/src/components/Listbox/Listbox.tsx +201 -0
  70. package/src/components/Listbox/index.ts +5 -0
  71. package/src/components/Picker/Picker.stories.tsx +131 -0
  72. package/src/components/Picker/Picker.tsx +368 -0
  73. package/src/components/Picker/context.ts +43 -0
  74. package/src/components/Picker/index.ts +6 -0
  75. package/src/components/RowList/RowList.stories.tsx +163 -0
  76. package/src/components/RowList/RowList.tsx +353 -0
  77. package/src/components/RowList/index.ts +6 -0
  78. package/src/components/Tree/Tree.stories.tsx +4 -5
  79. package/src/components/Tree/TreeItem.tsx +13 -9
  80. package/src/components/Tree/TreeItemHeading.tsx +1 -2
  81. package/src/components/Tree/TreeItemToggle.tsx +3 -3
  82. package/src/components/Tree/testing.ts +5 -5
  83. package/src/components/index.ts +4 -0
@@ -2,22 +2,22 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
6
- import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
7
- import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
8
5
  import {
9
6
  type Edge,
10
7
  attachClosestEdge,
11
8
  extractClosestEdge,
12
9
  } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
10
+ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
11
+ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
12
+ import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
13
13
  import { createContext } from '@radix-ui/react-context';
14
14
  import { Slot } from '@radix-ui/react-slot';
15
15
  import React, {
16
16
  type ComponentProps,
17
17
  type HTMLAttributes,
18
- type MutableRefObject,
19
18
  type PropsWithChildren,
20
19
  type ReactNode,
20
+ RefObject,
21
21
  useEffect,
22
22
  useRef,
23
23
  useState,
@@ -47,22 +47,22 @@ export type ItemDragState =
47
47
  container: HTMLElement;
48
48
  }
49
49
  | {
50
- type: 'w-dragging';
50
+ type: 'is-dragging';
51
51
  }
52
52
  | {
53
- type: 'w-dragging-over';
53
+ type: 'is-dragging-over';
54
54
  closestEdge: Edge | null;
55
55
  };
56
56
 
57
57
  export const idle: ItemDragState = { type: 'idle' };
58
58
 
59
59
  const stateStyles: { [Key in ItemDragState['type']]?: HTMLAttributes<HTMLDivElement>['className'] } = {
60
- 'w-dragging': 'opacity-50',
60
+ 'is-dragging': 'opacity-50',
61
61
  };
62
62
 
63
63
  type ListItemContext<T extends ListItemRecord> = {
64
64
  item: T;
65
- dragHandleRef: MutableRefObject<HTMLElement | null>;
65
+ dragHandleRef: RefObject<HTMLButtonElement | null>;
66
66
  };
67
67
 
68
68
  /**
@@ -100,12 +100,12 @@ export const ListItem = <T extends ListItemRecord>({
100
100
  }: ListItemProps<T>) => {
101
101
  const Comp = asChild ? Slot : 'div';
102
102
  const { isItem, readonly, dragPreview, setState: setRootState } = useListContext(LIST_ITEM_NAME);
103
- const ref = useRef<HTMLDivElement | null>(null);
104
- const dragHandleRef = useRef<HTMLElement | null>(null);
103
+ const rootRef = useRef<HTMLDivElement | null>(null);
104
+ const dragHandleRef = useRef<HTMLButtonElement | null>(null);
105
105
  const [state, setState] = useState<ItemDragState>(idle);
106
106
 
107
107
  useEffect(() => {
108
- const element = ref.current;
108
+ const element = rootRef.current;
109
109
  invariant(element);
110
110
  return combine(
111
111
  //
@@ -135,8 +135,8 @@ export const ListItem = <T extends ListItemRecord>({
135
135
  }
136
136
  : undefined,
137
137
  onDragStart: () => {
138
- setState({ type: 'w-dragging' });
139
- setRootState({ type: 'w-dragging', item });
138
+ setState({ type: 'is-dragging' });
139
+ setRootState({ type: 'is-dragging', item });
140
140
  },
141
141
  onDrop: () => {
142
142
  setState(idle);
@@ -158,7 +158,7 @@ export const ListItem = <T extends ListItemRecord>({
158
158
  getIsSticky: () => true,
159
159
  onDragEnter: ({ self }) => {
160
160
  const closestEdge = extractClosestEdge(self.data);
161
- setState({ type: 'w-dragging-over', closestEdge });
161
+ setState({ type: 'is-dragging-over', closestEdge });
162
162
  },
163
163
  onDragLeave: () => {
164
164
  setState(idle);
@@ -166,10 +166,10 @@ export const ListItem = <T extends ListItemRecord>({
166
166
  onDrag: ({ self }) => {
167
167
  const closestEdge = extractClosestEdge(self.data);
168
168
  setState((current) => {
169
- if (current.type === 'w-dragging-over' && current.closestEdge === closestEdge) {
169
+ if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
170
170
  return current;
171
171
  }
172
- return { type: 'w-dragging-over', closestEdge };
172
+ return { type: 'is-dragging-over', closestEdge };
173
173
  });
174
174
  },
175
175
  onDrop: () => {
@@ -182,15 +182,15 @@ export const ListItem = <T extends ListItemRecord>({
182
182
  return (
183
183
  <ListItemProvider item={item} dragHandleRef={dragHandleRef}>
184
184
  <Comp
185
- ref={ref}
185
+ {...props}
186
186
  role='listitem'
187
187
  aria-selected={selected}
188
- className={mx('relative dx-selected', classNames, stateStyles[state.type])}
189
- {...props}
188
+ className={mx('relative p-1 dx-selected dx-hover', classNames, stateStyles[state.type])}
189
+ ref={rootRef}
190
190
  >
191
191
  {children}
192
192
  </Comp>
193
- {state.type === 'w-dragging-over' && state.closestEdge && (
193
+ {state.type === 'is-dragging-over' && state.closestEdge && (
194
194
  <NaturalListItem.DropIndicator edge={state.closestEdge} />
195
195
  )}
196
196
  </ListItemProvider>
@@ -237,12 +237,12 @@ export const ListItemDeleteButton = ({
237
237
  const { t } = useTranslation(osTranslations);
238
238
  return (
239
239
  <IconButton
240
- iconOnly
241
- variant='ghost'
242
240
  {...props}
243
- icon={icon}
241
+ variant='ghost'
244
242
  disabled={isDisabled}
245
- label={label ?? t('delete label')}
243
+ icon={icon}
244
+ iconOnly
245
+ label={label ?? t('delete.label')}
246
246
  classNames={[classNames, autoHide && disabled && 'hidden']}
247
247
  />
248
248
  );
@@ -253,12 +253,12 @@ export const ListItemDragHandle = ({ disabled }: Pick<IconButtonProps, 'disabled
253
253
  const { t } = useTranslation(osTranslations);
254
254
  return (
255
255
  <IconButton
256
- iconOnly
257
256
  variant='ghost'
258
- label={t('drag handle label')}
259
- ref={dragHandleRef as any}
260
- icon='ph--dots-six-vertical--regular'
261
257
  disabled={disabled}
258
+ icon='ph--dots-six-vertical--regular'
259
+ iconOnly
260
+ label={t('drag-handle.label')}
261
+ ref={dragHandleRef}
262
262
  />
263
263
  );
264
264
  };
@@ -2,9 +2,9 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
5
  import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
7
6
  import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index';
7
+ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
8
8
  import { createContext } from '@radix-ui/react-context';
9
9
  import React, { type ReactNode, useCallback, useEffect, useState } from 'react';
10
10
 
@@ -5,7 +5,7 @@
5
5
  import * as Schema from 'effect/Schema';
6
6
 
7
7
  import { Obj } from '@dxos/echo';
8
- import { faker } from '@dxos/random';
8
+ import { random } from '@dxos/random';
9
9
 
10
10
  export const TestItemSchema = Schema.Struct({
11
11
  id: Obj.ID,
@@ -21,10 +21,10 @@ export const TestList = Schema.Struct({
21
21
  export type TestList = Schema.Schema.Type<typeof TestList>;
22
22
 
23
23
  export const createList = (n = 10): TestList => ({
24
- items: faker.helpers.multiple(
24
+ items: random.helpers.multiple(
25
25
  () => ({
26
- id: faker.string.uuid(),
27
- name: faker.commerce.productName(),
26
+ id: random.string.uuid(),
27
+ name: random.commerce.productName(),
28
28
  }),
29
29
  { count: n },
30
30
  ),
@@ -0,0 +1,48 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useState } from 'react';
7
+
8
+ import { random } from '@dxos/random';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { Listbox } from './Listbox';
12
+
13
+ random.seed(1234);
14
+
15
+ type StoryItem = { value: string; label: string };
16
+
17
+ const options: StoryItem[] = random.helpers.multiple(
18
+ () => ({ value: random.string.uuid(), label: random.commerce.productName() }) satisfies StoryItem,
19
+ { count: 16 },
20
+ );
21
+
22
+ const DefaultStory = () => {
23
+ const [selectedValue, setSelectedValue] = useState<string>();
24
+
25
+ return (
26
+ <Listbox.Root value={selectedValue} onValueChange={setSelectedValue}>
27
+ {options.map((option) => (
28
+ <Listbox.Option key={option.value} value={option.value}>
29
+ <Listbox.OptionLabel>{option.label}</Listbox.OptionLabel>
30
+ <Listbox.OptionIndicator />
31
+ </Listbox.Option>
32
+ ))}
33
+ </Listbox.Root>
34
+ );
35
+ };
36
+
37
+ const meta = {
38
+ title: 'ui/react-ui-list/Listbox',
39
+ component: Listbox.Root,
40
+ render: DefaultStory,
41
+ decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'p-2' })],
42
+ } satisfies Meta<typeof Listbox.Root>;
43
+
44
+ export default meta;
45
+
46
+ type Story = StoryObj<typeof meta>;
47
+
48
+ export const Default: Story = {};
@@ -0,0 +1,201 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ // `Listbox` — single-select listbox with optional check indicator.
6
+ //
7
+ // Internally composes `RowList` from this same package: `Listbox.Root`
8
+ // is `RowList.Root` + `RowList.Content`, and `Listbox.Option` is `Row`.
9
+ // The compound API (`Listbox.Root` / `.Option` / `.OptionLabel` /
10
+ // `.OptionIndicator`) is preserved so existing call sites keep working.
11
+ //
12
+ // Why this shape (when `RowList` is right there): `Listbox` historically
13
+ // rendered as a flat `<ul>` with no `ScrollArea` wrapper — it's used
14
+ // inside dialogs / popovers / panels that own their own scroll. Skipping
15
+ // `RowList.Viewport` keeps that behaviour. If a caller wants the styled
16
+ // scroll surface, they wrap the listbox in `RowList.Viewport` themselves.
17
+
18
+ import { type Scope, createContextScope } from '@radix-ui/react-context';
19
+ import React, { type ComponentPropsWithRef, type ReactNode, forwardRef } from 'react';
20
+
21
+ import { Icon, type IconProps, type ThemedClassName } from '@dxos/react-ui';
22
+ import { mx } from '@dxos/ui-theme';
23
+
24
+ import { Row, RowList, createRowListScope, useRowListSelection } from '../RowList';
25
+
26
+ const commandItem = 'flex items-center overflow-hidden';
27
+
28
+ const LISTBOX_NAME = 'Listbox';
29
+ const LISTBOX_OPTION_NAME = 'ListboxOption';
30
+ const LISTBOX_OPTION_LABEL_NAME = 'ListboxOptionLabel';
31
+ const LISTBOX_OPTION_INDICATOR_NAME = 'ListboxOptionIndicator';
32
+
33
+ //
34
+ // Context — only used to thread `value` through to `OptionIndicator` so
35
+ // it knows whether to show the checkmark. Selection state itself lives
36
+ // in `RowList`'s context (we delegate to it via composition).
37
+ //
38
+
39
+ type ListboxScopedProps<P> = P & { __listboxScope?: Scope };
40
+ type ListboxOptionScopedProps<P> = P & { __listboxOptionScope?: Scope };
41
+
42
+ const [createListboxContext, createListboxScope] = createContextScope(LISTBOX_NAME, [createRowListScope]);
43
+ const [createListboxOptionContext, createListboxOptionScope] = createContextScope(LISTBOX_OPTION_NAME, [
44
+ createListboxScope,
45
+ ]);
46
+
47
+ type ListboxOptionContextValue = {
48
+ value: string;
49
+ isSelected: boolean;
50
+ };
51
+
52
+ const [ListboxOptionProvider, useListboxOptionContext] =
53
+ createListboxOptionContext<ListboxOptionContextValue>(LISTBOX_OPTION_NAME);
54
+
55
+ //
56
+ // Root — composes `RowList.Root` + `RowList.Content`.
57
+ //
58
+ // Maps the public `value` / `onValueChange` API to RowList's
59
+ // `selectedId` / `onSelectChange` so existing consumers don't change.
60
+ //
61
+
62
+ type ListboxRootProps = ThemedClassName<ComponentPropsWithRef<'ul'>> & {
63
+ value?: string;
64
+ defaultValue?: string;
65
+ onValueChange?: (value: string) => void;
66
+ /** Reserved — autoFocus on mount. RowList's focus-on-entry covers the typical case. */
67
+ autoFocus?: boolean;
68
+ };
69
+
70
+ const ListboxRoot = forwardRef<HTMLUListElement, ListboxRootProps>(
71
+ (props: ListboxScopedProps<ListboxRootProps>, forwardedRef) => {
72
+ const {
73
+ __listboxScope: _scope,
74
+ children,
75
+ classNames,
76
+ value,
77
+ defaultValue,
78
+ onValueChange,
79
+ autoFocus: _autoFocus,
80
+ ...rootProps
81
+ } = props;
82
+
83
+ return (
84
+ <RowList.Root selectedId={value} defaultSelectedId={defaultValue} onSelectChange={onValueChange}>
85
+ <RowList.Content {...rootProps} classNames={mx('w-full', classNames)} ref={forwardedRef}>
86
+ {children}
87
+ </RowList.Content>
88
+ </RowList.Root>
89
+ );
90
+ },
91
+ );
92
+
93
+ ListboxRoot.displayName = LISTBOX_NAME;
94
+
95
+ //
96
+ // Option — composes `Row`. Adds the listbox-specific styling and
97
+ // publishes `{ value, isSelected }` so `OptionIndicator` can render a
98
+ // checkmark.
99
+ //
100
+
101
+ type ListboxOptionProps = ThemedClassName<ComponentPropsWithRef<'li'>> & {
102
+ value: string;
103
+ };
104
+
105
+ const ListboxOption = forwardRef<HTMLLIElement, ListboxOptionProps>(
106
+ (props: ListboxScopedProps<ListboxOptionProps>, forwardedRef) => {
107
+ const { __listboxScope, children, classNames, value, ...rootProps } = props;
108
+
109
+ // Selection state is read inside `ListboxOptionProviderHost` via
110
+ // the public `useRowListSelection` hook and republished on the
111
+ // listbox-option scope so `OptionIndicator` can render its
112
+ // checkmark synchronously.
113
+ return (
114
+ <Row
115
+ id={value}
116
+ {...rootProps}
117
+ classNames={mx('dx-focus-ring rounded-xs', commandItem, classNames)}
118
+ ref={forwardedRef}
119
+ >
120
+ <ListboxOptionProviderHost value={value}>{children}</ListboxOptionProviderHost>
121
+ </Row>
122
+ );
123
+ },
124
+ );
125
+
126
+ ListboxOption.displayName = LISTBOX_OPTION_NAME;
127
+
128
+ // Reads selection state from RowList's context (via `useRowListSelection`)
129
+ // and publishes it on the listbox-option scope so `OptionIndicator` can
130
+ // render its checkmark. Tiny adapter — keeps Listbox's public option API
131
+ // intact while delegating the actual state to RowList.
132
+ const ListboxOptionProviderHost = ({
133
+ value,
134
+ children,
135
+ }: ListboxScopedProps<{ value: string; children?: ReactNode }>) => {
136
+ const isSelected = useRowListSelection(value);
137
+ return (
138
+ <ListboxOptionProvider scope={undefined} value={value} isSelected={isSelected}>
139
+ {children}
140
+ </ListboxOptionProvider>
141
+ );
142
+ };
143
+
144
+ //
145
+ // OptionLabel
146
+ //
147
+
148
+ const ListboxOptionLabel = forwardRef<HTMLDivElement, ThemedClassName<ComponentPropsWithRef<'div'>>>(
149
+ ({ children, classNames, ...rootProps }, forwardedRef) => {
150
+ return (
151
+ <span {...rootProps} className={mx('grow truncate', classNames)} ref={forwardedRef}>
152
+ {children}
153
+ </span>
154
+ );
155
+ },
156
+ );
157
+
158
+ ListboxOptionLabel.displayName = LISTBOX_OPTION_LABEL_NAME;
159
+
160
+ //
161
+ // OptionIndicator — checkmark for the selected option.
162
+ //
163
+ // Reads `isSelected` from the option context. The visual indicator is
164
+ // also covered by `dx-selected` on the row, so the checkmark is purely
165
+ // confirmatory.
166
+ //
167
+
168
+ type ListboxOptionIndicatorProps = Omit<IconProps, 'icon'> & Partial<Pick<IconProps, 'icon'>>;
169
+
170
+ const ListboxOptionIndicator = forwardRef<SVGSVGElement, ListboxOptionIndicatorProps>(
171
+ (props: ListboxOptionScopedProps<ListboxOptionIndicatorProps>, forwardedRef) => {
172
+ const { __listboxOptionScope, classNames, ...rootProps } = props;
173
+ const { isSelected } = useListboxOptionContext(LISTBOX_OPTION_INDICATOR_NAME, __listboxOptionScope);
174
+
175
+ return (
176
+ <Icon
177
+ icon='ph--check--regular'
178
+ {...rootProps}
179
+ classNames={mx(!isSelected && 'invisible', classNames)}
180
+ ref={forwardedRef}
181
+ />
182
+ );
183
+ },
184
+ );
185
+
186
+ ListboxOptionIndicator.displayName = LISTBOX_OPTION_INDICATOR_NAME;
187
+
188
+ //
189
+ // Listbox
190
+ //
191
+
192
+ export const Listbox = {
193
+ Root: ListboxRoot,
194
+ Option: ListboxOption,
195
+ OptionLabel: ListboxOptionLabel,
196
+ OptionIndicator: ListboxOptionIndicator,
197
+ };
198
+
199
+ export { createListboxScope };
200
+
201
+ export type { ListboxRootProps, ListboxOptionProps, ListboxScopedProps };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ export * from './Listbox';
@@ -0,0 +1,131 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // `Picker` stories — exercises the generic listbox-with-input compound
6
+ // in isolation. The compound is search-agnostic; the `Filtering` story
7
+ // shows how a caller wires in-memory filtering on top, and the
8
+ // `WithDisabled` story demonstrates the registry's skip-disabled
9
+ // behaviour during keyboard nav.
10
+ //
11
+ // For a search-themed wrapper with debounced query / auto-select-first /
12
+ // fuzzy filtering, see `SearchList` in `@dxos/react-ui-search`.
13
+
14
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
15
+ import React, { useMemo, useState } from 'react';
16
+
17
+ import { random } from '@dxos/random';
18
+ import { Column, ScrollArea } from '@dxos/react-ui';
19
+ import { withTheme } from '@dxos/react-ui/testing';
20
+
21
+ import { Picker } from './Picker';
22
+
23
+ random.seed(1234);
24
+
25
+ type StoryItem = { value: string; label: string };
26
+
27
+ const allItems: StoryItem[] = Array.from({ length: 24 }, (_, i) => ({
28
+ value: `item-${i}`,
29
+ label: random.commerce.productName(),
30
+ })).sort((a, b) => a.label.localeCompare(b.label));
31
+
32
+ //
33
+ // Single configurable story. Each variant exported below sets a
34
+ // different combination of props — keeps the per-variant code to one
35
+ // line at the bottom of the file. See `AUDIT.md` §11 corrections for
36
+ // the convention.
37
+ //
38
+
39
+ type StoryArgs = {
40
+ /** Items to render. Defaults to the full 24-item catalog. */
41
+ items?: StoryItem[];
42
+ /** When true, the input is controlled and filters the rendered set. */
43
+ controlled?: boolean;
44
+ /** Indices into `items` that should render disabled. */
45
+ disabledIndices?: number[];
46
+ };
47
+
48
+ const DefaultStory = ({ items = allItems, controlled = false, disabledIndices = [] }: StoryArgs = {}) => {
49
+ const [picked, setPicked] = useState<string | undefined>();
50
+ const [query, setQuery] = useState('');
51
+
52
+ const visible = useMemo(
53
+ () =>
54
+ controlled
55
+ ? items
56
+ .map((item, originalIndex) => ({ item, originalIndex }))
57
+ .filter(({ item }) => item.label.toLowerCase().includes(query.toLowerCase()))
58
+ : items.map((item, originalIndex) => ({ item, originalIndex })),
59
+ [controlled, items, query],
60
+ );
61
+
62
+ return (
63
+ <Column.Root gutter='sm' classNames='w-[24rem] border border-separator rounded-md py-form-gap'>
64
+ <Picker.Root>
65
+ <Column.Center>
66
+ <Picker.Input
67
+ autoFocus
68
+ placeholder={controlled ? 'Filter…' : '↑/↓ to navigate, Enter to pick'}
69
+ {...(controlled && { value: query, onValueChange: setQuery })}
70
+ />
71
+ </Column.Center>
72
+ <ScrollArea.Root classNames='max-h-[20rem] py-form-gap' thin>
73
+ <ScrollArea.Viewport>
74
+ <ul role='listbox' className='flex flex-col'>
75
+ {visible.map(({ item, originalIndex }) => {
76
+ const disabled = disabledIndices.includes(originalIndex);
77
+ return (
78
+ <Picker.Item
79
+ key={item.value}
80
+ value={item.value}
81
+ disabled={disabled}
82
+ onSelect={() => setPicked(item.value)}
83
+ >
84
+ {item.label}
85
+ {disabled && ' (disabled)'}
86
+ </Picker.Item>
87
+ );
88
+ })}
89
+ {controlled && visible.length === 0 && (
90
+ <li role='status' className='px-2 py-1 text-description italic'>
91
+ No matches
92
+ </li>
93
+ )}
94
+ </ul>
95
+ </ScrollArea.Viewport>
96
+ </ScrollArea.Root>
97
+ </Picker.Root>
98
+ <Column.Center classNames='text-sm text-description'>
99
+ Picked: <span className='font-mono'>{picked ?? '—'}</span>
100
+ </Column.Center>
101
+ </Column.Root>
102
+ );
103
+ };
104
+
105
+ const meta = {
106
+ title: 'ui/react-ui-list/Picker',
107
+ render: (args) => <DefaultStory {...args} />,
108
+ decorators: [withTheme()],
109
+ parameters: {
110
+ layout: 'centered',
111
+ },
112
+ } satisfies Meta<StoryArgs>;
113
+
114
+ export default meta;
115
+
116
+ type Story = StoryObj<StoryArgs>;
117
+
118
+ export const Default: Story = {};
119
+
120
+ export const Filtering: Story = {
121
+ args: {
122
+ controlled: true,
123
+ },
124
+ };
125
+
126
+ export const WithDisabled: Story = {
127
+ args: {
128
+ items: allItems.slice(0, 8),
129
+ disabledIndices: [2, 5],
130
+ },
131
+ };