@dxos/react-ui-searchlist 0.8.4-main.fd6878d → 0.8.4-main.fffef41

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 (58) hide show
  1. package/dist/lib/browser/index.mjs +258 -139
  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 +258 -139
  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/Combobox/Combobox.d.ts +44 -0
  8. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -0
  9. package/dist/types/src/components/Combobox/Combobox.stories.d.ts +21 -0
  10. package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -0
  11. package/dist/types/src/components/Combobox/index.d.ts +2 -0
  12. package/dist/types/src/components/Combobox/index.d.ts.map +1 -0
  13. package/dist/types/src/components/Listbox/Listbox.d.ts +31 -0
  14. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
  15. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +21 -0
  16. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Listbox/index.d.ts +2 -0
  18. package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
  19. package/dist/types/src/components/{SearchList.d.ts → SearchList/SearchList.d.ts} +7 -27
  20. package/dist/types/src/components/SearchList/SearchList.d.ts.map +1 -0
  21. package/dist/types/src/components/SearchList/SearchList.stories.d.ts +25 -0
  22. package/dist/types/src/components/SearchList/SearchList.stories.d.ts.map +1 -0
  23. package/dist/types/src/components/SearchList/index.d.ts +2 -0
  24. package/dist/types/src/components/SearchList/index.d.ts.map +1 -0
  25. package/dist/types/src/components/index.d.ts +2 -0
  26. package/dist/types/src/components/index.d.ts.map +1 -1
  27. package/dist/types/src/index.d.ts +0 -1
  28. package/dist/types/src/index.d.ts.map +1 -1
  29. package/dist/types/src/translations.d.ts +3 -1
  30. package/dist/types/src/translations.d.ts.map +1 -1
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +16 -14
  33. package/src/components/Combobox/Combobox.stories.tsx +57 -0
  34. package/src/components/Combobox/Combobox.tsx +335 -0
  35. package/src/components/Combobox/index.ts +5 -0
  36. package/src/components/Listbox/Listbox.stories.tsx +53 -0
  37. package/src/components/Listbox/Listbox.tsx +209 -0
  38. package/src/components/Listbox/index.ts +5 -0
  39. package/src/components/SearchList/SearchList.stories.tsx +64 -0
  40. package/src/components/SearchList/SearchList.tsx +163 -0
  41. package/src/components/SearchList/index.ts +5 -0
  42. package/src/components/index.ts +2 -0
  43. package/src/index.ts +0 -1
  44. package/src/translations.ts +3 -1
  45. package/dist/types/src/components/SearchList.d.ts.map +0 -1
  46. package/dist/types/src/components/SearchList.stories.d.ts +0 -15
  47. package/dist/types/src/components/SearchList.stories.d.ts.map +0 -1
  48. package/dist/types/src/composites/PopoverCombobox.d.ts +0 -32
  49. package/dist/types/src/composites/PopoverCombobox.d.ts.map +0 -1
  50. package/dist/types/src/composites/PopoverCombobox.stories.d.ts +0 -28
  51. package/dist/types/src/composites/PopoverCombobox.stories.d.ts.map +0 -1
  52. package/dist/types/src/composites/index.d.ts +0 -2
  53. package/dist/types/src/composites/index.d.ts.map +0 -1
  54. package/src/components/SearchList.stories.tsx +0 -47
  55. package/src/components/SearchList.tsx +0 -250
  56. package/src/composites/PopoverCombobox.stories.tsx +0 -44
  57. package/src/composites/PopoverCombobox.tsx +0 -208
  58. package/src/composites/index.ts +0 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-searchlist",
3
- "version": "0.8.4-main.fd6878d",
3
+ "version": "0.8.4-main.fffef41",
4
4
  "description": "A themed ⌘K-style combobox component, triggered by a button (or keyboard shortcut), where values are queried only within the invoked modal.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -25,27 +25,29 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
+ "@fluentui/react-tabster": "^9.24.2",
28
29
  "@preact-signals/safe-react": "^0.9.0",
30
+ "@radix-ui/react-compose-refs": "1.1.1",
29
31
  "@radix-ui/react-context": "1.1.1",
30
32
  "@radix-ui/react-use-controllable-state": "1.1.0",
31
33
  "cmdk": "^0.2.0"
32
34
  },
