@dxos/react-ui 0.13.0 → 0.14.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 (46) hide show
  1. package/dist/lib/Accordion.mjs +5 -6
  2. package/dist/lib/Accordion.mjs.map +1 -1
  3. package/dist/lib/Banner.mjs +2 -2
  4. package/dist/lib/Card.mjs +2 -2
  5. package/dist/lib/Combobox.mjs +1 -1
  6. package/dist/lib/Editable.mjs +24 -8
  7. package/dist/lib/Editable.mjs.map +1 -1
  8. package/dist/lib/Layout.mjs +1 -1
  9. package/dist/lib/Listbox.mjs +5 -4
  10. package/dist/lib/Listbox.mjs.map +1 -1
  11. package/dist/lib/Menu.mjs +1 -1
  12. package/dist/lib/Select.mjs +1 -1
  13. package/dist/lib/Tag.mjs +1 -1
  14. package/dist/lib/chunk-Button.mjs +1 -1
  15. package/dist/lib/chunk-Container.mjs +1 -1
  16. package/dist/lib/chunk-Empty.mjs +1 -1
  17. package/dist/lib/chunk-Input.mjs +1 -1
  18. package/dist/lib/chunk-Main.mjs +1 -1
  19. package/dist/lib/chunk-grid.mjs +2 -0
  20. package/dist/lib/chunk-grid.mjs.map +1 -1
  21. package/dist/lib/chunk-recipes.mjs +1 -0
  22. package/dist/lib/chunk-recipes.mjs.map +1 -1
  23. package/dist/lib/chunk-useThemeContext.mjs +1 -1
  24. package/dist/types/src/next/components/Accordion/Accordion.d.ts.map +1 -1
  25. package/dist/types/src/next/components/Editable/Editable.d.ts +1 -1
  26. package/dist/types/src/next/components/Editable/Editable.d.ts.map +1 -1
  27. package/dist/types/src/next/components/Listbox/Listbox.d.ts +11 -5
  28. package/dist/types/src/next/components/Listbox/Listbox.d.ts.map +1 -1
  29. package/dist/types/src/next/components/Listbox/grid.d.ts.map +1 -1
  30. package/dist/types/src/next/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  31. package/dist/types/src/next/recipes.d.ts +1 -0
  32. package/dist/types/src/next/recipes.d.ts.map +1 -1
  33. package/dist/types/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +16 -16
  35. package/src/next/components/Accordion/Accordion.tsx +8 -5
  36. package/src/next/components/Editable/Editable.tsx +50 -22
  37. package/src/next/components/Listbox/Listbox.tsx +8 -5
  38. package/src/next/components/Listbox/grid.ts +6 -0
  39. package/src/next/components/Toolbar/Toolbar.stories.tsx +2 -1
  40. package/src/next/recipes.ts +1 -0
  41. package/src/next/theme/accordion.css +5 -2
  42. package/src/next/theme/card.css +6 -0
  43. package/src/next/theme/control.css +1 -3
  44. package/src/next/theme/editable.css +18 -0
  45. package/src/next/theme/row.css +4 -0
  46. package/src/next/theme/toggle.css +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Low-level React components for DXOS, applying a theme to a core group of primitives",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -297,17 +297,17 @@
297
297
  "i18next": "^21.10.0",
298
298
  "react-error-boundary": "^4.0.13",
299
299
  "react-i18next": "^11.18.6",
300
- "@dxos/async": "0.13.0",
301
- "@dxos/effect": "0.13.0",
302
- "@dxos/i18n": "0.13.0",
303
- "@dxos/invariant": "0.13.0",
304
- "@dxos/lit-ui": "0.13.0",
305
- "@dxos/log": "0.13.0",
306
- "@dxos/react-error-boundary": "0.13.0",
307
- "@dxos/react-focus": "0.13.0",
308
- "@dxos/react-hooks": "0.13.0",
309
- "@dxos/ui-types": "0.13.0",
310
- "@dxos/util": "0.13.0"
300
+ "@dxos/async": "0.14.0",
301
+ "@dxos/i18n": "0.14.0",
302
+ "@dxos/invariant": "0.14.0",
303
+ "@dxos/lit-ui": "0.14.0",
304
+ "@dxos/effect": "0.14.0",
305
+ "@dxos/log": "0.14.0",
306
+ "@dxos/react-error-boundary": "0.14.0",
307
+ "@dxos/react-focus": "0.14.0",
308
+ "@dxos/react-hooks": "0.14.0",
309
+ "@dxos/ui-types": "0.14.0",
310
+ "@dxos/util": "0.14.0"
311
311
  },
