@dxos/react-ui-stack 0.8.4-main.b97322e → 0.8.4-main.bc674ce

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 (107) hide show
  1. package/dist/lib/browser/index.mjs +923 -50
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/playwright/index.mjs +4 -0
  5. package/dist/lib/browser/playwright/index.mjs.map +2 -2
  6. package/dist/lib/node-esm/index.mjs +924 -50
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/playwright/index.mjs +4 -0
  10. package/dist/lib/node-esm/playwright/index.mjs.map +2 -2
  11. package/dist/types/src/{exemplars → components}/CardStack/CardStack.d.ts +20 -13
  12. package/dist/types/src/components/CardStack/CardStack.d.ts.map +1 -0
  13. package/dist/types/src/components/CardStack/CardStack.stories.d.ts +15 -0
  14. package/dist/types/src/components/CardStack/CardStack.stories.d.ts.map +1 -0
  15. package/dist/types/src/{exemplars → components}/CardStack/CardStackDragPreview.d.ts +1 -1
  16. package/dist/types/src/components/CardStack/CardStackDragPreview.d.ts.map +1 -0
  17. package/dist/types/src/components/CardStack/index.d.ts.map +1 -0
  18. package/dist/types/src/components/Stack/Stack.d.ts +15 -7
  19. package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
  20. package/dist/types/src/components/Stack/Stack.stories.d.ts +12 -3
  21. package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
  22. package/dist/types/src/components/StackContext.d.ts +2 -1
  23. package/dist/types/src/components/StackContext.d.ts.map +1 -1
  24. package/dist/types/src/components/StackItem/StackItem.d.ts +9 -12
  25. package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
  26. package/dist/types/src/components/StackItem/StackItem.stories.d.ts +13 -5
  27. package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
  28. package/dist/types/src/components/StackItem/StackItemContent.d.ts +4 -37
  29. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  30. package/dist/types/src/components/StackItem/StackItemHeading.d.ts +1 -1
  31. package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
  32. package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts.map +1 -1
  33. package/dist/types/src/components/StackItem/StackItemSigil.d.ts +2 -2
  34. package/dist/types/src/components/StackItem/StackItemSigil.d.ts.map +1 -1
  35. package/dist/types/src/components/deprecated/LayoutControls.d.ts +3 -0
  36. package/dist/types/src/components/deprecated/LayoutControls.d.ts.map +1 -1
  37. package/dist/types/src/components/index.d.ts +2 -1
  38. package/dist/types/src/components/index.d.ts.map +1 -1
  39. package/dist/types/src/components/{defs.d.ts → types.d.ts} +1 -1
  40. package/dist/types/src/components/types.d.ts.map +1 -0
  41. package/dist/types/src/hooks/useStackDropForElements.d.ts +9 -7
  42. package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
  43. package/dist/types/src/index.d.ts +0 -1
  44. package/dist/types/src/index.d.ts.map +1 -1
  45. package/dist/types/src/translations.d.ts +2 -2
  46. package/dist/types/src/translations.d.ts.map +1 -1
  47. package/dist/types/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +41 -38
  49. package/src/{exemplars → components}/CardStack/CardStack.stories.tsx +19 -18
  50. package/src/{exemplars → components}/CardStack/CardStack.tsx +114 -54
  51. package/src/{exemplars → components}/CardStack/CardStackDragPreview.tsx +3 -3
  52. package/src/components/Stack/Stack.stories.tsx +8 -9
  53. package/src/components/Stack/Stack.tsx +225 -26
  54. package/src/components/StackContext.tsx +2 -1
  55. package/src/components/StackItem/StackItem.stories.tsx +19 -15
  56. package/src/components/StackItem/StackItem.tsx +49 -29
  57. package/src/components/StackItem/StackItemContent.tsx +18 -39
  58. package/src/components/StackItem/StackItemHeading.tsx +5 -9
  59. package/src/components/StackItem/StackItemResizeHandle.tsx +2 -1
  60. package/src/components/StackItem/StackItemSigil.tsx +5 -4
  61. package/src/components/deprecated/LayoutControls.tsx +3 -0
  62. package/src/components/index.ts +2 -1
  63. package/src/hooks/useStackDropForElements.ts +59 -45
  64. package/src/index.ts +0 -3
  65. package/src/playwright/playwright.config.ts +1 -1
  66. package/src/translations.ts +1 -1
  67. package/dist/lib/browser/chunk-P3TQV4BA.mjs +0 -1198
  68. package/dist/lib/browser/chunk-P3TQV4BA.mjs.map +0 -7
  69. package/dist/lib/browser/testing/index.mjs +0 -31
  70. package/dist/lib/browser/testing/index.mjs.map +0 -7
  71. package/dist/lib/node-esm/chunk-3WVEPAJ4.mjs +0 -1200
  72. package/dist/lib/node-esm/chunk-3WVEPAJ4.mjs.map +0 -7
  73. package/dist/lib/node-esm/testing/index.mjs +0 -32
  74. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  75. package/dist/types/src/components/defs.d.ts.map +0 -1
  76. package/dist/types/src/exemplars/Card/Card.d.ts +0 -58
  77. package/dist/types/src/exemplars/Card/Card.d.ts.map +0 -1
  78. package/dist/types/src/exemplars/Card/Card.stories.d.ts +0 -13
  79. package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +0 -1
  80. package/dist/types/src/exemplars/Card/CardDragPreview.d.ts +0 -6
  81. package/dist/types/src/exemplars/Card/CardDragPreview.d.ts.map +0 -1
  82. package/dist/types/src/exemplars/Card/fragments.d.ts +0 -12
  83. package/dist/types/src/exemplars/Card/fragments.d.ts.map +0 -1
  84. package/dist/types/src/exemplars/Card/index.d.ts +0 -4
  85. package/dist/types/src/exemplars/Card/index.d.ts.map +0 -1
  86. package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +0 -1
  87. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +0 -9
  88. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +0 -1
  89. package/dist/types/src/exemplars/CardStack/CardStackDragPreview.d.ts.map +0 -1
  90. package/dist/types/src/exemplars/CardStack/index.d.ts.map +0 -1
  91. package/dist/types/src/exemplars/index.d.ts +0 -3
  92. package/dist/types/src/exemplars/index.d.ts.map +0 -1
  93. package/dist/types/src/testing/CardContainer.d.ts +0 -6
  94. package/dist/types/src/testing/CardContainer.d.ts.map +0 -1
  95. package/dist/types/src/testing/index.d.ts +0 -2
  96. package/dist/types/src/testing/index.d.ts.map +0 -1
  97. package/src/exemplars/Card/Card.stories.tsx +0 -78
  98. package/src/exemplars/Card/Card.tsx +0 -186
  99. package/src/exemplars/Card/CardDragPreview.tsx +0 -22
  100. package/src/exemplars/Card/fragments.ts +0 -23
  101. package/src/exemplars/Card/index.ts +0 -7
  102. package/src/exemplars/index.ts +0 -6
  103. package/src/testing/CardContainer.tsx +0 -34
  104. package/src/testing/index.ts +0 -5
  105. /package/dist/types/src/{exemplars → components}/CardStack/index.d.ts +0 -0
  106. /package/src/{exemplars → components}/CardStack/index.ts +0 -0
  107. /package/src/components/{defs.ts → types.ts} +0 -0
