@dxos/react-ui-stack 0.8.4-main.c1de068 → 0.8.4-main.e098934

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 (75) hide show
  1. package/dist/lib/browser/{chunk-P3TQV4BA.mjs → chunk-3V2YUQK5.mjs} +346 -169
  2. package/dist/lib/browser/chunk-3V2YUQK5.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/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-HE3BRF7A.mjs} +346 -169
  10. package/dist/lib/node-esm/chunk-HE3BRF7A.mjs.map +7 -0
  11. package/dist/lib/node-esm/index.mjs +5 -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 +31 -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 +9 -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 -2
  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 +3 -3
  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 -4
  32. package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  34. package/dist/types/src/components/StackItem/StackItemHeading.d.ts +1 -1
  35. package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
  36. package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts.map +1 -1
  37. package/dist/types/src/components/StackItem/StackItemSigil.d.ts.map +1 -1
  38. package/dist/types/src/components/index.d.ts +1 -0
  39. package/dist/types/src/components/index.d.ts.map +1 -1
  40. package/dist/types/src/exemplars/Card/Card.d.ts +2 -2
  41. package/dist/types/src/exemplars/Card/Card.d.ts.map +1 -1
  42. package/dist/types/src/exemplars/Card/Card.stories.d.ts +34 -3
  43. package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +1 -1
  44. package/dist/types/src/exemplars/Card/fragments.d.ts +2 -1
  45. package/dist/types/src/exemplars/Card/fragments.d.ts.map +1 -1
  46. package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +1 -1
  47. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +6 -2
  48. package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +1 -1
  49. package/dist/types/src/hooks/useStackDropForElements.d.ts +1 -1
  50. package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
  51. package/dist/types/src/testing/CardContainer.d.ts.map +1 -1
  52. package/dist/types/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +21 -21
  54. package/src/components/Image/Image.stories.tsx +58 -0
  55. package/src/components/Image/Image.tsx +137 -0
  56. package/src/components/Image/index.ts +5 -0
  57. package/src/components/Stack/Stack.stories.tsx +6 -5
  58. package/src/components/Stack/Stack.tsx +160 -18
  59. package/src/components/StackContext.tsx +2 -1
  60. package/src/components/StackItem/StackItem.stories.tsx +14 -10
  61. package/src/components/StackItem/StackItem.tsx +15 -14
  62. package/src/components/StackItem/StackItemContent.tsx +1 -0
  63. package/src/components/StackItem/StackItemHeading.tsx +3 -3
  64. package/src/components/StackItem/StackItemResizeHandle.tsx +2 -1
  65. package/src/components/StackItem/StackItemSigil.tsx +2 -1
  66. package/src/components/index.ts +1 -0
  67. package/src/exemplars/Card/Card.stories.tsx +33 -23
  68. package/src/exemplars/Card/Card.tsx +11 -11
  69. package/src/exemplars/Card/fragments.ts +2 -1
  70. package/src/exemplars/CardStack/CardStack.stories.tsx +6 -6
  71. package/src/exemplars/CardStack/CardStack.tsx +1 -1
  72. package/src/hooks/useStackDropForElements.ts +1 -1
  73. package/src/testing/CardContainer.tsx +9 -6
  74. package/dist/lib/browser/chunk-P3TQV4BA.mjs.map +0 -7
  75. 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.c1de068",
3
+ "version": "0.8.4-main.e098934",
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.c1de068",
58
- "@dxos/keyboard": "0.8.4-main.c1de068",
59
- "@dxos/live-object": "0.8.4-main.c1de068",
60
- "@dxos/react-ui-attention": "0.8.4-main.c1de068",
61
- "@dxos/react-ui-dnd": "0.8.4-main.c1de068",
62
- "@dxos/storybook-utils": "0.8.4-main.c1de068",
63
- "@dxos/util": "0.8.4-main.c1de068"
57
+ "@dxos/echo-schema": "0.8.4-main.e098934",
58
+ "@dxos/keyboard": "0.8.4-main.e098934",
59
+ "@dxos/live-object": "0.8.4-main.e098934",
60
+ "@dxos/storybook-utils": "0.8.4-main.e098934",
61
+ "@dxos/react-ui-attention": "0.8.4-main.e098934",
62
+ "@dxos/util": "0.8.4-main.e098934",
63
+ "@dxos/react-ui-dnd": "0.8.4-main.e098934"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/react": "~18.2.0",
67
67
  "@types/react-dom": "~18.2.0",