312
312
  "devDependencies": {
313
313
  "@effect/atom-react": "4.0.0",
@@ -323,16 +323,16 @@
323
323
  "react-aria-components": "^1.17.0",
324
324
  "react-dom": "~19.2.7",
325
325
  "vite": "^8.2.2",
326
- "@dxos/random": "0.13.0",
327
- "@dxos/ui-theme": "0.13.0",
328
- "@dxos/util": "0.13.0"
326
+ "@dxos/random": "0.14.0",
327
+ "@dxos/ui-theme": "0.14.0",
328
+ "@dxos/util": "0.14.0"
329
329
  },
330
330
  "peerDependencies": {
331
331
  "@effect/atom-react": "4.0.0",
332
332
  "effect": "4.0.0",
333
333
  "react": "~19.2.7",
334
334
  "react-dom": "~19.2.7",
335
- "@dxos/ui-theme": "^0.13.0"
335
+ "@dxos/ui-theme": "^0.14.0"
336
336
  },
337
337
  "publishConfig": {
338
338
  "access": "public"
@@ -11,6 +11,7 @@ import { mx } from '@dxos/ui-theme';
11
11
  import { type ThemedClassName } from '@dxos/ui-types';
12
12
 
13
13
  import { recipes } from '../../recipes.ts';
14
+ import { Block } from '../Block/Block.tsx';
14
15
  import * as Icon from '../Icon/Icon.tsx';
15
16
 
16
17
  //
@@ -80,17 +81,19 @@ const AccordionItemTrigger = forwardRef<HTMLButtonElement, AccordionItemTriggerP
80
81
  ref={forwardedRef}
81
82
  >
82
83
  {icon && (
83
- <span data-scope='accordion' data-part='item-icon' className={recipes.accordionItemIcon()}>
84
+ <Block classNames={recipes.accordionItemIcon()}>
84
85
  <Icon.Icon icon={icon} />
85
- </span>
86
+ </Block>
86
87
  )}
87
88
  <span data-scope='accordion' data-part='item-text' className={recipes.accordionItemText()}>
88
89
  {children}
89
90
  </span>
90
91
  {!disabled && (
91
- <AccordionPrimitive.ItemIndicator className={recipes.accordionItemIndicator()}>
92
- <Icon.Icon icon='ph--caret-right--regular' />
93
- </AccordionPrimitive.ItemIndicator>
92
+ <Block>
93
+ <AccordionPrimitive.ItemIndicator className={recipes.accordionItemIndicator()}>
94
+ <Icon.Icon icon='ph--caret-right--regular' />
95
+ </AccordionPrimitive.ItemIndicator>
96
+ </Block>
94
97
  )}
95
98
  </AccordionPrimitive.ItemTrigger>
96
99
  );
@@ -23,6 +23,9 @@ const EDITABLE_INPUT_NAME = 'Editable.Input';
23
23
  // parts read does not expose.
24
24
  const [EditableActivationProvider, useEditableActivation] = createContext<EditableActivationBinding>(EDITABLE_NAME);
25
25
 
26
+ // A controlled field's submit never reaches the machine's commit callback, so Save commits through the hook.
27
+ const [EditableCommitProvider, useEditableCommit] = createContext<{ commit: () => void }>(EDITABLE_NAME);
28
+
26
29
  //
27
30
  // Root
28
31
  //
@@ -50,7 +53,7 @@ const EditableRoot = forwardRef<HTMLDivElement, EditableRootProps>(
50
53
  },
