@dxos/react-ui-stack 0.8.4-main.c1de068 → 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.
- package/dist/lib/browser/{chunk-P3TQV4BA.mjs → chunk-SM27YTH3.mjs} +406 -186
- package/dist/lib/browser/chunk-SM27YTH3.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +9 -1
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/playwright/index.mjs +23 -6
- package/dist/lib/browser/playwright/index.mjs.map +2 -2
- package/dist/lib/browser/testing/index.mjs +3 -3
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/node-esm/{chunk-3WVEPAJ4.mjs → chunk-MMAOXKOM.mjs} +406 -186
- package/dist/lib/node-esm/chunk-MMAOXKOM.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +9 -1
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/playwright/index.mjs +23 -6
- package/dist/lib/node-esm/playwright/index.mjs.map +2 -2
- package/dist/lib/node-esm/testing/index.mjs +3 -3
- package/dist/lib/node-esm/testing/index.mjs.map +3 -3
- package/dist/types/src/components/Image/Image.d.ts +11 -0
- package/dist/types/src/components/Image/Image.d.ts.map +1 -0
- package/dist/types/src/components/Image/Image.stories.d.ts +30 -0
- package/dist/types/src/components/Image/Image.stories.d.ts.map +1 -0
- package/dist/types/src/components/Image/index.d.ts +2 -0
- package/dist/types/src/components/Image/index.d.ts.map +1 -0
- package/dist/types/src/components/Stack/Stack.d.ts +10 -2
- package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
- package/dist/types/src/components/Stack/Stack.stories.d.ts +12 -3
- package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
- package/dist/types/src/components/StackContext.d.ts +2 -1
- package/dist/types/src/components/StackContext.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItem.d.ts +6 -5
- package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItem.stories.d.ts +13 -5
- package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItemContent.d.ts +16 -8
- package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItemHeading.d.ts +1 -1
- package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItemResizeHandle.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItemSigil.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/exemplars/Card/Card.d.ts +13 -6
- package/dist/types/src/exemplars/Card/Card.d.ts.map +1 -1
- package/dist/types/src/exemplars/Card/Card.stories.d.ts +12 -4
- package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +1 -1
- package/dist/types/src/exemplars/Card/fragments.d.ts +3 -2
- package/dist/types/src/exemplars/Card/fragments.d.ts.map +1 -1
- package/dist/types/src/exemplars/CardStack/CardStack.d.ts +3 -1
- package/dist/types/src/exemplars/CardStack/CardStack.d.ts.map +1 -1
- package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts +9 -3
- package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +1 -1
- package/dist/types/src/hooks/useStackDropForElements.d.ts +1 -1
- package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
- package/dist/types/src/testing/CardContainer.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +27 -27
- package/src/components/Image/Image.stories.tsx +56 -0
- package/src/components/Image/Image.tsx +137 -0
- package/src/components/Image/index.ts +5 -0
- package/src/components/Stack/Stack.stories.tsx +8 -9
- package/src/components/Stack/Stack.tsx +215 -18
- package/src/components/StackContext.tsx +2 -1
- package/src/components/StackItem/StackItem.stories.tsx +16 -14
- package/src/components/StackItem/StackItem.tsx +26 -18
- package/src/components/StackItem/StackItemContent.tsx +17 -5
- package/src/components/StackItem/StackItemHeading.tsx +4 -8
- package/src/components/StackItem/StackItemResizeHandle.tsx +2 -1
- package/src/components/StackItem/StackItemSigil.tsx +2 -1
- package/src/components/index.ts +1 -0
- package/src/exemplars/Card/Card.stories.tsx +29 -43
- package/src/exemplars/Card/Card.tsx +30 -12
- package/src/exemplars/Card/fragments.ts +3 -2
- package/src/exemplars/CardStack/CardStack.stories.tsx +11 -10
- package/src/exemplars/CardStack/CardStack.tsx +12 -9
- package/src/hooks/useStackDropForElements.ts +1 -1
- package/src/testing/CardContainer.tsx +9 -6
- package/dist/lib/browser/chunk-P3TQV4BA.mjs.map +0 -7
- 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.
|
|
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
|
|
58
|
-
"@dxos/
|
|
59
|
-
"@dxos/
|
|
60
|
-
"@dxos/react-ui-
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/storybook-utils": "0.8.4-main.
|
|
63
|
-
"@dxos/
|
|
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": "~
|
|
67
|
-
"@types/react-dom": "~
|
|
68
|
-
"react": "~
|
|
69
|
-
"react-dom": "~
|
|
70
|
-
"vite": "
|
|
71
|
-
"@dxos/app-graph": "0.8.4-main.
|
|
72
|
-
"@dxos/
|
|
73
|
-
"@dxos/
|
|
74
|
-
"@dxos/random": "0.8.4-main.
|
|
75
|
-
"@dxos/react-ui": "0.8.4-main.
|
|
76
|
-
"@dxos/react-ui-theme": "0.8.4-main.
|
|
77
|
-
"@dxos/
|
|
78
|
-
"@dxos/
|
|
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": "
|
|
82
|
-
"react-dom": "
|
|
83
|
-
"@dxos/
|
|
84
|
-
"@dxos/
|
|
85
|
-
"@dxos/
|
|
86
|
-
"@dxos/react-ui-theme": "0.8.4-main.
|
|
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
|
+
};
|
|
@@ -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, {
|
|
7
|
+
import React, { useCallback, useState } from 'react';
|
|
10
8
|
|
|
11
9
|
import { faker } from '@dxos/random';
|
|
12
|
-
import { withTheme } from '@dxos/
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
298
|
+
return railGridHorizontal;
|
|
106
299
|
} else {
|
|
107
|
-
return
|
|
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
|
-
|
|
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>({
|