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

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 (77) hide show
  1. package/dist/lib/browser/{chunk-P3TQV4BA.mjs → chunk-SM27YTH3.mjs} +406 -186
  2. package/dist/lib/browser/chunk-SM27YTH3.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +9 -1
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/playwright/index.mjs +23 -6
  6. package/dist/lib/browser/playwright/index.mjs.map +2 -2
  7. package/dist/lib/browser/testing/index.mjs +3 -3
  8. package/dist/lib/browser/testing/index.mjs.map +3 -3
  9. package/dist/lib/node-esm/{chunk-3WVEPAJ4.mjs → chunk-MMAOXKOM.mjs} +406 -186
  10. package/dist/lib/node-esm/chunk-MMAOXKOM.mjs.map +7 -0
  11. package/dist/lib/node-esm/index.mjs +9 -1
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/playwright/index.mjs +23 -6
  14. package/dist/lib/node-esm/playwright/index.mjs.map +2 -2
  15. package/dist/lib/node-esm/testing/index.mjs +3 -3
  16. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  17. package/dist/types/src/components/Image/Image.d.ts +11 -0
  18. package/dist/types/src/components/Image/Image.d.ts.map +1 -0
  19. package/dist/types/src/components/Image/Image.stories.d.ts +30 -0
  20. package/dist/types/src/components/Image/Image.stories.d.ts.map +1 -0
  21. package/dist/types/src/components/Image/index.d.ts +2 -0
  22. package/dist/types/src/components/Image/index.d.ts.map +1 -0
  23. package/dist/types/src/components/Stack/Stack.d.ts +10 -2
  24. package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
  25. package/dist/types/src/components/Stack/Stack.stories.d.ts +12 -3
  26. package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
  27. package/dist/types/src/components/StackContext.d.ts +2 -1
  28. package/dist/types/src/components/StackContext.d.ts.map +1 -1
  29. package/dist/types/src/components/StackItem/StackItem.d.ts +6 -5
  30. package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
  31. package/dist/types/src/components/StackItem/StackItem.stories.d.ts +13 -5
  32. package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/StackItem/StackItemContent.d.ts +16 -8
  34. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  35. package/dist/types/src/components/StackItem/StackItemHeading.d.ts +1 -1
  36. package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
  37. package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts.map +1 -1
  38. package/dist/types/src/components/StackItem/StackItemSigil.d.ts.map +1 -1
  39. package/dist/types/src/components/index.d.ts +1 -0
  40. package/dist/types/src/components/index.d.ts.map +1 -1
  41. package/dist/types/src/exemplars/Card/Card.d.ts +13 -6
  42. package/dist/types/src/exemplars/Card/Card.d.ts.map +1 -1
  43. package/dist/types/src/exemplars/Card/Card.stories.d.ts +12 -4
  44. package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +1 -1
  45. package/dist/types/src/exemplars/Card/fragments.d.ts +3 -2
  46. package/dist/types/src/exemplars/Card/fragments.d.ts.map +1 -1
  47. package/dist/types/src/exemplars/CardStack/CardStack.d.ts +3 -1
  48. package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +1 -1
  49. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +9 -3
  50. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +1 -1
  51. package/dist/types/src/hooks/useStackDropForElements.d.ts +1 -1
  52. package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
  53. package/dist/types/src/testing/CardContainer.d.ts.map +1 -1
  54. package/dist/types/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +27 -27
  56. package/src/components/Image/Image.stories.tsx +56 -0
  57. package/src/components/Image/Image.tsx +137 -0
  58. package/src/components/Image/index.ts +5 -0
  59. package/src/components/Stack/Stack.stories.tsx +8 -9
  60. package/src/components/Stack/Stack.tsx +215 -18
  61. package/src/components/StackContext.tsx +2 -1
  62. package/src/components/StackItem/StackItem.stories.tsx +16 -14
  63. package/src/components/StackItem/StackItem.tsx +26 -18
  64. package/src/components/StackItem/StackItemContent.tsx +17 -5
  65. package/src/components/StackItem/StackItemHeading.tsx +4 -8
  66. package/src/components/StackItem/StackItemResizeHandle.tsx +2 -1
  67. package/src/components/StackItem/StackItemSigil.tsx +2 -1
  68. package/src/components/index.ts +1 -0
  69. package/src/exemplars/Card/Card.stories.tsx +29 -43
  70. package/src/exemplars/Card/Card.tsx +30 -12
  71. package/src/exemplars/Card/fragments.ts +3 -2
  72. package/src/exemplars/CardStack/CardStack.stories.tsx +11 -10
  73. package/src/exemplars/CardStack/CardStack.tsx +12 -9
  74. package/src/hooks/useStackDropForElements.ts +1 -1
  75. package/src/testing/CardContainer.tsx +9 -6
  76. package/dist/lib/browser/chunk-P3TQV4BA.mjs.map +0 -7
  77. package/dist/lib/node-esm/chunk-3WVEPAJ4.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-stack",