68
68
  "react": "~18.2.0",
69
69
  "react-dom": "~18.2.0",
70
- "vite": "5.4.7",
71
- "@dxos/app-graph": "0.8.4-main.c1de068",
72
- "@dxos/echo-schema": "0.8.4-main.c1de068",
73
- "@dxos/client": "0.8.4-main.c1de068",
74
- "@dxos/random": "0.8.4-main.c1de068",
75
- "@dxos/react-ui": "0.8.4-main.c1de068",
76
- "@dxos/react-ui-theme": "0.8.4-main.c1de068",
77
- "@dxos/test-utils": "0.8.4-main.c1de068",
78
- "@dxos/storybook-utils": "0.8.4-main.c1de068"
70
+ "vite": "7.1.1",
71
+ "@dxos/app-graph": "0.8.4-main.e098934",
72
+ "@dxos/client": "0.8.4-main.e098934",
73
+ "@dxos/echo-schema": "0.8.4-main.e098934",
74
+ "@dxos/random": "0.8.4-main.e098934",
75
+ "@dxos/react-ui": "0.8.4-main.e098934",
76
+ "@dxos/react-ui-theme": "0.8.4-main.e098934",
77
+ "@dxos/storybook-utils": "0.8.4-main.e098934",
78
+ "@dxos/test-utils": "0.8.4-main.e098934"
79
79
  },
80
80
  "peerDependencies": {
81
81
  "react": "~18.2.0",
82
82
  "react-dom": "~18.2.0",
83
- "@dxos/client": "0.8.4-main.c1de068",
84
- "@dxos/react-ui": "0.8.4-main.c1de068",
85
- "@dxos/random": "0.8.4-main.c1de068",
86
- "@dxos/react-ui-theme": "0.8.4-main.c1de068"
83
+ "@dxos/client": "0.8.4-main.e098934",
84
+ "@dxos/react-ui": "0.8.4-main.e098934",
85
+ "@dxos/random": "0.8.4-main.e098934",
86
+ "@dxos/react-ui-theme": "0.8.4-main.e098934"
87
87
  },
