@dxos/react-ui-list 0.8.4-main.ead640a → 0.8.4-main.f466a3d56e
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.
- package/LICENSE +102 -5
- package/dist/lib/browser/index.mjs +1340 -728
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +1340 -728
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
- package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
- package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
- package/dist/types/src/components/Combobox/Combobox.d.ts +105 -0
- package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -0
- package/dist/types/src/components/Combobox/Combobox.stories.d.ts +12 -0
- package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -0
- package/dist/types/src/components/Combobox/index.d.ts +2 -0
- package/dist/types/src/components/Combobox/index.d.ts.map +1 -0
- package/dist/types/src/components/List/List.d.ts +19 -8
- package/dist/types/src/components/List/List.d.ts.map +1 -1
- package/dist/types/src/components/List/List.stories.d.ts +2 -2
- package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
- package/dist/types/src/components/List/ListItem.d.ts +10 -8
- package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
- package/dist/types/src/components/List/ListRoot.d.ts +2 -2
- package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
- package/dist/types/src/components/List/testing.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
- package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/index.d.ts +2 -0
- package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
- package/dist/types/src/components/Picker/Picker.d.ts +49 -0
- package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
- package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
- package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
- package/dist/types/src/components/Picker/context.d.ts +29 -0
- package/dist/types/src/components/Picker/context.d.ts.map +1 -0
- package/dist/types/src/components/Picker/index.d.ts +3 -0
- package/dist/types/src/components/Picker/index.d.ts.map +1 -0
- package/dist/types/src/components/RowList/RowList.d.ts +61 -0
- package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
- package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
- package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
- package/dist/types/src/components/RowList/index.d.ts +3 -0
- package/dist/types/src/components/RowList/index.d.ts.map +1 -0
- package/dist/types/src/components/Tree/Tree.d.ts +10 -6
- package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts +9 -28
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeContext.d.ts +22 -9
- package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeItem.d.ts +20 -3
- package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeItemHeading.d.ts +1 -1
- package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
- package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
- package/dist/types/src/components/Tree/index.d.ts +2 -0
- package/dist/types/src/components/Tree/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/testing.d.ts +2 -2
- package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/util/path.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +35 -32
- package/src/components/Accordion/Accordion.stories.tsx +4 -4
- package/src/components/Accordion/AccordionItem.tsx +4 -7
- package/src/components/Accordion/AccordionRoot.tsx +1 -1
- package/src/components/Combobox/Combobox.stories.tsx +60 -0
- package/src/components/Combobox/Combobox.tsx +387 -0
- package/src/components/Combobox/index.ts +5 -0
- package/src/components/List/List.stories.tsx +35 -23
- package/src/components/List/List.tsx +14 -10
- package/src/components/List/ListItem.tsx +57 -39
- package/src/components/List/ListRoot.tsx +3 -3
- package/src/components/List/testing.ts +6 -6
- package/src/components/Listbox/Listbox.stories.tsx +48 -0
- package/src/components/Listbox/Listbox.tsx +201 -0
- package/src/components/Listbox/index.ts +5 -0
- package/src/components/Picker/Picker.stories.tsx +131 -0
- package/src/components/Picker/Picker.tsx +368 -0
- package/src/components/Picker/context.ts +43 -0
- package/src/components/Picker/index.ts +6 -0
- package/src/components/RowList/RowList.stories.tsx +163 -0
- package/src/components/RowList/RowList.tsx +350 -0
- package/src/components/RowList/index.ts +6 -0
- package/src/components/Tree/Tree.stories.tsx +153 -64
- package/src/components/Tree/Tree.tsx +40 -42
- package/src/components/Tree/TreeContext.tsx +19 -8
- package/src/components/Tree/TreeItem.tsx +186 -112
- package/src/components/Tree/TreeItemHeading.tsx +9 -6
- package/src/components/Tree/TreeItemToggle.tsx +4 -4
- package/src/components/Tree/index.ts +2 -0
- package/src/components/Tree/testing.ts +9 -8
- package/src/components/index.ts +4 -0
|
@@ -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,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
|
+
};
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// `Picker` — generic listbox-with-input compound implementing the
|
|
6
|
+
// WAI-ARIA combobox keyboard pattern. Search / filtering live one layer
|
|
7
|
+
// up in `@dxos/react-ui-search`.
|
|
8
|
+
//
|
|
9
|
+
// The two contexts (Input / Item) are split so items don't re-render on
|
|
10
|
+
// every keystroke and the input doesn't re-render on every (un)register.
|
|
11
|
+
|
|
12
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
13
|
+
import React, {
|
|
14
|
+
type ChangeEvent,
|
|
15
|
+
type ComponentPropsWithRef,
|
|
16
|
+
type KeyboardEvent,
|
|
17
|
+
type MouseEvent as ReactMouseEvent,
|
|
18
|
+
type PropsWithChildren,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
forwardRef,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useMemo,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
} from 'react';
|
|
27
|
+
|
|
28
|
+
import { type Density, type Elevation, Input, type ThemedClassName, useThemeContext } from '@dxos/react-ui';
|
|
29
|
+
import { mx } from '@dxos/ui-theme';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
PickerInputContextProvider,
|
|
33
|
+
PickerItemContextProvider,
|
|
34
|
+
usePickerInputContext,
|
|
35
|
+
usePickerItemContext,
|
|
36
|
+
} from './context';
|
|
37
|
+
|
|
38
|
+
type ItemData = {
|
|
39
|
+
element: HTMLElement;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
onSelect?: () => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
//
|
|
45
|
+
// Root
|
|
46
|
+
//
|
|
47
|
+
|
|
48
|
+
type PickerRootProps = PropsWithChildren<{}>;
|
|
49
|
+
|
|
50
|
+
const PickerRoot = ({ children }: PickerRootProps) => {
|
|
51
|
+
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
|
|
52
|
+
const itemsRef = useRef<Map<string, ItemData>>(new Map());
|
|
53
|
+
// Bumped on every (un)register to retrigger auto-select.
|
|
54
|
+
const [itemVersion, setItemVersion] = useState(0);
|
|
55
|
+
|
|
56
|
+
// Auto-select first non-disabled item when the current selection is
|
|
57
|
+
// gone or disabled.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const current = selectedValue !== undefined ? itemsRef.current.get(selectedValue) : undefined;
|
|
60
|
+
const isValid = current !== undefined && !current.disabled;
|
|
61
|
+
if (!isValid && itemsRef.current.size > 0) {
|
|
62
|
+
const entries = Array.from(itemsRef.current.entries()).filter(([, data]) => !data.disabled);
|
|
63
|
+
if (entries.length > 0) {
|
|
64
|
+
entries.sort(([, a], [, b]) => {
|
|
65
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
66
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
});
|
|
74
|
+
const firstValue = entries[0]?.[0];
|
|
75
|
+
if (firstValue !== undefined && firstValue !== selectedValue) {
|
|
76
|
+
setSelectedValue(firstValue);
|
|
77
|
+
}
|
|
78
|
+
} else if (selectedValue !== undefined) {
|
|
79
|
+
setSelectedValue(undefined);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, [itemVersion, selectedValue]);
|
|
83
|
+
|
|
84
|
+
const registerItem = useCallback(
|
|
85
|
+
(value: string, element: HTMLElement | null, onSelect: (() => void) | undefined, disabled?: boolean) => {
|
|
86
|
+
if (element) {
|
|
87
|
+
itemsRef.current.set(value, { element, onSelect, disabled });
|
|
88
|
+
setItemVersion((v) => v + 1);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const unregisterItem = useCallback((value: string) => {
|
|
95
|
+
itemsRef.current.delete(value);
|
|
96
|
+
setItemVersion((v) => v + 1);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// DOM-order list of enabled item values.
|
|
100
|
+
const getItemValues = useCallback(() => {
|
|
101
|
+
return Array.from(itemsRef.current.entries())
|
|
102
|
+
.filter(([, data]) => !data.disabled)
|
|
103
|
+
.sort(([, a], [, b]) => {
|
|
104
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
105
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : position & Node.DOCUMENT_POSITION_PRECEDING ? 1 : 0;
|
|
106
|
+
})
|
|
107
|
+
.map(([value]) => value);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const triggerSelect = useCallback(() => {
|
|
111
|
+
if (selectedValue !== undefined) {
|
|
112
|
+
const item = itemsRef.current.get(selectedValue);
|
|
113
|
+
item?.onSelect?.();
|
|
114
|
+
}
|
|
115
|
+
}, [selectedValue]);
|
|
116
|
+
|
|
117
|
+
// Stable values items subscribe to.
|
|
118
|
+
const itemContextValue = useMemo(
|
|
119
|
+
() => ({
|
|
120
|
+
selectedValue,
|
|
121
|
+
onSelectedValueChange: setSelectedValue,
|
|
122
|
+
registerItem,
|
|
123
|
+
unregisterItem,
|
|
124
|
+
}),
|
|
125
|
+
[selectedValue, registerItem, unregisterItem],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Volatile values the input subscribes to (keyboard helpers).
|
|
129
|
+
const inputContextValue = useMemo(
|
|
130
|
+
() => ({
|
|
131
|
+
selectedValue,
|
|
132
|
+
onSelectedValueChange: setSelectedValue,
|
|
133
|
+
getItemValues,
|
|
134
|
+
triggerSelect,
|
|
135
|
+
}),
|
|
136
|
+
[selectedValue, getItemValues, triggerSelect],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<PickerInputContextProvider {...inputContextValue}>
|
|
141
|
+
<PickerItemContextProvider {...itemContextValue}>{children}</PickerItemContextProvider>
|
|
142
|
+
</PickerInputContextProvider>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
PickerRoot.displayName = 'Picker.Root';
|
|
147
|
+
|
|
148
|
+
//
|
|
149
|
+
// Input
|
|
150
|
+
//
|
|
151
|
+
|
|
152
|
+
type InputVariant = 'default' | 'subdued';
|
|
153
|
+
|
|
154
|
+
type PickerInputProps = ThemedClassName<
|
|
155
|
+
Omit<ComponentPropsWithRef<'input'>, 'value'> & {
|
|
156
|
+
/** Controlled input value. Caller owns this — e.g. binds to query state. */
|
|
157
|
+
value?: string;
|
|
158
|
+
/** Called on every keystroke with the new input string. */
|
|
159
|
+
onValueChange?: (value: string) => void;
|
|
160
|
+
density?: Density;
|
|
161
|
+
elevation?: Elevation;
|
|
162
|
+
variant?: InputVariant;
|
|
163
|
+
}
|
|
164
|
+
>;
|
|
165
|
+
|
|
166
|
+
const PickerInput = forwardRef<HTMLInputElement, PickerInputProps>(
|
|
167
|
+
({ value, onValueChange, onChange, onKeyDown, autoFocus, ...props }, forwardedRef) => {
|
|
168
|
+
const { hasIosKeyboard } = useThemeContext();
|
|
169
|
+
const { selectedValue, onSelectedValueChange, getItemValues, triggerSelect } =
|
|
170
|
+
usePickerInputContext('Picker.Input');
|
|
171
|
+
|
|
172
|
+
const handleChange = useCallback(
|
|
173
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
174
|
+
onValueChange?.(event.target.value);
|
|
175
|
+
onChange?.(event);
|
|
176
|
+
},
|
|
177
|
+
[onValueChange, onChange],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const handleKeyDown = useCallback(
|
|
181
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
182
|
+
onKeyDown?.(event);
|
|
183
|
+
if (event.defaultPrevented) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const values = getItemValues();
|
|
187
|
+
if (values.length === 0) {
|
|
188
|
+
if (event.key === 'Escape') {
|
|
189
|
+
onValueChange?.('');
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const currentIndex = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
|
|
195
|
+
|
|
196
|
+
switch (event.key) {
|
|
197
|
+
case 'ArrowDown': {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, values.length - 1);
|
|
200
|
+
const nextValue = values[nextIndex];
|
|
201
|
+
if (nextValue !== undefined) {
|
|
202
|
+
onSelectedValueChange(nextValue);
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'ArrowUp': {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
const prevIndex = currentIndex === -1 ? values.length - 1 : Math.max(currentIndex - 1, 0);
|
|
209
|
+
const prevValue = values[prevIndex];
|
|
210
|
+
if (prevValue !== undefined) {
|
|
211
|
+
onSelectedValueChange(prevValue);
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case 'Enter': {
|
|
216
|
+
if (selectedValue !== undefined) {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
triggerSelect();
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'Home': {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
const firstValue = values[0];
|
|
225
|
+
if (firstValue !== undefined) {
|
|
226
|
+
onSelectedValueChange(firstValue);
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'End': {
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
const lastValue = values[values.length - 1];
|
|
233
|
+
if (lastValue !== undefined) {
|
|
234
|
+
onSelectedValueChange(lastValue);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'Escape': {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
if (selectedValue !== undefined) {
|
|
241
|
+
onSelectedValueChange(undefined);
|
|
242
|
+
} else {
|
|
243
|
+
onValueChange?.('');
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
[selectedValue, onSelectedValueChange, getItemValues, triggerSelect, onValueChange, onKeyDown],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Only force-control when `value` is provided; otherwise leave the
|
|
253
|
+
// input uncontrolled so it accepts keystrokes without `onValueChange`.
|
|
254
|
+
return (
|
|
255
|
+
<Input.Root>
|
|
256
|
+
<Input.TextInput
|
|
257
|
+
{...props}
|
|
258
|
+
autoFocus={autoFocus && !hasIosKeyboard}
|
|
259
|
+
{...(value !== undefined && { value })}
|
|
260
|
+
onChange={handleChange}
|
|
261
|
+
onKeyDown={handleKeyDown}
|
|
262
|
+
ref={forwardedRef}
|
|
263
|
+
/>
|
|
264
|
+
</Input.Root>
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
PickerInput.displayName = 'Picker.Input';
|
|
270
|
+
|
|
271
|
+
//
|
|
272
|
+
// Item
|
|
273
|
+
//
|
|
274
|
+
|
|
275
|
+
type PickerItemProps = ThemedClassName<{
|
|
276
|
+
/** Unique identifier; used by the registry and DOM-order traversal. */
|
|
277
|
+
value: string;
|
|
278
|
+
/** Callback when the item is committed (click, or Enter while highlighted). */
|
|
279
|
+
onSelect?: () => void;
|
|
280
|
+
/** Disable the item — registry-visible but not focusable, not navigable, not clickable. */
|
|
281
|
+
disabled?: boolean;
|
|
282
|
+
asChild?: boolean;
|
|
283
|
+
children?: ReactNode;
|
|
284
|
+
}>;
|
|
285
|
+
|
|
286
|
+
const PickerItem = forwardRef<HTMLDivElement, PickerItemProps>(
|
|
287
|
+
({ classNames, value, onSelect, disabled, asChild, children, ...props }, forwardedRef) => {
|
|
288
|
+
const { selectedValue, onSelectedValueChange, registerItem, unregisterItem } = usePickerItemContext('Picker.Item');
|
|
289
|
+
const internalRef = useRef<HTMLDivElement>(null);
|
|
290
|
+
|
|
291
|
+
const isSelected = selectedValue === value && !disabled;
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const element = internalRef.current;
|
|
295
|
+
if (element) {
|
|
296
|
+
registerItem(value, element, onSelect, disabled);
|
|
297
|
+
}
|
|
298
|
+
return () => unregisterItem(value);
|
|
299
|
+
}, [value, onSelect, disabled, registerItem, unregisterItem]);
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (isSelected && internalRef.current) {
|
|
303
|
+
internalRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
304
|
+
}
|
|
305
|
+
}, [isSelected]);
|
|
306
|
+
|
|
307
|
+
const handleClick = useCallback(() => {
|
|
308
|
+
if (disabled) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
onSelectedValueChange(value);
|
|
312
|
+
onSelect?.();
|
|
313
|
+
}, [disabled, value, onSelectedValueChange, onSelect]);
|
|
314
|
+
|
|
315
|
+
// Keep focus on `Picker.Input`: any `tabIndex` (incl. `-1`) would
|
|
316
|
+
// steal focus on click and break the input's arrow-key handler.
|
|
317
|
+
const handleMouseDown = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
const Comp: any = asChild ? Slot : 'div';
|
|
322
|
+
|
|
323
|
+
// Padding follows `--gutter` to align with sibling `Column.Center`
|
|
324
|
+
// content; falls back to `0.75rem` when not nested under `Column.Root`.
|
|
325
|
+
return (
|
|
326
|
+
<Comp
|
|
327
|
+
{...props}
|
|
328
|
+
ref={(node: HTMLDivElement | null) => {
|
|
329
|
+
internalRef.current = node;
|
|
330
|
+
if (typeof forwardedRef === 'function') {
|
|
331
|
+
forwardedRef(node);
|
|
332
|
+
} else if (forwardedRef) {
|
|
333
|
+
forwardedRef.current = node;
|
|
334
|
+
}
|
|
335
|
+
}}
|
|
336
|
+
role='option'
|
|
337
|
+
aria-selected={isSelected}
|
|
338
|
+
aria-disabled={disabled}
|
|
339
|
+
data-selected={isSelected}
|
|
340
|
+
data-disabled={disabled}
|
|
341
|
+
data-value={value}
|
|
342
|
+
// Browser focus stays on the input; highlight is via `aria-selected`.
|
|
343
|
+
tabIndex={-1}
|
|
344
|
+
className={mx(
|
|
345
|
+
'dx-hover dx-selected px-[var(--gutter,0.75rem)] py-1 cursor-pointer select-none',
|
|
346
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
347
|
+
classNames,
|
|
348
|
+
)}
|
|
349
|
+
onMouseDown={handleMouseDown}
|
|
350
|
+
onClick={handleClick}
|
|
351
|
+
>
|
|
352
|
+
{children}
|
|
353
|
+
</Comp>
|
|
354
|
+
);
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
PickerItem.displayName = 'Picker.Item';
|
|
359
|
+
|
|
360
|
+
export const Picker = {
|
|
361
|
+
Root: PickerRoot,
|
|
362
|
+
Input: PickerInput,
|
|
363
|
+
Item: PickerItem,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
export type { PickerRootProps, PickerInputProps, PickerItemProps };
|
|
367
|
+
|
|
368
|
+
export { usePickerInputContext, usePickerItemContext } from './context';
|