@dxos/react-ui-stack 0.8.4-main.f5c0578 → 0.8.4-main.fcc0d83b33

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 (109) hide show
  1. package/dist/lib/browser/index.mjs +706 -65
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +23 -0
  5. package/dist/lib/browser/translations.mjs.map +7 -0
  6. package/dist/lib/node-esm/index.mjs +707 -65
  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/translations.mjs +25 -0
  10. package/dist/lib/node-esm/translations.mjs.map +7 -0
  11. package/dist/types/src/components/Stack/Stack.d.ts +13 -10
  12. package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
  13. package/dist/types/src/components/Stack/Stack.stories.d.ts +12 -3
  14. package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/StackContext.d.ts +2 -1
  16. package/dist/types/src/components/StackContext.d.ts.map +1 -1
  17. package/dist/types/src/components/StackItem/MenuSignifier.d.ts.map +1 -1
  18. package/dist/types/src/components/StackItem/StackItem.d.ts +12 -15
  19. package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
  20. package/dist/types/src/components/StackItem/StackItem.stories.d.ts +13 -5
  21. package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
  22. package/dist/types/src/components/StackItem/StackItemContent.d.ts +4 -37
  23. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  24. package/dist/types/src/components/StackItem/StackItemDragHandle.d.ts.map +1 -1
  25. package/dist/types/src/components/StackItem/StackItemHeading.d.ts +1 -1
  26. package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
  27. package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts +1 -1
  28. package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts.map +1 -1
  29. package/dist/types/src/components/StackItem/StackItemSigil.d.ts +2 -2
  30. package/dist/types/src/components/StackItem/StackItemSigil.d.ts.map +1 -1
  31. package/dist/types/src/components/index.d.ts +1 -1
  32. package/dist/types/src/components/index.d.ts.map +1 -1
  33. package/dist/types/src/components/{defs.d.ts → types.d.ts} +1 -1
  34. package/dist/types/src/components/types.d.ts.map +1 -0
  35. package/dist/types/src/hooks/useStackDropForElements.d.ts +8 -6
  36. package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
  37. package/dist/types/src/index.d.ts +0 -2
  38. package/dist/types/src/index.d.ts.map +1 -1
  39. package/dist/types/src/playwright/playwright.config.d.ts.map +1 -1
  40. package/dist/types/src/playwright/stack-manager.d.ts.map +1 -1
  41. package/dist/types/src/translations.d.ts +10 -10
  42. package/dist/types/src/translations.d.ts.map +1 -1
  43. package/dist/types/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +49 -47
  45. package/src/components/Stack/Stack.stories.tsx +13 -17
  46. package/src/components/Stack/Stack.tsx +238 -52
  47. package/src/components/StackContext.tsx +2 -1
  48. package/src/components/StackItem/MenuSignifier.tsx +2 -9
  49. package/src/components/StackItem/StackItem.stories.tsx +21 -17
  50. package/src/components/StackItem/StackItem.tsx +51 -34
  51. package/src/components/StackItem/StackItemContent.tsx +24 -44
  52. package/src/components/StackItem/StackItemDragHandle.tsx +4 -3
  53. package/src/components/StackItem/StackItemHeading.tsx +14 -17
  54. package/src/components/StackItem/StackItemResizeHandle.tsx +1 -2
  55. package/src/components/StackItem/StackItemSigil.tsx +10 -7
  56. package/src/components/index.ts +2 -1
  57. package/src/hooks/useStackDropForElements.ts +60 -46
  58. package/src/index.ts +0 -4
  59. package/src/playwright/playwright.config.ts +1 -1
  60. package/src/translations.ts +9 -9
  61. package/dist/lib/browser/chunk-WOG2GQRG.mjs +0 -1200
  62. package/dist/lib/browser/chunk-WOG2GQRG.mjs.map +0 -7
  63. package/dist/lib/browser/testing/index.mjs +0 -31
  64. package/dist/lib/browser/testing/index.mjs.map +0 -7
  65. package/dist/lib/node-esm/chunk-PO2QGNXW.mjs +0 -1202
  66. package/dist/lib/node-esm/chunk-PO2QGNXW.mjs.map +0 -7
  67. package/dist/lib/node-esm/testing/index.mjs +0 -32
  68. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  69. package/dist/types/src/components/defs.d.ts.map +0 -1
  70. package/dist/types/src/components/deprecated/LayoutControls.d.ts +0 -19
  71. package/dist/types/src/components/deprecated/LayoutControls.d.ts.map +0 -1
  72. package/dist/types/src/exemplars/Card/Card.d.ts +0 -58
  73. package/dist/types/src/exemplars/Card/Card.d.ts.map +0 -1
  74. package/dist/types/src/exemplars/Card/Card.stories.d.ts +0 -13
  75. package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +0 -1
  76. package/dist/types/src/exemplars/Card/CardDragPreview.d.ts +0 -6
  77. package/dist/types/src/exemplars/Card/CardDragPreview.d.ts.map +0 -1
  78. package/dist/types/src/exemplars/Card/fragments.d.ts +0 -13
  79. package/dist/types/src/exemplars/Card/fragments.d.ts.map +0 -1
  80. package/dist/types/src/exemplars/Card/index.d.ts +0 -4
  81. package/dist/types/src/exemplars/Card/index.d.ts.map +0 -1
  82. package/dist/types/src/exemplars/CardStack/CardStack.d.ts +0 -40
  83. package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +0 -1
  84. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +0 -9
  85. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +0 -1
  86. package/dist/types/src/exemplars/CardStack/CardStackDragPreview.d.ts +0 -9
  87. package/dist/types/src/exemplars/CardStack/CardStackDragPreview.d.ts.map +0 -1
  88. package/dist/types/src/exemplars/CardStack/index.d.ts +0 -3
  89. package/dist/types/src/exemplars/CardStack/index.d.ts.map +0 -1
  90. package/dist/types/src/exemplars/index.d.ts +0 -3
  91. package/dist/types/src/exemplars/index.d.ts.map +0 -1
  92. package/dist/types/src/testing/CardContainer.d.ts +0 -6
  93. package/dist/types/src/testing/CardContainer.d.ts.map +0 -1
  94. package/dist/types/src/testing/index.d.ts +0 -2
  95. package/dist/types/src/testing/index.d.ts.map +0 -1
  96. package/src/components/deprecated/LayoutControls.tsx +0 -109
  97. package/src/exemplars/Card/Card.stories.tsx +0 -78
  98. package/src/exemplars/Card/Card.tsx +0 -187
  99. package/src/exemplars/Card/CardDragPreview.tsx +0 -22
  100. package/src/exemplars/Card/fragments.ts +0 -24
  101. package/src/exemplars/Card/index.ts +0 -7
  102. package/src/exemplars/CardStack/CardStack.stories.tsx +0 -173
  103. package/src/exemplars/CardStack/CardStack.tsx +0 -136
  104. package/src/exemplars/CardStack/CardStackDragPreview.tsx +0 -61
  105. package/src/exemplars/CardStack/index.ts +0 -6
  106. package/src/exemplars/index.ts +0 -6
  107. package/src/testing/CardContainer.tsx +0 -37
  108. package/src/testing/index.ts +0 -5
  109. /package/src/components/{defs.ts → types.ts} +0 -0
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-stack",
3
- "version": "0.8.4-main.f5c0578",
3
+ "version": "0.8.4-main.fcc0d83b33",
4
4
  "description": "A stack component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