@@ -2,40 +2,42 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { useArrowNavigationGroup } from '@fluentui/react-tabster';
6
5
  import { composeRefs } from '@radix-ui/react-compose-refs';
7
6
  import React, {
8
- Children,
9
7
  type CSSProperties,
8
+ Children,
10
9
  type ComponentPropsWithRef,
10
+ type FocusEvent,
11
+ type KeyboardEvent,
11
12
  forwardRef,
12
- useState,
13
- useMemo,
14
13
  useCallback,
15
14
  useEffect,
15
+ useMemo,
16
+ useState,
16
17
  } from 'react';
17
18
 
18
- import { type ThemedClassName, ListItem } from '@dxos/react-ui';
19
- import { mx } from '@dxos/react-ui-theme';
19
+ import { ListItem, type ThemedClassName, useId } from '@dxos/react-ui';
20
+ import { mx } from '@dxos/ui-theme';
20
21
 
21
22
  import { useStackDropForElements } from '../../hooks';
22
23
  import { StackContext } from '../StackContext';
23
- import { type StackContextValue } from '../defs';
24
+ import { type StackContextValue } from '../types';
24
25
 
25
26
  export type Orientation = 'horizontal' | 'vertical';
26
- export type Size = 'intrinsic' | 'contain' | 'contain-fit-content';
27
27
 