3
- "version": "0.8.4-main.b97322e",
3
+ "version": "0.8.4-main.c4373fc",
4
4
  "description": "A stack component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -54,36 +54,36 @@
54
54
  "@radix-ui/react-slot": "1.1.2",
55
55
  "@radix-ui/react-use-controllable-state": "1.1.0",
56
56
  "react-resize-detector": "^11.0.1",
57
- "@dxos/echo-schema": "0.8.4-main.b97322e",
58
- "@dxos/keyboard": "0.8.4-main.b97322e",
59
- "@dxos/live-object": "0.8.4-main.b97322e",
60
- "@dxos/storybook-utils": "0.8.4-main.b97322e",
61
- "@dxos/react-ui-dnd": "0.8.4-main.b97322e",
62
- "@dxos/util": "0.8.4-main.b97322e",
63
- "@dxos/react-ui-attention": "0.8.4-main.b97322e"
57
+ "@dxos/echo": "0.8.4-main.c4373fc",
58
+ "@dxos/live-object": "0.8.4-main.c4373fc",
59
+ "@dxos/keyboard": "0.8.4-main.c4373fc",
60
+ "@dxos/react-ui-dnd": "0.8.4-main.c4373fc",
61
+ "@dxos/util": "0.8.4-main.c4373fc",
62
+ "@dxos/storybook-utils": "0.8.4-main.c4373fc",
63
+ "@dxos/react-ui-attention": "0.8.4-main.c4373fc"
64
64
  },
65
65
  "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.b97322e",
72
- "@dxos/client": "0.8.4-main.b97322e",
73
- "@dxos/echo-schema": "0.8.4-main.b97322e",
74
- "@dxos/random": "0.8.4-main.b97322e",
75
- "@dxos/react-ui": "0.8.4-main.b97322e",
76
- "@dxos/storybook-utils": "0.8.4-main.b97322e",
77
- "@dxos/react-ui-theme": "0.8.4-main.b97322e",
78
- "@dxos/test-utils": "0.8.4-main.b97322e"
66
+ "@types/react": "~19.2.2",
67
+ "@types/react-dom": "~19.2.1",
68
+ "react": "~19.2.0",
69
+ "react-dom": "~19.2.0",
70
+ "vite": "7.1.9",
71
+ "@dxos/app-graph": "0.8.4-main.c4373fc",
72
+ "@dxos/client": "0.8.4-main.c4373fc",
73
+ "@dxos/echo": "0.8.4-main.c4373fc",
74
+ "@dxos/random": "0.8.4-main.c4373fc",
75
+ "@dxos/react-ui": "0.8.4-main.c4373fc",
76
+ "@dxos/react-ui-theme": "0.8.4-main.c4373fc",
77
+ "@dxos/storybook-utils": "0.8.4-main.c4373fc",
78
+ "@dxos/test-utils": "0.8.4-main.c4373fc"
79
79
  },
80
80
  "peerDependencies": {
81
- "react": "~18.2.0",
82
- "react-dom": "~18.2.0",
83
- "@dxos/client": "0.8.4-main.b97322e",
84
- "@dxos/random": "0.8.4-main.b97322e",
85
- "@dxos/react-ui": "0.8.4-main.b97322e",
86
- "@dxos/react-ui-theme": "0.8.4-main.b97322e"
81
+ "react": "^19.0.0",
82
+ "react-dom": "^19.0.0",
83
+ "@dxos/random": "0.8.4-main.c4373fc",
84
+ "@dxos/client": "0.8.4-main.c4373fc",
85
+ "@dxos/react-ui": "0.8.4-main.c4373fc",
86
+ "@dxos/react-ui-theme": "0.8.4-main.c4373fc"
87
87
  },
