@dxos/react-ui-stack 0.8.4-main.dedc0f3 → 0.8.4-main.dfabb4ec29

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 (122) hide show
  1. package/dist/lib/browser/index.mjs +704 -67
  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 +10 -23
  5. package/dist/lib/browser/playwright/index.mjs.map +2 -2
  6. package/dist/lib/browser/translations.mjs +23 -0
  7. package/dist/lib/browser/translations.mjs.map +7 -0
  8. package/dist/lib/node-esm/index.mjs +705 -67
  9. package/dist/lib/node-esm/index.mjs.map +4 -4
  10. package/dist/lib/node-esm/meta.json +1 -1
  11. package/dist/lib/node-esm/playwright/index.mjs +10 -23
  12. package/dist/lib/node-esm/playwright/index.mjs.map +2 -2
  13. package/dist/lib/node-esm/translations.mjs +25 -0
  14. package/dist/lib/node-esm/translations.mjs.map +7 -0
  15. package/dist/types/src/components/Stack/Stack.d.ts +5 -9
  16. package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
  17. package/dist/types/src/components/Stack/Stack.stories.d.ts +1 -2
  18. package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/StackContext.d.ts +1 -1
  20. package/dist/types/src/components/StackContext.d.ts.map +1 -1
  21. package/dist/types/src/components/StackItem/MenuSignifier.d.ts.map +1 -1
  22. package/dist/types/src/components/StackItem/StackItem.d.ts +12 -15
  23. package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
  24. package/dist/types/src/components/StackItem/StackItem.stories.d.ts +0 -1
  25. package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
  26. package/dist/types/src/components/StackItem/StackItemContent.d.ts +4 -37
  27. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  28. package/dist/types/src/components/StackItem/StackItemDragHandle.d.ts.map +1 -1
  29. package/dist/types/src/components/StackItem/StackItemHeading.d.ts +1 -1
  30. package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
  31. package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts +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/index.d.ts +1 -2
  36. package/dist/types/src/components/index.d.ts.map +1 -1
  37. package/dist/types/src/components/{defs.d.ts → types.d.ts} +1 -1
  38. package/dist/types/src/components/types.d.ts.map +1 -0
  39. package/dist/types/src/hooks/useStackDropForElements.d.ts +8 -6
  40. package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
  41. package/dist/types/src/index.d.ts +0 -2
  42. package/dist/types/src/index.d.ts.map +1 -1
  43. package/dist/types/src/playwright/playwright.config.d.ts.map +1 -1
  44. package/dist/types/src/playwright/stack-manager.d.ts.map +1 -1
  45. package/dist/types/src/translations.d.ts +10 -10
  46. package/dist/types/src/translations.d.ts.map +1 -1
  47. package/dist/types/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +49 -47
  49. package/src/components/Stack/Stack.stories.tsx +10 -14
  50. package/src/components/Stack/Stack.tsx +216 -172
  51. package/src/components/StackContext.tsx +1 -1
  52. package/src/components/StackItem/MenuSignifier.tsx +2 -9
  53. package/src/components/StackItem/StackItem.stories.tsx +8 -8
  54. package/src/components/StackItem/StackItem.tsx +48 -31
  55. package/src/components/StackItem/StackItemContent.tsx +23 -44
  56. package/src/components/StackItem/StackItemDragHandle.tsx +4 -3
  57. package/src/components/StackItem/StackItemHeading.tsx +14 -21
  58. package/src/components/StackItem/StackItemResizeHandle.tsx +1 -2
  59. package/src/components/StackItem/StackItemSigil.tsx +10 -7
  60. package/src/components/index.ts +2 -2
  61. package/src/hooks/useStackDropForElements.ts +60 -46
  62. package/src/index.ts +0 -4
  63. package/src/playwright/playwright.config.ts +1 -1
  64. package/src/translations.ts +9 -9
  65. package/dist/lib/browser/chunk-3V2YUQK5.mjs +0 -1375
  66. package/dist/lib/browser/chunk-3V2YUQK5.mjs.map +0 -7
  67. package/dist/lib/browser/testing/index.mjs +0 -31
  68. package/dist/lib/browser/testing/index.mjs.map +0 -7
  69. package/dist/lib/node-esm/chunk-HE3BRF7A.mjs +0 -1377
  70. package/dist/lib/node-esm/chunk-HE3BRF7A.mjs.map +0 -7
  71. package/dist/lib/node-esm/testing/index.mjs +0 -32
  72. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  73. package/dist/types/src/components/Image/Image.d.ts +0 -11
  74. package/dist/types/src/components/Image/Image.d.ts.map +0 -1
  75. package/dist/types/src/components/Image/Image.stories.d.ts +0 -31
  76. package/dist/types/src/components/Image/Image.stories.d.ts.map +0 -1
  77. package/dist/types/src/components/Image/index.d.ts +0 -2
  78. package/dist/types/src/components/Image/index.d.ts.map +0 -1
  79. package/dist/types/src/components/defs.d.ts.map +0 -1
  80. package/dist/types/src/components/deprecated/LayoutControls.d.ts +0 -19
  81. package/dist/types/src/components/deprecated/LayoutControls.d.ts.map +0 -1
  82. package/dist/types/src/exemplars/Card/Card.d.ts +0 -58
  83. package/dist/types/src/exemplars/Card/Card.d.ts.map +0 -1
  84. package/dist/types/src/exemplars/Card/Card.stories.d.ts +0 -44
  85. package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +0 -1
  86. package/dist/types/src/exemplars/Card/CardDragPreview.d.ts +0 -6
  87. package/dist/types/src/exemplars/Card/CardDragPreview.d.ts.map +0 -1
  88. package/dist/types/src/exemplars/Card/fragments.d.ts +0 -13
  89. package/dist/types/src/exemplars/Card/fragments.d.ts.map +0 -1
  90. package/dist/types/src/exemplars/Card/index.d.ts +0 -4
  91. package/dist/types/src/exemplars/Card/index.d.ts.map +0 -1
  92. package/dist/types/src/exemplars/CardStack/CardStack.d.ts +0 -40
  93. package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +0 -1
  94. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +0 -13
  95. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +0 -1
  96. package/dist/types/src/exemplars/CardStack/CardStackDragPreview.d.ts +0 -9
  97. package/dist/types/src/exemplars/CardStack/CardStackDragPreview.d.ts.map +0 -1
  98. package/dist/types/src/exemplars/CardStack/index.d.ts +0 -3
  99. package/dist/types/src/exemplars/CardStack/index.d.ts.map +0 -1
  100. package/dist/types/src/exemplars/index.d.ts +0 -3
  101. package/dist/types/src/exemplars/index.d.ts.map +0 -1
  102. package/dist/types/src/testing/CardContainer.d.ts +0 -6
  103. package/dist/types/src/testing/CardContainer.d.ts.map +0 -1
  104. package/dist/types/src/testing/index.d.ts +0 -2
  105. package/dist/types/src/testing/index.d.ts.map +0 -1
  106. package/src/components/Image/Image.stories.tsx +0 -58
  107. package/src/components/Image/Image.tsx +0 -137
  108. package/src/components/Image/index.ts +0 -5
  109. package/src/components/deprecated/LayoutControls.tsx +0 -109
  110. package/src/exemplars/Card/Card.stories.tsx +0 -88
  111. package/src/exemplars/Card/Card.tsx +0 -186
  112. package/src/exemplars/Card/CardDragPreview.tsx +0 -22
  113. package/src/exemplars/Card/fragments.ts +0 -24
  114. package/src/exemplars/Card/index.ts +0 -7
  115. package/src/exemplars/CardStack/CardStack.stories.tsx +0 -172
  116. package/src/exemplars/CardStack/CardStack.tsx +0 -136
  117. package/src/exemplars/CardStack/CardStackDragPreview.tsx +0 -61
  118. package/src/exemplars/CardStack/index.ts +0 -6
  119. package/src/exemplars/index.ts +0 -6
  120. package/src/testing/CardContainer.tsx +0 -37
  121. package/src/testing/index.ts +0 -5
  122. /package/src/components/{defs.ts → types.ts} +0 -0
