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

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 (52) hide show
  1. package/dist/lib/browser/{chunk-3V2YUQK5.mjs → chunk-T4ZCIFCF.mjs} +156 -83
  2. package/dist/lib/browser/chunk-T4ZCIFCF.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +5 -1
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/testing/index.mjs +1 -1
  6. package/dist/lib/node-esm/{chunk-HE3BRF7A.mjs → chunk-G2QYUH52.mjs} +156 -83
  7. package/dist/lib/node-esm/chunk-G2QYUH52.mjs.map +7 -0
  8. package/dist/lib/node-esm/index.mjs +5 -1
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/lib/node-esm/testing/index.mjs +1 -1
  11. package/dist/types/src/components/Image/Image.d.ts +5 -2
  12. package/dist/types/src/components/Image/Image.d.ts.map +1 -1
  13. package/dist/types/src/components/Image/Image.stories.d.ts +2 -1
  14. package/dist/types/src/components/Image/Image.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Stack/Stack.d.ts +1 -0
  16. package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
  17. package/dist/types/src/components/Stack/Stack.stories.d.ts +1 -2
  18. package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/StackItem/StackItem.d.ts +4 -3
  20. package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
  21. package/dist/types/src/components/StackItem/StackItem.stories.d.ts +0 -1
  22. package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/StackItem/StackItemContent.d.ts +20 -10
  24. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  25. package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
  26. package/dist/types/src/exemplars/Card/Card.d.ts +15 -7
  27. package/dist/types/src/exemplars/Card/Card.d.ts.map +1 -1
  28. package/dist/types/src/exemplars/Card/Card.stories.d.ts +0 -23
  29. package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +1 -1
  30. package/dist/types/src/exemplars/Card/fragments.d.ts +1 -1
  31. package/dist/types/src/exemplars/Card/fragments.d.ts.map +1 -1
  32. package/dist/types/src/exemplars/CardStack/CardStack.d.ts +3 -1
  33. package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +1 -1
  34. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +3 -1
  35. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +1 -1
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +27 -27
  38. package/src/components/Image/Image.stories.tsx +26 -6
  39. package/src/components/Image/Image.tsx +121 -66
  40. package/src/components/Stack/Stack.stories.tsx +2 -4
  41. package/src/components/Stack/Stack.tsx +81 -26
  42. package/src/components/StackItem/StackItem.stories.tsx +2 -4
  43. package/src/components/StackItem/StackItem.tsx +11 -4
  44. package/src/components/StackItem/StackItemContent.tsx +19 -8
  45. package/src/components/StackItem/StackItemHeading.tsx +1 -5
  46. package/src/exemplars/Card/Card.stories.tsx +1 -25
  47. package/src/exemplars/Card/Card.tsx +20 -2
  48. package/src/exemplars/Card/fragments.ts +1 -1
  49. package/src/exemplars/CardStack/CardStack.stories.tsx +5 -4
  50. package/src/exemplars/CardStack/CardStack.tsx +11 -8
  51. package/dist/lib/browser/chunk-3V2YUQK5.mjs.map +0 -7
  52. package/dist/lib/node-esm/chunk-HE3BRF7A.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.dedc0f3",
3
+ "version": "0.8.4-main.ead640a",
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.dedc0f3",
58
- "@dxos/keyboard": "0.8.4-main.dedc0f3",
59
- "@dxos/live-object": "0.8.4-main.dedc0f3",
60
- "@dxos/react-ui-dnd": "0.8.4-main.dedc0f3",
61
- "@dxos/storybook-utils": "0.8.4-main.dedc0f3",
62
- "@dxos/util": "0.8.4-main.dedc0f3",
63
- "@dxos/react-ui-attention": "0.8.4-main.dedc0f3"
57
+ "@dxos/echo": "0.8.4-main.ead640a",
58
+ "@dxos/keyboard": "0.8.4-main.ead640a",
59
+ "@dxos/react-ui-attention": "0.8.4-main.ead640a",
60
+ "@dxos/react-ui-dnd": "0.8.4-main.ead640a",
61
+ "@dxos/storybook-utils": "0.8.4-main.ead640a",
62
+ "@dxos/live-object": "0.8.4-main.ead640a",
63
+ "@dxos/util": "0.8.4-main.ead640a"
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": "7.1.1",
71
- "@dxos/app-graph": "0.8.4-main.dedc0f3",
72
- "@dxos/random": "0.8.4-main.dedc0f3",
73
- "@dxos/client": "0.8.4-main.dedc0f3",
74
- "@dxos/react-ui": "0.8.4-main.dedc0f3",
75
- "@dxos/echo-schema": "0.8.4-main.dedc0f3",
76
- "@dxos/react-ui-theme": "0.8.4-main.dedc0f3",
77
- "@dxos/storybook-utils": "0.8.4-main.dedc0f3",
78
- "@dxos/test-utils": "0.8.4-main.dedc0f3"
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.ead640a",
72
+ "@dxos/client": "0.8.4-main.ead640a",
73
+ "@dxos/echo": "0.8.4-main.ead640a",
74
+ "@dxos/react-ui": "0.8.4-main.ead640a",
75
+ "@dxos/random": "0.8.4-main.ead640a",
76
+ "@dxos/react-ui-theme": "0.8.4-main.ead640a",
77
+ "@dxos/storybook-utils": "0.8.4-main.ead640a",
78
+ "@dxos/test-utils": "0.8.4-main.ead640a"
79
79
  },