88
88
  "publishConfig": {
89
89
  "access": "public"
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React from 'react';
7
+
8
+ import { faker } from '@dxos/random';
9
+ import { withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { Image } from './Image';
12
+
13
+ faker.seed(1);
14
+
15
+ const meta = {
16
+ title: 'ui/react-ui-stack/Image',
17
+ component: Image,
18
+ render: (args) => (
19
+ <div className='absolute inset-0 flex place-items-center'>
20
+ <Image {...args} />
21
+ </div>
22
+ ),
23
+ decorators: [withTheme],
24
+ parameters: {
25
+ layout: 'fullscreen',
26
+ },
27
+ } satisfies Meta<typeof Image>;
28
+
29
+ export default meta;
30
+
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ export const Default: Story = {
34
+ args: {
35
+ src: faker.image.url(),
36
+ },
37
+ };
38
+
39
+ /**
40
+ * Access to image at 'https://dxos.network/dxos-logotype-blue.png'
41
+ * from origin 'http://localhost:9009' has been blocked by CORS policy:
42
+ * No 'Access-Control-Allow-Origin' header is present on the requested resource.
43
+ */
44
+ export const Cors: Story = {
45
+ args: {
46
+ src: 'https://dxos.network/dxos-logotype-blue.png',
47
+ classNames: 'w-[20rem]',
48
+ },
49
+ };
50
+
51
+ export const SVG: Story = {
52
+ args: {
53
+ src: 'https://dxos.network/bg-kube.svg',
54
+ classNames: 'w-[20rem]',
55
+ },
56
+ };
@@ -0,0 +1,137 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { type SyntheticEvent, useRef, useState } from 'react';
6
+
7
+ import { type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/react-ui-theme';
9
+
10
+ export type ImageProps = ThemedClassName<{
11
+ src: string;
12
+ alt?: string;
13
+ crossOrigin?: 'anonymous' | 'use-credentials' | '';
14
+ sampleSize?: number;
15
+ contrast?: number;
16
+ }>;
17
+
18
+ export const Image = ({
19
+ classNames,
20
+ src,
21
+ alt = '',
22
+ crossOrigin = 'anonymous',
23
+ sampleSize = 64,
24
+ contrast = 0.95,
25
+ }: ImageProps) => {
26
+ const [crossOriginState, setCrossOriginState] = useState<ImageProps['crossOrigin']>(crossOrigin);
27
+ const [dominantColor, setDominantColor] = useState<string | undefined>(undefined);
28
+ const [imageLoaded, setImageLoaded] = useState<boolean>(false);
29
+ const canvasRef = useRef<HTMLCanvasElement>(null);
30
+
31
+ // TODO(burdon): Cache?
32
+ const extractDominantColor = (img: HTMLImageElement): void => {
33
+ const canvas = canvasRef.current;
34
+ const ctx = canvas?.getContext('2d');
35
+ if (!canvas || !ctx) {
36
+ return;
37
+ }
38
+
39
+ // Draw the image scaled down.
40
+ canvas.width = sampleSize;
41
+ canvas.height = sampleSize;
42
+ ctx.drawImage(img, 0, 0, sampleSize, sampleSize);
43
+
44
+ try {
45
+ // Get image data.
46
+ const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize);
47
+ const pixels = imageData.data;
48
+
49
+ // Calculate average color with more weight to vibrant colors.
50
+ let r = 0;
51
+ let g = 0;
52
+ let b = 0;
53
+ let totalWeight = 0;
54
+ for (let i = 0; i < pixels.length; i += 4) {
55
+ const red = pixels[i];
56
+ const green = pixels[i + 1];
57
+ const blue = pixels[i + 2];
58
+ const alpha = pixels[i + 3];
59
+
60
+ // Skip transparent pixels.
61
+ if (alpha === 0) continue;
62
+
63
+ // Calculate saturation to weight vibrant colors more.
64
+ const max = Math.max(red, green, blue);
65
+ const min = Math.min(red, green, blue);
66
+ const saturation = max === 0 ? 0 : (max - min) / max;
67
+ const weight = 1 + saturation * 2; // Give more weight to saturated colors.
68
+
69
+ r += red * weight;
70
+ g += green * weight;
71
+ b += blue * weight;
72
+ totalWeight += weight;
73
+ }
74
+
75
+ if (totalWeight > 0) {
76
+ r = Math.round(r / totalWeight);
77
+ g = Math.round(g / totalWeight);
78
+ b = Math.round(b / totalWeight);
79
+
80
+ // Slightly darken the color for better contrast.
81
+ r = Math.round(r * contrast);
82
+ g = Math.round(g * contrast);
83
+ b = Math.round(b * contrast);
84
+ setDominantColor(`rgb(${r}, ${g}, ${b})`);
85
+ }
86
+ } catch {
87
+ setCrossOriginState(undefined);
88
+ }
89
+ };
90
+
91
+ // CORS not supported by server.
92
+ const handleImageError = (): void => {
93
+ setCrossOriginState(undefined);
94
+ };
95
+
96
+ const handleImageLoad = (ev: SyntheticEvent<HTMLImageElement>): void => {
97
+ const img = ev.target as HTMLImageElement;
98
+ extractDominantColor(img);
99
+ setImageLoaded(true);
100
+ };
101
+
102
+ return (
103
+ <div
104
+ className={mx(`relative flex is-full justify-center overflow-hidden transition-all duration-700`, classNames)}
105
+ style={{
106
+ backgroundColor: dominantColor,
107
+ }}
108
+ >
109
+ {/* Hidden canvas for color extraction. */}
110
+ <canvas ref={canvasRef} style={{ display: 'none' }} aria-hidden='true' />
111
+
112
+ {/* Background gradient overlay for smooth transition. */}
113
+ <div
114
+ className='absolute inset-0 pointer-events-none'
115
+ style={{
116
+ background: dominantColor
117
+ ? `radial-gradient(circle at center, transparent 30%, ${dominantColor} 100%)`
118
+ : undefined,
119
+ transition: 'opacity 0.7s ease-in-out',
120
+ opacity: 0.5,
121
+ }}
122
+ />
123
+
124
+ <img
125
+ src={src}
126
+ alt={alt}
127
+ crossOrigin={crossOriginState}
128
+ onError={handleImageError}
129
+ onLoad={handleImageLoad}
130
+ className={mx('z-10 object-contain transition-opacity duration-500', classNames)}
131
+ style={{
132
+ opacity: imageLoaded ? 1 : 0,
133
+ }}
134
+ />
135
+ </div>
136
+ );
137
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Image';
@@ -2,18 +2,17 @@
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
- import React, { useState, useCallback } from 'react';
7
+ import React, { useCallback, useState } from 'react';
10
8
 
11
9
  import { faker } from '@dxos/random';
12
- import { withTheme } from '@dxos/storybook-utils';
10
+ import { withTheme } from '@dxos/react-ui/testing';
13
11
 
14
- import { Stack } from './Stack';
15
- import { StackItem } from '../StackItem';
16
12
  import { type StackItemData } from '../defs';
13
+ import { StackItem } from '../StackItem';
14
+
15
+ import { Stack } from './Stack';
17
16
 
18
17
  type StoryStackItem = {
19
18
  id: string;
@@ -129,12 +128,12 @@ const DefaultStory = () => {
129
128
  );
130
129
  };
131
130
 
132
- const meta: Meta<typeof DefaultStory> = {
131
+ const meta = {
133
132
  title: 'ui/react-ui-stack/Stack',
134
133
  component: DefaultStory,
135
- decorators: [withTheme],
136
134
  argTypes: { orientation: { control: 'radio', options: ['horizontal', 'vertical'] } },
137
- };
135
+ decorators: [withTheme],
136
+ } satisfies Meta<typeof DefaultStory>;
138
137
 
139
138
  export default meta;
140
139
 
@@ -2,40 +2,48 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { useArrowNavigationGroup } from '@fluentui/react-tabster';
6
5
  import { composeRefs } from '@radix-ui/react-compose-refs';
7
6
  import React, {
8
- Children,
9
7
  type CSSProperties,
8
+ Children,
10
9
  type ComponentPropsWithRef,
10
+ type KeyboardEvent,
11
11
  forwardRef,
12
- useState,
13
- useMemo,
14
12
  useCallback,
15
13
  useEffect,
14
+ useMemo,
15
+ useState,
16
16
  } from 'react';
17
17
 
18
- import { type ThemedClassName, ListItem } from '@dxos/react-ui';
18
+ import { ListItem, type ThemedClassName, useId } from '@dxos/react-ui';
19
19
  import { mx } from '@dxos/react-ui-theme';
20
20
 
21
21
  import { useStackDropForElements } from '../../hooks';
22
- import { StackContext } from '../StackContext';
23
22
  import { type StackContextValue } from '../defs';
23
+ import { StackContext } from '../StackContext';
24
24
 
25
25
  export type Orientation = 'horizontal' | 'vertical';
26
- export type Size = 'intrinsic' | 'contain' | 'contain-fit-content';
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';
27
34
 
28
35
  export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
29
36
  Partial<StackContextValue> & {
30
37
  itemsCount?: number;
31
38
  getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
32
39
  separatorOnScroll?: number;
40
+ circularFocus?: boolean;
33
41
  };
34
42
 
35
43
  export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
36
44
  export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
37
45
 
38
- // TODO(ZaymonFC): Magic 2px to stop overflow (tabster dummies... ask @thure).
46
+ // TODO(ZaymonFC): Magic 2px to stop overflow.
39
47
  export const railGridHorizontalContainFitContent =
40
48
  'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
41
49
  export const railGridVerticalContainFitContent =
@@ -43,6 +51,16 @@ export const railGridVerticalContainFitContent =
43
51
 
44
52
  export const autoScrollRootAttributes = { 'data-drag-autoscroll': 'idle' };
45
53
 
54
+ const PERPENDICULAR_FOCUS_THRESHHOLD = 128;
55
+
56
+ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orientation']) => {
57
+ el.scrollIntoView({
58
+ behavior: 'instant',
59
+ [orientation === 'vertical' ? 'block' : 'inline']: 'center',
60
+ });
61
+ return el.focus();
62
+ };
63
+
46
64
  export const Stack = forwardRef<HTMLDivElement, StackProps>(
47
65
  (
48
66
  {
@@ -56,17 +74,19 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
56
74
  itemsCount = Children.count(children),
57
75
  getDropElement,
58
76
  separatorOnScroll,
77
+ circularFocus,
59
78
  ...props
60
79
  },
61
80
  forwardedRef,
62
81
  ) => {
82
+ const stackId = useId('stack', props.id);
63
83
  const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
84
+ const [lastFocusedItem, setLastFocusedItem] = useState<string>();
64
85
  const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
65
- const arrowNavigationAttrs = useArrowNavigationGroup({ axis: orientation });
66
86
 
67
87
  const styles: CSSProperties = {
68
88
  [orientation === 'horizontal' ? 'gridTemplateColumns' : 'gridTemplateRows']:
69
- `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
89
+ size === 'split' ? `repeat(${itemsCount}, 1fr)` : `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
70
90
  ...style,
71
91
  };
72
92
 
@@ -97,14 +117,187 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
97
117
  }
98
118
  }, [stackElement, separatorOnScroll, orientation]);
99
119
 
120
+ /**
121
+ * Handles blur events to track the last focused item within this stack.
122
+ */
123
+ const handleBlur = useCallback(
124
+ (event: React.FocusEvent<HTMLDivElement>) => {
125
+ if (event.target) {
126
+ const target = event.target as HTMLElement;
127
+ const closestStackItem = target.closest(`[data-dx-item-id]`) as HTMLElement | null;
128
+ if (closestStackItem?.closest(`[data-dx-stack="${stackId}"]`)) {
129
+ setLastFocusedItem(closestStackItem?.getAttribute('data-dx-item-id') ?? undefined);
130
+ }
131
+ }
132
+ props.onBlur?.(event);
133
+ },
134
+ [stackId, props.onBlur],
135
+ );
136
+
137
+ /**
138
+ * Handles moving focus using the arrow keys. Focus is only handled by the nearest stack; if the arrow key matches the
139
+ * orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item; or, if there is no
140
+ * such stack item, focus is passed to the adjacent empty stack if one can be found.
141
+ */
142
+ const handleKeyDown = useCallback(
143
+ (event: KeyboardEvent<HTMLDivElement>) => {
144
+ const target = event.target as HTMLElement;
145
+ if (
146
+ event.key.startsWith('Arrow') &&
147
+ !target.closest(
148
+ `input, textarea, [role="textbox"], [data-tabster*="mover"], [data-arrow-keys="all"], [data-arrow-keys~="${event.key.toLowerCase().slice(5)}"]`,
149
+ )
150
+ ) {
151
+ const closestOwnedItem = target.closest(`[data-dx-stack-item="${stackId}"]`);
152
+ const closestStack = target.closest('[data-dx-stack]') as HTMLElement | null;
153
+ const closestStackItems = Array.from(
154
+ closestStack?.querySelectorAll(`[data-dx-stack-item="${stackId}"]`) ?? [],
155
+ );
156
+ const closestStackOrientation = closestStack?.getAttribute('aria-orientation') as Orientation;
157
+ const ancestorStack = closestStack?.parentElement?.closest('[data-dx-stack]') as HTMLElement | null;
158
+ if (closestOwnedItem && closestStack) {
159
+ const ancestorOrientation = ancestorStack?.getAttribute('aria-orientation') as Orientation | undefined;
160
+ const parallelDelta = (
161
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowUp' : event.key === 'ArrowLeft'
162
+ )
163
+ ? -1
164
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowDown' : event.key === 'ArrowRight')
165
+ ? 1
166
+ : 0;
167
+ const perpendicularDelta = (
168
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
169
+ )
170
+ ? -1
171
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowRight' : event.key === 'ArrowDown')
172
+ ? 1
173
+ : 0;
174
+ if (parallelDelta !== 0) {
175
+ const currentIndex = closestStackItems.indexOf(closestOwnedItem);
176
+ const nextIndex = currentIndex + parallelDelta;
177
+ let adjacentItem: HTMLElement | undefined;
178
+
179
+ if (circularFocus) {
180
+ // Circular navigation: wrap around using modulo.
181
+ adjacentItem = closestStackItems[(nextIndex + closestStackItems.length) % closestStackItems.length] as
182
+ | HTMLElement
183
+ | undefined;
184
+ } else {
185
+ // Non-circular navigation: only move if within bounds.
186
+ if (nextIndex >= 0 && nextIndex < closestStackItems.length) {
187
+ adjacentItem = closestStackItems[nextIndex] as HTMLElement | undefined;
188
+ }
189
+ }
190
+
191
+ if (adjacentItem) {
192
+ event.preventDefault();
193
+ scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
194
+ }
195
+ }
196
+ if (perpendicularDelta !== 0) {
197
+ if (ancestorStack && ancestorOrientation !== closestStackOrientation) {
198
+ const siblingStacks = Array.from(
199
+ ancestorStack.querySelectorAll(
200
+ `[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
201
+ ),
202
+ ) as HTMLElement[];
203
+ const currentStackIndex = siblingStacks.indexOf(closestStack);
204
+ const nextStackIndex = currentStackIndex + perpendicularDelta;
205
+ let adjacentStack: HTMLElement | undefined;
206
+
207
+ if (ancestorStack.getAttribute('data-dx-stack-circular-focus') === 'true') {
208
+ // Circular navigation: wrap around using modulo.
209
+ adjacentStack = siblingStacks[(nextStackIndex + siblingStacks.length) % siblingStacks.length] as
210
+ | HTMLElement
211
+ | undefined;
212
+ } else {
213
+ // Non-circular navigation: only move if within bounds.
214
+ if (nextStackIndex >= 0 && nextStackIndex < siblingStacks.length) {
215
+ adjacentStack = siblingStacks[nextStackIndex] as HTMLElement | undefined;
216
+ }
217
+ }
218
+ const adjacentStackSelfItem = adjacentStack?.closest(
219
+ `[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
220
+ ) as HTMLElement | undefined;
221
+ const adjacentStackItems = adjacentStack
222
+ ? (Array.from(
223
+ adjacentStack.querySelectorAll(
224
+ `[data-dx-stack-item="${adjacentStack.getAttribute('data-dx-stack')}"]`,
225
+ ),
226
+ ) as HTMLElement[])
227
+ : [];
228
+ if (adjacentStack && adjacentStackItems.length > 0) {
229
+ // Check if the adjacent stack has a last focused item recorded, otherwise find the closest item by position.
230
+ let closestItem = adjacentStackItems[0];
231
+ // Try to find an item with matching data-dx-stack-item value.
232
+ const lastFocusedItem = adjacentStack.querySelector(
233
+ `[data-dx-item-id="${adjacentStack.getAttribute('data-dx-last-focused-item') ?? 'never'}"]`,
234
+ );
235
+ if (lastFocusedItem) {
236
+ closestItem = lastFocusedItem as HTMLElement;
237
+ } else {
238
+ // Fall back to positional calculation
239
+ const ownedItemRect = closestOwnedItem.getBoundingClientRect();
240
+ const targetPosition =
241
+ closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
242
+
243
+ let closestDistance = Infinity;
244
+
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
+
250
+ if (distance < closestDistance) {
251
+ closestDistance = distance;
252
+ closestItem = item;
253
+ }
254
+ if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
255
+ break;
256
+ }
257
+ }
258
+ }
259
+
260
+ event.preventDefault();
261
+ scrollIntoViewAndFocus(closestItem, closestStackOrientation);
262
+ } else if (adjacentStackSelfItem) {
263
+ event.preventDefault();
264
+ scrollIntoViewAndFocus(adjacentStackSelfItem, ancestorOrientation);
265
+ }
266
+ } else if (closestOwnedItem) {
267
+ const closestOwnedItemStack = closestOwnedItem.querySelector('[data-dx-stack]');
268
+ const closestOwnedItemStackItems = closestOwnedItemStack
269
+ ? (Array.from(
270
+ closestOwnedItemStack.querySelectorAll(
271
+ `[data-dx-stack-item="${closestOwnedItemStack.getAttribute('data-dx-stack')}"]`,
272
+ ),
273
+ ) as HTMLElement[])
274
+ : [];
275
+ if (closestOwnedItemStackItems.length > 0) {
276
+ event.preventDefault();
277
+ scrollIntoViewAndFocus(
278
+ closestOwnedItemStackItems[
279
+ ['ArrowUp', 'ArrowLeft'].includes(event.key) ? closestOwnedItemStackItems.length - 1 : 0
280
+ ],
281
+ closestOwnedItemStack?.getAttribute('aria-orientation') as Orientation,
282
+ );
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ props.onKeyDown?.(event);
289
+ },
290
+ [props.onKeyDown, stackId, circularFocus],
291
+ );
292
+
100
293
  const gridClasses = useMemo(() => {
101
294
  if (!rail) {
102
- return orientation === 'horizontal' ? 'grid-rows-1 pli-1' : 'grid-cols-1 plb-1';
295
+ return orientation === 'horizontal' ? 'grid-rows-1 pli-[--stack-gap]' : 'grid-cols-1 plb-[--stack-gap]';
103
296
  }
104
297
  if (orientation === 'horizontal') {
105
- return size === 'contain-fit-content' ? railGridHorizontalContainFitContent : railGridHorizontal;
298
+ return railGridHorizontal;
106
299
  } else {
107
- return size === 'contain-fit-content' ? railGridVerticalContainFitContent : railGridVertical;
300
+ return railGridVertical;
108
301
  }
109
302
  }, [rail, orientation, size]);
110
303
 
@@ -125,19 +318,23 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
125
318
  }, [stackElement, handleScroll]);
126
319
 
127
320
  return (
128
- <StackContext.Provider value={{ orientation, rail, size, onRearrange }}>
321
+ <StackContext.Provider value={{ orientation, rail, size, onRearrange, stackId }}>
129
322
  <div
130
323
  {...props}
131
- {...arrowNavigationAttrs}
132
324
  className={mx(
133
- 'grid relative',
325
+ 'grid relative [--stack-gap:var(--dx-trimXs)]',
134
326
  gridClasses,
135
- (size === 'contain' || size === 'contain-fit-content') &&
327
+ size === 'contain' &&
136
328
  (orientation === 'horizontal'
137
- ? 'overflow-x-auto min-bs-0 max-bs-full bs-full'
329
+ ? 'overflow-x-auto overscroll-x-contain min-bs-0 max-bs-full bs-full'
138
330
  : 'overflow-y-auto min-is-0 max-is-full is-full'),
139
331
  classNames,
140
332
  )}
333
+ onKeyDown={handleKeyDown}
334
+ onBlur={handleBlur}
335
+ data-dx-stack={stackId}
336
+ data-dx-stack-circular-focus={circularFocus}
337
+ data-dx-last-focused-item={lastFocusedItem}
141
338
  data-rail={rail}
142
339
  aria-orientation={orientation}
143
340
  style={styles}
@@ -4,14 +4,15 @@
4
4
 
5
5
  import { createContext, useContext } from 'react';
6
6
 
7
+ import { type StackItemRearrangeHandler, type StackItemSize } from './defs';
7
8
  import { type Orientation, type Size } from './Stack';
8
- import { type StackItemSize, type StackItemRearrangeHandler } from './defs';
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>({