@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.
Files changed (101) hide show
  1. package/dist/lib/browser/index.mjs +628 -167
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +628 -167
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/aspects/useListDisclosure.d.ts +4 -4
  8. package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -1
  9. package/dist/types/src/aspects/useListNavigation.d.ts +9 -7
  10. package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -1
  11. package/dist/types/src/aspects/useListSelection.d.ts +3 -3
  12. package/dist/types/src/aspects/useListSelection.d.ts.map +1 -1
  13. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  14. package/dist/types/src/components/Accordion/AccordionItem.d.ts +3 -1
  15. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  16. package/dist/types/src/components/Combobox/Combobox.d.ts +10 -3
  17. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -1
  18. package/dist/types/src/components/DropIndicator/DropIndicator.d.ts +12 -0
  19. package/dist/types/src/components/DropIndicator/DropIndicator.d.ts.map +1 -0
  20. package/dist/types/src/components/DropIndicator/index.d.ts +2 -0
  21. package/dist/types/src/components/DropIndicator/index.d.ts.map +1 -0
  22. package/dist/types/src/components/Empty/Empty.d.ts +17 -0
  23. package/dist/types/src/components/Empty/Empty.d.ts.map +1 -0
  24. package/dist/types/src/components/Empty/Empty.stories.d.ts +18 -0
  25. package/dist/types/src/components/Empty/Empty.stories.d.ts.map +1 -0
  26. package/dist/types/src/components/Empty/index.d.ts +2 -0
  27. package/dist/types/src/components/Empty/index.d.ts.map +1 -0
  28. package/dist/types/src/components/List.theme.d.ts +193 -0
  29. package/dist/types/src/components/List.theme.d.ts.map +1 -0
  30. package/dist/types/src/components/Listbox/ListItemContent.d.ts +27 -0
  31. package/dist/types/src/components/Listbox/ListItemContent.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts +24 -0
  33. package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Listbox/Listbox.d.ts +19 -2
  35. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
  36. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +4 -0
  37. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/OrderedList/OrderedList.d.ts +1 -1
  39. package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -1
  40. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -1
  41. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +1 -1
  42. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -1
  43. package/dist/types/src/components/Picker/Picker.d.ts +12 -4
  44. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -1
  45. package/dist/types/src/components/Picker/index.d.ts +1 -1
  46. package/dist/types/src/components/Picker/index.d.ts.map +1 -1
  47. package/dist/types/src/components/Tree/Tree.d.ts +1 -1
  48. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  49. package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -2
  50. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/Tree/TreeContext.d.ts +2 -0
  52. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  53. package/dist/types/src/components/Tree/TreeDropIndicator.d.ts +10 -0
  54. package/dist/types/src/components/Tree/TreeDropIndicator.d.ts.map +1 -0
  55. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/testing.d.ts +4 -1
  57. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  58. package/dist/types/src/components/Treegrid/Treegrid.d.ts +33 -0
  59. package/dist/types/src/components/Treegrid/Treegrid.d.ts.map +1 -0
  60. package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts +11 -0
  61. package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts.map +1 -0
  62. package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts +37 -0
  63. package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts.map +1 -0
  64. package/dist/types/src/components/Treegrid/index.d.ts +2 -0
  65. package/dist/types/src/components/Treegrid/index.d.ts.map +1 -0
  66. package/dist/types/src/components/index.d.ts +3 -0
  67. package/dist/types/src/components/index.d.ts.map +1 -1
  68. package/dist/types/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +23 -22
  70. package/src/aspects/useListDisclosure.ts +8 -8
  71. package/src/aspects/useListNavigation.ts +9 -7
  72. package/src/aspects/useListSelection.ts +4 -4
  73. package/src/components/Accordion/AccordionItem.tsx +31 -13
  74. package/src/components/Combobox/Combobox.tsx +46 -38
  75. package/src/components/DropIndicator/DropIndicator.tsx +74 -0
  76. package/src/components/DropIndicator/index.ts +5 -0
  77. package/src/components/Empty/Empty.stories.tsx +37 -0
  78. package/src/components/Empty/Empty.tsx +41 -0
  79. package/src/components/Empty/index.ts +5 -0
  80. package/src/components/List.theme.ts +99 -0
  81. package/src/components/Listbox/ListItemContent.stories.tsx +41 -0
  82. package/src/components/Listbox/ListItemContent.tsx +49 -0
  83. package/src/components/Listbox/Listbox.stories.tsx +106 -7
  84. package/src/components/Listbox/Listbox.tsx +85 -42
  85. package/src/components/OrderedList/OrderedList.tsx +1 -1
  86. package/src/components/OrderedList/OrderedListItem.tsx +13 -13
  87. package/src/components/OrderedList/OrderedListRoot.tsx +6 -3
  88. package/src/components/Picker/Picker.tsx +25 -15
  89. package/src/components/Picker/index.ts +1 -1
  90. package/src/components/Tree/Tree.stories.tsx +39 -15
  91. package/src/components/Tree/Tree.tsx +13 -3
  92. package/src/components/Tree/TreeContext.tsx +2 -0
  93. package/src/components/Tree/TreeDropIndicator.tsx +72 -0
  94. package/src/components/Tree/TreeItem.tsx +43 -3
  95. package/src/components/Tree/TreeItemHeading.tsx +2 -2
  96. package/src/components/Tree/testing.ts +19 -14
  97. package/src/components/Treegrid/Treegrid.stories.tsx +132 -0
  98. package/src/components/Treegrid/Treegrid.theme.ts +38 -0
  99. package/src/components/Treegrid/Treegrid.tsx +187 -0
  100. package/src/components/Treegrid/index.ts +5 -0
  101. package/src/components/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-list",