80
80
  "peerDependencies": {
81
- "react": "~18.2.0",
82
- "react-dom": "~18.2.0",
83
- "@dxos/client": "0.8.4-main.dedc0f3",
84
- "@dxos/react-ui": "0.8.4-main.dedc0f3",
85
- "@dxos/random": "0.8.4-main.dedc0f3",
86
- "@dxos/react-ui-theme": "0.8.4-main.dedc0f3"
81
+ "react": "^19.0.0",
82
+ "react-dom": "^19.0.0",
83
+ "@dxos/client": "0.8.4-main.ead640a",
84
+ "@dxos/random": "0.8.4-main.ead640a",
85
+ "@dxos/react-ui-theme": "0.8.4-main.ead640a",
86
+ "@dxos/react-ui": "0.8.4-main.ead640a"
87
87
  },
88
88
  "publishConfig": {
89
89
  "access": "public"
@@ -2,17 +2,17 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
- import React from 'react';
6
+ import React, { useMemo } from 'react';
9
7
 
10
8
  import { faker } from '@dxos/random';
11
- import { withTheme } from '@dxos/storybook-utils';
9
+ import { withTheme } from '@dxos/react-ui/testing';
12
10
 
13
11
  import { Image } from './Image';
14
12
 
15
- faker.seed(1);
13
+ const seed = Math.random();
14
+
15
+ faker.seed(seed);
16
16
 
17
17
  const meta = {
18
18
  title: 'ui/react-ui-stack/Image',
@@ -24,7 +24,7 @@ const meta = {
24
24
  ),
25
25
  decorators: [withTheme],
26
26
  parameters: {
27
- layout: 'fullscreen',
27
+ layout: 'centered',
28
28
  },
29
29
  } satisfies Meta<typeof Image>;
30
30
 
@@ -56,3 +56,23 @@ export const SVG: Story = {
56
56
  classNames: 'w-[20rem]',
57
57
  },
58
58
  };
59
+
60
+ export const Many: Story = {
61
+ args: {
62
+ src: 'https://dxos.network/bg-kube.svg',
63
+ },
64
+ render: () => {
65
+ const images = useMemo(
66
+ () => Array.from({ length: 9 }, (_, i) => `https://picsum.photos/seed/${seed + i}/500/500`),
67
+ [],
68
+ );
69
+ console.log(images);
70
+ return (
71
+ <div className='is-[60rem] grid grid-cols-3 grid-rows-3 gap-8'>
72
+ {images.map((src, i) => (
73
+ <Image key={i} src={src} classNames='is-[18rem] bs-[12rem]' />
74
+ ))}
75
+ </div>
76
+ );
77
+ },
78
+ };
@@ -7,13 +7,13 @@ import React, { type SyntheticEvent, useRef, useState } from 'react';
7
7
  import { type ThemedClassName } from '@dxos/react-ui';
8
8
  import { mx } from '@dxos/react-ui-theme';
9
9
 
10
- export type ImageProps = ThemedClassName<{
11
- src: string;
12
- alt?: string;
13
- crossOrigin?: 'anonymous' | 'use-credentials' | '';
14
- sampleSize?: number;
15
- contrast?: number;
16
- }>;
10
+ export type ImageProps = ThemedClassName<
11
+ {
12
+ src: string;
13
+ alt?: string;
14
+ crossOrigin?: 'anonymous' | 'use-credentials' | '';
15
+ } & ColorOptions
16
+ >;
17
17
 