28
- export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
29
- Partial<StackContextValue> & {
30
- itemsCount?: number;
31
- getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
32
- separatorOnScroll?: number;
33
- };
28
+ /**
29
+ * Size is how Stack and its StackItems coordinate the dimensions of the items with the available space.
30
+ * - `intrinsic` signals to Stack and its StackItems to occupy their intrinsic size
31
+ * - Any other size will extrinsically fill the available space along the axis of its orientation and handle overflow:
32
+ * - `contain` causes StackItems to occupy their intrinsic size
33
+ * - `split` divides the Stack’s available space among the StackItems
34
+ */
35
+ export type Size = 'intrinsic' | 'contain' | 'split';
34
36
 
35
37
  export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
36
38
  export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
37
39
 
38
- // TODO(ZaymonFC): Magic 2px to stop overflow (tabster dummies... ask @thure).
40
+ // TODO(ZaymonFC): Magic 2px to stop overflow.
39
41
  export const railGridHorizontalContainFitContent =
40
42
  'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
41
43
  export const railGridVerticalContainFitContent =
@@ -43,6 +45,24 @@ export const railGridVerticalContainFitContent =
43
45
 
44
46
  export const autoScrollRootAttributes = { 'data-drag-autoscroll': 'idle' };
45
47
 
48
+ const PERPENDICULAR_FOCUS_THRESHHOLD = 128;
49
+
50
+ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orientation']) => {
51
+ el.scrollIntoView({
52
+ behavior: 'instant',
53
+ [orientation === 'vertical' ? 'block' : 'inline']: 'center',
54
+ });
55
+ return el.focus();
56
+ };
57
+
58
+ export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
59
+ Partial<StackContextValue> & {
60
+ itemsCount?: number;
61
+ getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
62
+ separatorOnScroll?: number;
63
+ circularFocus?: boolean;
64
+ };
65
+
46
66
  export const Stack = forwardRef<HTMLDivElement, StackProps>(
47
67
  (
48
68
  {
@@ -50,23 +70,25 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
50
70
  classNames,
51
71
  style,
52
72
  orientation = 'vertical',
53
- rail = true,
73
+ rail = true, // TODO(burdon): Change default to false.
54
74
  size = 'intrinsic',
55
75
  onRearrange,
56
76
  itemsCount = Children.count(children),
57
77
  getDropElement,
58
78
  separatorOnScroll,
79
+ circularFocus,
59
80
  ...props
60
81
  },
61
82
  forwardedRef,
62
83
  ) => {
84
+ const stackId = useId('stack', props.id);
63
85
  const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
86
+ const [lastFocusedItem, setLastFocusedItem] = useState<string>();
64
87
  const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
65
- const arrowNavigationAttrs = useArrowNavigationGroup({ axis: orientation });
66
88
 
67
89
  const styles: CSSProperties = {
68
90
  [orientation === 'horizontal' ? 'gridTemplateColumns' : 'gridTemplateRows']:
69
- `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
91
+ size === 'split' ? `repeat(${itemsCount}, 1fr)` : `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
70
92
  ...style,
71
93
  };
72
94
 
@@ -97,14 +119,187 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
97
119
  }
98
120
  }, [stackElement, separatorOnScroll, orientation]);
99
121
 