@@ -4,25 +4,25 @@
4
4
 
5
5
  import { composeRefs } from '@radix-ui/react-compose-refs';
6
6
  import React, {
7
- type CSSProperties,
8
7
  Children,
9
8
  type ComponentPropsWithRef,
9
+ type FocusEvent,
10
10
  type KeyboardEvent,
11
11
  forwardRef,
12
12
  useCallback,
13
13
  useEffect,
14
- useMemo,
15
14
  useState,
16
15
  } from 'react';
17
16
 
18
17
  import { ListItem, type ThemedClassName, useId } from '@dxos/react-ui';
19
- import { mx } from '@dxos/react-ui-theme';
18
+ import { mx } from '@dxos/ui-theme';
20
19
 
21
20
  import { useStackDropForElements } from '../../hooks';
22
- import { type StackContextValue } from '../defs';
23
21
  import { StackContext } from '../StackContext';
22
+ import { type StackContextValue } from '../types';
24
23
 
25
24
  export type Orientation = 'horizontal' | 'vertical';
25
+
26
26
  /**
27
27
  * Size is how Stack and its StackItems coordinate the dimensions of the items with the available space.
28
28
  * - `intrinsic` signals to Stack and its StackItems to occupy their intrinsic size
@@ -32,23 +32,8 @@ export type Orientation = 'horizontal' | 'vertical';
32
32
  */
