@dxos/react-ui-list 0.8.4-main.c85a9c8dae → 0.8.4-main.cb12b3f963
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/dist/lib/browser/index.mjs +886 -212
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +886 -212
- 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 +18 -7
- 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 +12 -10
- package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeContext.d.ts +4 -0
- package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeItem.d.ts.map +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/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 +21 -20
- package/src/components/Accordion/Accordion.stories.tsx +3 -3
- package/src/components/Accordion/AccordionItem.tsx +0 -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 +5 -5
- package/src/components/List/List.tsx +14 -10
- package/src/components/List/ListItem.tsx +66 -46
- package/src/components/List/ListRoot.tsx +1 -1
- package/src/components/List/testing.ts +4 -4
- 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 +439 -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 +353 -0
- package/src/components/RowList/index.ts +6 -0
- package/src/components/Tree/Tree.stories.tsx +4 -5
- package/src/components/Tree/TreeContext.tsx +4 -0
- package/src/components/Tree/TreeItem.tsx +94 -64
- package/src/components/Tree/TreeItemHeading.tsx +1 -4
- package/src/components/Tree/TreeItemToggle.tsx +3 -3
- package/src/components/Tree/testing.ts +5 -5
- package/src/components/index.ts +4 -0
|
@@ -2,21 +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
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
14
15
|
import React, {
|
|
15
16
|
type ComponentProps,
|
|
16
17
|
type HTMLAttributes,
|
|
17
|
-
type MutableRefObject,
|
|
18
18
|
type PropsWithChildren,
|
|
19
19
|
type ReactNode,
|
|
20
|
+
RefObject,
|
|
20
21
|
useEffect,
|
|
21
22
|
useRef,
|
|
22
23
|
useState,
|
|
@@ -46,22 +47,22 @@ export type ItemDragState =
|
|
|
46
47
|
container: HTMLElement;
|
|
47
48
|
}
|
|
48
49
|
| {
|
|
49
|
-
type: '
|
|
50
|
+
type: 'is-dragging';
|
|
50
51
|
}
|
|
51
52
|
| {
|
|
52
|
-
type: '
|
|
53
|
+
type: 'is-dragging-over';
|
|
53
54
|
closestEdge: Edge | null;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export const idle: ItemDragState = { type: 'idle' };
|
|
57
58
|
|
|
58
59
|
const stateStyles: { [Key in ItemDragState['type']]?: HTMLAttributes<HTMLDivElement>['className'] } = {
|
|
59
|
-
'
|
|
60
|
+
'is-dragging': 'opacity-50',
|
|
60
61
|
};
|
|
61
62
|
|
|
62
63
|
type ListItemContext<T extends ListItemRecord> = {
|
|
63
64
|
item: T;
|
|
64
|
-
dragHandleRef:
|
|
65
|
+
dragHandleRef: RefObject<HTMLButtonElement | null>;
|
|
65
66
|
};
|
|
66
67
|
|
|
67
68
|
/**
|
|
@@ -80,6 +81,8 @@ export type ListItemProps<T extends ListItemRecord> = ThemedClassName<
|
|
|
80
81
|
PropsWithChildren<
|
|
81
82
|
{
|
|
82
83
|
item: T;
|
|
84
|
+
asChild?: boolean;
|
|
85
|
+
selected?: boolean;
|
|
83
86
|
} & HTMLAttributes<HTMLDivElement>
|
|
84
87
|
>
|
|
85
88
|
>;
|
|
@@ -87,14 +90,22 @@ export type ListItemProps<T extends ListItemRecord> = ThemedClassName<
|
|
|
87
90
|
/**
|
|
88
91
|
* Draggable list item.
|
|
89
92
|
*/
|
|
90
|
-
export const ListItem = <T extends ListItemRecord>({
|
|
93
|
+
export const ListItem = <T extends ListItemRecord>({
|
|
94
|
+
children,
|
|
95
|
+
classNames,
|
|
96
|
+
item,
|
|
97
|
+
asChild,
|
|
98
|
+
selected,
|
|
99
|
+
...props
|
|
100
|
+
}: ListItemProps<T>) => {
|
|
101
|
+
const Comp = asChild ? Slot : 'div';
|
|
91
102
|
const { isItem, readonly, dragPreview, setState: setRootState } = useListContext(LIST_ITEM_NAME);
|
|
92
|
-
const
|
|
93
|
-
const dragHandleRef = useRef<
|
|
103
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
104
|
+
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
|
|
94
105
|
const [state, setState] = useState<ItemDragState>(idle);
|
|
95
106
|
|
|
96
107
|
useEffect(() => {
|
|
97
|
-
const element =
|
|
108
|
+
const element = rootRef.current;
|
|
98
109
|
invariant(element);
|
|
99
110
|
return combine(
|
|
100
111
|
//
|
|
@@ -124,8 +135,8 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
|
|
|
124
135
|
}
|
|
125
136
|
: undefined,
|
|
126
137
|
onDragStart: () => {
|
|
127
|
-
setState({ type: '
|
|
128
|
-
setRootState({ type: '
|
|
138
|
+
setState({ type: 'is-dragging' });
|
|
139
|
+
setRootState({ type: 'is-dragging', item });
|
|
129
140
|
},
|
|
130
141
|
onDrop: () => {
|
|
131
142
|
setState(idle);
|
|
@@ -147,7 +158,7 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
|
|
|
147
158
|
getIsSticky: () => true,
|
|
148
159
|
onDragEnter: ({ self }) => {
|
|
149
160
|
const closestEdge = extractClosestEdge(self.data);
|
|
150
|
-
setState({ type: '
|
|
161
|
+
setState({ type: 'is-dragging-over', closestEdge });
|
|
151
162
|
},
|
|
152
163
|
onDragLeave: () => {
|
|
153
164
|
setState(idle);
|
|
@@ -155,10 +166,10 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
|
|
|
155
166
|
onDrag: ({ self }) => {
|
|
156
167
|
const closestEdge = extractClosestEdge(self.data);
|
|
157
168
|
setState((current) => {
|
|
158
|
-
if (current.type === '
|
|
169
|
+
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
|
|
159
170
|
return current;
|
|
160
171
|
}
|
|
161
|
-
return { type: '
|
|
172
|
+
return { type: 'is-dragging-over', closestEdge };
|
|
162
173
|
});
|
|
163
174
|
},
|
|
164
175
|
onDrop: () => {
|
|
@@ -170,12 +181,18 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
|
|
|
170
181
|
|
|
171
182
|
return (
|
|
172
183
|
<ListItemProvider item={item} dragHandleRef={dragHandleRef}>
|
|
173
|
-
<
|
|
184
|
+
<Comp
|
|
185
|
+
{...props}
|
|
186
|
+
role='listitem'
|
|
187
|
+
aria-selected={selected}
|
|
188
|
+
className={mx('relative p-1 dx-selected dx-hover', classNames, stateStyles[state.type])}
|
|
189
|
+
ref={rootRef}
|
|
190
|
+
>
|
|
174
191
|
{children}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
</Comp>
|
|
193
|
+
{state.type === 'is-dragging-over' && state.closestEdge && (
|
|
194
|
+
<NaturalListItem.DropIndicator edge={state.closestEdge} />
|
|
195
|
+
)}
|
|
179
196
|
</ListItemProvider>
|
|
180
197
|
);
|
|
181
198
|
};
|
|
@@ -184,47 +201,48 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
|
|
|
184
201
|
// List item components
|
|
185
202
|
//
|
|
186
203
|
|
|
187
|
-
export const
|
|
204
|
+
export const ListItemIconButton = ({
|
|
188
205
|
autoHide = true,
|
|
206
|
+
iconOnly = true,
|
|
207
|
+
variant = 'ghost',
|
|
189
208
|
classNames,
|
|
190
209
|
disabled,
|
|
191
|
-
icon = 'ph--x--regular',
|
|
192
|
-
label,
|
|
193
210
|
...props
|
|
194
|
-
}:
|
|
195
|
-
|
|
196
|
-
const { state } = useListContext('DELETE_BUTTON');
|
|
211
|
+
}: IconButtonProps & { autoHide?: boolean }) => {
|
|
212
|
+
const { state } = useListContext('ITEM_BUTTON');
|
|
197
213
|
const isDisabled = state.type !== 'idle' || disabled;
|
|
198
|
-
const { t } = useTranslation(osTranslations);
|
|
199
214
|
return (
|
|
200
215
|
<IconButton
|
|
201
|
-
iconOnly
|
|
202
|
-
variant='ghost'
|
|
203
216
|
{...props}
|
|
204
|
-
icon={icon}
|
|
205
217
|
disabled={isDisabled}
|
|
206
|
-
|
|
218
|
+
iconOnly={iconOnly}
|
|
219
|
+
variant={variant}
|
|
207
220
|
classNames={[classNames, autoHide && disabled && 'hidden']}
|
|
208
221
|
/>
|
|
209
222
|
);
|
|
210
223
|
};
|
|
211
224
|
|
|
212
|
-
|
|
225
|
+
// TODO(burdon): Generalize to action button.
|
|
226
|
+
export const ListItemDeleteButton = ({
|
|
213
227
|
autoHide = true,
|
|
214
|
-
iconOnly = true,
|
|
215
|
-
variant = 'ghost',
|
|
216
228
|
classNames,
|
|
217
229
|
disabled,
|
|
230
|
+
icon = 'ph--x--regular',
|
|
231
|
+
label,
|
|
218
232
|
...props
|
|
219
|
-
}: IconButtonProps &
|
|
220
|
-
|
|
233
|
+
}: Partial<Pick<IconButtonProps, 'icon'>> &
|
|
234
|
+
Omit<IconButtonProps, 'icon' | 'label'> & { autoHide?: boolean; label?: string }) => {
|
|
235
|
+
const { state } = useListContext('DELETE_BUTTON');
|
|
221
236
|
const isDisabled = state.type !== 'idle' || disabled;
|
|
237
|
+
const { t } = useTranslation(osTranslations);
|
|
222
238
|
return (
|
|
223
239
|
<IconButton
|
|
224
240
|
{...props}
|
|
241
|
+
variant='ghost'
|
|
225
242
|
disabled={isDisabled}
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
icon={icon}
|
|
244
|
+
iconOnly
|
|
245
|
+
label={label ?? t('delete.label')}
|
|
228
246
|
classNames={[classNames, autoHide && disabled && 'hidden']}
|
|
229
247
|
/>
|
|
230
248
|
);
|
|
@@ -235,12 +253,12 @@ export const ListItemDragHandle = ({ disabled }: Pick<IconButtonProps, 'disabled
|
|
|
235
253
|
const { t } = useTranslation(osTranslations);
|
|
236
254
|
return (
|
|
237
255
|
<IconButton
|
|
238
|
-
iconOnly
|
|
239
256
|
variant='ghost'
|
|
240
|
-
label={t('drag handle label')}
|
|
241
|
-
ref={dragHandleRef as any}
|
|
242
|
-
icon='ph--dots-six-vertical--regular'
|
|
243
257
|
disabled={disabled}
|
|
258
|
+
icon='ph--dots-six-vertical--regular'
|
|
259
|
+
iconOnly
|
|
260
|
+
label={t('drag-handle.label')}
|
|
261
|
+
ref={dragHandleRef}
|
|
244
262
|
/>
|
|
245
263
|
);
|
|
246
264
|
};
|
|
@@ -255,7 +273,9 @@ export const ListItemDragPreview = <T extends ListItemRecord>({
|
|
|
255
273
|
};
|
|
256
274
|
|
|
257
275
|
export const ListItemWrapper = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
|
|
258
|
-
<div className={mx('flex w-full gap-2', classNames)}>
|
|
276
|
+
<div role='none' className={mx('flex w-full gap-2', classNames)}>
|
|
277
|
+
{children}
|
|
278
|
+
</div>
|
|
259
279
|
);
|
|
260
280
|
|
|
261
281
|
export const ListItemTitle = ({
|
|
@@ -263,7 +283,7 @@ export const ListItemTitle = ({
|
|
|
263
283
|
children,
|
|
264
284
|
...props
|
|
265
285
|
}: ThemedClassName<PropsWithChildren<ComponentProps<'div'>>>) => (
|
|
266
|
-
<div className={mx('flex grow items-center truncate', classNames)} {...props}>
|
|
286
|
+
<div role='none' className={mx('flex grow items-center truncate', classNames)} {...props}>
|
|
267
287
|
{children}
|
|
268
288
|
</div>
|
|
269
289
|
);
|
|
@@ -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 {
|
|
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:
|
|
24
|
+
items: random.helpers.multiple(
|
|
25
25
|
() => ({
|
|
26
|
-
id:
|
|
27
|
-
name:
|
|
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,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
|
+
};
|