122
+ /**
123
+ * Handles blur events to track the last focused item within this stack.
124
+ */
125
+ const handleBlur = useCallback(
126
+ (event: FocusEvent<HTMLDivElement>) => {
127
+ if (event.target) {
128
+ const target = event.target as HTMLElement;
129
+ const closestStackItem = target.closest(`[data-dx-item-id]`) as HTMLElement | null;
130
+ if (closestStackItem?.closest(`[data-dx-stack="${stackId}"]`)) {
131
+ setLastFocusedItem(closestStackItem?.getAttribute('data-dx-item-id') ?? undefined);
132
+ }
133
+ }
134
+ props.onBlur?.(event);
135
+ },
136
+ [stackId, props.onBlur],
137
+ );
138
+
139
+ /**
140
+ * Handles moving focus using the arrow keys. Focus is only handled by the nearest stack;
141
+ * if the arrow key matches the orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item;
142
+ * or, if there is no such stack item, focus is passed to the adjacent empty stack if one can be found.
143
+ */
144
+ const handleKeyDown = useCallback(
145
+ (event: KeyboardEvent<HTMLDivElement>) => {
146
+ const target = event.target as HTMLElement;
147
+ if (
148
+ event.key.startsWith('Arrow') &&
149
+ !target.closest(
150
+ `input, textarea, [role="textbox"], [data-tabster*="mover"], [data-arrow-keys="all"], [data-arrow-keys~="${event.key.toLowerCase().slice(5)}"]`,
151
+ )
152
+ ) {
153
+ const closestOwnedItem = target.closest(`[data-dx-stack-item="${stackId}"]`);
154
+ const closestStack = target.closest('[data-dx-stack]') as HTMLElement | null;
155
+ const closestStackItems = Array.from(
156
+ closestStack?.querySelectorAll(`[data-dx-stack-item="${stackId}"]`) ?? [],
157
+ );
158
+ const closestStackOrientation = closestStack?.getAttribute('aria-orientation') as Orientation;
159
+ const ancestorStack = closestStack?.parentElement?.closest('[data-dx-stack]') as HTMLElement | null;
160
+ if (closestOwnedItem && closestStack) {
161
+ const ancestorOrientation = ancestorStack?.getAttribute('aria-orientation') as Orientation | undefined;
162
+ const parallelDelta = (
163
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowUp' : event.key === 'ArrowLeft'
164
+ )
165
+ ? -1
166
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowDown' : event.key === 'ArrowRight')
167
+ ? 1
168
+ : 0;
169
+ const perpendicularDelta = (
170
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
171
+ )
172
+ ? -1
173
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowRight' : event.key === 'ArrowDown')
174
+ ? 1
175
+ : 0;
176
+ if (parallelDelta !== 0) {
177
+ const currentIndex = closestStackItems.indexOf(closestOwnedItem);
178
+ const nextIndex = currentIndex + parallelDelta;
179
+ let adjacentItem: HTMLElement | undefined;
180
+
181
+ if (circularFocus) {
182
+ // Circular navigation: wrap around using modulo.
183
+ adjacentItem = closestStackItems[(nextIndex + closestStackItems.length) % closestStackItems.length] as
184
+ | HTMLElement
185
+ | undefined;
186
+ } else {
187
+ // Non-circular navigation: only move if within bounds.
188
+ if (nextIndex >= 0 && nextIndex < closestStackItems.length) {
189
+ adjacentItem = closestStackItems[nextIndex] as HTMLElement | undefined;
190
+ }
191
+ }
192
+
193
+ if (adjacentItem) {
194
+ event.preventDefault();
195
+ scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
196
+ }
197
+ }
198
+
199
+ if (perpendicularDelta !== 0) {
200
+ if (ancestorStack && ancestorOrientation !== closestStackOrientation) {
201
+ const siblingStacks = Array.from(
202
+ ancestorStack.querySelectorAll(
203
+ `[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
204
+ ),
205
+ ) as HTMLElement[];
206
+ const currentStackIndex = siblingStacks.indexOf(closestStack);
207
+ const nextStackIndex = currentStackIndex + perpendicularDelta;
208
+ let adjacentStack: HTMLElement | undefined;
209
+
210
+ if (ancestorStack.getAttribute('data-dx-stack-circular-focus') === 'true') {
211
+ // Circular navigation: wrap around using modulo.
212
+ adjacentStack = siblingStacks[(nextStackIndex + siblingStacks.length) % siblingStacks.length] as
213
+ | HTMLElement
214
+ | undefined;
215
+ } else {
216
+ // Non-circular navigation: only move if within bounds.
217
+ if (nextStackIndex >= 0 && nextStackIndex < siblingStacks.length) {
218
+ adjacentStack = siblingStacks[nextStackIndex] as HTMLElement | undefined;
219
+ }
220
+ }
221
+ const adjacentStackSelfItem = adjacentStack?.closest(
222
+ `[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
223
+ ) as HTMLElement | undefined;
224
+ const adjacentStackItems = adjacentStack
225
+ ? (Array.from(
226
+ adjacentStack.querySelectorAll(
227
+ `[data-dx-stack-item="${adjacentStack.getAttribute('data-dx-stack')}"]`,
228
+ ),
229
+ ) as HTMLElement[])
230
+ : [];
231
+ if (adjacentStack && adjacentStackItems.length > 0) {
232
+ // Check if the adjacent stack has a last focused item recorded, otherwise find the closest item by position.
233
+ let closestItem = adjacentStackItems[0];
234
+ // Try to find an item with matching data-dx-stack-item value.
235
+ const lastFocusedItem = adjacentStack.querySelector(
236
+ `[data-dx-item-id="${adjacentStack.getAttribute('data-dx-last-focused-item') ?? 'never'}"]`,
237
+ );
238
+ if (lastFocusedItem) {
239
+ closestItem = lastFocusedItem as HTMLElement;
240
+ } else {
241
+ // Fall back to positional calculation
242
+ const ownedItemRect = closestOwnedItem.getBoundingClientRect();
243
+ const targetPosition =
244
+ closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
245
+
246
+ let closestDistance = Infinity;
247
+ for (const item of adjacentStackItems) {
248
+ const itemRect = item.getBoundingClientRect();
249
+ const itemPosition = closestStackOrientation === 'vertical' ? itemRect.top : itemRect.left;
250
+ const distance = Math.abs(itemPosition - targetPosition);
251
+ if (distance < closestDistance) {
252
+ closestDistance = distance;
253
+ closestItem = item;
254
+ }
255
+ if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
256
+ break;
257
+ }
258
+ }
259
+ }
260
+
261
+ event.preventDefault();
262
+ scrollIntoViewAndFocus(closestItem, closestStackOrientation);
263
+ } else if (adjacentStackSelfItem) {
264
+ event.preventDefault();
265
+ scrollIntoViewAndFocus(adjacentStackSelfItem, ancestorOrientation);
266
+ }
267
+ } else if (closestOwnedItem) {
268
+ const closestOwnedItemStack = closestOwnedItem.querySelector('[data-dx-stack]');
269
+ const closestOwnedItemStackItems = closestOwnedItemStack
270
+ ? (Array.from(
271
+ closestOwnedItemStack.querySelectorAll(
272
+ `[data-dx-stack-item="${closestOwnedItemStack.getAttribute('data-dx-stack')}"]`,
273
+ ),
274
+ ) as HTMLElement[])
275
+ : [];
276
+ if (closestOwnedItemStackItems.length > 0) {
277
+ event.preventDefault();
278
+ scrollIntoViewAndFocus(
279
+ closestOwnedItemStackItems[
280
+ ['ArrowUp', 'ArrowLeft'].includes(event.key) ? closestOwnedItemStackItems.length - 1 : 0
281
+ ],
282
+ closestOwnedItemStack?.getAttribute('aria-orientation') as Orientation,
283
+ );
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ props.onKeyDown?.(event);
290
+ },
291
+ [props.onKeyDown, stackId, circularFocus],
292
+ );
293
+
100
294
  const gridClasses = useMemo(() => {
101
295
  if (!rail) {
102
- return orientation === 'horizontal' ? 'grid-rows-1 pli-1' : 'grid-cols-1 plb-1';
296
+ return orientation === 'horizontal' ? 'grid-rows-1 pli-[--stack-gap]' : 'grid-cols-1 plb-[--stack-gap]';
103
297
  }
298
+
104
299
  if (orientation === 'horizontal') {
105
- return size === 'contain-fit-content' ? railGridHorizontalContainFitContent : railGridHorizontal;
300
+ return railGridHorizontal;
106
301
  } else {
107
- return size === 'contain-fit-content' ? railGridVerticalContainFitContent : railGridVertical;
302
+ return railGridVertical;
108
303
  }
109
304
  }, [rail, orientation, size]);
110
305
 
@@ -125,19 +320,23 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
125
320
  }, [stackElement, handleScroll]);
126
321
 
127
322
  return (
128
- <StackContext.Provider value={{ orientation, rail, size, onRearrange }}>
323
+ <StackContext.Provider value={{ orientation, rail, size, onRearrange, stackId }}>
129
324
  <div
130
325
  {...props}
131
- {...arrowNavigationAttrs}
132
326
  className={mx(
133
- 'grid relative',
327
+ 'grid relative [--stack-gap:var(--dx-trimXs)]',
134
328
  gridClasses,
135
- (size === 'contain' || size === 'contain-fit-content') &&
329
+ size === 'contain' &&
136
330
  (orientation === 'horizontal'
137
- ? 'overflow-x-auto min-bs-0 max-bs-full bs-full'
331
+ ? 'overflow-x-auto overscroll-x-contain min-bs-0 max-bs-full bs-full'
138
332
  : 'overflow-y-auto min-is-0 max-is-full is-full'),
139
333
  classNames,
140
334
  )}
335
+ onKeyDown={handleKeyDown}
336
+ onBlur={handleBlur}
337
+ data-dx-stack={stackId}
338
+ data-dx-stack-circular-focus={circularFocus}
339
+ data-dx-last-focused-item={lastFocusedItem}
141
340
  data-rail={rail}
142
341
  aria-orientation={orientation}
143
342
  style={styles}
@@ -5,13 +5,14 @@
5
5
  import { createContext, useContext } from 'react';
6
6
 
7
7
  import { type Orientation, type Size } from './Stack';
8
- import { type StackItemSize, type StackItemRearrangeHandler } from './defs';
8
+ import { type StackItemRearrangeHandler, type StackItemSize } from './types';
9
9
 
10
10
  export type StackContextValue = {
11
11
  orientation: Orientation;
12
12
  rail: boolean;
13
13
  size: Size;
14
14
  onRearrange?: StackItemRearrangeHandler;
15
+ stackId?: string;
15
16
  };
16
17
 
17
18
  export const StackContext = createContext<StackContextValue>({
@@ -2,22 +2,18 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
6
  import React from 'react';
9
7
 
10
- import { Icon, DropdownMenu } from '@dxos/react-ui';
11
- import { withTheme } from '@dxos/storybook-utils';
8
+ import { DropdownMenu, Icon } from '@dxos/react-ui';
9
+ import { withTheme } from '@dxos/react-ui/testing';
12
10
 
13
- import { StackItem } from './StackItem';
11
+ import { StackItem, type StackItemRootProps } from './StackItem';
14
12
 
15
- const meta: Meta<typeof StackItem.Root> = {
16
- title: 'ui/react-ui-stack/StackItem',
17
- component: StackItem.Root,
18
- render: (args) => (
19
- <StackItem.Root role='section' {...args} classNames='w-[20rem] border border-separator'>
20
- <StackItem.Heading>
13
+ const DefaultStory = (props: StackItemRootProps) => {
14
+ return (
15
+ <StackItem.Root role='section' {...props} classNames='is-[20rem] border border-separator'>
16
+ <StackItem.Heading classNames='is-full border-be border-separator'>
21
17
  <span className='sr-only'>Title</span>
22
18
  <div role='none' className='sticky -block-start-px bg-[--sticky-bg] p-1 is-full'>
23
19
  <DropdownMenu.Root>
@@ -29,18 +25,26 @@ const meta: Meta<typeof StackItem.Root> = {
29
25
  </DropdownMenu.Root>
30
26
  </div>
31
27
  </StackItem.Heading>
32
- <StackItem.Content classNames='p-2'>Content</StackItem.Content>
28
+ <StackItem.Content>
29
+ <div className='p-4 text-center'>Content</div>
30
+ </StackItem.Content>
33
31
  </StackItem.Root>
34
- ),
32
+ );
33
+ };
34
+
35
+ const meta = {
36
+ title: 'ui/react-ui-stack/StackItem',
37
+ component: StackItem.Root as any,
38
+ render: DefaultStory,
35
39
  decorators: [withTheme],
36
40
  parameters: {
37
41
  layout: 'centered',
38
42
  },
39
- };
43
+ } satisfies Meta<typeof DefaultStory>;
40
44
 
41
45
  export default meta;
42
46
 
43
- type Story = StoryObj<typeof StackItem.Root>;
47
+ type Story = StoryObj<typeof meta>;
44
48
 
45
49
  export const Default: Story = {
46
50
  args: {
@@ -7,52 +7,57 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
7
7
  import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
8
8
  import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
9
9
  import {
10
+ type Edge,
10
11
  attachClosestEdge,
11
12
  extractClosestEdge,
12
- type Edge,
13
13
  } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
14
14
  import { useFocusableGroup } from '@fluentui/react-tabster';
15
15
  import { composeRefs } from '@radix-ui/react-compose-refs';
16
16
  import React, {
17
- forwardRef,
18
- useLayoutEffect,
19
- useState,
20
17
  type ComponentPropsWithRef,
21
- useCallback,
22
18
  type ReactNode,
19
+ forwardRef,
20
+ useCallback,
21
+ useLayoutEffect,
23
22
  useMemo,
23
+ useState,
24
24
  } from 'react';
25
25
  import { createPortal } from 'react-dom';
26
26
 
27
- import { type ThemedClassName, ListItem } from '@dxos/react-ui';
27
+ import { ListItem, type ThemedClassName } from '@dxos/react-ui';
28
28
  import { resizeAttributes, sizeStyle } from '@dxos/react-ui-dnd';
29
- import { mx } from '@dxos/react-ui-theme';
29
+ import { mx } from '@dxos/ui-theme';
30
+
31
+ import { type ItemDragState, StackItemContext, idle, useStack, useStackItem } from '../StackContext';
32
+ import { type StackItemData, type StackItemSize } from '../types';
30
33
 
31
34
  import { StackItemContent, type StackItemContentProps } from './StackItemContent';
32
35
  import { StackItemDragHandle, type StackItemDragHandleProps } from './StackItemDragHandle';
33
36
  import {
34
37
  StackItemHeading,
35
38
  StackItemHeadingLabel,
36
- type StackItemHeadingProps,
37
39
  type StackItemHeadingLabelProps,
40
+ type StackItemHeadingProps,
38
41
  StackItemHeadingStickyContent,
39
42
  } from './StackItemHeading';
40
43
  import { StackItemResizeHandle, type StackItemResizeHandleProps } from './StackItemResizeHandle';
41
44
  import {
42
45
  StackItemSigil,
43
- type StackItemSigilProps,
44
46
  type StackItemSigilAction,
45
- type StackItemSigilButtonProps,
46
47
  StackItemSigilButton,
48
+ type StackItemSigilButtonProps,
49
+ type StackItemSigilProps,
47
50
  } from './StackItemSigil';
48
- import { useStack, StackItemContext, idle, type ItemDragState, useStackItem } from '../StackContext';
49
- import { type StackItemSize, type StackItemData } from '../defs';
50
51
 
51
52
  // NOTE: 48rem fills the screen on a MacbookPro with the sidebars closed.
52
53
  export const DEFAULT_HORIZONTAL_SIZE = 48 satisfies StackItemSize;
53
54
  export const DEFAULT_VERTICAL_SIZE = 'min-content' satisfies StackItemSize;
54
55
  export const DEFAULT_EXTRINSIC_SIZE = DEFAULT_HORIZONTAL_SIZE satisfies StackItemSize;
55
56
 
57
+ //
58
+ // StackItemRoot
59
+ //
60
+
56
61
  type StackItemRootProps = ThemedClassName<ComponentPropsWithRef<'div'>> & {
57
62
  item: Omit<StackItemData, 'type'>;
58
63
  order?: number;
@@ -62,7 +67,7 @@ type StackItemRootProps = ThemedClassName<ComponentPropsWithRef<'div'>> & {
62
67
  onSizeChange?: (nextSize: StackItemSize) => void;
63
68
  role?: 'article' | 'section';
64
69
  disableRearrange?: boolean;
65
- focusIndicatorVariant?: 'over-all' | 'group';
70
+ focusIndicatorVariant?: 'over-all' | 'group' | 'over-all-always' | 'group-always';
66
71
  };
67
72
 
68
73
  const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
@@ -85,18 +90,17 @@ const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
85
90
  forwardedRef,
86
91
  ) => {
87
92
  const [itemElement, itemRef] = useState<HTMLDivElement | null>(null);
93
+ const composedItemRef = composeRefs<HTMLDivElement>(itemRef, forwardedRef);
88
94
  const [selfDragHandleElement, selfDragHandleRef] = useState<HTMLDivElement | null>(null);
89
95
  const [closestEdge, setEdge] = useState<Edge | null>(null);
90
96
  const [sourceId, setSourceId] = useState<string | null>(null);
91
97
  const [dragState, setDragState] = useState<ItemDragState>(idle);
92
- const { orientation, rail, onRearrange } = useStack();
98
+ const { orientation, rail, onRearrange, size: stackSize, stackId } = useStack();
93
99
  const [size = orientation === 'horizontal' ? DEFAULT_HORIZONTAL_SIZE : DEFAULT_VERTICAL_SIZE, setInternalSize] =
94
100
  useState(propsSize);
95
101
 
96
102
  const Root = role ?? 'div';
97
103
 
98
- const composedItemRef = composeRefs<HTMLDivElement>(itemRef, forwardedRef);
99
-
100
104
  const setSize = useCallback(
101
105
  (nextSize: StackItemSize, commit?: boolean) => {
102
106
  setInternalSize(nextSize);
@@ -109,6 +113,7 @@ const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
109
113
 
110
114
  const type = orientation === 'horizontal' ? 'column' : 'card';
111
115
 
116
+ // TODO(burdon): Factor out?
112
117
  useLayoutEffect(() => {
113
118
  if (!itemElement || !onRearrange || disableRearrange) {
114
119
  return;
@@ -182,18 +187,18 @@ const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
182
187
 
183
188
  const focusableGroupAttrs = useFocusableGroup({ tabBehavior: 'limited' });
184
189
 
185
- // Determine if the drop would result in any changes
190
+ // Determine if the drop would result in any changes.
186
191
  const shouldShowDropIndicator = () => {
187
192
  if (!closestEdge || !sourceId) {
188
193
  return false;
189
194
  }
190
195
 
191
- // Don't show indicator when dragged item is over itself
196
+ // Don't show indicator when dragged item is over itself.
192
197
  if (sourceId === item.id) {
193
198
  return false;
194
199
  }
195
200
 
196
- // Don't show indicator when dragged item is over the trailing edge of its previous sibling
201
+ // Don't show indicator when dragged item is over the trailing edge of its previous sibling.
197
202
  const isTrailingEdgeOfPrevSibling =
198
203
  prevSiblingId !== undefined &&
199
204
  sourceId === prevSiblingId &&
@@ -231,18 +236,25 @@ const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
231
236
  'group/stack-item grid relative',
232
237
  focusIndicatorVariant === 'over-all'
233
238
  ? 'dx-focus-ring-inset-over-all'
234
- : orientation === 'horizontal'
235
- ? 'dx-focus-ring-group-x'
236
- : 'dx-focus-ring-group-y',
239
+ : focusIndicatorVariant === 'over-all-always'
240
+ ? 'dx-focus-ring-inset-over-all-always'
241
+ : orientation === 'horizontal'
242
+ ? focusIndicatorVariant === 'group-always'
243
+ ? 'dx-focus-ring-group-x-always'
244
+ : 'dx-focus-ring-group-x'
245
+ : focusIndicatorVariant === 'group-always'
246
+ ? 'dx-focus-ring-group-y-always'
247
+ : 'dx-focus-ring-group-y',
237
248
  orientation === 'horizontal' ? 'grid-rows-subgrid' : 'grid-cols-subgrid',
238
249
  rail && (orientation === 'horizontal' ? 'row-span-2' : 'col-span-2'),
239
250
  role === 'section' && orientation !== 'horizontal' && 'border-be border-subduedSeparator',
240
251
  classNames,
241
252
  )}
242
- data-dx-stack-item
253
+ data-dx-stack-item={stackId}
254
+ data-dx-item-id={item.id}
243
255
  {...resizeAttributes}
244
256
  style={{
245
- ...sizeStyle(size, orientation),
257
+ ...(stackSize !== 'split' && sizeStyle(size, orientation)),
246
258
  ...(Number.isFinite(order) && {
247
259
  [orientation === 'horizontal' ? 'gridColumn' : 'gridRow']: `${order}`,
248
260
  }),
@@ -260,37 +272,45 @@ const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
260
272
  },
261
273
  );
262
274
 
275
+ //
276
+ // StackItemDragPreview
277
+ //
278
+
263
279
  type StackItemDragPreviewProps = {
264
280
  children: ({ item }: { item: any }) => ReactNode;
265
281
  };
266
282
 
267
- export const StackItemDragPreview = ({ children }: StackItemDragPreviewProps) => {
283
+ const StackItemDragPreview = ({ children }: StackItemDragPreviewProps) => {
268
284
  const { state } = useStackItem();
269
285
  return state?.type === 'preview' ? createPortal(children({ item: state.item }), state.container) : null;
270
286
  };
271
287
 
288
+ //
289
+ // StackItem
290
+ //
291
+
272
292
  export const StackItem = {
273
293
  Root: StackItemRoot,
274
294
  Content: StackItemContent,
295
+ DragHandle: StackItemDragHandle,
296
+ DragPreview: StackItemDragPreview,
275
297
  Heading: StackItemHeading,
276
298
  HeadingLabel: StackItemHeadingLabel,
277
299
  HeadingStickyContent: StackItemHeadingStickyContent,
278
300
  ResizeHandle: StackItemResizeHandle,
279
- DragHandle: StackItemDragHandle,
280
301
  Sigil: StackItemSigil,
281
302
  SigilButton: StackItemSigilButton,
282
- DragPreview: StackItemDragPreview,
283
303
  };
284
304
 
285
305
  export type {
286
306
  StackItemRootProps,
287
307
  StackItemContentProps,
308
+ StackItemDragHandleProps,
309
+ StackItemDragPreviewProps,
288
310
  StackItemHeadingProps,
289
311
  StackItemHeadingLabelProps,
290
312
  StackItemResizeHandleProps,
291
- StackItemDragHandleProps,
292
313
  StackItemSigilProps,
293
314
  StackItemSigilButtonProps,
294
315
  StackItemSigilAction,
295
- StackItemDragPreviewProps,
296
316
  };