@dxos/react-ui-stack 0.8.4-main.72ec0f3 → 0.8.4-main.74a063c4e0

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