3
- "version": "0.9.1-main.c7dcc2e112",
3
+ "version": "0.10.0",
4
4
  "description": "A list component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -32,37 +32,38 @@
32
32
  "@fluentui/react-tabster": "9.26.11",
33
33
  "@radix-ui/react-accordion": "1.2.3",
34
34
  "@radix-ui/react-context": "1.1.1",
35
+ "@radix-ui/react-primitive": "2.0.2",
35
36
  "@radix-ui/react-slot": "1.1.2",
36
37
  "@radix-ui/react-use-controllable-state": "1.1.0",
37
- "@dxos/debug": "0.9.1-main.c7dcc2e112",
38
- "@dxos/echo": "0.9.1-main.c7dcc2e112",
39
- "@dxos/invariant": "0.9.1-main.c7dcc2e112",
40
- "@dxos/react-list": "0.9.1-main.c7dcc2e112",
41
- "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
42
- "@dxos/log": "0.9.1-main.c7dcc2e112",
43
- "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
44
- "@dxos/ui-types": "0.9.1-main.c7dcc2e112",
45
- "@dxos/util": "0.9.1-main.c7dcc2e112"
38
+ "@dxos/debug": "0.10.0",
39
+ "@dxos/echo": "0.10.0",
40
+ "@dxos/invariant": "0.10.0",
41
+ "@dxos/log": "0.10.0",
42
+ "@dxos/react-ui": "0.10.0",
43
+ "@dxos/ui-theme": "0.10.0",
44
+ "@dxos/react-list": "0.10.0",
45
+ "@dxos/ui-types": "0.10.0",
46
+ "@dxos/util": "0.10.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@testing-library/jest-dom": "^6.9.1",
49
- "@testing-library/react": "^16.3.0",
50
- "@types/react": "~19.2.7",
50
+ "@testing-library/react": "^16.3.2",
51
+ "@types/react": "~19.2.17",
51
52
  "@types/react-dom": "~19.2.3",
52
- "effect": "3.21.3",
53
- "react": "~19.2.3",
54
- "react-dom": "~19.2.3",
53
+ "effect": "3.21.4",
54
+ "react": "~19.2.7",
55
+ "react-dom": "~19.2.7",
55
56
  "resize-observer-polyfill": "^1.5.1",
56
57
  "vite": "^8.0.16",
57
- "@dxos/random": "0.9.1-main.c7dcc2e112",
58
- "@dxos/storybook-utils": "0.9.1-main.c7dcc2e112"
58
+ "@dxos/random": "0.10.0",
59
+ "@dxos/storybook-utils": "0.10.0"
59
60
  },