- "sideEffects": true,
13
+ "sideEffects": false,
10
14
  "type": "module",
15
+ "imports": {
16
+ "#translations": "./src/translations.ts"
17
+ },
11
18
  "exports": {
12
19
  ".": {
13
20
  "types": "./dist/types/src/index.d.ts",
@@ -19,33 +26,24 @@
19
26
  "browser": "./dist/lib/browser/playwright/index.mjs",
20
27
  "node": "./dist/lib/node-esm/playwright/index.mjs"
21
28
  },
22
- "./testing": {
23
- "types": "./dist/types/src/testing/index.d.ts",
24
- "browser": "./dist/lib/browser/testing/index.mjs",
25
- "node": "./dist/lib/node-esm/testing/index.mjs"
29
+ "./translations": {
30
+ "source": "./src/translations.ts",
31
+ "types": "./dist/types/src/translations.d.ts",
32
+ "browser": "./dist/lib/browser/translations.mjs",
33
+ "node": "./dist/lib/node-esm/translations.mjs"
26
34
  }
27
35
  },
28
36
  "types": "dist/types/src/index.d.ts",
29
- "typesVersions": {
30
- "*": {
31
- "playwright": [
32
- "dist/types/src/playwright/index.d.ts"
33
- ],
34
- "testing": [
35
- "dist/types/src/testing/index.d.ts"
36
- ]
37
- }
38
- },
39
37
  "files": [
40
38
  "dist",
41
39
  "src"
42
40
  ],
43
41
  "dependencies": {
44
- "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
45
- "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
46
- "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
47
- "@fluentui/react-tabster": "^9.24.2",
48
- "@preact-signals/safe-react": "^0.9.0",
42
+ "@atlaskit/pragmatic-drag-and-drop": "1.7.7",
43
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
44
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
45
+ "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "3.2.10",
46
+ "@fluentui/react-tabster": "9.26.11",
49
47
  "@radix-ui/primitive": "1.1.1",
50
48
  "@radix-ui/react-compose-refs": "1.1.1",
51
49
  "@radix-ui/react-context": "1.1.1",
@@ -54,36 +52,40 @@
54
52
  "@radix-ui/react-slot": "1.1.2",
55
53
  "@radix-ui/react-use-controllable-state": "1.1.0",
56
54
  "react-resize-detector": "^11.0.1",
57
- "@dxos/echo-schema": "0.8.4-main.f5c0578",
58
- "@dxos/keyboard": "0.8.4-main.f5c0578",
59
- "@dxos/live-object": "0.8.4-main.f5c0578",
60
- "@dxos/react-ui-dnd": "0.8.4-main.f5c0578",
61
- "@dxos/util": "0.8.4-main.f5c0578",
62
- "@dxos/react-ui-attention": "0.8.4-main.f5c0578",
63
- "@dxos/storybook-utils": "0.8.4-main.f5c0578"
55
+ "@dxos/keyboard": "0.8.4-main.fcc0d83b33",
56
+ "@dxos/echo": "0.8.4-main.fcc0d83b33",
57
+ "@dxos/react-ui-dnd": "0.8.4-main.fcc0d83b33",
58
+ "@dxos/react-ui-attention": "0.8.4-main.fcc0d83b33",
59
+ "@dxos/util": "0.8.4-main.fcc0d83b33",
60
+ "@dxos/react-ui-mosaic": "0.8.4-main.fcc0d83b33"
64
61
  },
65
62
  "devDependencies": {
66
- "@types/react": "~18.2.0",
67
- "@types/react-dom": "~18.2.0",
68
- "react": "~18.2.0",
69
- "react-dom": "~18.2.0",
70
- "vite": "5.4.7",
71
- "@dxos/app-graph": "0.8.4-main.f5c0578",
72
- "@dxos/client": "0.8.4-main.f5c0578",
73
- "@dxos/random": "0.8.4-main.f5c0578",
74
- "@dxos/echo-schema": "0.8.4-main.f5c0578",
75
- "@dxos/react-ui": "0.8.4-main.f5c0578",
76
- "@dxos/react-ui-theme": "0.8.4-main.f5c0578",
77
- "@dxos/storybook-utils": "0.8.4-main.f5c0578",
78
- "@dxos/test-utils": "0.8.4-main.f5c0578"
63
+ "@types/react": "~19.2.7",
64
+ "@types/react-dom": "~19.2.3",
65
+ "react": "~19.2.3",
66
+ "react-dom": "~19.2.3",
67
+ "vite": "^8.0.10",
68
+ "@dxos/app-framework": "0.8.4-main.fcc0d83b33",
69
+ "@dxos/app-graph": "0.8.4-main.fcc0d83b33",
70
+ "@dxos/client": "0.8.4-main.fcc0d83b33",
71
+ "@dxos/echo-db": "0.8.4-main.fcc0d83b33",
72
+ "@dxos/random": "0.8.4-main.fcc0d83b33",
73
+ "@dxos/echo": "0.8.4-main.fcc0d83b33",
74
+ "@dxos/react-client": "0.8.4-main.fcc0d83b33",
75
+ "@dxos/schema": "0.8.4-main.fcc0d83b33",
76
+ "@dxos/react-ui": "0.8.4-main.fcc0d83b33",
77
+ "@dxos/storybook-utils": "0.8.4-main.fcc0d83b33",
78
+ "@dxos/test-utils": "0.8.4-main.fcc0d83b33",
79
+ "@dxos/ui-theme": "0.8.4-main.fcc0d83b33",
80
+ "@dxos/types": "0.8.4-main.fcc0d83b33"
79
81
  },
80
82
  "peerDependencies": {
81
- "react": "~18.2.0",
82
- "react-dom": "~18.2.0",
83
- "@dxos/client": "0.8.4-main.f5c0578",
84
- "@dxos/random": "0.8.4-main.f5c0578",
85
- "@dxos/react-ui": "0.8.4-main.f5c0578",
86
- "@dxos/react-ui-theme": "0.8.4-main.f5c0578"
83
+ "react": "~19.2.3",
84
+ "react-dom": "~19.2.3",
85
+ "@dxos/client": "0.8.4-main.fcc0d83b33",
86
+ "@dxos/random": "0.8.4-main.fcc0d83b33",
87
+ "@dxos/ui-theme": "0.8.4-main.fcc0d83b33",
88
+ "@dxos/react-ui": "0.8.4-main.fcc0d83b33"
87
89
  },
88
90
  "publishConfig": {
89
91
  "access": "public"
@@ -2,18 +2,15 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
8
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
9
7
  import React, { useCallback, useState } from 'react';
10
8
 
11
- import { faker } from '@dxos/random';
12
- import { withTheme } from '@dxos/storybook-utils';
9
+ import { random } from '@dxos/random';
10
+ import { withTheme } from '@dxos/react-ui/testing';
13
11
 
14
- import { type StackItemData } from '../defs';
15
12
  import { StackItem } from '../StackItem';
16
-
13
+ import { type StackItemData } from '../types';
17
14
  import { Stack } from './Stack';
18
15
 
19
16
  type StoryStackItem = {
@@ -24,7 +21,7 @@ type StoryStackItem = {
24
21
 
25
22
  const KanbanBlock = ({ item }: { item: StoryStackItem }) => {
26
23
  return (
27
- <div className='overflow-hidden'>
24
+ <div role='none' className='overflow-hidden'>
28
25
  <p className='place-content-center p-4'>{item.title}</p>
29
26
  </div>
30
27
  );
@@ -32,16 +29,16 @@ const KanbanBlock = ({ item }: { item: StoryStackItem }) => {
32
29
 
33
30
  const DefaultStory = () => {
34
31
  const [columns, setColumns] = useState<StoryStackItem[]>(
35
- faker.helpers.multiple(
32
+ random.helpers.multiple(
36
33
  () =>
37
34
  ({
38
- id: faker.string.uuid(),
39
- title: faker.lorem.paragraph(),
40
- items: faker.helpers.multiple(
35
+ id: random.string.uuid(),
36
+ title: random.lorem.paragraph(),
37
+ items: random.helpers.multiple(
41
38
  () =>
42
39
  ({
43
- id: faker.string.uuid(),
44
- title: faker.lorem.paragraph(),
40
+ id: random.string.uuid(),
41
+ title: random.lorem.paragraph(),
45
42
  }) satisfies StoryStackItem,
46
43
  { count: { min: 32, max: 64 } },
47
44
  ),
@@ -79,7 +76,6 @@ const DefaultStory = () => {
79
76
  targetColumn.items
80
77
  ) {
81
78
  const [movedCard] = sourceColumn.items.splice(sourceCardIndex, 1);
82
-
83
79
  let insertIndex;
84
80
  if (sourceColumn === targetColumn && sourceCardIndex < targetCardIndex) {
85
81
  insertIndex = closestEdge === 'bottom' ? targetCardIndex : targetCardIndex - 1;
@@ -130,12 +126,12 @@ const DefaultStory = () => {
130
126
  );
131
127
  };
132
128
 
133
- const meta: Meta<typeof DefaultStory> = {
129
+ const meta = {
134
130
  title: 'ui/react-ui-stack/Stack',
135
131
  component: DefaultStory,
136
- decorators: [withTheme],
137
132
  argTypes: { orientation: { control: 'radio', options: ['horizontal', 'vertical'] } },
138
- };
133
+ decorators: [withTheme()],
134
+ } satisfies Meta<typeof DefaultStory>;
139
135
 
140
136
  export default meta;
141
137
 
@@ -2,78 +2,86 @@
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
- type CSSProperties,
9
7
  Children,
10
8
  type ComponentPropsWithRef,
9
+ type FocusEvent,
10
+ type KeyboardEvent,
11
11
  forwardRef,
12
12
  useCallback,
13
13
  useEffect,
14
- useMemo,
15
14
  useState,
16
15
  } from 'react';
17
16
 
18
- import { ListItem, type ThemedClassName } from '@dxos/react-ui';
19
- import { mx } from '@dxos/react-ui-theme';
17
+ import { ListItem, type ThemedClassName, useId } from '@dxos/react-ui';
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
- export type Size = 'intrinsic' | 'contain' | 'contain-fit-content';
25
+
26
+ /**
27
+ * Size is how Stack and its StackItems coordinate the dimensions of the items with the available space.
28
+ * - `intrinsic` signals to Stack and its StackItems to occupy their intrinsic size
29
+ * - Any other size will extrinsically fill the available space along the axis of its orientation and handle overflow:
30
+ * - `contain` causes StackItems to occupy their intrinsic size
31
+ * - `split` divides the Stack’s available space among the StackItems
32
+ */
33
+ export type Size = 'intrinsic' | 'contain' | 'split';
34
+
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]]';
37
+
38
+ const PERPENDICULAR_FOCUS_THRESHHOLD = 128;
39
+
40
+ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orientation']) => {
41
+ el.scrollIntoView({
42
+ behavior: 'instant',
43
+ [orientation === 'vertical' ? 'block' : 'inline']: 'center',
44
+ });
45
+ return el.focus();
46
+ };
27
47
 
28
48
  export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
29
49
  Partial<StackContextValue> & {
30
50
  itemsCount?: number;
31
- getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
51
+ circularFocus?: boolean;
32
52
  separatorOnScroll?: number;
53
+ getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
33
54
  };
34
55
 
35
- export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
36
- export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
37
-
38
- // TODO(ZaymonFC): Magic 2px to stop overflow (tabster dummies... ask @thure).
39
- export const railGridHorizontalContainFitContent =
40
- 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
41
- export const railGridVerticalContainFitContent =
42
- 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
43
-
44
- export const autoScrollRootAttributes = { 'data-drag-autoscroll': 'idle' };
45
-
46
56
  export const Stack = forwardRef<HTMLDivElement, StackProps>(
47
57
  (
48
58
  {
49
59
  children,
50
60
  classNames,
61
+ id,
51
62
  style,
52
63
  orientation = 'vertical',
53
64
  rail = true,
54
65
  size = 'intrinsic',
55
- onRearrange,
56
66
  itemsCount = Children.count(children),
57
- getDropElement,
67
+ circularFocus,
58
68
  separatorOnScroll,
69
+ getDropElement,
70
+ onBlur,
71
+ onKeyDown,
72
+ onRearrange,
59
73
  ...props
60
74
  },
61
75
  forwardedRef,
62
76
  ) => {
77
+ const stackId = useId('stack', id);
63
78
  const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
79
+ const [lastFocusedItem, setLastFocusedItem] = useState<string>();
64
80
  const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
65
- const arrowNavigationAttrs = useArrowNavigationGroup({ axis: orientation });
66
-
67
- const styles: CSSProperties = {
68
- [orientation === 'horizontal' ? 'gridTemplateColumns' : 'gridTemplateRows']:
69
- `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
70
- ...style,
71
- };
72
-
73
- const selfDroppable = !!(itemsCount < 1 && onRearrange && props.id);
74
81
 
82
+ const selfDroppable = !!(itemsCount < 1 && onRearrange && id);
75
83
  const { dropping } = useStackDropForElements({
76
- id: props.id,
84
+ id,
77
85
  element: getDropElement && stackElement ? getDropElement(stackElement) : stackElement,
78
86
  scrollElement: stackElement,
79
87
  selfDroppable,
@@ -81,6 +89,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
81
89
  onRearrange,
82
90
  });
83
91
 
92
+ /** Updates scroll separator data attributes based on current scroll position. */
84
93
  const handleScroll = useCallback(() => {
85
94
  if (stackElement && Number.isFinite(separatorOnScroll)) {
86
95
  const scrollPosition = orientation === 'horizontal' ? stackElement.scrollLeft : stackElement.scrollTop;
@@ -97,17 +106,25 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
97
106
  }
98
107
  }, [stackElement, separatorOnScroll, orientation]);
99
108
 
100
- const gridClasses = useMemo(() => {
101
- if (!rail) {
102
- return orientation === 'horizontal' ? 'grid-rows-1 pli-1' : 'grid-cols-1 plb-1';
103
- }
104
- if (orientation === 'horizontal') {
105
- return size === 'contain-fit-content' ? railGridHorizontalContainFitContent : railGridHorizontal;
106
- } else {
107
- return size === 'contain-fit-content' ? railGridVerticalContainFitContent : railGridVertical;
108
- }
109
- }, [rail, orientation, size]);
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);
117
+ }
118
+ }
119
+ onBlur?.(event);
120
+ },
121
+ [stackId, onBlur],
122
+ );
123
+
124
+ /** Handles keyboard navigation within the stack. */
125
+ const handleKeyDown = useKeyDown(stackId, circularFocus, onKeyDown);
110
126
 
127
+ /** Observes DOM mutations to keep scroll separator state in sync. */
111
128
  useEffect(() => {
112
129
  if (!(stackElement && Number.isFinite(separatorOnScroll))) {
113
130
  return;
@@ -118,31 +135,46 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
118
135
  });
119
136
 
120
137
  observer.observe(stackElement, { childList: true, subtree: true });
121
-
122
138
  return () => {
123
139
  observer.disconnect();
124
140
  };
125
141
  }, [stackElement, handleScroll]);
126
142
 
127
143
  return (
128
- <StackContext.Provider value={{ orientation, rail, size, onRearrange }}>
144
+ <StackContext.Provider value={{ stackId, orientation, rail, size, onRearrange }}>
129
145
  <div
130
146
  {...props}
131
- {...arrowNavigationAttrs}
147
+ {...(Number.isFinite(separatorOnScroll) && { onScroll: handleScroll })}
132
148
  className={mx(
133
- 'grid relative',
134
- gridClasses,
135
- (size === 'contain' || size === 'contain-fit-content') &&
149
+ 'relative grid [--stack-gap:var(--spacing-trim-xs)]',
150
+ size === 'contain' &&
136
151
  (orientation === 'horizontal'
137
- ? 'overflow-x-auto min-bs-0 max-bs-full bs-full'
138
- : '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)',
139
161
  classNames,
140
162
  )}
141
- data-rail={rail}
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
+ }}
142
170
  aria-orientation={orientation}
143
- style={styles}
171
+ data-dx-stack={stackId}
172
+ data-dx-stack-circular-focus={circularFocus}
173
+ data-dx-last-focused-item={lastFocusedItem}
174
+ data-rail={rail}
175
+ onBlur={handleBlur}
176
+ onKeyDown={handleKeyDown}
144
177
  ref={composedItemRef}
145
- {...(Number.isFinite(separatorOnScroll) && { onScroll: handleScroll })}
146
178
  >
147
179
  {children}
148
180
  {selfDroppable && dropping && (
@@ -162,3 +194,157 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
162
194
  export { StackContext };
163
195
 
164
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,14 +4,15 @@
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;
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>({
@@ -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
  );