33
35
  "devDependencies": {
34
- "@types/react": "~18.2.0",
35
- "@types/react-dom": "~18.2.0",
36
- "react": "~18.2.0",
37
- "react-dom": "~18.2.0",
38
- "vite": "5.4.7",
39
- "@dxos/random": "0.8.4-main.fd6878d",
40
- "@dxos/react-ui": "0.8.4-main.fd6878d",
41
- "@dxos/react-ui-theme": "0.8.4-main.fd6878d",
42
- "@dxos/storybook-utils": "0.8.4-main.fd6878d"
36
+ "@types/react": "~19.2.2",
37
+ "@types/react-dom": "~19.2.2",
38
+ "react": "~19.2.0",
39
+ "react-dom": "~19.2.0",
40
+ "vite": "7.1.9",
41
+ "@dxos/random": "0.8.4-main.fffef41",
42
+ "@dxos/react-ui": "0.8.4-main.fffef41",
43
+ "@dxos/react-ui-theme": "0.8.4-main.fffef41",
44
+ "@dxos/storybook-utils": "0.8.4-main.fffef41"
43
45
  },
44
46
  "peerDependencies": {
45
- "react": "~18.2.0",
46
- "react-dom": "~18.2.0",
47
- "@dxos/react-ui-theme": "0.8.4-main.fd6878d",
48
- "@dxos/react-ui": "0.8.4-main.fd6878d"
47
+ "react": "^19.0.0",
48
+ "react-dom": "^19.0.0",
49
+ "@dxos/react-ui": "0.8.4-main.fffef41",
50
+ "@dxos/react-ui-theme": "0.8.4-main.fffef41"
49
51
  },