60
61
  "peerDependencies": {
61
- "effect": "3.21.3",
62
- "react": "~19.2.3",
63
- "react-dom": "~19.2.3",
64
- "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
65
- "@dxos/ui-theme": "0.9.1-main.c7dcc2e112"
62
+ "effect": "3.21.4",
63
+ "react": "~19.2.7",
64
+ "react-dom": "~19.2.7",
65
+ "@dxos/react-ui": "0.10.0",
66
+ "@dxos/ui-theme": "0.10.0"
66
67
  },
67
68
  "publishConfig": {
68
69
  "access": "public"
@@ -29,15 +29,15 @@ export type DisclosureItemBinding = {
29
29
  panelId: string;
30
30
  /** Spread onto the trigger element (button, title row, …). */
31
31
  triggerProps: {
32
- id: string;
32
+ 'id': string;
33
33
  'aria-expanded': boolean;
34
34
  'aria-controls': string;
35
- onClick: (event: MouseEvent) => void;
35
+ 'onClick': (event: MouseEvent) => void;
36
36
  };
37
37
  /** Spread onto the disclosed panel; carries role=region for SR navigation. */
38
38
  panelProps: {
39
- id: string;
40
- role: 'region';
39
+ 'id': string;
40
+ 'role': 'region';
41
41
  'aria-labelledby': string;
42
42
  };
43
43
  };
@@ -141,14 +141,14 @@ export const useListDisclosure: {
141
141
  triggerId,
142
142
  panelId,
143
143
  triggerProps: {
144
- id: triggerId,
144
+ 'id': triggerId,
145
145
  'aria-expanded': expanded,
146
146
  'aria-controls': panelId,
147
- onClick: () => setExpanded(id, !expanded),
147
+ 'onClick': () => setExpanded(id, !expanded),
148
148
  },
149
149
  panelProps: {
150
- id: panelId,
151
- role: 'region',
150
+ 'id': panelId,
151
+ 'role': 'region',
152
152
  'aria-labelledby': triggerId,
153
153
  },
154
154
  };
@@ -45,17 +45,17 @@ export type UseListNavigationReturn = {
45
45
  * stable contract.
46
46
  */
47
47
  containerProps: TabsterDOMAttribute & {
48
- role: ContainerRole;
48
+ 'role': ContainerRole;
49
49
  'aria-orientation'?: 'vertical' | 'horizontal';
50
- onFocus: (event: FocusEvent<HTMLElement>) => void;
50
+ 'onFocus': (event: FocusEvent<HTMLElement>) => void;
51
51
  };
52
52
  /**
53
53
  * Apply to each item. Returns role, tabIndex, and aria-disabled. Disabled options remain
54
54
  * focusable so screen readers can announce them, per WAI-ARIA listbox guidance.
55
55
  */
56
56
  itemProps: (opts?: { disabled?: boolean }) => {
57
- role: ItemRole;
58
- tabIndex: number;
57
+ 'role': ItemRole;
58
+ 'tabIndex': number;
59
59
  'aria-disabled'?: true;
60
60
  };
61
61
  };
@@ -96,9 +96,11 @@ const findListboxEntryTarget = (container: HTMLElement): HTMLElement | null => {
96
96
  * and adds a focus-on-entry redirect (Tabster handles traversal once focus is on a
97
97
  * child; first-entry is the consumer's responsibility).
98
98
  *
99
- * Canonical for all list-shaped surfaces (List, OrderedList, RowList, Tree,
100
- * Combobox.List, Mosaic.Stack). Non-list focus zones — e.g. Composer's multi-pane
101
- * chrome should keep their own Tabster wiring (Focus.Group).
99
+ * The canonical roving-tabindex keyboard aspect. Currently consumed by `Listbox` and
100
+ * `OrderedList`; `Tree` (Treegrid), `Picker`/`Combobox` (input-driven virtual focus), and
101
+ * `Mosaic.Stack` still ship bespoke navigation see `react-ui-list/AUDIT.md` for the
102
+ * convergence analysis. Non-list focus zones — e.g. Composer's multi-pane chrome — keep
103
+ * their own Tabster wiring (`Focus.Group`).
102
104
  */
103
105
  export const useListNavigation = ({
104
106
  mode,
@@ -30,8 +30,8 @@ export type SelectionItemBinding = {
30
30
  /** Spread onto the row element to bind click + focus + ARIA. */
31
31
  rowProps: {
32
32
  'aria-selected': boolean;
33
- onClick: (event: MouseEvent) => void;
34
- onFocus?: (event: FocusEvent) => void;
33
+ 'onClick': (event: MouseEvent) => void;
34
+ 'onFocus'?: (event: FocusEvent) => void;
35
35
  };
36
36
  };
37
37
 
@@ -46,7 +46,7 @@ const isMulti = (value: SingleValue | MultiValue): value is MultiValue => value
46
46
  * controllable value semantics; emits `aria-selected` + click/focus handlers per row.
47
47
  *
48
48
  * `single` mode: at most one selected id, selection follows focus by default. Matches the
49
- * existing `RowList` behaviour and the WAI-ARIA listbox single-select pattern.
49
+ * `Listbox` behaviour and the WAI-ARIA listbox single-select pattern.
50
50
  *
51
51
  * `multi` mode: tracks a `Set<string>`. Selection does NOT follow focus by default — multi
52
52
  * select usually pairs with an explicit toggle affordance (checkbox or keyboard Space) rather
@@ -129,7 +129,7 @@ export const useListSelection: {
129
129
  },
130
130
  rowProps: {
131
131
  'aria-selected': selected,
132
- onClick: () => {
132
+ 'onClick': () => {
133
133
  if (disabled) {
134
134
  return;
135
135
  }
@@ -9,12 +9,16 @@ import React, { type PropsWithChildren } from 'react';
9
9
  import { Icon, type ThemedClassName } from '@dxos/react-ui';
10
10
  import { mx } from '@dxos/ui-theme';
11
11
 
12
+ import { listTheme } from '../List.theme';
13
+
12
14
  // See `AccordionRoot.tsx` for the rationale on `ListItemRecord = any`.
13
15
  type ListItemRecord = any;
14
16
  import { useAccordionContext } from './AccordionRoot';
15
17
 
16
18
  const ACCORDION_ITEM_NAME = 'AccordionItem';
17
19
 
20
+ const styles = listTheme.styles();
21
+
18
22
  type AccordionItemContext<T extends ListItemRecord> = {
19
23
  item: T;
20
24
  };
@@ -31,29 +35,43 @@ export const AccordionItem = <T extends ListItemRecord>({ children, classNames,
31
35
 
32
36
  return (
33
37
  <AccordionItemProvider {...{ item }}>
34
- <AccordionPrimitive.Item value={getId(item)} className={mx('overflow-hidden', classNames)}>
38
+ <AccordionPrimitive.Item value={getId(item)} className={styles.accordionItem({ class: mx(classNames) })}>
35
39
  {children}
36
40
  </AccordionPrimitive.Item>
37
41
  </AccordionItemProvider>
38
42
  );
39
43
  };
40
44
 
41
- export type AccordionItemHeaderProps = ThemedClassName<AccordionPrimitive.AccordionHeaderProps & { icon?: string }>;
45
+ export type AccordionItemHeaderProps = ThemedClassName<
46
+ AccordionPrimitive.AccordionHeaderProps & {
47
+ icon?: string;
48
+ /** Apply `dx-hover` row styling on the trigger (off by default; mirrors `Listbox.Item`). */
49
+ hover?: boolean;
50
+ }
51
+ >;
42
52
 
43
- export const AccordionItemHeader = ({ classNames, children, icon, ...props }: AccordionItemHeaderProps) => {
53
+ export const AccordionItemHeader = ({ classNames, children, icon, hover, ...props }: AccordionItemHeaderProps) => {
44
54
  return (
45
55
  <AccordionPrimitive.Header {...props} className={mx(classNames)}>
46
56
  {/* `justify-between` pins the toggle caret to the trailing edge of the row regardless of
47
57
  the header content's intrinsic width — so the affordance lives at a predictable
48
58
  right-end position. The content wrapper grabs the remaining space. */}
49
- <AccordionPrimitive.Trigger className='group flex items-center justify-between gap-2 p-2 dx-focus-ring-inset w-full text-start'>
50
- {icon && <Icon icon={icon} size={4} />}
51
- <span className='min-w-0 flex-1 truncate'>{children}</span>
52
- <Icon
53
- icon='ph--caret-right--regular'
54
- size={4}
55
- classNames='shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90'
56
- />
59
+ <AccordionPrimitive.Trigger className={styles.accordionTrigger({ class: hover && 'dx-hover' })}>
60
+ {/* Leading icon and caret center within a single line-height band (`h-6`) so they sit on
61
+ the same centerline as the first line of the content, which may span multiple lines. */}
62
+ {icon && (
63
+ <span className={styles.accordionTriggerIcon()}>
64
+ <Icon icon={icon} size={4} />
65
+ </span>
66
+ )}
67
+ <div className={styles.accordionTriggerContent()}>{children}</div>
68
+ <span className={styles.accordionTriggerIcon()}>
69
+ <Icon
70
+ icon='ph--caret-right--regular'
71
+ size={4}
72
+ classNames='transition-transform duration-200 group-data-[state=open]:rotate-90'
73
+ />
74
+ </span>
57
75
  </AccordionPrimitive.Trigger>
58
76
  </AccordionPrimitive.Header>
59
77
  );
@@ -63,8 +81,8 @@ export type AccordionItemBodyProps = ThemedClassName<PropsWithChildren>;
63
81
 
64
82
  export const AccordionItemBody = ({ children, classNames }: AccordionItemBodyProps) => {
65
83
  return (
66
- <AccordionPrimitive.Content className='overflow-hidden data-[state=closed]:animate-slide-up data-[state=open]:animate-slide-down'>
67
- <div className={mx('p-2', classNames)}>{children}</div>
84
+ <AccordionPrimitive.Content className={styles.accordionBody()}>
85
+ <div className={styles.accordionBodyContent({ class: mx(classNames) })}>{children}</div>
68
86
  </AccordionPrimitive.Content>
69
87
  );
70
88
  };
@@ -40,8 +40,11 @@ import {
40
40
  import { composable, composableProps } from '@dxos/react-ui';
41
41
  import { mx } from '@dxos/ui-theme';
42
42
 
43
+ import { listTheme } from '../List.theme';
43
44
  import { Picker, type PickerInputProps, type PickerItemProps } from '../Picker';
44
45
 
46
+ const styles = listTheme.styles();
47
+
45
48
  const COMBOBOX_NAME = 'Combobox';
46
49
  const COMBOBOX_CONTENT_NAME = 'ComboboxContent';
47
50
  const COMBOBOX_ITEM_NAME = 'ComboboxItem';
@@ -125,19 +128,24 @@ const ComboboxRoot = ({
125
128
  // Filtering is caller-driven: pass already-matching <Combobox.Item> children.
126
129
  //
127
130
 
128
- type ComboboxContentProps = PopoverContentProps;
131
+ type ComboboxContentProps = PopoverContentProps & {
132
+ /** Snap the highlight to the first item whenever the list changes (type-to-filter lists). */
133
+ resetSelectionOnChange?: boolean;
134
+ };
129
135
 
130
- const ComboboxContent = composable<HTMLDivElement, ComboboxContentProps>(({ children, ...props }, forwardedRef) => {
131
- const { modalId } = useComboboxContext(COMBOBOX_CONTENT_NAME);
136
+ const ComboboxContent = composable<HTMLDivElement, ComboboxContentProps>(
137
+ ({ children, resetSelectionOnChange, ...props }, forwardedRef) => {
138
+ const { modalId } = useComboboxContext(COMBOBOX_CONTENT_NAME);
132
139
 
133
- return (
134
- <Popover.Content {...composableProps(props, { id: modalId })} ref={forwardedRef}>
135
- <Popover.Viewport classNames='w-(--radix-popover-trigger-width)'>
136
- <Picker.Root>{children}</Picker.Root>
137
- </Popover.Viewport>
138
- </Popover.Content>
139
- );
140
- });
140
+ return (
141
+ <Popover.Content {...composableProps(props, { id: modalId })} ref={forwardedRef}>
142
+ <Popover.Viewport classNames='w-(--radix-popover-trigger-width)'>
143
+ <Picker.Root resetSelectionOnChange={resetSelectionOnChange}>{children}</Picker.Root>
144
+ </Popover.Viewport>
145
+ </Popover.Content>
146
+ );
147
+ },
148
+ );
141
149
 
142
150
  ComboboxContent.displayName = COMBOBOX_CONTENT_NAME;
143
151
 
@@ -171,7 +179,7 @@ const ComboboxTrigger = forwardRef<HTMLButtonElement, ComboboxTriggerProps>(
171
179
  >
172
180
  {children ?? (
173
181
  <>
174
- <span className={mx('font-normal text-start flex-1 min-w-0 truncate me-2', !value && 'text-subdued')}>
182
+ <span className={styles.comboboxTriggerText({ class: !value && 'text-subdued' })}>
175
183
  {value || placeholder}
176
184
  </span>
177
185
  <Icon icon='ph--caret-down--bold' size={3} />
@@ -202,13 +210,7 @@ type ComboboxInputProps = ThemedClassName<
202
210
  >;
203
211
 
204
212
  const ComboboxInput = forwardRef<HTMLInputElement, ComboboxInputProps>(({ classNames, ...props }, forwardedRef) => {
205
- return (
206
- <Picker.Input
207
- {...props}
208
- classNames={['m-form-chrome mb-0 w-[calc(100%-2*var(--spacing-form-chrome))]', classNames]}
209
- ref={forwardedRef}
210
- />
211
- );
213
+ return <Picker.Input {...props} classNames={styles.comboboxInput({ class: classNames })} ref={forwardedRef} />;
212
214
  });
213
215
 
214
216
  ComboboxInput.displayName = 'Combobox.Input';
@@ -223,7 +225,7 @@ const ComboboxList = forwardRef<HTMLDivElement, ComboboxListProps>(
223
225
  ({ classNames, children, ...props }, forwardedRef) => {
224
226
  return (
225
227
  <ScrollArea.Root
226
- {...composableProps(props, { classNames: ['py-form-chrome', classNames] })}
228
+ {...composableProps(props, { classNames: styles.comboboxList({ class: classNames }) })}
227
229
  role='listbox'
228
230
  centered
229
231
  padding
@@ -248,6 +250,8 @@ type ComboboxItemProps = ThemedClassName<
248
250
  value: string;
249
251
  /** Display label (used when `children` are not provided). */
250
252
  label?: string;
253
+ /** Optional secondary line shown beneath the label (muted, smaller). */
254
+ description?: string;
251
255
  /** Optional icon id (Phosphor) shown before the label. */
252
256
  icon?: string;
253
257
  /** Additional class names for the icon. */
@@ -272,6 +276,7 @@ const ComboboxItem = forwardRef<HTMLDivElement, ComboboxItemProps>(
272
276
  onSelect,
273
277
  value,
274
278
  label,
279
+ description,
275
280
  icon,
276
281
  iconClassNames,
277
282
  checked,
@@ -299,21 +304,24 @@ const ComboboxItem = forwardRef<HTMLDivElement, ComboboxItemProps>(
299
304
  disabled={disabled}
300
305
  onSelect={handleSelect}
301
306
  ref={forwardedRef}
302
- classNames={[
303
- // Full width inside the viewport (no horizontal margin).
304
- // `px-3 py-1`, `cursor-pointer`, `select-none` and the
305
- // `dx-hover` / `dx-selected` pairing come from `Picker.Item`'s
306
- // defaults; we only add the row-shape (flex / icons + label)
307
- // and the disabled overrides on top.
308
- 'flex w-full gap-2 items-center',
309
- disabled && 'hover:bg-transparent data-[selected=true]:bg-transparent',
310
- classNames,
311
- ]}
307
+ classNames={styles.comboboxItem({
308
+ // `px-3 py-1`, `cursor-pointer`, `select-none` and the `dx-hover` / `dx-selected`
309
+ // pairing come from `Picker.Item`'s defaults; the slot only adds row-shape (flex /
310
+ // icons + label). Disabled overrides are layered on per-instance.
311
+ class: mx(disabled && 'hover:bg-transparent data-[selected=true]:bg-transparent', classNames),
312
+ })}
312
313
  >
313
314
  {children ?? (
314
315
  <>
315
316
  {icon && <Icon icon={icon} classNames={iconClassNames} />}
316
- <span className='w-0 grow truncate'>{label}</span>
317
+ {description ? (
318
+ <span className='w-0 grow flex flex-col'>
319
+ <span className='truncate'>{label}</span>
320
+ <span className={styles.comboboxItemDescription()}>{description}</span>
321
+ </span>
322
+ ) : (
323
+ <span className='w-0 grow truncate'>{label}</span>
324
+ )}
317
325
  {suffix && <span className='shrink-0 text-description'>{suffix}</span>}
318
326
  {checked && <Icon icon='ph--check--regular' />}
319
327
  </>
@@ -375,14 +383,14 @@ export const Combobox = {
375
383
  };
376
384
 
377
385
  export type {
378
- ComboboxRootProps,
379
- ComboboxPortalProps,
386
+ ComboboxArrowProps,
380
387
  ComboboxContentProps,
381
- ComboboxTriggerProps,
382
- ComboboxVirtualTriggerProps,
388
+ ComboboxEmptyProps,
383
389
  ComboboxInputProps,
384
- ComboboxListProps,
385
390
  ComboboxItemProps,
386
- ComboboxArrowProps,
387
- ComboboxEmptyProps,
391
+ ComboboxListProps,
392
+ ComboboxPortalProps,
393
+ ComboboxRootProps,
394
+ ComboboxTriggerProps,
395
+ ComboboxVirtualTriggerProps,
388
396
  };
@@ -0,0 +1,74 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types';
6
+ import React, { type CSSProperties, type HTMLAttributes } from 'react';
7
+
8
+ // Tailwind port of `@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box`. The atlaskit
9
+ // React component `require`s a `.compiled.css` file, which crashes the node test loader (and
10
+ // thus every node-tested plugin that transitively imports this list); this port is CSS-free
11
+ // and theme-aware (`bg-accent-bg`). `react-ui-mosaic` keeps the atlaskit component. See
12
+ // `react-ui-list/AUDIT.md` D4.
13
+
14
+ type Orientation = 'horizontal' | 'vertical';
15
+
16
+ const edgeToOrientationMap: Record<Edge, Orientation> = {
17
+ top: 'horizontal',
18
+ bottom: 'horizontal',
19
+ left: 'vertical',
20
+ right: 'vertical',
21
+ };
22
+
23
+ const orientationStyles: Record<Orientation, HTMLAttributes<HTMLElement>['className']> = {
24
+ horizontal:
25
+ 'h-(--line-thickness) left-[calc(var(--line-inset)+var(--terminal-radius))] right-(--line-inset) before:left-(--terminal-inset)',
26
+ vertical:
27
+ 'w-(--line-thickness) top-[calc(var(--line-inset)+var(--terminal-radius))] bottom-(--line-inset) before:top-(--terminal-inset)',
28
+ };
29
+
30
+ const edgeStyles: Record<Edge, HTMLAttributes<HTMLElement>['className']> = {
31
+ top: 'top-(--line-offset) before:top-(--offset-terminal)',
32
+ right: 'right-(--line-offset) before:right-(--offset-terminal)',
33
+ bottom: 'bottom-(--line-offset) before:bottom-(--offset-terminal)',
34
+ left: 'left-(--line-offset) before:left-(--offset-terminal)',
35
+ };
36
+
37
+ const strokeSize = 2;
38
+ const terminalSize = 8;
39
+ const offsetToAlignTerminalWithLine = (strokeSize - terminalSize) / 2;
40
+
41
+ /** Props for {@link DropIndicator}. */
42
+ export type DropIndicatorProps = {
43
+ edge: Edge;
44
+ gap?: number;
45
+ terminalInset?: number;
46
+ lineInset?: number;
47
+ };
48
+
49
+ /** Themed box-edge drop indicator (line + circular terminal) for pragmatic-dnd reorder. */
50
+ export const DropIndicator = ({
51
+ edge,
52
+ gap = 0,
53
+ lineInset = 0,
54
+ terminalInset = lineInset - terminalSize,
55
+ }: DropIndicatorProps) => {
56
+ const orientation = edgeToOrientationMap[edge];
57
+
58
+ return (
59
+ <div
60
+ style={
61
+ {
62
+ '--line-thickness': `${strokeSize}px`,
63
+ '--line-offset': `calc(-0.5 * (${gap}px + ${strokeSize}px))`,
64
+ '--line-inset': `${lineInset}px`,
65
+ '--terminal-size': `${terminalSize}px`,
66
+ '--terminal-radius': `${terminalSize / 2}px`,
67
+ '--terminal-inset': `${terminalInset}px`,
68
+ '--offset-terminal': `${offsetToAlignTerminalWithLine}px`,
69
+ } as CSSProperties
70
+ }
71
+ className={`absolute z-10 pointer-events-none bg-accent-bg before:content-[''] before:w-(--terminal-size) before:h-(--terminal-size) box-border before:box-border before:absolute before:border-[length:--line-thickness] before:border-solid before:border-accent-bg before:rounded-full ${orientationStyles[orientation]} ${edgeStyles[edge]}`}
72
+ />
73
+ );
74
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './DropIndicator';
@@ -0,0 +1,37 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+
7
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
8
+
9
+ import { Empty, type EmptyProps } from './Empty';
10
+
11
+ const meta = {
12
+ title: 'ui/react-ui-list/Empty',
13
+ component: Empty,
14
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
15
+ } satisfies Meta<typeof Empty>;
16
+
17
+ export default meta;
18
+
19
+ type Story = StoryObj<EmptyProps>;
20
+
21
+ /** No label — falls back to the generic message. */
22
+ export const Fallback: Story = {};
23
+
24
+ /** Caller-supplied (already-translated) label. */
25
+ export const WithLabel: Story = {
26
+ args: {
27
+ label: 'No automations',
28
+ },
29
+ };
30
+
31
+ /** Label plus a leading icon. */
32
+ export const WithIcon: Story = {
33
+ args: {
34
+ label: 'No automations',
35
+ icon: 'ph--lightning--regular',
36
+ },
37
+ };
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { type ReactNode } from 'react';
6
+
7
+ import { Icon, composable, composableProps, useTranslation } from '@dxos/react-ui';
8
+ import { osTranslations } from '@dxos/ui-theme';
9
+ import { type ComposableProps } from '@dxos/ui-types';
10
+
11
+ export type EmptyProps = ComposableProps<{
12
+ /** Message to show; caller is responsible for translating it. Falls back to a generic message when omitted. */
13
+ label?: ReactNode;
14
+ /** Optional Phosphor icon name shown above the message. */
15
+ icon?: string;
16
+ }>;
17
+
18
+ /**
19
+ * Empty-state placeholder for a list/collection: a subdued, centered message shown in place of the list
20
+ * when there are no items. Pass a domain-specific {@link EmptyProps.label} (already translated); when none is
21
+ * given it falls back to a generic "No items" message from the shared `os` translation namespace.
22
+ */
23
+ export const Empty = composable<HTMLDivElement, EmptyProps>(({ label, icon, ...props }, forwardedRef) => {
24
+ const { t } = useTranslation(osTranslations);
25
+ // `defaultValue` keeps the fallback working even before a host registers the key, and leaves it translatable.
26
+ const message = label ?? t('empty.label', { defaultValue: 'No items' });
27
+ return (
28
+ <div
29
+ {...composableProps<HTMLDivElement>(props, {
30
+ classNames: 'flex flex-col items-center justify-center gap-2 p-4 text-sm text-center text-description',
31
+ role: 'status',
32
+ })}
33
+ ref={forwardedRef}
34
+ >
35
+ {icon && <Icon icon={icon} size={6} classNames='text-subdued' />}
36
+ <span>{message}</span>
37
+ </div>
38
+ );
39
+ });
40
+
41
+ Empty.displayName = 'Empty';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Empty';