88
88
  "publishConfig": {
89
89
  "access": "public"
@@ -0,0 +1,58 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import '@dxos-theme';
6
+
7
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
+ import React from 'react';
9
+
10
+ import { faker } from '@dxos/random';
11
+ import { withTheme } from '@dxos/storybook-utils';
12
+
13
+ import { Image } from './Image';
14
+
15
+ faker.seed(1);
16
+
17
+ const meta = {
18
+ title: 'ui/react-ui-stack/Image',
19
+ component: Image,
20
+ render: (args) => (
21
+ <div className='absolute inset-0 flex place-items-center'>
22
+ <Image {...args} />
23
+ </div>
24
+ ),
25
+ decorators: [withTheme],
26
+ parameters: {
27
+ layout: 'fullscreen',
28
+ },
29
+ } satisfies Meta<typeof Image>;
30
+
31
+ export default meta;
32
+
33
+ type Story = StoryObj<typeof meta>;
34
+
35
+ export const Default: Story = {
36
+ args: {
37
+ src: faker.image.url(),
38
+ },
39
+ };
40
+
41
+ /**
42
+ * Access to image at 'https://dxos.network/dxos-logotype-blue.png'
43
+ * from origin 'http://localhost:9009' has been blocked by CORS policy:
44
+ * No 'Access-Control-Allow-Origin' header is present on the requested resource.
45
+ */
46
+ export const Cors: Story = {
47
+ args: {
48
+ src: 'https://dxos.network/dxos-logotype-blue.png',
49
+ classNames: 'w-[20rem]',
50
+ },
51
+ };
52
+
53
+ export const SVG: Story = {
54
+ args: {
55
+ src: 'https://dxos.network/bg-kube.svg',
56
+ classNames: 'w-[20rem]',
57
+ },
58
+ };
@@ -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';
@@ -6,14 +6,15 @@ import '@dxos-theme';
6
6
 
7
7
  import { type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
8
8
  import { type Meta, type StoryObj } from '@storybook/react-vite';
9
- import React, { useState, useCallback } from 'react';
9
+ import React, { useCallback, useState } from 'react';
10
10
 
11
11
  import { faker } from '@dxos/random';
12
12
  import { withTheme } from '@dxos/storybook-utils';
13
13
 
14
- import { Stack } from './Stack';
15
- import { StackItem } from '../StackItem';
16
14
  import { type StackItemData } from '../defs';
15
+ import { StackItem } from '../StackItem';
16
+
17
+ import { Stack } from './Stack';
17
18
 
18
19
  type StoryStackItem = {
19
20
  id: string;
@@ -129,12 +130,12 @@ const DefaultStory = () => {
129
130
  );
130
131
  };
131
132
 
132
- const meta: Meta<typeof DefaultStory> = {
133
+ const meta = {
133
134
  title: 'ui/react-ui-stack/Stack',
134
135
  component: DefaultStory,
135
136
  decorators: [withTheme],
136
137
  argTypes: { orientation: { control: 'radio', options: ['horizontal', 'vertical'] } },
137
- };
138
+ } satisfies Meta<typeof DefaultStory>;
138
139
 
139
140
  export default meta;
140
141
 
@@ -2,28 +2,35 @@
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> & {
@@ -35,7 +42,7 @@ export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'ar
35
42
  export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
36
43
  export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
37
44
 
38
- // TODO(ZaymonFC): Magic 2px to stop overflow (tabster dummies... ask @thure).
45
+ // TODO(ZaymonFC): Magic 2px to stop overflow.
39
46
  export const railGridHorizontalContainFitContent =
40
47
  'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_fit-content(calc(100%-var(--rail-size)*2+2px))_[content-end]]';
41
48
  export const railGridVerticalContainFitContent =
@@ -43,6 +50,16 @@ export const railGridVerticalContainFitContent =
43
50
 
44
51
  export const autoScrollRootAttributes = { 'data-drag-autoscroll': 'idle' };
45
52
 
53
+ const PERPENDICULAR_FOCUS_THRESHHOLD = 128;
54
+
55
+ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orientation']) => {
56
+ el.scrollIntoView({
57
+ behavior: 'instant',
58
+ [orientation === 'vertical' ? 'block' : 'inline']: 'center',
59
+ });
60
+ return el.focus();
61
+ };
62
+
46
63
  export const Stack = forwardRef<HTMLDivElement, StackProps>(
47
64
  (
48
65
  {
@@ -60,13 +77,13 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
60
77
  },
61
78
  forwardedRef,
62
79
  ) => {
80
+ const stackId = useId('stack', props.id);
63
81
  const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
64
82
  const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
65
- const arrowNavigationAttrs = useArrowNavigationGroup({ axis: orientation });
66
83
 
67
84
  const styles: CSSProperties = {
68
85
  [orientation === 'horizontal' ? 'gridTemplateColumns' : 'gridTemplateRows']:
69
- `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
86
+ size === 'split' ? `repeat(${itemsCount}, 1fr)` : `repeat(${itemsCount}, min-content) [tabster-dummies] 0`,
70
87
  ...style,
71
88
  };
72
89
 
@@ -97,14 +114,138 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
97
114
  }
98
115
  }, [stackElement, separatorOnScroll, orientation]);
99
116
 
117
+ /**
118
+ * Handles moving focus using the arrow keys. Focus is only handled by the nearest stack; if the arrow key matches the
119
+ * orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item; or, if there is no
120
+ * such stack item, focus is passed to the adjacent empty stack if one can be found.
121
+ */
122
+ const handleKeyDown = useCallback(
123
+ (event: KeyboardEvent<HTMLDivElement>) => {
124
+ const target = event.target as HTMLElement;
125
+ if (
126
+ event.key.startsWith('Arrow') &&
127
+ !target.closest(
128
+ `input, textarea, [role="textbox"], [data-tabster*="mover"], [data-arrow-keys="all"], [data-arrow-keys~="${event.key.toLowerCase().slice(5)}"]`,
129
+ )
130
+ ) {
131
+ const closestOwnedItem = target.closest(`[data-dx-stack-item="${stackId}"]`);
132
+ const closestStack = target.closest('[data-dx-stack]') as HTMLElement | null;
133
+ const closestStackItems = Array.from(
134
+ closestStack?.querySelectorAll(`[data-dx-stack-item="${stackId}"]`) ?? [],
135
+ );
136
+ const closestStackOrientation = closestStack?.getAttribute('aria-orientation') as Orientation;
137
+ const ancestorStack = closestStack?.parentElement?.closest('[data-dx-stack]') as HTMLElement | null;
138
+ if (closestOwnedItem && closestStack) {
139
+ const ancestorOrientation = ancestorStack?.getAttribute('aria-orientation') as Orientation | undefined;
140
+ const parallelDelta = (
141
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowUp' : event.key === 'ArrowLeft'
142
+ )
143
+ ? -1
144
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowDown' : event.key === 'ArrowRight')
145
+ ? 1
146
+ : 0;
147
+ const perpendicularDelta = (
148
+ closestStackOrientation === 'vertical' ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
149
+ )
150
+ ? -1
151
+ : (closestStackOrientation === 'vertical' ? event.key === 'ArrowRight' : event.key === 'ArrowDown')
152
+ ? 1
153
+ : 0;
154
+ if (parallelDelta !== 0) {
155
+ const adjacentItem = closestStackItems[
156
+ (closestStackItems.indexOf(closestOwnedItem) + parallelDelta + closestStackItems.length) %
157
+ closestStackItems.length
158
+ ] as HTMLElement | undefined;
159
+ if (adjacentItem) {
160
+ event.preventDefault();
161
+ scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
162
+ }
163
+ }
164
+ if (perpendicularDelta !== 0) {
165
+ if (ancestorStack && ancestorOrientation !== closestStackOrientation) {
166
+ const siblingStacks = Array.from(
167
+ ancestorStack.querySelectorAll(
168
+ `[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
169
+ ),
170
+ ) as HTMLElement[];
171
+ const adjacentStack = siblingStacks[
172
+ (siblingStacks.indexOf(closestStack) + perpendicularDelta + siblingStacks.length) %
173
+ siblingStacks.length
174
+ ] as HTMLElement | undefined;
175
+ const adjacentStackSelfItem = adjacentStack?.closest(
176
+ `[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
177
+ ) as HTMLElement | undefined;
178
+ const adjacentStackItems = adjacentStack
179
+ ? (Array.from(
180
+ adjacentStack.querySelectorAll(
181
+ `[data-dx-stack-item="${adjacentStack.getAttribute('data-dx-stack')}"]`,
182
+ ),
183
+ ) as HTMLElement[])
184
+ : [];
185
+ if (adjacentStackItems.length > 0) {
186
+ // Find the closest item by position
187
+ const ownedItemRect = closestOwnedItem.getBoundingClientRect();
188
+ const targetPosition =
189
+ closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
190
+
191
+ let closestItem = adjacentStackItems[0];
192
+ let closestDistance = Infinity;
193
+
194
+ for (const item of adjacentStackItems) {
195
+ const itemRect = item.getBoundingClientRect();
196
+ const itemPosition = closestStackOrientation === 'vertical' ? itemRect.top : itemRect.left;
197
+ const distance = Math.abs(itemPosition - targetPosition);
198
+
199
+ if (distance < closestDistance) {
200
+ closestDistance = distance;
201
+ closestItem = item;
202
+ }
203
+ if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
204
+ break;
205
+ }
206
+ }
207
+
208
+ event.preventDefault();
209
+ scrollIntoViewAndFocus(closestItem, closestStackOrientation);
210
+ } else if (adjacentStackSelfItem) {
211
+ event.preventDefault();
212
+ scrollIntoViewAndFocus(adjacentStackSelfItem, ancestorOrientation);
213
+ }
214
+ } else if (closestOwnedItem) {
215
+ const closestOwnedItemStack = closestOwnedItem.querySelector('[data-dx-stack]');
216
+ const closestOwnedItemStackItems = closestOwnedItemStack
217
+ ? (Array.from(
218
+ closestOwnedItemStack.querySelectorAll(
219
+ `[data-dx-stack-item="${closestOwnedItemStack.getAttribute('data-dx-stack')}"]`,
220
+ ),
221
+ ) as HTMLElement[])
222
+ : [];
223
+ if (closestOwnedItemStackItems.length > 0) {
224
+ event.preventDefault();
225
+ scrollIntoViewAndFocus(
226
+ closestOwnedItemStackItems[
227
+ ['ArrowUp', 'ArrowLeft'].includes(event.key) ? closestOwnedItemStackItems.length - 1 : 0
228
+ ],
229
+ closestOwnedItemStack?.getAttribute('aria-orientation') as Orientation,
230
+ );
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }
236
+ props.onKeyDown?.(event);
237
+ },
238
+ [props.onKeyDown, stackId],
239
+ );
240
+
100
241
  const gridClasses = useMemo(() => {
101
242
  if (!rail) {
102
- return orientation === 'horizontal' ? 'grid-rows-1 pli-1' : 'grid-cols-1 plb-1';
243
+ return orientation === 'horizontal' ? 'grid-rows-1 pli-[--stack-gap]' : 'grid-cols-1 plb-[--stack-gap]';
103
244
  }
104
245
  if (orientation === 'horizontal') {
105
- return size === 'contain-fit-content' ? railGridHorizontalContainFitContent : railGridHorizontal;
246
+ return railGridHorizontal;
106
247
  } else {
107
- return size === 'contain-fit-content' ? railGridVerticalContainFitContent : railGridVertical;
248
+ return railGridVertical;
108
249
  }
109
250
  }, [rail, orientation, size]);
110
251
 
@@ -125,19 +266,20 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
125
266
  }, [stackElement, handleScroll]);
126
267
 
127
268
  return (
128
- <StackContext.Provider value={{ orientation, rail, size, onRearrange }}>
269
+ <StackContext.Provider value={{ orientation, rail, size, onRearrange, stackId }}>
129
270
  <div
130
271
  {...props}
131
- {...arrowNavigationAttrs}
132
272
  className={mx(
133
- 'grid relative',
273
+ 'grid relative [--stack-gap:var(--dx-trimXs)]',
134
274
  gridClasses,
135
- (size === 'contain' || size === 'contain-fit-content') &&
275
+ size === 'contain' &&
136
276
  (orientation === 'horizontal'
137
- ? 'overflow-x-auto min-bs-0 max-bs-full bs-full'
277
+ ? 'overflow-x-auto overscroll-x-contain min-bs-0 max-bs-full bs-full'
138
278
  : 'overflow-y-auto min-is-0 max-is-full is-full'),
139
279
  classNames,
140
280
  )}
281
+ onKeyDown={handleKeyDown}
282
+ data-dx-stack={stackId}
141
283
  data-rail={rail}
142
284
  aria-orientation={orientation}
143
285
  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>({
@@ -7,16 +7,14 @@ import '@dxos-theme';
7
7
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
8
  import React from 'react';
9
9
 
10
- import { Icon, DropdownMenu } from '@dxos/react-ui';
10
+ import { DropdownMenu, Icon } from '@dxos/react-ui';
11
11
  import { withTheme } from '@dxos/storybook-utils';
12
12
 
13
- import { StackItem } from './StackItem';
13
+ import { StackItem, type StackItemRootProps } from './StackItem';
14
14
 
15
- const meta: Meta<typeof StackItem.Root> = {
16
- title: 'ui/react-ui-stack/StackItem',
17
- component: StackItem.Root,
18
- render: (args) => (
19
- <StackItem.Root role='section' {...args} classNames='w-[20rem] border border-separator'>
15
+ const DefaultStory = (props: StackItemRootProps) => {
16
+ return (
17
+ <StackItem.Root role='section' {...props} classNames='w-[20rem] border border-separator'>
20
18
  <StackItem.Heading>
21
19
  <span className='sr-only'>Title</span>
22
20
  <div role='none' className='sticky -block-start-px bg-[--sticky-bg] p-1 is-full'>
@@ -31,16 +29,22 @@ const meta: Meta<typeof StackItem.Root> = {
31
29
  </StackItem.Heading>
32
30
  <StackItem.Content classNames='p-2'>Content</StackItem.Content>
33
31
  </StackItem.Root>
34
- ),
32
+ );
33
+ };
34
+
35
+ const meta = {
36
+ title: 'ui/react-ui-stack/StackItem',
37
+ component: StackItem.Root as any,
38
+ render: DefaultStory,
35
39
  decorators: [withTheme],
36
40
  parameters: {
37
41
  layout: 'centered',
38
42
  },
39
- };
43
+ } satisfies Meta<typeof DefaultStory>;
40
44
 
41
45
  export default meta;
42
46
 
43
- type Story = StoryObj<typeof StackItem.Root>;
47
+ type Story = StoryObj<typeof meta>;
44
48
 
45
49
  export const Default: Story = {
46
50
  args: {