50
52
  "publishConfig": {
51
53
  "access": "public"
@@ -0,0 +1,57 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React from 'react';
7
+
8
+ import { faker } from '@dxos/random';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { translations } from '../../translations';
12
+
13
+ import { Combobox } from './Combobox';
14
+
15
+ faker.seed(1234);
16
+
17
+ const items = faker.helpers.uniqueArray(faker.commerce.productName, 16).sort();
18
+
19
+ const DefaultStory = () => {
20
+ return (
21
+ <Combobox.Root
22
+ placeholder='Nothing selected'
23
+ onValueChange={(value) => {
24
+ console.log('[Combobox.Root.onValueChange]', value);
25
+ }}
26
+ >
27
+ <Combobox.Trigger />
28
+ <Combobox.Content filter={(value, search) => (value.includes(search) ? 1 : 0)}>
29
+ <Combobox.Input placeholder='Search...' />
30
+ <Combobox.List>
31
+ {items.map((value) => (
32
+ <Combobox.Item key={value}>{value}</Combobox.Item>
33
+ ))}
34
+ </Combobox.List>
35
+ <Combobox.Arrow />
36
+ </Combobox.Content>
37
+ </Combobox.Root>
38
+ );
39
+ };
40
+
41
+ const meta = {
42
+ title: 'ui/react-ui-searchlist/Combobox',
43
+ component: Combobox.Root as any,
44
+ render: DefaultStory,
45
+ decorators: [withTheme, withLayout({ container: 'column', classNames: 'p-2' })],
46
+ parameters: {
47
+ translations,
48
+ },
49
+ } satisfies Meta<typeof DefaultStory>;
50
+
51
+ export default meta;
52
+
53
+ type Story = StoryObj<typeof meta>;
54
+
55
+ export const Default: Story = {
56
+ args: {},
57
+ };
@@ -0,0 +1,335 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+ import { useControllableState } from '@radix-ui/react-use-controllable-state';
7
+ import React, { type PropsWithChildren, forwardRef, useCallback } from 'react';
8
+
9
+ import {
10
+ Button,
11
+ type ButtonProps,
12
+ Icon,
13
+ Popover,
14
+ type PopoverArrowProps,
15
+ type PopoverContentProps,
16
+ type PopoverVirtualTriggerProps,
17
+ } from '@dxos/react-ui';
18
+ import { useId } from '@dxos/react-ui';
19
+ import { mx, staticPlaceholderText } from '@dxos/react-ui-theme';
20
+
21
+ import {
22
+ SearchList,
23
+ type SearchListContentProps,
24
+ type SearchListEmptyProps,
25
+ type SearchListInputProps,
26
+ type SearchListItemProps,
27
+ type SearchListRootProps,
28
+ } from '../SearchList';
29
+
30
+ const COMBOBOX_NAME = 'Combobox';
31
+ const COMBOBOX_CONTENT_NAME = 'ComboboxContent';
32
+ const COMBOBOX_ITEM_NAME = 'ComboboxItem';
33
+ const COMBOBOX_TRIGGER_NAME = 'ComboboxTrigger';
34
+
35
+ //
36
+ // Context
37
+ //
38
+
39
+ type ComboboxContextValue = {
40
+ modalId: string;
41
+ isCombobox: true;
42
+ placeholder?: string;
43
+ open: boolean;
44
+ onOpenChange: (nextOpen: boolean) => void;
45
+ value: string;
46
+ onValueChange: (nextValue: string) => void;
47
+ };
48
+
49
+ const [ComboboxProvider, useComboboxContext] = createContext<Partial<ComboboxContextValue>>(COMBOBOX_NAME, {});
50
+
51
+ //
52
+ // Root
53
+ //
54
+
55
+ type ComboboxRootProps = PropsWithChildren<
56
+ Partial<ComboboxContextValue & { modal: boolean; defaultOpen: boolean; defaultValue: string; placeholder: string }>
57
+ >;
58
+
59
+ const ComboboxRoot = ({
60
+ modal,
61
+ modalId: propsModalId,
62
+ open: propsOpen,
63
+ defaultOpen,
64
+ onOpenChange: propsOnOpenChange,
65
+ value: propsValue,
66
+ defaultValue,
67
+ onValueChange: propsOnValueChange,
68
+ placeholder,
69
+ children,
70
+ }: ComboboxRootProps) => {
71
+ const modalId = useId(COMBOBOX_NAME, propsModalId);
72
+ const [open = false, onOpenChange] = useControllableState({
73
+ prop: propsOpen,
74
+ onChange: propsOnOpenChange,
75
+ defaultProp: defaultOpen,
76
+ });
77
+ const [value = '', onValueChange] = useControllableState({
78
+ prop: propsValue,
79
+ onChange: propsOnValueChange,
80
+ defaultProp: defaultValue,
81
+ });
82
+
83
+ return (
84
+ <Popover.Root open={open} onOpenChange={onOpenChange} modal={modal}>
85
+ <ComboboxProvider
86
+ isCombobox
87
+ modalId={modalId}
88
+ placeholder={placeholder}
89
+ open={open}
90
+ onOpenChange={onOpenChange}
91
+ value={value}
92
+ onValueChange={onValueChange}
93
+ >
94
+ {children}
95
+ </ComboboxProvider>
96
+ </Popover.Root>
97
+ );
98
+ };
99
+
100
+ //
101
+ // ContentProps
102
+ //
103
+
104
+ type ComboboxContentProps = SearchListRootProps & PopoverContentProps;
105
+
106
+ const ComboboxContent = forwardRef<HTMLDivElement, ComboboxContentProps>(
107
+ (
108
+ {
109
+ side = 'bottom',
110
+ collisionPadding = 48,
111
+ sideOffset,
112
+ align,
113
+ alignOffset,
114
+ avoidCollisions,
115
+ collisionBoundary,
116
+ arrowPadding,
117
+ sticky,
118
+ hideWhenDetached,
119
+ onOpenAutoFocus,
120
+ onCloseAutoFocus,
121
+ onEscapeKeyDown,
122
+ onPointerDownOutside,
123
+ onFocusOutside,
124
+ onInteractOutside,
125
+ forceMount,
126
+ children,
127
+ classNames,
128
+ ...props
129
+ },
130
+ forwardedRef,
131
+ ) => {
132
+ const { modalId } = useComboboxContext(COMBOBOX_CONTENT_NAME);
133
+
134
+ return (
135
+ <Popover.Content
136
+ {...{
137
+ side,
138
+ sideOffset,
139
+ align,
140
+ alignOffset,
141
+ avoidCollisions,
142
+ collisionBoundary,
143
+ collisionPadding,
144
+ arrowPadding,
145
+ sticky,
146
+ hideWhenDetached,
147
+ onOpenAutoFocus,
148
+ onCloseAutoFocus,
149
+ onEscapeKeyDown,
150
+ onPointerDownOutside,
151
+ onFocusOutside,
152
+ onInteractOutside,
153
+ forceMount,
154
+ }}
155
+ classNames={[
156
+ 'is-[--radix-popover-trigger-width] max-bs-[--radix-popover-content-available-height] grid grid-rows-[min-content_1fr]',
157
+ classNames,
158
+ ]}
159
+ id={modalId}
160
+ ref={forwardedRef}
161
+ >
162
+ <SearchList.Root {...props} classNames='contents density-fine' role='none'>
163
+ {children}
164
+ </SearchList.Root>
165
+ </Popover.Content>
166
+ );
167
+ },
168
+ );
169
+
170
+ ComboboxContent.displayName = COMBOBOX_CONTENT_NAME;
171
+
172
+ //
173
+ // Trigger
174
+ //
175
+
176
+ type ComboboxTriggerProps = ButtonProps;
177
+
178
+ const ComboboxTrigger = forwardRef<HTMLButtonElement, ComboboxTriggerProps>(
179
+ ({ children, onClick, ...props }, forwardedRef) => {
180
+ const { modalId, open, onOpenChange, placeholder, value } = useComboboxContext(COMBOBOX_TRIGGER_NAME);
181
+ const handleClick = useCallback(
182
+ (event: Parameters<Exclude<ButtonProps['onClick'], undefined>>[0]) => {
183
+ onClick?.(event);
184
+ onOpenChange?.(true);
185
+ },
186
+ [onClick, onOpenChange],
187
+ );
188
+
189
+ return (
190
+ <Popover.Trigger asChild>
191
+ <Button
192
+ {...props}
193
+ role='combobox'
194
+ aria-expanded={open}
195
+ aria-controls={modalId}
196
+ aria-haspopup='dialog'
197
+ onClick={handleClick}
198
+ ref={forwardedRef}
199
+ >
200
+ {children ?? (
201
+ <>
202
+ <span
203
+ className={mx('font-normal text-start flex-1 min-is-0 truncate mie-2', !value && staticPlaceholderText)}
204
+ >
205
+ {value || placeholder}
206
+ </span>
207
+ <Icon icon='ph--caret-down--bold' size={3} />
208
+ </>
209
+ )}
210
+ </Button>
211
+ </Popover.Trigger>
212
+ );
213
+ },
214
+ );
215
+
216
+ ComboboxTrigger.displayName = COMBOBOX_TRIGGER_NAME;
217
+
218
+ //
219
+ // VirtualTrigger
220
+ //
221
+
222
+ type ComboboxVirtualTriggerProps = PopoverVirtualTriggerProps;
223
+
224
+ const ComboboxVirtualTrigger = Popover.VirtualTrigger;
225
+
226
+ //
227
+ // Input
228
+ //
229
+
230
+ type ComboboxInputProps = SearchListInputProps;
231
+
232
+ const ComboboxInput = forwardRef<HTMLInputElement, ComboboxInputProps>(({ classNames, ...props }, forwardedRef) => {
233
+ return (
234
+ <SearchList.Input
235
+ {...props}
236
+ classNames={[
237
+ 'mli-cardSpacingChrome mbs-cardSpacingChrome mbe-0 is-[calc(100%-2*var(--dx-cardSpacingChrome))]',
238
+ classNames,
239
+ ]}
240
+ ref={forwardedRef}
241
+ />
242
+ );
243
+ });
244
+
245
+ //
246
+ // List
247
+ //
248
+
249
+ type ComboboxListProps = SearchListContentProps;
250
+
251
+ const ComboboxList = forwardRef<HTMLDivElement, ComboboxListProps>(({ classNames, ...props }, forwardedRef) => {
252
+ return (
253
+ <SearchList.Content
254
+ {...props}
255
+ classNames={['min-bs-0 overflow-y-auto plb-cardSpacingChrome', classNames]}
256
+ ref={forwardedRef}
257
+ />
258
+ );
259
+ });
260
+
261
+ //
262
+ // Item
263
+ //
264
+
265
+ type ComboboxItemProps = SearchListItemProps;
266
+
267
+ const ComboboxItem = forwardRef<HTMLDivElement, ComboboxItemProps>(
268
+ ({ classNames, onSelect, ...props }, forwardedRef) => {
269
+ const { onValueChange, onOpenChange } = useComboboxContext(COMBOBOX_ITEM_NAME);
270
+ const handleSelect = useCallback<NonNullable<SearchListItemProps['onSelect']>>(
271
+ (nextValue) => {
272
+ onSelect?.(nextValue);
273
+ onValueChange?.(nextValue);
274
+ onOpenChange?.(false);
275
+ },
276
+ [onSelect, onValueChange, onOpenChange],
277
+ );
278
+
279
+ return (
280
+ <SearchList.Item
281
+ {...props}
282
+ classNames={['mli-cardSpacingChrome pli-cardSpacingChrome', classNames]}
283
+ onSelect={handleSelect}
284
+ ref={forwardedRef}
285
+ />
286
+ );
287
+ },
288
+ );
289
+
290
+ ComboboxItem.displayName = COMBOBOX_ITEM_NAME;
291
+
292
+ //
293
+ // Arrow
294
+ //
295
+
296
+ type ComboboxArrowProps = PopoverArrowProps;
297
+
298
+ const ComboboxArrow = Popover.Arrow;
299
+
300
+ //
301
+ // Empty
302
+ //
303
+
304
+ type ComboboxEmptyProps = SearchListEmptyProps;
305
+
306
+ const ComboboxEmpty = SearchList.Empty;
307
+
308
+ //
309
+ // Combobox
310
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox
311
+ //
312
+
313
+ export const Combobox = {
314
+ Root: ComboboxRoot,
315
+ Content: ComboboxContent,
316
+ Trigger: ComboboxTrigger,
317
+ VirtualTrigger: ComboboxVirtualTrigger,
318
+ Input: ComboboxInput,
319
+ List: ComboboxList,
320
+ Item: ComboboxItem,
321
+ Arrow: ComboboxArrow,
322
+ Empty: ComboboxEmpty,
323
+ };
324
+
325
+ export type {
326
+ ComboboxRootProps,
327
+ ComboboxContentProps,
328
+ ComboboxTriggerProps,
329
+ ComboboxVirtualTriggerProps,
330
+ ComboboxInputProps,
331
+ ComboboxListProps,
332
+ ComboboxItemProps,
333
+ ComboboxArrowProps,
334
+ ComboboxEmptyProps,
335
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './Combobox';
@@ -0,0 +1,53 @@
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 { faker } from '@dxos/random';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { translations } from '../../translations';
12
+
13
+ import { Listbox } from './Listbox';
14
+
15
+ faker.seed(1234);
16
+
17
+ type StoryItem = { value: string; label: string };
18
+
19
+ const options: StoryItem[] = faker.helpers.multiple(
20
+ () => ({ value: faker.string.uuid(), label: faker.commerce.productName() }) satisfies StoryItem,
21
+ { count: 16 },
22
+ );
23
+
24
+ const DefaultStory = () => {
25
+ const [selectedValue, setSelectedValue] = useState<string>();
26
+
27
+ return (
28
+ <Listbox.Root value={selectedValue} onValueChange={setSelectedValue}>
29
+ {options.map((option) => (
30
+ <Listbox.Option key={option.value} value={option.value}>
31
+ <Listbox.OptionLabel>{option.label}</Listbox.OptionLabel>
32
+ <Listbox.OptionIndicator />
33
+ </Listbox.Option>
34
+ ))}
35
+ </Listbox.Root>
36
+ );
37
+ };
38
+
39
+ const meta = {
40
+ title: 'ui/react-ui-searchlist/Listbox',
41
+ component: Listbox.Root,
42
+ render: DefaultStory,
43
+ decorators: [withTheme, withLayout({ container: 'column', classNames: 'p-2' })],
44
+ parameters: {
45
+ translations,
46
+ },
47
+ } satisfies Meta<typeof Listbox.Root>;
48
+
49
+ export default meta;
50
+
51
+ type Story = StoryObj<typeof meta>;
52
+
53
+ export const Default: Story = {};
@@ -0,0 +1,209 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { useArrowNavigationGroup } from '@fluentui/react-tabster';
6
+ import { useComposedRefs } from '@radix-ui/react-compose-refs';
7
+ import { type Scope, createContextScope } from '@radix-ui/react-context';
8
+ import { useControllableState } from '@radix-ui/react-use-controllable-state';
9
+ import React, { type ComponentPropsWithRef, forwardRef, useCallback, useEffect, useRef } from 'react';
10
+
11
+ import { Icon, type IconProps, type ThemedClassName } from '@dxos/react-ui';
12
+ import { mx } from '@dxos/react-ui-theme';
13
+
14
+ import { commandItem, searchListItem } from '../SearchList';
15
+
16
+ const LISTBOX_NAME = 'Listbox';
17
+ const LISTBOX_OPTION_NAME = 'ListboxOption';
18
+ const LISTBOX_OPTION_LABEL_NAME = 'ListboxOptionLabel';
19
+ const LISTBOX_OPTION_INDICATOR_NAME = 'ListboxOptionIndicator';
20
+
21
+ //
22
+ // Context
23
+ //
24
+
25
+ type ListboxScopedProps<P> = P & { __listboxScope?: Scope };
26
+ type ListboxOptionScopedProps<P> = P & { __listboxOptionScope?: Scope };
27
+
28
+ type ListboxOptionProps = ThemedClassName<ComponentPropsWithRef<'li'>> & {
29
+ value: string;
30
+ };
31
+
32
+ const [createListboxContext, createListboxScope] = createContextScope(LISTBOX_NAME, []);
33
+ const [createListboxOptionContext, createListboxOptionScope] = createContextScope(LISTBOX_OPTION_NAME, [
34
+ createListboxScope,
35
+ ]);
36
+
37
+ type ListboxContextValue = {
38
+ selectedValue: string | undefined;
39
+ onValueChange: (value: string) => void;
40
+ };
41
+
42
+ type ListboxOptionContextValue = {
43
+ value: string;
44
+ isSelected: boolean;
45
+ };
46
+
47
+ const [ListboxProvider, useListboxContext] = createListboxContext<ListboxContextValue>(LISTBOX_NAME);
48
+ const [ListboxOptionProvider, useListboxOptionContext] =
49
+ createListboxOptionContext<ListboxOptionContextValue>(LISTBOX_OPTION_NAME);
50
+
51
+ //
52
+ // Root
53
+ //
54
+
55
+ type ListboxRootProps = ThemedClassName<ComponentPropsWithRef<'ul'>> & {
56
+ value?: string;
57
+ defaultValue?: string;
58
+ onValueChange?: (value: string) => void;
59
+ autoFocus?: boolean;
60
+ };
61
+
62
+ // TODO(thure): Note that this overlaps significantly with the the `SelectableListbox` story of `List.tsx` in `react-ui`,
63
+ // making this an exemplar of `List` specifying standard `role="listbox"` interactivity, though it is here because it
64
+ // coheres with SearchList’s styles and norms. This can be promoted to `react-ui`, but doing so should involve clearing
65
+ // the technical- and design-debt in its `List` component.
66
+ const ListboxRoot = forwardRef<HTMLUListElement, ListboxRootProps>(
67
+ (props: ListboxScopedProps<ListboxRootProps>, forwardedRef) => {
68
+ const {
69
+ __listboxScope,
70
+ children,
71
+ classNames,
72
+ value: propsValue,
73
+ defaultValue,
74
+ onValueChange,
75
+ autoFocus,
76
+ ...rootProps
77
+ } = props;
78
+
79
+ const arrowGroup = useArrowNavigationGroup({ axis: 'vertical' });
80
+ const ref = useRef<HTMLUListElement | null>(null);
81
+ const rootRef = useComposedRefs<HTMLUListElement>(ref, forwardedRef);
82
+
83
+ const [selectedValue, setSelectedValue] = useControllableState({
84
+ prop: propsValue,
85
+ defaultProp: defaultValue,
86
+ onChange: onValueChange,
87
+ });
88
+
89
+ const handleValueChange = (value: string) => {
90
+ setSelectedValue(value);
91
+ };
92
+
93
+ useEffect(() => {
94
+ // Autofocus the selected option on mount using querySelector
95
+ (ref.current?.querySelector('[aria-selected="true"]') as HTMLLIElement)?.focus();
96
+ }, [autoFocus]);
97
+
98
+ return (
99
+ <ListboxProvider scope={__listboxScope} selectedValue={selectedValue} onValueChange={handleValueChange}>
100
+ <ul
101
+ role='listbox'
102
+ {...rootProps}
103
+ className={mx('is-full p-cardSpacingChrome', classNames)}
104
+ ref={rootRef}
105
+ {...arrowGroup}
106
+ >
107
+ {children}
108
+ </ul>
109
+ </ListboxProvider>
110
+ );
111
+ },
112
+ );
113
+
114
+ ListboxRoot.displayName = LISTBOX_NAME;
115
+
116
+ //
117
+ // Option
118
+ //
119
+
120
+ const ListboxOption = forwardRef<HTMLLIElement, ListboxOptionProps>(
121
+ (props: ListboxScopedProps<ListboxOptionProps>, forwardedRef) => {
122
+ const { __listboxScope, children, classNames, value, ...rootProps } = props;
123
+ const { selectedValue, onValueChange } = useListboxContext(LISTBOX_OPTION_NAME, __listboxScope);
124
+
125
+ const isSelected = selectedValue === value;
126
+
127
+ const handleSelect = useCallback(() => {
128
+ onValueChange(value);
129
+ }, [value, onValueChange]);
130
+
131
+ return (
132
+ <ListboxOptionProvider scope={__listboxScope} value={value} isSelected={isSelected}>
133
+ <li
134
+ role='option'
135
+ {...rootProps}
136
+ aria-selected={isSelected}
137
+ tabIndex={0}
138
+ className={mx('dx-focus-ring', commandItem, searchListItem, classNames)}
139
+ onClick={handleSelect}
140
+ onKeyDown={({ key }) => {
141
+ if (['Enter', ' '].includes(key)) {
142
+ handleSelect();
143
+ }
144
+ }}
145
+ ref={forwardedRef}
146
+ >
147
+ {children}
148
+ </li>
149
+ </ListboxOptionProvider>
150
+ );
151
+ },
152
+ );
153
+
154
+ ListboxOption.displayName = LISTBOX_OPTION_NAME;
155
+
156
+ //
157
+ // OptionLabel
158
+ //
159
+
160
+ const ListboxOptionLabel = forwardRef<HTMLDivElement, ThemedClassName<ComponentPropsWithRef<'div'>>>(
161
+ ({ children, classNames, ...rootProps }, forwardedRef) => {
162
+ return (
163
+ <span {...rootProps} className={mx('grow truncate', classNames)} ref={forwardedRef}>
164
+ {children}
165
+ </span>
166
+ );
167
+ },
168
+ );
169
+
170
+ ListboxOptionLabel.displayName = LISTBOX_OPTION_LABEL_NAME;
171
+
172
+ type ListboxOptionIndicatorProps = Omit<IconProps, 'icon'> & Partial<Pick<IconProps, 'icon'>>;
173
+
174
+ //
175
+ // OptionIndicator
176
+ //
177
+
178
+ const ListboxOptionIndicator = forwardRef<SVGSVGElement, ListboxOptionIndicatorProps>(
179
+ (props: ListboxOptionScopedProps<ListboxOptionIndicatorProps>, forwardedRef) => {
180
+ const { __listboxOptionScope, classNames, ...rootProps } = props;
181
+ const { isSelected } = useListboxOptionContext(LISTBOX_OPTION_INDICATOR_NAME, __listboxOptionScope);
182
+
183
+ return (
184
+ <Icon
185
+ icon='ph--check--regular'
186
+ {...rootProps}
187
+ classNames={mx(!isSelected && 'invisible', classNames)}
188
+ ref={forwardedRef}
189
+ />
190
+ );
191
+ },
192
+ );
193
+
194
+ ListboxOptionIndicator.displayName = LISTBOX_OPTION_INDICATOR_NAME;
195
+
196
+ //
197
+ // Listbox
198
+ //
199
+
200
+ export const Listbox = {
201
+ Root: ListboxRoot,
202
+ Option: ListboxOption,
203
+ OptionLabel: ListboxOptionLabel,
204
+ OptionIndicator: ListboxOptionIndicator,
205
+ };
206
+
207
+ export { createListboxScope, useListboxContext };
208
+
209
+ export type { ListboxRootProps, ListboxOptionProps, ListboxScopedProps };