18
18
  export const Image = ({
19
19
  classNames,
@@ -28,74 +28,26 @@ export const Image = ({
28
28
  const [imageLoaded, setImageLoaded] = useState<boolean>(false);
29
29
  const canvasRef = useRef<HTMLCanvasElement>(null);
30
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) {
31
+ // CORS not supported by server.
32
+ const handleImageError = (): void => {
33
+ setCrossOriginState(undefined);
34
+ };
35
+
36
+ const handleImageLoad = ({ target }: SyntheticEvent<HTMLImageElement>): void => {
37
+ const img = target as HTMLImageElement;
38
+ if (!canvasRef.current) {
36
39
  return;
37
40
  }
38
41
 
39
- // Draw the image scaled down.
40
- canvas.width = sampleSize;
41
- canvas.height = sampleSize;
42
- ctx.drawImage(img, 0, 0, sampleSize, sampleSize);
43
-
44
42
  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})`);
43
+ const color = extractDominantColor(canvasRef.current, img, { sampleSize, contrast });
44
+ if (color) {
45
+ setDominantColor(`rgb(${color[0]}, ${color[1]}, ${color[2]})`);
85
46
  }
86
47
  } catch {
87
48
  setCrossOriginState(undefined);
88
49
  }
89
- };
90
50
 
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
51
  setImageLoaded(true);
100
52
  };
101
53
 
@@ -135,3 +87,106 @@ export const Image = ({
135
87
  </div>
136
88
  );
137
89
  };
90
+
91
+ type ColorOptions = {
92
+ sampleSize?: number;
93
+ contrast?: number;
94
+ };
95
+
96
+ // TODO(burdon): Cache?
97
+ const extractDominantColor = (
98
+ canvas: HTMLCanvasElement,
99
+ img: HTMLImageElement,
100
+ { sampleSize = 64, contrast = 0.95 }: ColorOptions,
101
+ ): [number, number, number] | null => {
102
+ const ctx = canvas.getContext('2d');
103
+ if (!ctx) {
104
+ return null;
105
+ }
106
+
107
+ // Draw the image scaled down.
108
+ canvas.width = sampleSize;
109
+ canvas.height = sampleSize;
110
+ ctx.drawImage(img, 0, 0, sampleSize, sampleSize);
111
+
112
+ // Get image data.
113
+ const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize);
114
+ const pixels = imageData.data;
115
+
116
+ // Check for transparent background.
117
+ if (isTransparent(pixels, sampleSize)) {
118
+ return null;
119
+ }
120
+
121
+ let r = 0;
122
+ let g = 0;
123
+ let b = 0;
124
+ let totalWeight = 0;
125
+
126
+ // Calculate average color with more weight to vibrant colors.
127
+ for (let i = 0; i < pixels.length; i += 4) {
128
+ const red = pixels[i];
129
+ const green = pixels[i + 1];
130
+ const blue = pixels[i + 2];
131
+ const alpha = pixels[i + 3];
132
+
133
+ // Skip transparent pixels.
134
+ if (alpha === 0) continue;
135
+
136
+ // Calculate saturation to weight vibrant colors more.
137
+ const max = Math.max(red, green, blue);
138
+ const min = Math.min(red, green, blue);
139
+ // Give more weight to saturated colors.
140
+ const saturation = max === 0 ? 0 : (max - min) / max;
141
+ const weight = 1 + saturation * 2;
142
+
143
+ r += red * weight;
144
+ g += green * weight;
145
+ b += blue * weight;
146
+ totalWeight += weight;
147
+ }
148
+
149
+ if (totalWeight > 0) {
150
+ // Slightly darken the color for better contrast.
151
+ r = Math.round(Math.round(r / totalWeight) * contrast);
152
+ g = Math.round(Math.round(g / totalWeight) * contrast);
153
+ b = Math.round(Math.round(b / totalWeight) * contrast);
154
+ return [r, g, b];
155
+ }
156
+
157
+ return null;
158
+ };
159
+
160
+ /**
161
+ * Detects if an image has a transparent background by examining edge pixels.
162
+ * @param pixels - Image pixel data from canvas
163
+ * @param sampleSize - Size of the sampled image
164
+ * @param threshold - Percentage threshold for considering background transparent (default: 0.5)
165
+ * @returns True if the image has a transparent background
166
+ */
167
+ const isTransparent = (pixels: Uint8ClampedArray, sampleSize: number, threshold: number = 0.5): boolean => {
168
+ let edgeTransparentPixels = 0;
169
+ const edgePixels = sampleSize * 4 - 4; // Perimeter minus corners counted twice.
170
+
171
+ for (let x = 0; x < sampleSize; x++) {
172
+ // Top edge.
173
+ const topIndex = x * 4;
174
+ if (pixels[topIndex + 3] === 0) edgeTransparentPixels++;
175
+
176
+ // Bottom edge.
177
+ const bottomIndex = ((sampleSize - 1) * sampleSize + x) * 4;
178
+ if (pixels[bottomIndex + 3] === 0) edgeTransparentPixels++;
179
+ }
180
+
181
+ for (let y = 1; y < sampleSize - 1; y++) {
182
+ // Left edge.
183
+ const leftIndex = y * sampleSize * 4;
184
+ if (pixels[leftIndex + 3] === 0) edgeTransparentPixels++;
185
+
186
+ // Right edge.
187
+ const rightIndex = (y * sampleSize + sampleSize - 1) * 4;
188
+ if (pixels[rightIndex + 3] === 0) edgeTransparentPixels++;
189
+ }
190
+
191
+ return edgeTransparentPixels / edgePixels > threshold;
192
+ };
@@ -2,14 +2,12 @@
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
9
  import { faker } from '@dxos/random';
12
- import { withTheme } from '@dxos/storybook-utils';
10
+ import { withTheme } from '@dxos/react-ui/testing';
13
11
 
14
12
  import { type StackItemData } from '../defs';
15
13
  import { StackItem } from '../StackItem';
@@ -133,8 +131,8 @@ const DefaultStory = () => {
133
131
  const meta = {
134
132
  title: 'ui/react-ui-stack/Stack',
135
133
  component: DefaultStory,
136
- decorators: [withTheme],
137
134
  argTypes: { orientation: { control: 'radio', options: ['horizontal', 'vertical'] } },
135
+ decorators: [withTheme],
138
136
  } satisfies Meta<typeof DefaultStory>;
139
137
 
140
138
  export default meta;
@@ -37,6 +37,7 @@ export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'ar
37
37
  itemsCount?: number;
38
38
  getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
39
39
  separatorOnScroll?: number;
40
+ circularFocus?: boolean;
40
41
  };
41
42
 
42
43
  export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
@@ -73,12 +74,14 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
73
74
  itemsCount = Children.count(children),
74
75
  getDropElement,
75
76
  separatorOnScroll,
77
+ circularFocus,
76
78
  ...props
77
79
  },
78
80
  forwardedRef,
79
81
  ) => {
80
82
  const stackId = useId('stack', props.id);
81
83
  const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
84
+ const [lastFocusedItem, setLastFocusedItem] = useState<string>();
82
85
  const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
83
86
 
84
87
  const styles: CSSProperties = {
@@ -114,6 +117,23 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
114
117
  }
115
118
  }, [stackElement, separatorOnScroll, orientation]);
116
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
+
117
137
  /**
118
138
  * Handles moving focus using the arrow keys. Focus is only handled by the nearest stack; if the arrow key matches the
119
139
  * orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item; or, if there is no
@@ -152,10 +172,22 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
152
172
  ? 1
153
173
  : 0;
154
174
  if (parallelDelta !== 0) {
155
- const adjacentItem = closestStackItems[
156
- (closestStackItems.indexOf(closestOwnedItem) + parallelDelta + closestStackItems.length) %
157
- closestStackItems.length
158
- ] as HTMLElement | undefined;
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
+
159
191
  if (adjacentItem) {
160
192
  event.preventDefault();
161
193
  scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
@@ -168,10 +200,21 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
168
200
  `[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
169
201
  ),
170
202
  ) as HTMLElement[];
171
- const adjacentStack = siblingStacks[
172
- (siblingStacks.indexOf(closestStack) + perpendicularDelta + siblingStacks.length) %
173
- siblingStacks.length
174
- ] as HTMLElement | undefined;
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
+ }
175
218
  const adjacentStackSelfItem = adjacentStack?.closest(
176
219
  `[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
177
220
  ) as HTMLElement | undefined;
@@ -182,26 +225,35 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
182
225
  ),
183
226
  ) as HTMLElement[])
184
227
  : [];
185
- if (adjacentStackItems.length > 0) {
186
- // Find the closest item by position
187
- const ownedItemRect = closestOwnedItem.getBoundingClientRect();
188
- const targetPosition =
189
- closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
190
-
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.
191
230
  let closestItem = adjacentStackItems[0];
192
- let closestDistance = Infinity;
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;
193
242
 
194
- for (const item of adjacentStackItems) {
195
- const itemRect = item.getBoundingClientRect();
196
- const itemPosition = closestStackOrientation === 'vertical' ? itemRect.top : itemRect.left;
197
- const distance = Math.abs(itemPosition - targetPosition);
243
+ let closestDistance = Infinity;
198
244
 
199
- if (distance < closestDistance) {
200
- closestDistance = distance;
201
- closestItem = item;
202
- }
203
- if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
204
- break;
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
+ }
205
257
  }
206
258
  }
207
259
 
@@ -235,7 +287,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
235
287
  }
236
288
  props.onKeyDown?.(event);
237
289
  },
238
- [props.onKeyDown, stackId],
290
+ [props.onKeyDown, stackId, circularFocus],
239
291
  );
240
292
 
241
293
  const gridClasses = useMemo(() => {
@@ -279,7 +331,10 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
279
331
  classNames,
280
332
  )}
281
333
  onKeyDown={handleKeyDown}
334
+ onBlur={handleBlur}
282
335
  data-dx-stack={stackId}
336
+ data-dx-stack-circular-focus={circularFocus}
337
+ data-dx-last-focused-item={lastFocusedItem}
283
338
  data-rail={rail}
284
339
  aria-orientation={orientation}
285
340
  style={styles}
@@ -2,13 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
6
  import React from 'react';
9
7
 
10
8
  import { DropdownMenu, Icon } from '@dxos/react-ui';
11
- import { withTheme } from '@dxos/storybook-utils';
9
+ import { withTheme } from '@dxos/react-ui/testing';
12
10
 
13
11
  import { StackItem, type StackItemRootProps } from './StackItem';
14
12
 
@@ -27,7 +25,7 @@ const DefaultStory = (props: StackItemRootProps) => {
27
25
  </DropdownMenu.Root>
28
26
  </div>
29
27
  </StackItem.Heading>
30
- <StackItem.Content classNames='p-2'>Content</StackItem.Content>
28
+ <StackItem.Content>Content</StackItem.Content>
31
29
  </StackItem.Root>
32
30
  );
33
31
  };
@@ -63,7 +63,7 @@ type StackItemRootProps = ThemedClassName<ComponentPropsWithRef<'div'>> & {
63
63
  onSizeChange?: (nextSize: StackItemSize) => void;
64
64
  role?: 'article' | 'section';
65
65
  disableRearrange?: boolean;
66
- focusIndicatorVariant?: 'over-all' | 'group';
66
+ focusIndicatorVariant?: 'over-all' | 'group' | 'over-all-always' | 'group-always';
67
67
  };
68
68
 
69
69
  const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
@@ -232,15 +232,22 @@ const StackItemRoot = forwardRef<HTMLDivElement, StackItemRootProps>(
232
232
  'group/stack-item grid relative',
233
233
  focusIndicatorVariant === 'over-all'
234
234
  ? 'dx-focus-ring-inset-over-all'
235
- : orientation === 'horizontal'
236
- ? 'dx-focus-ring-group-x'
237
- : 'dx-focus-ring-group-y',
235
+ : focusIndicatorVariant === 'over-all-always'
236
+ ? 'dx-focus-ring-inset-over-all-always'
237
+ : orientation === 'horizontal'
238
+ ? focusIndicatorVariant === 'group-always'
239
+ ? 'dx-focus-ring-group-x-always'
240
+ : 'dx-focus-ring-group-x'
241
+ : focusIndicatorVariant === 'group-always'
242
+ ? 'dx-focus-ring-group-y-always'
243
+ : 'dx-focus-ring-group-y',
238
244
  orientation === 'horizontal' ? 'grid-rows-subgrid' : 'grid-cols-subgrid',
239
245
  rail && (orientation === 'horizontal' ? 'row-span-2' : 'col-span-2'),
240
246
  role === 'section' && orientation !== 'horizontal' && 'border-be border-subduedSeparator',
241
247
  classNames,
242
248
  )}
243
249
  data-dx-stack-item={stackId}
250
+ data-dx-item-id={item.id}
244
251
  {...resizeAttributes}
245
252
  style={{
246
253
  ...(stackSize !== 'split' && sizeStyle(size, orientation)),
@@ -9,7 +9,8 @@ import { mx } from '@dxos/react-ui-theme';
9
9
 
10
10
  import { useStack, useStackItem } from '../StackContext';
11
11
 
12
- export type StackItemContentProps = ThemedClassName<Omit<ComponentPropsWithoutRef<'div'>, 'role'>> & {
12
+ // TODO(burdon): Add prop for container-max-width?
13
+ export type StackItemContentProps = ThemedClassName<Omit<ComponentPropsWithoutRef<'div'>, 'role' | 'scrollable'>> & {
13
14
  /**
14
15
  * This flag is required in order to clarify a developer experience that seemed like it needed extra boilerplate
15
16
  * (`row-span-2`) or was buggy. See the description of the StackItem.Content component itself for more information.
@@ -22,15 +23,21 @@ export type StackItemContentProps = ThemedClassName<Omit<ComponentPropsWithoutRe
22
23
  statusbar?: boolean;
23
24
 
24
25
  /**
25
- * Whether the consumer intends to do something custom and typical affordances should not apply
26
+ * Whether to support y-axis scrolling.
26
27
  */
27
- layoutManaged?: boolean;
28
+ scrollable?: boolean;
28
29
 
29
30
  /**
30
- * Whether to set a certain aspect ratio on the content, including the toolbar and statusbar. This is provided for
31
- * convenience and consistency; it can instead be specified by the `classNames` or `style` props as needed.
31
+ * Whether to set a certain aspect ratio on the content, including the toolbar and statusbar.
32
+ * This is provided for convenience and consistency; it can instead be specified by the `classNames` or `style` props as needed.
32
33
  */
33
34
  size?: 'intrinsic' | 'video' | 'square';
35
+
36
+ /**
37
+ * Whether the consumer intends to do something custom and typical affordances should not apply.
38
+ * @deprecated Replace with override for gridTempateRows.
39
+ */
40
+ layoutManaged?: boolean;
34
41
  };
35
42
 
36
43
  /**
@@ -38,7 +45,10 @@ export type StackItemContentProps = ThemedClassName<Omit<ComponentPropsWithoutRe
38
45
  * The `toolbar` flag must be provided since this component provides for the layout of content with the toolbar.
39
46
  */
40
47
  export const StackItemContent = forwardRef<HTMLDivElement, StackItemContentProps>(
41
- ({ children, toolbar, statusbar, layoutManaged, classNames, size = 'intrinsic', ...props }, forwardedRef) => {
48
+ (
49
+ { children, toolbar, statusbar, layoutManaged, classNames, size = 'intrinsic', scrollable, ...props },
50
+ forwardedRef,
51
+ ) => {
42
52
  const { size: stackItemSize } = useStack();
43
53
  const { role } = useStackItem();
44
54
  const style = useMemo(
@@ -61,12 +71,13 @@ export const StackItemContent = forwardRef<HTMLDivElement, StackItemContentProps
61
71
  {...props}
62
72
  className={mx(
63
73
  'group grid grid-cols-[100%] density-coarse',
64
- stackItemSize === 'contain' && 'min-bs-0 overflow-hidden',
65
74
  size === 'video' ? 'aspect-video' : size === 'square' && 'aspect-square',
66
- toolbar && '[&>.dx-toolbar]:relative [&>.dx-toolbar]:border-be [&>.dx-toolbar]:border-subduedSeparator',
75
+ stackItemSize === 'contain' && 'min-bs-0 overflow-hidden',
76
+ scrollable ? 'min-bs-0 overflow-y-auto scrollbar-thin contain-layout' : 'overflow-hidden',
67
77
  role === 'section' &&
68
78
  toolbar &&
69
79
  '[&_.dx-toolbar]:sticky [&_.dx-toolbar]:z-[1] [&_.dx-toolbar]:block-start-0 [&_.dx-toolbar]:-mbe-px [&_.dx-toolbar]:min-is-0',
80
+ toolbar && '[&>.dx-toolbar]:relative [&>.dx-toolbar]:border-be [&>.dx-toolbar]:border-subduedSeparator',
70
81
  classNames,
71
82
  )}
72
83
  style={style}