33
33
  export type Size = 'intrinsic' | 'contain' | 'split';
34
34
 
35
- export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
36
- Partial<StackContextValue> & {
37
- itemsCount?: number;
38
- getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
39
- separatorOnScroll?: number;
40
- };
41
-
42
- export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
43
- export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
44
-
45
- // TODO(ZaymonFC): Magic 2px to stop overflow.
46
- export const railGridHorizontalContainFitContent =
47
- 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
48
- export const railGridVerticalContainFitContent =
49
- 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
50
-
51
- export const autoScrollRootAttributes = { 'data-drag-autoscroll': 'idle' };
35
+ export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--dx-rail-size)_[content-start]_1fr_[content-end]]';
36
+ export const railGridVertical = 'grid-cols-[[rail-start]_var(--dx-rail-size)_[content-start]_1fr_[content-end]]';
52
37
 
53
38
  const PERPENDICULAR_FOCUS_THRESHHOLD = 128;
54
39
 
@@ -60,37 +45,43 @@ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orient
60
45
  return el.focus();
61
46
  };
62
47
 
48
+ export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
49
+ Partial<StackContextValue> & {
50
+ itemsCount?: number;
51
+ circularFocus?: boolean;
52
+ separatorOnScroll?: number;
53
+ getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
54
+ };
55
+
63
56
  export const Stack = forwardRef<HTMLDivElement, StackProps>(
64
57
  (
65
58
  {
66
59
  children,
67
60
  classNames,
61
+ id,
68
62
  style,
69
63
  orientation = 'vertical',
70
64
  rail = true,
71
65
  size = 'intrinsic',
72
- onRearrange,
73
66
  itemsCount = Children.count(children),
74
- getDropElement,
67
+ circularFocus,
75
68
  separatorOnScroll,
69
+ getDropElement,
70
+ onBlur,
71
+ onKeyDown,
72
+ onRearrange,
76
73
  ...props
77
74
  },
78
75
  forwardedRef,
79
76
  ) => {
80
- const stackId = useId('stack', props.id);
77
+ const stackId = useId('stack', id);
81
78
  const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
79
+ const [lastFocusedItem, setLastFocusedItem] = useState<string>();
82
80
  const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
83
81
 
84
- const styles: CSSProperties = {
85
- [orientation === 'horizontal' ? 'gridTemplateColumns' : 'gridTemplateRows']:
86
- size === 'split' ? `repeat(${itemsCount}, 1fr)` : `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
87
- ...style,
88
- };
89
-
90
- const selfDroppable = !!(itemsCount < 1 && onRearrange && props.id);
91
-
82
+ const selfDroppable = !!(itemsCount < 1 && onRearrange && id);
92
83
  const { dropping } = useStackDropForElements({
93
- id: props.id,
84
+ id,
94
85
  element: getDropElement && stackElement ? getDropElement(stackElement) : stackElement,
95
86
  scrollElement: stackElement,
96
87
  selfDroppable,
@@ -98,6 +89,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
98
89
  onRearrange,
99
90
  });
100
91
 
92
+ /** Updates scroll separator data attributes based on current scroll position. */
101
93
  const handleScroll = useCallback(() => {
102
94
  if (stackElement && Number.isFinite(separatorOnScroll)) {
103
95
  const scrollPosition = orientation === 'horizontal' ? stackElement.scrollLeft : stackElement.scrollTop;
@@ -114,141 +106,25 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
114
106
  }
115
107
  }, [stackElement, separatorOnScroll, orientation]);
116
108
 
117
- /**
118
- * Handles moving focus using the arrow keys. Focus is only handled by the nearest stack; if the arrow key matches the
119
- * orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item; or, if there is no
120
- * such stack item, focus is passed to the adjacent empty stack if one can be found.
121
- */
122
- const handleKeyDown = useCallback(
123
- (event: KeyboardEvent<HTMLDivElement>) => {
124
- const target = event.target as HTMLElement;
125
- if (
126
- event.key.startsWith('Arrow') &&
127
- !target.closest(
128
- `input, textarea, [role="textbox"], [data-tabster*="mover"], [data-arrow-keys="all"], [data-arrow-keys~="${event.key.toLowerCase().slice(5)}"]`,
129
- )
130
- ) {
131
- const closestOwnedItem = target.closest(`[data-dx-stack-item="${stackId}"]`);
132
- const closestStack = target.closest('[data-dx-stack]') as HTMLElement | null;
133
- const closestStackItems = Array.from(
134
- closestStack?.querySelectorAll(`[data-dx-stack-item="${stackId}"]`) ?? [],
135
- );
136
- const closestStackOrientation = closestStack?.getAttribute('aria-orientation') as Orientation;
137
- const ancestorStack = closestStack?.parentElement?.closest('[data-dx-stack]') as HTMLElement | null;
138
- if (closestOwnedItem && closestStack) {
139
- const ancestorOrientation = ancestorStack?.getAttribute('aria-orientation') as Orientation | undefined;
140
- const parallelDelta = (
141
- closestStackOrientation === 'vertical' ? event.key === 'ArrowUp' : event.key === 'ArrowLeft'
142
- )
143
- ? -1
144
- : (closestStackOrientation === 'vertical' ? event.key === 'ArrowDown' : event.key === 'ArrowRight')
145
- ? 1
146
- : 0;
147
- const perpendicularDelta = (
148
- closestStackOrientation === 'vertical' ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
149
- )
150
- ? -1
151
- : (closestStackOrientation === 'vertical' ? event.key === 'ArrowRight' : event.key === 'ArrowDown')
152
- ? 1
153
- : 0;
154
- if (parallelDelta !== 0) {
155
- const adjacentItem = closestStackItems[
156
- (closestStackItems.indexOf(closestOwnedItem) + parallelDelta + closestStackItems.length) %
157
- closestStackItems.length
158
- ] as HTMLElement | undefined;
159
- if (adjacentItem) {
160
- event.preventDefault();
161
- scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
162
- }
163
- }
164
- if (perpendicularDelta !== 0) {
165
- if (ancestorStack && ancestorOrientation !== closestStackOrientation) {
166
- const siblingStacks = Array.from(
167
- ancestorStack.querySelectorAll(
168
- `[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
169
- ),
170
- ) as HTMLElement[];
171
- const adjacentStack = siblingStacks[
172
- (siblingStacks.indexOf(closestStack) + perpendicularDelta + siblingStacks.length) %
173
- siblingStacks.length
174
- ] as HTMLElement | undefined;
175
- const adjacentStackSelfItem = adjacentStack?.closest(
176
- `[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
177
- ) as HTMLElement | undefined;
178
- const adjacentStackItems = adjacentStack
179
- ? (Array.from(
180
- adjacentStack.querySelectorAll(
181
- `[data-dx-stack-item="${adjacentStack.getAttribute('data-dx-stack')}"]`,
182
- ),
183
- ) as HTMLElement[])
184
- : [];
185
- if (adjacentStackItems.length > 0) {
186
- // Find the closest item by position
187
- const ownedItemRect = closestOwnedItem.getBoundingClientRect();
188
- const targetPosition =
189
- closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
190
-
191
- let closestItem = adjacentStackItems[0];
192
- let closestDistance = Infinity;
193
-
194
- for (const item of adjacentStackItems) {
195
- const itemRect = item.getBoundingClientRect();
196
- const itemPosition = closestStackOrientation === 'vertical' ? itemRect.top : itemRect.left;
197
- const distance = Math.abs(itemPosition - targetPosition);
198
-
199
- if (distance < closestDistance) {
200
- closestDistance = distance;
201
- closestItem = item;
202
- }
203
- if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
204
- break;
205
- }
206
- }
207
-
208
- event.preventDefault();
209
- scrollIntoViewAndFocus(closestItem, closestStackOrientation);
210
- } else if (adjacentStackSelfItem) {
211
- event.preventDefault();
212
- scrollIntoViewAndFocus(adjacentStackSelfItem, ancestorOrientation);
213
- }
214
- } else if (closestOwnedItem) {
215
- const closestOwnedItemStack = closestOwnedItem.querySelector('[data-dx-stack]');
216
- const closestOwnedItemStackItems = closestOwnedItemStack
217
- ? (Array.from(
218
- closestOwnedItemStack.querySelectorAll(
219
- `[data-dx-stack-item="${closestOwnedItemStack.getAttribute('data-dx-stack')}"]`,
220
- ),
221
- ) as HTMLElement[])
222
- : [];
223
- if (closestOwnedItemStackItems.length > 0) {
224
- event.preventDefault();
225
- scrollIntoViewAndFocus(
226
- closestOwnedItemStackItems[
227
- ['ArrowUp', 'ArrowLeft'].includes(event.key) ? closestOwnedItemStackItems.length - 1 : 0
228
- ],
229
- closestOwnedItemStack?.getAttribute('aria-orientation') as Orientation,
230
- );
231
- }
232
- }
233
- }
109
+ /** Handles blur events to track the last focused item within this stack. */
110
+ const handleBlur = useCallback(
111
+ (event: FocusEvent<HTMLDivElement>) => {
112
+ if (event.target) {
113
+ const target = event.target as HTMLElement;
114
+ const closestStackItem = target.closest(`[data-dx-item-id]`) as HTMLElement | null;
115
+ if (closestStackItem?.closest(`[data-dx-stack="${stackId}"]`)) {
116
+ setLastFocusedItem(closestStackItem?.getAttribute('data-dx-item-id') ?? undefined);
234
117
  }
235
118
  }
236
- props.onKeyDown?.(event);
119
+ onBlur?.(event);
237
120
  },
238
- [props.onKeyDown, stackId],
121
+ [stackId, onBlur],
239
122
  );
240
123
 
241
- const gridClasses = useMemo(() => {
242
- if (!rail) {
243
- return orientation === 'horizontal' ? 'grid-rows-1 pli-[--stack-gap]' : 'grid-cols-1 plb-[--stack-gap]';
244
- }
245
- if (orientation === 'horizontal') {
246
- return railGridHorizontal;
247
- } else {
248
- return railGridVertical;
249
- }
250
- }, [rail, orientation, size]);
124
+ /** Handles keyboard navigation within the stack. */
125
+ const handleKeyDown = useKeyDown(stackId, circularFocus, onKeyDown);
251
126
 
127
+ /** Observes DOM mutations to keep scroll separator state in sync. */
252
128
  useEffect(() => {
253
129
  if (!(stackElement && Number.isFinite(separatorOnScroll))) {
254
130
  return;
@@ -259,32 +135,46 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
259
135
  });
260
136
 
261
137
  observer.observe(stackElement, { childList: true, subtree: true });
262
-
263
138
  return () => {
264
139
  observer.disconnect();
265
140
  };
266
141
  }, [stackElement, handleScroll]);
267
142
 
268
143
  return (
269
- <StackContext.Provider value={{ orientation, rail, size, onRearrange, stackId }}>
144
+ <StackContext.Provider value={{ stackId, orientation, rail, size, onRearrange }}>
270
145
  <div
271
146
  {...props}
147
+ {...(Number.isFinite(separatorOnScroll) && { onScroll: handleScroll })}
272
148
  className={mx(
273
- 'grid relative [--stack-gap:var(--dx-trimXs)]',
274
- gridClasses,
149
+ 'relative grid [--stack-gap:var(--spacing-trim-xs)]',
275
150
  size === 'contain' &&
276
151
  (orientation === 'horizontal'
277
- ? 'overflow-x-auto overscroll-x-contain min-bs-0 max-bs-full bs-full'
278
- : 'overflow-y-auto min-is-0 max-is-full is-full'),
152
+ ? 'overflow-x-auto overscroll-x-contain min-h-0 max-h-full h-full'
153
+ : 'overflow-y-auto min-w-0 max-w-full w-full'),
154
+ rail
155
+ ? orientation === 'horizontal'
156
+ ? railGridHorizontal
157
+ : railGridVertical
158
+ : orientation === 'horizontal'
159
+ ? 'grid-rows-1 px-(--stack-gap)'
160
+ : 'grid-cols-1 py-(--stack-gap)',
279
161
  classNames,
280
162
  )}
281
- onKeyDown={handleKeyDown}
163
+ style={{
164
+ [orientation === 'horizontal' ? 'gridTemplateColumns' : 'gridTemplateRows']:
165
+ size === 'split'
166
+ ? `repeat(${itemsCount}, 1fr)`
167
+ : `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
168
+ ...style,
169
+ }}
170
+ aria-orientation={orientation}
282
171
  data-dx-stack={stackId}
172
+ data-dx-stack-circular-focus={circularFocus}
173
+ data-dx-last-focused-item={lastFocusedItem}
283
174
  data-rail={rail}
284
- aria-orientation={orientation}
285
- style={styles}
175
+ onBlur={handleBlur}
176
+ onKeyDown={handleKeyDown}
286
177
  ref={composedItemRef}
287
- {...(Number.isFinite(separatorOnScroll) && { onScroll: handleScroll })}
288
178
  >
289
179
  {children}
290
180
  {selfDroppable && dropping && (
@@ -304,3 +194,157 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
304
194
  export { StackContext };
305
195
 
306
196
  export type { StackContextValue };
197
+
198
+ /**
199
+ * Handles moving focus using the arrow keys. Focus is only handled by the nearest stack.
200
+ * If the arrow key matches the orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item;
201
+ * Or if there is no such stack item, focus is passed to the adjacent empty stack if one can be found.
202
+ */
203
+ // TODO(burdon): Replace with Mosaic.Stack which handles this automatically.
204
+ const useKeyDown = (stackId: string, circularFocus?: boolean, onKeyDown?: StackProps['onKeyDown']) =>
205
+ useCallback(
206
+ (event: KeyboardEvent<HTMLDivElement>) => {
207
+ const target = event.target as HTMLElement;
208
+ if (
209
+ event.key.startsWith('Arrow') &&
210
+ !target.closest(
211
+ `input, textarea, [role="textbox"], [data-tabster*="mover"], [data-arrow-keys="all"], [data-arrow-keys~="${event.key.toLowerCase().slice(5)}"]`,
212
+ )
213
+ ) {
214
+ const closestOwnedItem = target.closest(`[data-dx-stack-item="${stackId}"]`);
215
+ const closestStack = target.closest('[data-dx-stack]') as HTMLElement | null;
216
+ const closestStackItems = Array.from(closestStack?.querySelectorAll(`[data-dx-stack-item="${stackId}"]`) ?? []);
217
+ const closestStackOrientation = closestStack?.getAttribute('aria-orientation') as Orientation;
218
+ const ancestorStack = closestStack?.parentElement?.closest('[data-dx-stack]') as HTMLElement | null;
219
+ if (closestOwnedItem && closestStack) {
220
+ const ancestorOrientation = ancestorStack?.getAttribute('aria-orientation') as Orientation | undefined;
221
+ const parallelDelta = (
222
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowUp' : event.key === 'ArrowLeft'
223
+ )
224
+ ? -1
225
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowDown' : event.key === 'ArrowRight')
226
+ ? 1
227
+ : 0;
228
+ const perpendicularDelta = (
229
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
230
+ )
231
+ ? -1
232
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowRight' : event.key === 'ArrowDown')
233
+ ? 1
234
+ : 0;
235
+ if (parallelDelta !== 0) {
236
+ const currentIndex = closestStackItems.indexOf(closestOwnedItem);
237
+ const nextIndex = currentIndex + parallelDelta;
238
+ let adjacentItem: HTMLElement | undefined;
239
+ if (circularFocus) {
240
+ // Circular navigation: wrap around using modulo.
241
+ adjacentItem = closestStackItems[(nextIndex + closestStackItems.length) % closestStackItems.length] as
242
+ | HTMLElement
243
+ | undefined;
244
+ } else {
245
+ // Non-circular navigation: only move if within bounds.
246
+ if (nextIndex >= 0 && nextIndex < closestStackItems.length) {
247
+ adjacentItem = closestStackItems[nextIndex] as HTMLElement | undefined;
248
+ }
249
+ }
250
+
251
+ if (adjacentItem) {
252
+ event.preventDefault();
253
+ scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
254
+ }
255
+ }
256
+
257
+ if (perpendicularDelta !== 0) {
258
+ if (ancestorStack && ancestorOrientation !== closestStackOrientation) {
259
+ const siblingStacks = Array.from(
260
+ ancestorStack.querySelectorAll(
261
+ `[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
262
+ ),
263
+ ) as HTMLElement[];
264
+ const currentStackIndex = siblingStacks.indexOf(closestStack);
265
+ const nextStackIndex = currentStackIndex + perpendicularDelta;
266
+ let adjacentStack: HTMLElement | undefined;
267
+
268
+ if (ancestorStack.getAttribute('data-dx-stack-circular-focus') === 'true') {
269
+ // Circular navigation: wrap around using modulo.
270
+ adjacentStack = siblingStacks[(nextStackIndex + siblingStacks.length) % siblingStacks.length] as
271
+ | HTMLElement
272
+ | undefined;
273
+ } else {
274
+ // Non-circular navigation: only move if within bounds.
275
+ if (nextStackIndex >= 0 && nextStackIndex < siblingStacks.length) {
276
+ adjacentStack = siblingStacks[nextStackIndex] as HTMLElement | undefined;
277
+ }
278
+ }
279
+ const adjacentStackSelfItem = adjacentStack?.closest(
280
+ `[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
281
+ ) as HTMLElement | undefined;
282
+ const adjacentStackItems = adjacentStack
283
+ ? (Array.from(
284
+ adjacentStack.querySelectorAll(
285
+ `[data-dx-stack-item="${adjacentStack.getAttribute('data-dx-stack')}"]`,
286
+ ),
287
+ ) as HTMLElement[])
288
+ : [];
289
+ if (adjacentStack && adjacentStackItems.length > 0) {
290
+ // Check if the adjacent stack has a last focused item recorded, otherwise find the closest item by position.
291
+ let closestItem = adjacentStackItems[0];
292
+ // Try to find an item with matching data-dx-stack-item value.
293
+ const lastFocusedItem = adjacentStack.querySelector(
294
+ `[data-dx-item-id="${adjacentStack.getAttribute('data-dx-last-focused-item') ?? 'never'}"]`,
295
+ );
296
+ if (lastFocusedItem) {
297
+ closestItem = lastFocusedItem as HTMLElement;
298
+ } else {
299
+ // Fall back to positional calculation
300
+ const ownedItemRect = closestOwnedItem.getBoundingClientRect();
301
+ const targetPosition =
302
+ closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
303
+
304
+ let closestDistance = Infinity;
305
+ for (const item of adjacentStackItems) {
306
+ const itemRect = item.getBoundingClientRect();
307
+ const itemPosition = closestStackOrientation === 'vertical' ? itemRect.top : itemRect.left;
308
+ const distance = Math.abs(itemPosition - targetPosition);
309
+ if (distance < closestDistance) {
310
+ closestDistance = distance;
311
+ closestItem = item;
312
+ }
313
+ if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
314
+ break;
315
+ }
316
+ }
317
+ }
318
+
319
+ event.preventDefault();
320
+ scrollIntoViewAndFocus(closestItem, closestStackOrientation);
321
+ } else if (adjacentStackSelfItem) {
322
+ event.preventDefault();
323
+ scrollIntoViewAndFocus(adjacentStackSelfItem, ancestorOrientation);
324
+ }
325
+ } else if (closestOwnedItem) {
326
+ const closestOwnedItemStack = closestOwnedItem.querySelector('[data-dx-stack]');
327
+ const closestOwnedItemStackItems = closestOwnedItemStack
328
+ ? (Array.from(
329
+ closestOwnedItemStack.querySelectorAll(
330
+ `[data-dx-stack-item="${closestOwnedItemStack.getAttribute('data-dx-stack')}"]`,
331
+ ),
332
+ ) as HTMLElement[])
333
+ : [];
334
+ if (closestOwnedItemStackItems.length > 0) {
335
+ event.preventDefault();
336
+ scrollIntoViewAndFocus(
337
+ closestOwnedItemStackItems[
338
+ ['ArrowUp', 'ArrowLeft'].includes(event.key) ? closestOwnedItemStackItems.length - 1 : 0
339
+ ],
340
+ closestOwnedItemStack?.getAttribute('aria-orientation') as Orientation,
341
+ );
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ onKeyDown?.(event);
348
+ },
349
+ [onKeyDown, stackId, circularFocus],
350
+ );
@@ -4,8 +4,8 @@
4
4
 
5
5
  import { createContext, useContext } from 'react';
6
6
 
7
- import { type StackItemRearrangeHandler, type StackItemSize } from './defs';
8
7
  import { type Orientation, type Size } from './Stack';
8
+ import { type StackItemRearrangeHandler, type StackItemSize } from './types';
9
9
 
10
10
  export type StackContextValue = {
11
11
  orientation: Orientation;
@@ -5,14 +5,7 @@
5
5
  import React from 'react';
6
6
 
7
7
  export const MenuSignifierHorizontal = () => (
8
- <svg
9
- className='absolute block-end-[7px]'
10
- width={20}
11
- height={2}
12
- viewBox='0 0 20 2'
13
- stroke='currentColor'
14
- opacity={0.5}
15
- >
8
+ <svg className='absolute bottom-[7px]' width={20} height={2} viewBox='0 0 20 2' stroke='currentColor' opacity={0.5}>
16
9
  <line
17
10
  x1={0.5}
18
11
  y1={0.75}
@@ -27,7 +20,7 @@ export const MenuSignifierHorizontal = () => (
27
20
  );
28
21
 
29
22
  export const MenuSignifierVertical = () => (
30
- <svg className='absolute inline-start-1' width={2} height={18} viewBox='0 0 2 18' stroke='currentColor'>
23
+ <svg className='absolute left-1' width={2} height={18} viewBox='0 0 2 18' stroke='currentColor'>
31
24
  <line x1={1} y1={3} x2={1} y2={18} strokeWidth={1.5} strokeLinecap='round' strokeDasharray='0 6' />
32
25
  </svg>
33
26
  );
@@ -2,32 +2,32 @@
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
8
  import { DropdownMenu, Icon } from '@dxos/react-ui';
11
- import { withTheme } from '@dxos/storybook-utils';
9
+ import { withTheme } from '@dxos/react-ui/testing';
12
10
 
13
11
  import { StackItem, type StackItemRootProps } from './StackItem';
14
12
 
15
13
  const DefaultStory = (props: StackItemRootProps) => {
16
14
  return (
17
15
  <StackItem.Root role='section' {...props} classNames='w-[20rem] border border-separator'>
18
- <StackItem.Heading>
16
+ <StackItem.Heading classNames='w-full border-b border-separator'>
19
17
  <span className='sr-only'>Title</span>
20
- <div role='none' className='sticky -block-start-px bg-[--sticky-bg] p-1 is-full'>
18
+ <div className='sticky -top-px bg-(--sticky-bg) p-1 w-full'>
21
19
  <DropdownMenu.Root>
22
20
  <DropdownMenu.Trigger asChild>
23
21
  <StackItem.SigilButton>
24
- <Icon icon='ph--dots-three--regular' size={5} />
22
+ <Icon icon='ph--dots-three--regular' />
25
23
  </StackItem.SigilButton>
26
24
  </DropdownMenu.Trigger>
27
25
  </DropdownMenu.Root>
28
26
  </div>
29
27
  </StackItem.Heading>
30
- <StackItem.Content classNames='p-2'>Content</StackItem.Content>
28
+ <StackItem.Content>
29
+ <div className='p-4 text-center'>Content</div>
30
+ </StackItem.Content>
31
31
  </StackItem.Root>
32
32
  );
33
33
  };
@@ -36,7 +36,7 @@ const meta = {
36
36
  title: 'ui/react-ui-stack/StackItem',
37
37
  component: StackItem.Root as any,
38
38
  render: DefaultStory,
39
- decorators: [withTheme],
39
+ decorators: [withTheme()],
40
40
  parameters: {
41
41
  layout: 'centered',
42
42
  },