@dxos/react-ui-list 0.9.1-main.c7dcc2e112 → 0.10.0
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 +628 -167
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +628 -167
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/aspects/useListDisclosure.d.ts +4 -4
- package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -1
- package/dist/types/src/aspects/useListNavigation.d.ts +9 -7
- package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -1
- package/dist/types/src/aspects/useListSelection.d.ts +3 -3
- package/dist/types/src/aspects/useListSelection.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts +3 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
- package/dist/types/src/components/Combobox/Combobox.d.ts +10 -3
- package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/types/src/components/DropIndicator/DropIndicator.d.ts +12 -0
- package/dist/types/src/components/DropIndicator/DropIndicator.d.ts.map +1 -0
- package/dist/types/src/components/DropIndicator/index.d.ts +2 -0
- package/dist/types/src/components/DropIndicator/index.d.ts.map +1 -0
- package/dist/types/src/components/Empty/Empty.d.ts +17 -0
- package/dist/types/src/components/Empty/Empty.d.ts.map +1 -0
- package/dist/types/src/components/Empty/Empty.stories.d.ts +18 -0
- package/dist/types/src/components/Empty/Empty.stories.d.ts.map +1 -0
- package/dist/types/src/components/Empty/index.d.ts +2 -0
- package/dist/types/src/components/Empty/index.d.ts.map +1 -0
- package/dist/types/src/components/List.theme.d.ts +193 -0
- package/dist/types/src/components/List.theme.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/ListItemContent.d.ts +27 -0
- package/dist/types/src/components/Listbox/ListItemContent.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts +24 -0
- package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/Listbox.d.ts +19 -2
- package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts +4 -0
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedList.d.ts +1 -1
- package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +1 -1
- package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -1
- package/dist/types/src/components/Picker/Picker.d.ts +12 -4
- package/dist/types/src/components/Picker/Picker.d.ts.map +1 -1
- package/dist/types/src/components/Picker/index.d.ts +1 -1
- package/dist/types/src/components/Picker/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.d.ts +1 -1
- package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -2
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeContext.d.ts +2 -0
- package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeDropIndicator.d.ts +10 -0
- package/dist/types/src/components/Tree/TreeDropIndicator.d.ts.map +1 -0
- package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
- package/dist/types/src/components/Tree/testing.d.ts +4 -1
- package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
- package/dist/types/src/components/Treegrid/Treegrid.d.ts +33 -0
- package/dist/types/src/components/Treegrid/Treegrid.d.ts.map +1 -0
- package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts +11 -0
- package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts.map +1 -0
- package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts +37 -0
- package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts.map +1 -0
- package/dist/types/src/components/Treegrid/index.d.ts +2 -0
- package/dist/types/src/components/Treegrid/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +3 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -22
- package/src/aspects/useListDisclosure.ts +8 -8
- package/src/aspects/useListNavigation.ts +9 -7
- package/src/aspects/useListSelection.ts +4 -4
- package/src/components/Accordion/AccordionItem.tsx +31 -13
- package/src/components/Combobox/Combobox.tsx +46 -38
- package/src/components/DropIndicator/DropIndicator.tsx +74 -0
- package/src/components/DropIndicator/index.ts +5 -0
- package/src/components/Empty/Empty.stories.tsx +37 -0
- package/src/components/Empty/Empty.tsx +41 -0
- package/src/components/Empty/index.ts +5 -0
- package/src/components/List.theme.ts +99 -0
- package/src/components/Listbox/ListItemContent.stories.tsx +41 -0
- package/src/components/Listbox/ListItemContent.tsx +49 -0
- package/src/components/Listbox/Listbox.stories.tsx +106 -7
- package/src/components/Listbox/Listbox.tsx +85 -42
- package/src/components/OrderedList/OrderedList.tsx +1 -1
- package/src/components/OrderedList/OrderedListItem.tsx +13 -13
- package/src/components/OrderedList/OrderedListRoot.tsx +6 -3
- package/src/components/Picker/Picker.tsx +25 -15
- package/src/components/Picker/index.ts +1 -1
- package/src/components/Tree/Tree.stories.tsx +39 -15
- package/src/components/Tree/Tree.tsx +13 -3
- package/src/components/Tree/TreeContext.tsx +2 -0
- package/src/components/Tree/TreeDropIndicator.tsx +72 -0
- package/src/components/Tree/TreeItem.tsx +43 -3
- package/src/components/Tree/TreeItemHeading.tsx +2 -2
- package/src/components/Tree/testing.ts +19 -14
- package/src/components/Treegrid/Treegrid.stories.tsx +132 -0
- package/src/components/Treegrid/Treegrid.theme.ts +38 -0
- package/src/components/Treegrid/Treegrid.tsx +187 -0
- package/src/components/Treegrid/index.ts +5 -0
- package/src/components/index.ts +3 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { tv } from '@dxos/ui-theme';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tailwind-variants theme for all react-ui-list components.
|
|
9
|
+
* Consumers call `listTheme.styles()` to get the slot functions.
|
|
10
|
+
* Each slot function accepts an optional `{ class: ... }` override for per-instance merging.
|
|
11
|
+
*
|
|
12
|
+
* Structural pattern shared across list components:
|
|
13
|
+
* - viewport: ScrollArea wrapper (`dx-container`)
|
|
14
|
+
* - content: flex-col item container
|
|
15
|
+
* - item: base row (interactive affordances come from dx-hover / dx-selected / dx-current)
|
|
16
|
+
*/
|
|
17
|
+
const listStyles = tv({
|
|
18
|
+
slots: {
|
|
19
|
+
//
|
|
20
|
+
// Accordion
|
|
21
|
+
//
|
|
22
|
+
accordionItem: 'overflow-hidden',
|
|
23
|
+
// Row trigger: spans the full width and pins the trailing caret to the inline-end edge.
|
|
24
|
+
accordionTrigger: 'group flex items-start justify-between gap-2 p-2 dx-focus-ring-inset w-full text-start',
|
|
25
|
+
// Leading / trailing icon wrappers: fixed height so they sit on the centerline of the first
|
|
26
|
+
// content line even when the header spans multiple lines.
|
|
27
|
+
accordionTriggerIcon: 'flex items-center h-6 shrink-0',
|
|
28
|
+
accordionTriggerContent: 'min-w-0 flex-1',
|
|
29
|
+
// Slide animations are driven by Radix Accordion's data-state attribute.
|
|
30
|
+
accordionBody: 'overflow-hidden data-[state=closed]:animate-slide-up data-[state=open]:animate-slide-down',
|
|
31
|
+
accordionBodyContent: 'p-2',
|
|
32
|
+
|
|
33
|
+
//
|
|
34
|
+
// Listbox
|
|
35
|
+
//
|
|
36
|
+
listboxViewport: 'dx-container',
|
|
37
|
+
listboxContent: 'flex flex-col',
|
|
38
|
+
// `dx-selected` pairs with `aria-selected="true"` set per-option (see
|
|
39
|
+
// `ui-theme/src/css/components/state.md`). `outline-none` removes the native focus
|
|
40
|
+
// ring; Tabster / `dx-focus-ring` handles keyboard focus at the container level.
|
|
41
|
+
listboxItem: 'flex items-center dx-hover dx-selected px-3 py-2 cursor-pointer outline-none',
|
|
42
|
+
listboxItemLabel: 'grow truncate',
|
|
43
|
+
|
|
44
|
+
//
|
|
45
|
+
// OrderedList
|
|
46
|
+
//
|
|
47
|
+
orderedListViewport: 'dx-container',
|
|
48
|
+
orderedListContent: 'flex flex-col',
|
|
49
|
+
// `dx-current` enables `aria-current` row styling (not listbox/option semantics).
|
|
50
|
+
orderedListItem: 'relative dx-current',
|
|
51
|
+
// Bordered column wrapping title + detail panel in the master-detail layout.
|
|
52
|
+
orderedListDetailColumn: 'flex flex-col ring-1 ring-subdued-separator rounded-sm overflow-hidden',
|
|
53
|
+
// `min-h` matches the shared rail-item track so handles, title, and caret share a baseline.
|
|
54
|
+
orderedListDetailTitleRow: 'flex items-center min-h-[var(--dx-rail-item)]',
|
|
55
|
+
orderedListDetailPanel: 'px-2 pb-2',
|
|
56
|
+
orderedListTitle: 'flex grow items-center truncate cursor-pointer',
|
|
57
|
+
|
|
58
|
+
//
|
|
59
|
+
// Picker
|
|
60
|
+
//
|
|
61
|
+
// `px-[var(--gutter,…)]` aligns padding with sibling `Column.Center` content, falling back to
|
|
62
|
+
// 0.75rem when not nested under `Column.Root`.
|
|
63
|
+
pickerItem: 'dx-hover dx-selected px-[var(--gutter,0.75rem)] py-1 cursor-pointer select-none',
|
|
64
|
+
|
|
65
|
+
//
|
|
66
|
+
// Combobox
|
|
67
|
+
//
|
|
68
|
+
// `m-form-chrome mb-0` mirrors the rest of the form-chrome padding convention.
|
|
69
|
+
comboboxInput: 'm-form-chrome mb-0 w-[calc(100%-2*var(--spacing-form-chrome))]',
|
|
70
|
+
comboboxList: 'py-form-chrome',
|
|
71
|
+
// Trigger value / placeholder text — grows and truncates; subdued when placeholder.
|
|
72
|
+
comboboxTriggerText: 'font-normal text-start flex-1 min-w-0 truncate me-2',
|
|
73
|
+
// Item row adds flex layout; `dx-hover`/`dx-selected` and padding come from `Picker.Item`.
|
|
74
|
+
comboboxItem: 'flex w-full gap-2 items-center',
|
|
75
|
+
comboboxItemDescription: 'text-sm text-description truncate',
|
|
76
|
+
|
|
77
|
+
//
|
|
78
|
+
// ItemContent
|
|
79
|
+
//
|
|
80
|
+
// Two-column grid: fixed rail-item icon track + flexible content track.
|
|
81
|
+
itemContentRoot: 'grid grid-cols-[var(--dx-rail-item)_minmax(0,1fr)] items-center gap-x-2 is-full min-is-0',
|
|
82
|
+
itemContentIcon: 'col-start-1 row-start-1 place-self-center',
|
|
83
|
+
itemContentTitle: 'col-start-2 row-start-1 min-is-0 truncate',
|
|
84
|
+
itemContentDescription: 'col-start-2 row-start-2 min-is-0 truncate text-sm text-description',
|
|
85
|
+
|
|
86
|
+
//
|
|
87
|
+
// Empty
|
|
88
|
+
//
|
|
89
|
+
empty: 'flex flex-col items-center justify-center gap-2 p-4 text-sm text-center text-description',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** react-ui-list theme: call `.styles()` to get per-slot class functions. */
|
|
94
|
+
export const listTheme = {
|
|
95
|
+
styles: listStyles,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Slot names of {@link listTheme.styles}, for `bridgeTv` registration. */
|
|
99
|
+
export const listSlots = Object.keys(listTheme.styles()) as Array<keyof ReturnType<typeof listTheme.styles>>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
|
|
8
|
+
import { Icon } from '@dxos/react-ui';
|
|
9
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
10
|
+
|
|
11
|
+
import { ListItemContent } from './ListItemContent';
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: 'ui/react-ui-list/ItemContent',
|
|
15
|
+
component: ListItemContent,
|
|
16
|
+
render: (args) => (
|
|
17
|
+
<div className='flex flex-col divide-y divide-subdued-separator'>
|
|
18
|
+
<ListItemContent {...args} classNames='p-2' />
|
|
19
|
+
<ListItemContent icon='ph--clock--regular' title='Title only, no description' classNames='p-2' />
|
|
20
|
+
<ListItemContent
|
|
21
|
+
icon={<Icon icon='ph--x-circle--regular' size={5} classNames='text-error-text' />}
|
|
22
|
+
title='Failed run'
|
|
23
|
+
description='Failed · 2.6s'
|
|
24
|
+
classNames='p-2'
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
29
|
+
} satisfies Meta<typeof ListItemContent>;
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
|
|
33
|
+
type Story = StoryObj<typeof meta>;
|
|
34
|
+
|
|
35
|
+
export const Default: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
icon: <Icon icon='ph--check-circle--regular' size={5} classNames='text-success-text' />,
|
|
38
|
+
title: '6/26/2026, 5:00:00 AM',
|
|
39
|
+
description: 'Success · 2.5s',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type ReactElement, type ReactNode, forwardRef } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Icon, type ThemedClassName } from '@dxos/react-ui';
|
|
8
|
+
import { mx } from '@dxos/ui-theme';
|
|
9
|
+
|
|
10
|
+
import { listTheme } from '../List.theme';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Presentational row layout: a rail-sized leading icon centered on the primary line, with an
|
|
14
|
+
* optional secondary line aligned beneath the title in the content column. Drop inside a
|
|
15
|
+
* `Listbox.Item`, an `Accordion.ItemHeader`, or any row container.
|
|
16
|
+
*
|
|
17
|
+
* The grid uses the same `var(--dx-rail-item)` rail track as `useListGrid`, so an adjacent body
|
|
18
|
+
* (e.g. an `Accordion.ItemBody`) can reuse `grid-cols-[var(--dx-rail-item)_1fr]` to line its
|
|
19
|
+
* content up under the same content column.
|
|
20
|
+
*/
|
|
21
|
+
const styles = listTheme.styles();
|
|
22
|
+
|
|
23
|
+
export type ListItemContentProps = ThemedClassName<{
|
|
24
|
+
/**
|
|
25
|
+
* Leading icon: an icon name (rendered as a neutral size-5 `Icon`) or a custom `Icon` element
|
|
26
|
+
* carrying its own size/colour (e.g. `<Icon icon='…' classNames='text-success-text' />`).
|
|
27
|
+
*/
|
|
28
|
+
icon?: string | ReactElement;
|
|
29
|
+
/** Primary line. */
|
|
30
|
+
title: ReactNode;
|
|
31
|
+
/** Optional secondary line, aligned under the title in the content column. */
|
|
32
|
+
description?: ReactNode;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
export const ListItemContent = forwardRef<HTMLDivElement, ListItemContentProps>(
|
|
36
|
+
({ classNames, icon, title, description }, forwardedRef) => (
|
|
37
|
+
<div ref={forwardedRef} className={styles.itemContentRoot({ class: mx(classNames) })}>
|
|
38
|
+
{icon != null && (
|
|
39
|
+
<div className={styles.itemContentIcon()}>
|
|
40
|
+
{typeof icon === 'string' ? <Icon icon={icon} size={5} /> : icon}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
<span className={styles.itemContentTitle()}>{title}</span>
|
|
44
|
+
{description != null && <span className={styles.itemContentDescription()}>{description}</span>}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
ListItemContent.displayName = 'ItemContent';
|
|
@@ -6,9 +6,11 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
|
6
6
|
import React, { useState } from 'react';
|
|
7
7
|
|
|
8
8
|
import { random } from '@dxos/random';
|
|
9
|
-
import { Input, Panel, Toolbar } from '@dxos/react-ui';
|
|
9
|
+
import { Icon, Input, Panel, Toolbar } from '@dxos/react-ui';
|
|
10
10
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
|
+
import { mx } from '@dxos/ui-theme';
|
|
11
12
|
|
|
13
|
+
import { useListDisclosure } from '../../aspects';
|
|
12
14
|
import { Listbox } from './Listbox';
|
|
13
15
|
|
|
14
16
|
random.seed(1);
|
|
@@ -157,7 +159,7 @@ const popoverOptions: Option[] = random.helpers.multiple(
|
|
|
157
159
|
const PopoverStory = () => {
|
|
158
160
|
const [selected, setSelected] = useState<string | undefined>(popoverOptions[0]?.value);
|
|
159
161
|
return (
|
|
160
|
-
<div className='max-w-xs p-2
|
|
162
|
+
<div className='max-w-xs p-2 border-1 border-subdued-separator rounded'>
|
|
161
163
|
<Listbox.Root value={selected} onValueChange={setSelected}>
|
|
162
164
|
<Listbox.Content aria-label='Models'>
|
|
163
165
|
{popoverOptions.map((option) => (
|
|
@@ -177,6 +179,70 @@ const PopoverStory = () => {
|
|
|
177
179
|
);
|
|
178
180
|
};
|
|
179
181
|
|
|
182
|
+
//
|
|
183
|
+
// Plain (non-selectable) — no value model on Root, so rows render as `role=list`/`listitem`
|
|
184
|
+
// with hover but no `aria-selected`. This is the styled-content-list mode that replaces the
|
|
185
|
+
// deprecated `@dxos/react-ui` `List`/`ListItem`.
|
|
186
|
+
//
|
|
187
|
+
|
|
188
|
+
const PlainStory = () => (
|
|
189
|
+
<Listbox.Root>
|
|
190
|
+
<Listbox.Viewport>
|
|
191
|
+
<Listbox.Content aria-label='Items'>
|
|
192
|
+
{allItems.slice(0, 6).map((item) => (
|
|
193
|
+
<Listbox.Item key={item.id} id={item.id}>
|
|
194
|
+
<Listbox.ItemLabel>{item.name}</Listbox.ItemLabel>
|
|
195
|
+
</Listbox.Item>
|
|
196
|
+
))}
|
|
197
|
+
</Listbox.Content>
|
|
198
|
+
</Listbox.Viewport>
|
|
199
|
+
</Listbox.Root>
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
//
|
|
203
|
+
// Disclosure — expandable rows showing icon + title, each with a single full-row header
|
|
204
|
+
// button (icon + title + caret on one line) that toggles a description panel via the
|
|
205
|
+
// `useListDisclosure` aspect. Plain (non-selectable) list, so the header button is the only
|
|
206
|
+
// focusable element per row — arrow keys move row-to-row, not into the caret.
|
|
207
|
+
//
|
|
208
|
+
|
|
209
|
+
const DisclosureStory = () => {
|
|
210
|
+
const disclosure = useListDisclosure({ mode: 'multi' });
|
|
211
|
+
return (
|
|
212
|
+
<Listbox.Root>
|
|
213
|
+
<Listbox.Viewport>
|
|
214
|
+
<Listbox.Content aria-label='Items'>
|
|
215
|
+
{allItems.slice(0, 8).map((item) => {
|
|
216
|
+
const { expanded, triggerProps, panelProps } = disclosure.bind(item.id);
|
|
217
|
+
return (
|
|
218
|
+
<Listbox.Item key={item.id} id={item.id} classNames='flex-col items-stretch p-0'>
|
|
219
|
+
<button
|
|
220
|
+
{...triggerProps}
|
|
221
|
+
type='button'
|
|
222
|
+
className='flex items-center gap-2 px-3 py-2 text-start dx-hover dx-focus-ring-inset'
|
|
223
|
+
>
|
|
224
|
+
<Icon icon='ph--package--regular' size={5} classNames='shrink-0' />
|
|
225
|
+
<span className='flex-1 min-w-0 truncate'>{item.name}</span>
|
|
226
|
+
<Icon
|
|
227
|
+
icon='ph--caret-right--regular'
|
|
228
|
+
size={4}
|
|
229
|
+
classNames={mx('shrink-0 transition-transform', expanded && 'rotate-90')}
|
|
230
|
+
/>
|
|
231
|
+
</button>
|
|
232
|
+
{expanded && (
|
|
233
|
+
<div {...panelProps} className='ps-[var(--dx-rail-item)] px-3 pb-2 text-sm text-description'>
|
|
234
|
+
{item.description}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</Listbox.Item>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</Listbox.Content>
|
|
241
|
+
</Listbox.Viewport>
|
|
242
|
+
</Listbox.Root>
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
|
|
180
246
|
const meta = {
|
|
181
247
|
title: 'ui/react-ui-list/Listbox',
|
|
182
248
|
render: (args) => <DefaultStory {...args} />,
|
|
@@ -191,8 +257,41 @@ export default meta;
|
|
|
191
257
|
type Story = StoryObj<StoryArgs>;
|
|
192
258
|
|
|
193
259
|
export const Default: Story = {};
|
|
194
|
-
|
|
195
|
-
export const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
260
|
+
|
|
261
|
+
export const Thin: Story = {
|
|
262
|
+
args: {
|
|
263
|
+
thin: true,
|
|
264
|
+
padding: true,
|
|
265
|
+
showDescription: false,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const WithDisabled: Story = {
|
|
270
|
+
args: {
|
|
271
|
+
items: allItems.slice(0, 6),
|
|
272
|
+
disabledIndex: 2,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const MasterDetail: Story = {
|
|
277
|
+
render: () => <MasterDetailStory />,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const WithToolbar: Story = {
|
|
281
|
+
render: () => <WithToolbarStory />,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export const Popover: Story = {
|
|
285
|
+
decorators: [withTheme(), withLayout({ layout: 'centered' })],
|
|
286
|
+
render: () => <PopoverStory />,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Non-selectable: opt-out of the selection model — plain styled rows (role=list/listitem). */
|
|
290
|
+
export const Plain: Story = {
|
|
291
|
+
render: () => <PlainStory />,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** Items with icon + title + description and a per-row expand caret (disclosure aspect). */
|
|
295
|
+
export const Disclosure: Story = {
|
|
296
|
+
render: () => <DisclosureStory />,
|
|
297
|
+
};
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
// `Listbox` —
|
|
6
|
-
// option-list pattern
|
|
7
|
-
//
|
|
5
|
+
// `Listbox` — the package's general styled list, with **opt-in selection**. One compound for
|
|
6
|
+
// both the picker / option-list pattern (full-pane with `Listbox.Viewport`, or compact popover
|
|
7
|
+
// without) and plain styled content rows.
|
|
8
|
+
//
|
|
9
|
+
// Selection is inferred from props: pass `value`/`defaultValue`/`onValueChange` on `Root` and
|
|
10
|
+
// it's a single-select `role=listbox` (options carry `aria-selected` + `dx-selected`, selection
|
|
11
|
+
// follows focus); omit them and it's a plain `role=list` of `role=listitem` rows (hover only, no
|
|
12
|
+
// selection semantics) — the shape the deprecated `@dxos/react-ui` `List`/`ListItem` filled.
|
|
13
|
+
// (`Listbox` is slated to be renamed `List` once that legacy component is deleted.)
|
|
8
14
|
//
|
|
9
15
|
// Compound shape (matches Radix Select / Toolbar / Tabs):
|
|
10
16
|
//
|
|
@@ -27,7 +33,7 @@
|
|
|
27
33
|
// - `Content` — the `<ul role='listbox'>` holding the items. Applies the navigation aspect's
|
|
28
34
|
// container props (Tabster arrow nav, focus-on-entry redirect, role + aria-orientation).
|
|
29
35
|
// - `Item` — `<li role='option'>` with `aria-selected` on the selected row, paired with
|
|
30
|
-
// `dx-selected` styling. See `ui-theme/src/css/components/
|
|
36
|
+
// `dx-selected` styling. See `ui-theme/src/css/components/state.md`.
|
|
31
37
|
// - `ItemLabel` — text helper that truncates and takes most of the row width.
|
|
32
38
|
// - `Indicator` — optional checkmark icon next to the selected item (confirmatory, since
|
|
33
39
|
// `dx-selected` already styles the row).
|
|
@@ -70,6 +76,10 @@ import {
|
|
|
70
76
|
useListNavigation,
|
|
71
77
|
useListSelection,
|
|
72
78
|
} from '../../aspects';
|
|
79
|
+
import { listTheme } from '../List.theme';
|
|
80
|
+
import { ListItemContent, type ListItemContentProps } from './ListItemContent';
|
|
81
|
+
|
|
82
|
+
const styles = listTheme.styles();
|
|
73
83
|
|
|
74
84
|
const LISTBOX_NAME = 'Listbox';
|
|
75
85
|
const LISTBOX_ROOT_NAME = 'Listbox.Root';
|
|
@@ -86,6 +96,12 @@ const LISTBOX_INDICATOR_NAME = 'Listbox.Indicator';
|
|
|
86
96
|
//
|
|
87
97
|
|
|
88
98
|
type ListboxContextValue = {
|
|
99
|
+
/**
|
|
100
|
+
* Whether the list participates in selection. Inferred on `Root` from the presence of
|
|
101
|
+
* `value`/`defaultValue`/`onValueChange`. Drives `role` (listbox/option vs list/listitem),
|
|
102
|
+
* `aria-selected`, and whether row clicks update the selection model.
|
|
103
|
+
*/
|
|
104
|
+
selectable: boolean;
|
|
89
105
|
/** Selection aspect binding factory; items consume their own bindings from this. */
|
|
90
106
|
selection: UseListSelectionReturn;
|
|
91
107
|
};
|
|
@@ -103,7 +119,10 @@ const [ListboxItemProvider, useListboxItemContext] = createContext<ListboxItemCo
|
|
|
103
119
|
//
|
|
104
120
|
|
|
105
121
|
type RootProps = PropsWithChildren<{
|
|
106
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Currently-selected option id (controlled). Supplying any of `value`/`defaultValue`/
|
|
124
|
+
* `onValueChange` makes the list selectable; omitting all three renders plain rows.
|
|
125
|
+
*/
|
|
107
126
|
value?: string;
|
|
108
127
|
/** Initial selected option for uncontrolled mode. */
|
|
109
128
|
defaultValue?: string;
|
|
@@ -119,9 +138,15 @@ type RootProps = PropsWithChildren<{
|
|
|
119
138
|
}>;
|
|
120
139
|
|
|
121
140
|
const Root = ({ value, defaultValue, onValueChange, autoFocus: _autoFocus, children }: RootProps) => {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
141
|
+
// Selection is opt-in: a list is selectable only when the consumer wires the value model.
|
|
142
|
+
// Plain content lists (the migrated `@dxos/react-ui` `List` call sites) pass none of these
|
|
143
|
+
// and render as `role=list`/`listitem` rows.
|
|
144
|
+
const selectable = value !== undefined || defaultValue !== undefined || onValueChange !== undefined;
|
|
145
|
+
|
|
146
|
+
// `useListSelection` is a hook, so it is always called (stable hook order); `selectable`
|
|
147
|
+
// gates whether items actually consume the binding. The aspect emits `string | undefined`
|
|
148
|
+
// because it is mode-generic; in single-select the value only clears when the consumer
|
|
149
|
+
// drives it, never from a row click — filter to keep the public callback narrow.
|
|
125
150
|
const selection = useListSelection({
|
|
126
151
|
mode: 'single',
|
|
127
152
|
value,
|
|
@@ -133,7 +158,7 @@ const Root = ({ value, defaultValue, onValueChange, autoFocus: _autoFocus, child
|
|
|
133
158
|
},
|
|
134
159
|
});
|
|
135
160
|
|
|
136
|
-
const context = useMemo(() => ({ selection }), [selection]);
|
|
161
|
+
const context = useMemo(() => ({ selectable, selection }), [selectable, selection]);
|
|
137
162
|
|
|
138
163
|
return <ListboxProvider {...context}>{children}</ListboxProvider>;
|
|
139
164
|
};
|
|
@@ -154,7 +179,7 @@ const Viewport = composable<HTMLDivElement, ViewportProps>((props, forwardedRef)
|
|
|
154
179
|
>;
|
|
155
180
|
return (
|
|
156
181
|
<ScrollArea.Root
|
|
157
|
-
{...composableProps<HTMLDivElement>(rest, { classNames:
|
|
182
|
+
{...composableProps<HTMLDivElement>(rest, { classNames: styles.listboxViewport() })}
|
|
158
183
|
{...{ thin, padding, centered }}
|
|
159
184
|
orientation='vertical'
|
|
160
185
|
ref={forwardedRef}
|
|
@@ -179,12 +204,12 @@ type ContentProps = {
|
|
|
179
204
|
};
|
|
180
205
|
|
|
181
206
|
const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef) => {
|
|
182
|
-
|
|
183
|
-
useListboxContext(LISTBOX_CONTENT_NAME);
|
|
207
|
+
const { selectable } = useListboxContext(LISTBOX_CONTENT_NAME);
|
|
184
208
|
|
|
185
|
-
// `useListNavigation` bundles role
|
|
186
|
-
// focus-on-entry redirect (to selected, then first non-disabled option)
|
|
187
|
-
|
|
209
|
+
// `useListNavigation` bundles role + aria-orientation + Tabster arrow nav. In `listbox` mode
|
|
210
|
+
// it also adds the focus-on-entry redirect (to selected, then first non-disabled option);
|
|
211
|
+
// `list` mode is for the non-selectable rows (arrow nav across interactive descendants only).
|
|
212
|
+
const navigation = useListNavigation({ mode: selectable ? 'listbox' : 'list' });
|
|
188
213
|
|
|
189
214
|
const { children, ...rest } = props as PropsWithChildren<ContentProps & Record<string, unknown>>;
|
|
190
215
|
|
|
@@ -192,7 +217,7 @@ const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef)
|
|
|
192
217
|
// context-scope check. The container's role/aria/Tabster wiring comes from the navigation
|
|
193
218
|
// aspect rather than the primitive's `selectable` plumbing — that keeps the ARIA grammar
|
|
194
219
|
// (`aria-selected`) owned by `Item` below.
|
|
195
|
-
const composed = composableProps<HTMLUListElement>(rest, { classNames:
|
|
220
|
+
const composed = composableProps<HTMLUListElement>(rest, { classNames: styles.listboxContent() });
|
|
196
221
|
return (
|
|
197
222
|
<List
|
|
198
223
|
variant='unordered'
|
|
@@ -220,55 +245,70 @@ type ItemProps = PropsWithChildren<{
|
|
|
220
245
|
onClick?: (event: MouseEvent<HTMLLIElement>) => void;
|
|
221
246
|
/** Optional focus handler in addition to selection-follows-focus. */
|
|
222
247
|
onFocus?: (event: FocusEvent<HTMLLIElement>) => void;
|
|
248
|
+
/**
|
|
249
|
+
* Optional pointer-down handler. Fires before focus (and therefore before selection-follows-focus
|
|
250
|
+
* mutates the value), so consumers can observe the pre-gesture selection — e.g. to implement
|
|
251
|
+
* click-to-toggle without the focus-then-click double count.
|
|
252
|
+
*/
|
|
253
|
+
onMouseDown?: (event: MouseEvent<HTMLLIElement>) => void;
|
|
223
254
|
}>;
|
|
224
255
|
|
|
225
|
-
// `dx-selected` pairs with `aria-selected="true"` (set per-option below); see
|
|
226
|
-
// `ui-theme/src/css/components/selected.md`.
|
|
227
|
-
const ITEM_BASE = 'flex items-center dx-hover dx-selected px-3 py-2 cursor-pointer outline-none';
|
|
228
|
-
|
|
229
256
|
const Item = composable<HTMLLIElement, ItemProps>((props, forwardedRef) => {
|
|
230
|
-
const { id, disabled, onClick, onFocus, children, ...rest } = props as ItemProps &
|
|
231
|
-
|
|
257
|
+
const { id, disabled, onClick, onFocus, onMouseDown, children, ...rest } = props as ItemProps &
|
|
258
|
+
Record<string, unknown>;
|
|
259
|
+
const { selectable, selection } = useListboxContext(LISTBOX_ITEM_NAME);
|
|
232
260
|
const binding: SelectionItemBinding = selection.bind(id, { disabled });
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
261
|
+
const selected = selectable && binding.selected;
|
|
262
|
+
// A non-selectable row is interactive only if the caller wired a click; otherwise it's a
|
|
263
|
+
// plain display row (no pointer affordance).
|
|
264
|
+
const interactive = selectable || onClick != null;
|
|
265
|
+
|
|
266
|
+
// Compose the selection aspect's click/focus handlers with the row's optional ones so both
|
|
267
|
+
// wire-ups stay synchronized: selection happens before user code so a click that also runs
|
|
268
|
+
// imperative side effects sees the selected value first. Skipped entirely when not selectable
|
|
269
|
+
// so a plain row click doesn't mutate hidden selection state.
|
|
237
270
|
const handleClick = useCallback(
|
|
238
271
|
(event: MouseEvent<HTMLLIElement>) => {
|
|
239
|
-
|
|
272
|
+
if (selectable) {
|
|
273
|
+
binding.rowProps.onClick(event);
|
|
274
|
+
}
|
|
240
275
|
if (!disabled) {
|
|
241
276
|
onClick?.(event);
|
|
242
277
|
}
|
|
243
278
|
},
|
|
244
|
-
[binding, disabled, onClick],
|
|
279
|
+
[selectable, binding, disabled, onClick],
|
|
245
280
|
);
|
|
246
281
|
|
|
247
282
|
const handleFocus = useCallback(
|
|
248
283
|
(event: FocusEvent<HTMLLIElement>) => {
|
|
249
|
-
|
|
284
|
+
if (selectable) {
|
|
285
|
+
binding.rowProps.onFocus?.(event);
|
|
286
|
+
}
|
|
250
287
|
onFocus?.(event);
|
|
251
288
|
},
|
|
252
|
-
[binding, onFocus],
|
|
289
|
+
[selectable, binding, onFocus],
|
|
253
290
|
);
|
|
254
291
|
|
|
255
292
|
const composed = composableProps<HTMLLIElement>(rest, {
|
|
256
|
-
classNames:
|
|
293
|
+
classNames: styles.listboxItem({
|
|
294
|
+
class: [!interactive && 'cursor-default', disabled && 'opacity-50 cursor-not-allowed'],
|
|
295
|
+
}),
|
|
257
296
|
});
|
|
258
297
|
|
|
259
298
|
// Per WAI-ARIA APG listbox guidance, disabled options remain keyboard-navigable for SR
|
|
260
|
-
// announcement; the selection model is not updated for disabled rows (the aspect's
|
|
261
|
-
//
|
|
299
|
+
// announcement; the selection model is not updated for disabled rows (the aspect's binding
|
|
300
|
+
// enforces that internally). Non-selectable rows are `role=listitem` with no `aria-selected`.
|
|
262
301
|
return (
|
|
263
|
-
<ListItemProviderHost id={id} selected={
|
|
302
|
+
<ListItemProviderHost id={id} selected={selected}>
|
|
264
303
|
<ListItem
|
|
265
304
|
{...composed}
|
|
266
|
-
role='option'
|
|
267
|
-
tabIndex={0}
|
|
268
|
-
aria-selected={
|
|
305
|
+
role={selectable ? 'option' : 'listitem'}
|
|
306
|
+
tabIndex={selectable ? 0 : -1}
|
|
307
|
+
aria-selected={selectable ? selected : undefined}
|
|
269
308
|
aria-disabled={disabled || undefined}
|
|
270
309
|
onClick={handleClick}
|
|
271
310
|
onFocus={handleFocus}
|
|
311
|
+
onMouseDown={onMouseDown}
|
|
272
312
|
ref={forwardedRef}
|
|
273
313
|
>
|
|
274
314
|
{children}
|
|
@@ -297,7 +337,7 @@ const ListItemProviderHost = ({ id, selected, children }: PropsWithChildren<List
|
|
|
297
337
|
type ItemLabelProps = ThemedClassName<ComponentPropsWithRef<'span'>>;
|
|
298
338
|
|
|
299
339
|
const ItemLabel = forwardRef<HTMLSpanElement, ItemLabelProps>(({ classNames, children, ...rest }, forwardedRef) => (
|
|
300
|
-
<span {...rest} className={
|
|
340
|
+
<span {...rest} className={styles.listboxItemLabel({ class: mx(classNames) })} ref={forwardedRef}>
|
|
301
341
|
{children}
|
|
302
342
|
</span>
|
|
303
343
|
));
|
|
@@ -344,15 +384,18 @@ const Listbox = {
|
|
|
344
384
|
Content,
|
|
345
385
|
Item,
|
|
346
386
|
ItemLabel,
|
|
387
|
+
ItemContent: ListItemContent,
|
|
347
388
|
Indicator,
|
|
348
389
|
};
|
|
349
390
|
|
|
350
391
|
export { Listbox, useListboxSelection };
|
|
392
|
+
|
|
351
393
|
export type {
|
|
352
|
-
|
|
353
|
-
ViewportProps as ListboxViewportProps,
|
|
394
|
+
ListItemContentProps as ItemContentProps,
|
|
354
395
|
ContentProps as ListboxContentProps,
|
|
355
|
-
ItemProps as ListboxItemProps,
|
|
356
|
-
ItemLabelProps as ListboxItemLabelProps,
|
|
357
396
|
IndicatorProps as ListboxIndicatorProps,
|
|
397
|
+
ItemLabelProps as ListboxItemLabelProps,
|
|
398
|
+
ItemProps as ListboxItemProps,
|
|
399
|
+
RootProps as ListboxRootProps,
|
|
400
|
+
ViewportProps as ListboxViewportProps,
|
|
358
401
|
};
|
|
@@ -60,4 +60,4 @@ export const OrderedList = {
|
|
|
60
60
|
ExpandCaret: OrderedListExpandCaret,
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
export type {
|
|
63
|
+
export type { OrderedListDetailItemProps, OrderedListItemProps, OrderedListRootProps, OrderedListViewportProps };
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
IconBlock,
|
|
18
18
|
IconButton,
|
|
19
19
|
type IconButtonProps,
|
|
20
|
-
ListItem as NaturalListItem,
|
|
21
20
|
type ThemedClassName,
|
|
22
21
|
ToggleIconButton,
|
|
23
22
|
useTranslation,
|
|
@@ -25,8 +24,12 @@ import {
|
|
|
25
24
|
import { mx, osTranslations } from '@dxos/ui-theme';
|
|
26
25
|
|
|
27
26
|
import { useListGrid, useReorderItem } from '../../aspects';
|
|
27
|
+
import { DropIndicator } from '../DropIndicator';
|
|
28
|
+
import { listTheme } from '../List.theme';
|
|
28
29
|
import { type ListItemRecord, useOrderedListContext } from './OrderedListRoot';
|
|
29
30
|
|
|
31
|
+
const styles = listTheme.styles();
|
|
32
|
+
|
|
30
33
|
const ORDERED_LIST_ITEM_NAME = 'OrderedListItem';
|
|
31
34
|
|
|
32
35
|
type OrderedListItemContextValue = {
|
|
@@ -106,15 +109,12 @@ export const OrderedListItem = <T extends ListItemRecord>({
|
|
|
106
109
|
style={style}
|
|
107
110
|
aria-current={selected || undefined}
|
|
108
111
|
onClick={onClick}
|
|
109
|
-
className={
|
|
110
|
-
'
|
|
111
|
-
|
|
112
|
-
state.type === 'dragging' && 'opacity-50',
|
|
113
|
-
classNames,
|
|
114
|
-
)}
|
|
112
|
+
className={styles.orderedListItem({
|
|
113
|
+
class: mx(hover && 'dx-hover', state.type === 'dragging' && 'opacity-50', classNames),
|
|
114
|
+
})}
|
|
115
115
|
>
|
|
116
116
|
{children}
|
|
117
|
-
{closestEdge && <
|
|
117
|
+
{closestEdge && <DropIndicator edge={closestEdge} />}
|
|
118
118
|
</div>
|
|
119
119
|
</OrderedListItemProvider>
|
|
120
120
|
);
|
|
@@ -169,7 +169,7 @@ export const OrderedListTitle = ({
|
|
|
169
169
|
id={triggerProps.id}
|
|
170
170
|
aria-expanded={triggerProps['aria-expanded']}
|
|
171
171
|
aria-controls={triggerProps['aria-controls']}
|
|
172
|
-
className={
|
|
172
|
+
className={styles.orderedListTitle({ class: mx(classNames) })}
|
|
173
173
|
onClick={handleClick}
|
|
174
174
|
>
|
|
175
175
|
{children}
|
|
@@ -310,15 +310,15 @@ export const OrderedListDetailItem = <T extends ListItemRecord>({
|
|
|
310
310
|
classNames={mx(grid.rowProps.className, 'pb-1', classNames)}
|
|
311
311
|
>
|
|
312
312
|
<OrderedListDragHandle />
|
|
313
|
-
<div className=
|
|
314
|
-
<div className=
|
|
313
|
+
<div className={styles.orderedListDetailColumn()}>
|
|
314
|
+
<div className={styles.orderedListDetailTitleRow()}>
|
|
315
315
|
{expandable ? (
|
|
316
316
|
<OrderedListTitle classNames={mx('px-2', titleClassNames)}>{title}</OrderedListTitle>
|
|
317
317
|
) : (
|
|
318
318
|
// When the row is not expandable, render a plain (non-toggling) title so a click
|
|
319
319
|
// doesn't mutate hidden disclosure state. Mirrors `OrderedListTitle`'s structure
|
|
320
320
|
// minus the trigger plumbing.
|
|
321
|
-
<div className={mx('
|
|
321
|
+
<div className={styles.orderedListTitle({ class: mx('cursor-auto px-2', titleClassNames) })}>{title}</div>
|
|
322
322
|
)}
|
|
323
323
|
{actions}
|
|
324
324
|
{expandable && <OrderedListExpandCaret />}
|
|
@@ -341,7 +341,7 @@ const DetailPanel = ({ children }: PropsWithChildren) => {
|
|
|
341
341
|
return null;
|
|
342
342
|
}
|
|
343
343
|
return (
|
|
344
|
-
<div {...panelProps} className=
|
|
344
|
+
<div {...panelProps} className={styles.orderedListDetailPanel()}>
|
|
345
345
|
{children}
|
|
346
346
|
</div>
|
|
347
347
|
);
|