51
54
  forwardedRef,
52
55
  ) => {
53
- const { api, activationProps } = useEditable({
56
+ const { api, activationProps, commit } = useEditable({
54
57
  value,
55
58
  defaultValue,
56
59
  onValueChange,
@@ -69,7 +72,9 @@ const EditableRoot = forwardRef<HTMLDivElement, EditableRootProps>(
69
72
  className={mx(recipes.editable(), classNames)}
70
73
  ref={forwardedRef}
71
74
  >
72
- <EditableActivationProvider {...activationProps}>{children}</EditableActivationProvider>
75
+ <EditableActivationProvider {...activationProps}>
76
+ <EditableCommitProvider commit={commit}>{children}</EditableCommitProvider>
77
+ </EditableActivationProvider>
73
78
  </EditablePrimitive.RootProvider>
74
79
  );
75
80
  },
@@ -85,7 +90,7 @@ type EditablePreviewProps = ThemedClassName<Omit<ComponentPropsWithRef<'span'>,
85
90
 
86
91
  /** The static text and the activation affordance; hidden by the machine while editing. */
87
92
  const EditablePreview = forwardRef<HTMLSpanElement, EditablePreviewProps>(({ classNames, ...props }, forwardedRef) => {
88
- const { valueText } = useEditableContext();
93
+ const { valueText, edit } = useEditableContext();
89
94
  const { role, onKeyDown } = useEditableActivation(EDITABLE_PREVIEW_NAME);
90
95
 
91
96
  return (
@@ -101,7 +106,18 @@ const EditablePreview = forwardRef<HTMLSpanElement, EditablePreviewProps>(({ cla
101
106
  ref={forwardedRef}
102
107
  >
103
108
  <span className={recipes.editablePreviewText()}>{valueText}</span>
104
- <Icon.Icon icon='ph--pencil-simple--regular' classNames={recipes.editablePreviewIcon()} />
109
+ {/* The pencil is a door of its own: one click opens the field whatever gesture the preview takes (a double-click
110
+ in a list, where a single click selects the row). */}
111
+ <Icon.Icon
112
+ icon='ph--pencil-simple--regular'
113
+ classNames={recipes.editablePreviewIcon()}
114
+ data-testid='editable.edit'
115
+ // A disabled field hides the pencil (`editable.css`), so it takes no click.
116
+ onClick={(event) => {
117
+ event.stopPropagation();
118
+ edit();
119
+ }}
120
+ />
105
121
  </EditablePrimitive.Preview>
106
122
  );
107
123
  });
@@ -114,9 +130,10 @@ EditablePreview.displayName = EDITABLE_PREVIEW_NAME;
114
130
 
115
131
  type EditableInputProps = ThemedClassName<Omit<ComponentPropsWithRef<'input'>, 'value' | 'onChange' | 'placeholder'>>;
116
132
 
117
- /** The field shown while editing; focused with the caret at the end. */
133
+ /** The field shown while editing, focused with the caret at the end, with a save button at its end. */
118
134
  const EditableInput = forwardRef<HTMLInputElement, EditableInputProps>(({ classNames, ...props }, forwardedRef) => {
119
135
  const { editing } = useEditableContext();
136
+ const { commit } = useEditableCommit(EDITABLE_INPUT_NAME);
120
137
  const localRef = useRef<HTMLInputElement | null>(null);
121
138
 
122
139
  // The machine focuses the input but leaves the caret where the browser puts it, which from the keyboard is the start.
@@ -130,24 +147,35 @@ const EditableInput = forwardRef<HTMLInputElement, EditableInputProps>(({ classN
130
147
  }, [editing]);
131
148
 
132
149
  return (
133
- <EditablePrimitive.Input
134
- {...props}
135
- className={mx(recipes.editableInput(), classNames)}
136
- ref={(element) => {
137
- localRef.current = element;
138
- // Typed `unknown`: `ForwardedRef`'s callback is declared void, but React 19 may return a cleanup.
139
- const cleanup: unknown = typeof forwardedRef === 'function' ? forwardedRef(element) : undefined;
140
- if (forwardedRef && typeof forwardedRef !== 'function') {
141
- forwardedRef.current = element;
142
- }
143
- return () => {
144
- localRef.current = null;
145
- if (typeof cleanup === 'function') {
146
- cleanup();
150
+ <>
151
+ <EditablePrimitive.Input
152
+ {...props}
153
+ className={mx(recipes.editableInput(), classNames)}
154
+ ref={(element) => {
155
+ localRef.current = element;
156
+ // Typed `unknown`: `ForwardedRef`'s callback is declared void, but React 19 may return a cleanup.
157
+ const cleanup: unknown = typeof forwardedRef === 'function' ? forwardedRef(element) : undefined;
158
+ if (forwardedRef && typeof forwardedRef !== 'function') {
159
+ forwardedRef.current = element;
147
160
  }
148
- };
149
- }}
150
- />
161
+ return () => {
162
+ localRef.current = null;
163
+ if (typeof cleanup === 'function') {
164
+ cleanup();
165
+ }
166
+ };
167
+ }}
168
+ />
169
+ {/* Saves the edit, at the input's end in the same cell; the machine hides it while not editing. */}
170
+ <EditablePrimitive.SubmitTrigger
171
+ className={recipes.editableSubmit()}
172
+ aria-label='Save'
173
+ data-testid='editable.save'
174
+ onClick={() => commit()}
175
+ >
176
+ <Icon.Icon icon='ph--check--bold' />
177
+ </EditablePrimitive.SubmitTrigger>
178
+ </>
151
179
  );
152
180
  });
153
181
 
@@ -45,7 +45,7 @@ type ListboxOption = {
45
45
  };
46
46
 
47
47
  /** `none` keeps zag's focus, keyboard and typeahead with no selection (AUDIT §6 group B: every list runs the machine). */
48
- type ListboxSelectionMode = 'single' | 'multiple' | 'none';
48
+ type ListboxSelectionMode = 'single' | 'multiple' | 'extended' | 'none';
49
49
 
50
50
  type RootContextValue = {
51
51
  items: readonly ListboxOption[];
@@ -72,7 +72,10 @@ const useRootContext = (part: string) => {
72
72
 
73
73
  type ListboxRootProps = ThemedClassName<Omit<ComponentPropsWithoutRef<'div'>, 'defaultValue' | 'onSelect'>> & {
74
74
  items: readonly ListboxOption[];
75
- /** `single` by default; `multiple` toggles each item; `none` selects nothing but keeps navigation and typeahead. */
75
+ /**
76
+ * `single` by default; `multiple` toggles each item; `extended` selects one on a click and adds with Cmd/Ctrl or a
77
+ * Shift range, as a desktop list does; `none` selects nothing but keeps navigation and typeahead.
78
+ */
76
79
  selectionMode?: ListboxSelectionMode;
77
80
  value?: string[];
78
81
  defaultValue?: string[];
@@ -177,7 +180,7 @@ ListboxLabel.displayName = 'Listbox.Label';
177
180
 
178
181
  type ListboxContentProps = ThemedClassName<ComponentPropsWithoutRef<'div'>> &
179
182
  Pick<ScrollArea.RootProps, 'mode' | 'width' | 'native'> &
180
- Pick<ContainerProps, 'gutter' | 'gap'> & {
183
+ Pick<ContainerProps, 'gutter' | 'gap' | 'padBlock'> & {
181
184
  /**
182
185
  * `false` renders the rows without a ScrollArea of their own, for a host that already scrolls (a ScrollArea
183
186
  * composed in `Panel.Body`): the rows then inherit the host's rails (`gutter='inherit'` by default).
@@ -192,7 +195,7 @@ type ListboxContentProps = ThemedClassName<ComponentPropsWithoutRef<'div'>> &
192
195
  const ListboxViewport = composable<
193
196
  HTMLDivElement,
194
197
  ContainerProps & Omit<ComponentPropsWithoutRef<'div'>, 'className'> & { hostScrolls?: boolean }
195
- >(({ gutter, gap, hostScrolls, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
198
+ >(({ gutter, gap, padBlock, hostScrolls, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
196
199
  const api = useListboxContext();
197
200
  const { columns, virtual, scrollToIndexRef } = useRootContext('Content');
198
201
  const [element, setElement] = useState<HTMLDivElement | null>(null);
@@ -202,7 +205,7 @@ const ListboxViewport = composable<
202
205
  const ref = useComposedRefs<HTMLDivElement>(forwardedRef, setElement, windowing.listRef);
203
206
  useRowTabStops(element);
204
207
 
205
- const { style, ...attributes } = containerAttributes({ gutter, gap, columns });
208
+ const { style, ...attributes } = containerAttributes({ gutter, gap, columns, padBlock });
206
209
  const {
207
210
  className,
208
211
  style: ownStyle,
@@ -21,6 +21,7 @@ const CONTROL_SELECTOR = [
21
21
  ].join(', ');
22
22
 
23
23
  const ROW_SELECTOR = '[role="option"]';
24
+ const EDITABLE_PREVIEW_SELECTOR = '[data-scope="editable"][data-part="preview"]';
24
25
 
25
26
  /** The row (`option`) holding `element` in this list, not in a list nested in a row's detail. */
26
27
  const rowOf = (list: HTMLElement, element: Element): HTMLElement | null => {
@@ -49,6 +50,11 @@ export const isFromControl = (row: HTMLElement, target: EventTarget | null) => {
49
50
  return false;
50
51
  }
51
52
  const control = target.closest(CONTROL_SELECTOR);
53
+ // A row's text edited in place (an Editable preview) is the row's for a pointer: a press selects the row and leaves the
54
+ // list its keys; a double-click, its pencil, or Enter once the row is entered still opens it.
55
+ if (control?.matches(EDITABLE_PREVIEW_SELECTOR)) {
56
+ return false;
57
+ }
52
58
  return control !== null && control !== row && row.contains(control);
53
59
  };
54
60
 
@@ -188,7 +188,8 @@ export const Test: Story = {
188
188
  await expect(separator).not.toHaveAttribute('tabindex');
189
189
 
190
190
  for (const size of ['md', 'lg'] as const) {
191
- const expected = 3 * GEOMETRY[size].inset;
191
+ // The toolbar adds no gap of its own: items are spaced by their own insets.
192
+ const expected = 2 * GEOMETRY[size].inset;
192
193
  const items = ['add', 'remove', 'separator', 'button', 'input', 'select', 'list', 'grid'].map((part) =>
193
194
  byTestId(canvasElement, `${part}-${size}`).getBoundingClientRect(),
194
195
  );
@@ -102,6 +102,7 @@ export const recipes = {
102
102
  editablePreview: () => `dx-control dx-editable-preview ${FOCUS_RING}`,
103
103
  editablePreviewIcon: () => 'dx-editable-preview-icon',
104
104
  editablePreviewText: () => 'dx-editable-preview-text',
105
+ editableSubmit: () => 'dx-editable-submit',
105
106
  empty: () => 'dx-empty',
106
107
  emptyText: () => 'dx-empty-text',
107
108
  errorFallback: () => 'dx-error-fallback',
@@ -36,10 +36,10 @@
36
36
  .dx-accordion-item-trigger {
37
37
  display: flex;
38
38
  align-items: center;
39
- gap: var(--dx-gap-size);
40
39
  width: 100%;
41
40
  min-height: var(--dx-block-size);
42
- padding: 0 var(--dx-gap-size);
41
+ /* The icon and the caret are Blocks, which set the row's rhythm; only a row without an icon pads its text. */
42
+ padding: 0;
43
43
  border: none;
44
44
  border-radius: inherit;
45
45
  background: none;
@@ -73,6 +73,9 @@
73
73
  flex: 1;
74
74
  min-width: 0;
75
75
  }
76
+ .dx-accordion-item-trigger > .dx-accordion-item-text:first-child {
77
+ padding-inline-start: var(--dx-gap-size);
78
+ }
76
79
 
77
80
  .dx-accordion-item-indicator {
78
81
  transition: transform var(--dx-disclosure-duration) var(--dx-disclosure-ease-open);
@@ -141,8 +141,14 @@
141
141
  color: var(--color-fg-muted);
142
142
  }
143
143
 
144
+ /* Framed by its host, so neither edge nor rounding of its own; the border keeps its width so nothing shifts. */
144
145
  .dx-card[data-border='false'] {
145
146
  border-color: transparent;
147
+ border-radius: 0;
148
+ }
149
+ .dx-card[data-border='false'] > .dx-card-poster:first-child {
150
+ border-start-start-radius: 0;
151
+ border-start-end-radius: 0;
146
152
  }
147
153
  .dx-card[data-selected] {
148
154
  border-color: var(--color-accent-bg);
@@ -986,11 +986,9 @@
986
986
  display: flex;
987
987
  flex-shrink: 0;
988
988
  align-items: center;
989
- gap: var(--dx-control-inset);
989
+ /* No gap or padding of its own: the items are block-sized controls, which carry their own spacing. */
990
990
  width: 100%;
991
991
  min-height: var(--dx-block-size);
992
- /* Half a gap of inline padding (px-1 at md), so the end items are not flush with a pane's edge. */
993
- padding-inline: calc(var(--dx-gap-size) / 2);
994
992
  font-size: var(--dx-font-size);
995
993
  line-height: var(--dx-line-height);
996
994
  }
@@ -30,6 +30,10 @@
30
30
  .dx-editable-preview:hover:not([data-disabled]) {
31
31
  background: var(--color-hover-surface);
32
32
  }
33
+ /* In a list row the row is the hover target: the preview takes the row's background rather than painting its own. */
34
+ .dx-row .dx-editable-preview:hover:not([data-disabled]) {
35
+ background: transparent;
36
+ }
33
37
  .dx-editable-preview[data-placeholder-shown] {
34
38
  color: var(--color-fg-subtle);
35
39
  }
@@ -50,6 +54,7 @@
50
54
  .dx-editable-preview-icon {
51
55
  color: var(--color-fg-subtle);
52
56
  visibility: hidden;
57
+ cursor: pointer;
53
58
  }
54
59
  .dx-editable-preview:is(:hover, :focus-visible):not([data-disabled]) .dx-editable-preview-icon {
55
60
  visibility: visible;
@@ -57,6 +62,19 @@
57
62
 
58
63
  .dx-editable-input {
59
64
  color: var(--color-fg);
65
+ /* Room for the save button laid over the input's end. */
66
+ padding-inline-end: var(--dx-block-size);
67
+ }
68
+
69
+ .dx-editable-submit {
70
+ justify-self: end;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ width: var(--dx-block-size);
75
+ height: var(--dx-block-size);
76
+ color: var(--color-success-text);
77
+ cursor: pointer;
60
78
  }
61
79
  .dx-editable-input::placeholder {
62
80
  color: var(--color-fg-subtle);
@@ -10,6 +10,10 @@
10
10
  border-radius: var(--radius-sm, 0.25rem);
11
11
  cursor: default;
12
12
  }
13
+ /* Rows that run edge to edge would show their rounding against the container's own edges. */
14
+ .dx-container[data-gutter='none'] > .dx-row {
15
+ border-radius: 0;
16
+ }
13
17
  .dx-row[data-selectable] {
14
18
  cursor: pointer;
15
19
  }
@@ -22,6 +22,10 @@
22
22
  flex-direction: column;
23
23
  align-items: stretch;
24
24
  }
25
+ /* In a toolbar, which adds no gap between its items, a group adds none between its own either. */
26
+ .dx-toolbar > .dx-toggle-group {
27
+ gap: 0;
28
+ }
25
29
  .dx-toolbar > .dx-toggle-group > .dx-control:not([data-square]) {
26
30
  margin-inline: var(--dx-control-inset);
27
31
  }