@dxos/react-ui-stack 0.8.4-main.dedc0f3 → 0.8.4-main.e8ec1fe
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-3V2YUQK5.mjs → chunk-3F2KBXLP.mjs} +208 -101
- package/dist/lib/browser/chunk-3F2KBXLP.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +5 -1
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/playwright/index.mjs +10 -23
- package/dist/lib/browser/playwright/index.mjs.map +2 -2
- package/dist/lib/browser/testing/index.mjs +1 -1
- package/dist/lib/node-esm/{chunk-HE3BRF7A.mjs → chunk-SYKFLQGK.mjs} +208 -101
- package/dist/lib/node-esm/chunk-SYKFLQGK.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +5 -1
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/playwright/index.mjs +10 -23
- package/dist/lib/node-esm/playwright/index.mjs.map +2 -2
- package/dist/lib/node-esm/testing/index.mjs +1 -1
- package/dist/types/src/components/Image/Image.d.ts +5 -2
- package/dist/types/src/components/Image/Image.d.ts.map +1 -1
- package/dist/types/src/components/Image/Image.stories.d.ts +3 -1
- package/dist/types/src/components/Image/Image.stories.d.ts.map +1 -1
- package/dist/types/src/components/Stack/Stack.d.ts +6 -5
- package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
- package/dist/types/src/components/Stack/Stack.stories.d.ts +1 -2
- package/dist/types/src/components/Stack/Stack.stories.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItem.d.ts +4 -3
- package/dist/types/src/components/StackItem/StackItem.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItem.stories.d.ts +0 -1
- package/dist/types/src/components/StackItem/StackItem.stories.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItemContent.d.ts +20 -10
- package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
- package/dist/types/src/components/StackItem/StackItemHeading.d.ts.map +1 -1
- package/dist/types/src/exemplars/Card/Card.d.ts +21 -10
- package/dist/types/src/exemplars/Card/Card.d.ts.map +1 -1
- package/dist/types/src/exemplars/Card/Card.stories.d.ts +0 -23
- package/dist/types/src/exemplars/Card/Card.stories.d.ts.map +1 -1
- package/dist/types/src/exemplars/Card/fragments.d.ts +1 -1
- 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 +3 -1
- package/dist/types/src/exemplars/CardStack/CardStack.stories.d.ts.map +1 -1
- package/dist/types/src/hooks/useStackDropForElements.d.ts +2 -2
- package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +27 -27
- package/src/components/Image/Image.stories.tsx +34 -8
- package/src/components/Image/Image.tsx +158 -73
- package/src/components/Stack/Stack.stories.tsx +2 -4
- package/src/components/Stack/Stack.tsx +93 -38
- package/src/components/StackItem/StackItem.stories.tsx +3 -5
- package/src/components/StackItem/StackItem.tsx +11 -4
- package/src/components/StackItem/StackItemContent.tsx +19 -8
- package/src/components/StackItem/StackItemHeading.tsx +1 -5
- package/src/exemplars/Card/Card.stories.tsx +1 -25
- package/src/exemplars/Card/Card.tsx +39 -15
- package/src/exemplars/Card/fragments.ts +1 -1
- package/src/exemplars/CardStack/CardStack.stories.tsx +5 -4
- package/src/exemplars/CardStack/CardStack.tsx +11 -8
- package/src/hooks/useStackDropForElements.ts +42 -35
- package/dist/lib/browser/chunk-3V2YUQK5.mjs.map +0 -7
- 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.
|
|
3
|
+
"version": "0.8.4-main.e8ec1fe",
|
|
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/keyboard": "0.8.4-main.
|
|
59
|
-
"@dxos/live-object": "0.8.4-main.
|
|
60
|
-
"@dxos/react-ui-dnd": "0.8.4-main.
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/react-ui-attention": "0.8.4-main.
|
|
57
|
+
"@dxos/echo": "0.8.4-main.e8ec1fe",
|
|
58
|
+
"@dxos/keyboard": "0.8.4-main.e8ec1fe",
|
|
59
|
+
"@dxos/live-object": "0.8.4-main.e8ec1fe",
|
|
60
|
+
"@dxos/react-ui-dnd": "0.8.4-main.e8ec1fe",
|
|
61
|
+
"@dxos/util": "0.8.4-main.e8ec1fe",
|
|
62
|
+
"@dxos/storybook-utils": "0.8.4-main.e8ec1fe",
|
|
63
|
+
"@dxos/react-ui-attention": "0.8.4-main.e8ec1fe"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@types/react": "~
|
|
67
|
-
"@types/react-dom": "~
|
|
68
|
-
"react": "~
|
|
69
|
-
"react-dom": "~
|
|
70
|
-
"vite": "7.1.
|
|
71
|
-
"@dxos/
|
|
72
|
-
"@dxos/
|
|
73
|
-
"@dxos/
|
|
74
|
-
"@dxos/
|
|
75
|
-
"@dxos/
|
|
76
|
-
"@dxos/react-ui-theme": "0.8.4-main.
|
|
77
|
-
"@dxos/storybook-utils": "0.8.4-main.
|
|
78
|
-
"@dxos/test-utils": "0.8.4-main.
|
|
66
|
+
"@types/react": "~19.2.2",
|
|
67
|
+
"@types/react-dom": "~19.2.2",
|
|
68
|
+
"react": "~19.2.0",
|
|
69
|
+
"react-dom": "~19.2.0",
|
|
70
|
+
"vite": "7.1.9",
|
|
71
|
+
"@dxos/echo": "0.8.4-main.e8ec1fe",
|
|
72
|
+
"@dxos/client": "0.8.4-main.e8ec1fe",
|
|
73
|
+
"@dxos/random": "0.8.4-main.e8ec1fe",
|
|
74
|
+
"@dxos/app-graph": "0.8.4-main.e8ec1fe",
|
|
75
|
+
"@dxos/react-ui": "0.8.4-main.e8ec1fe",
|
|
76
|
+
"@dxos/react-ui-theme": "0.8.4-main.e8ec1fe",
|
|
77
|
+
"@dxos/storybook-utils": "0.8.4-main.e8ec1fe",
|
|
78
|
+
"@dxos/test-utils": "0.8.4-main.e8ec1fe"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"react": "
|
|
82
|
-
"react-dom": "
|
|
83
|
-
"@dxos/client": "0.8.4-main.
|
|
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/client": "0.8.4-main.e8ec1fe",
|
|
84
|
+
"@dxos/random": "0.8.4-main.e8ec1fe",
|
|
85
|
+
"@dxos/react-ui": "0.8.4-main.e8ec1fe",
|
|
86
|
+
"@dxos/react-ui-theme": "0.8.4-main.e8ec1fe"
|
|
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/
|
|
9
|
+
import { withTheme } from '@dxos/react-ui/testing';
|
|
12
10
|
|
|
13
11
|
import { Image } from './Image';
|
|
14
12
|
|
|
15
|
-
|
|
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: '
|
|
27
|
+
layout: 'centered',
|
|
28
28
|
},
|
|
29
29
|
} satisfies Meta<typeof Image>;
|
|
30
30
|
|
|
@@ -46,13 +46,39 @@ export const Default: Story = {
|
|
|
46
46
|
export const Cors: Story = {
|
|
47
47
|
args: {
|
|
48
48
|
src: 'https://dxos.network/dxos-logotype-blue.png',
|
|
49
|
-
classNames: '
|
|
49
|
+
classNames: 'is-[20rem]',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Corners: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
src: 'https://media.licdn.com/dms/image/v2/D4D0BAQEY4OiENeMR4A/company-logo_200_200/company-logo_200_200/0/1728648673877/moonfire_logo?e=1763596800&v=beta&t=_Jmhg-vu5uqUR88YiTbDFOC4ShlUbjk63_7-JQpgK9A',
|
|
56
|
+
classNames: 'is-[20rem]',
|
|
50
57
|
},
|
|
51
58
|
};
|
|
52
59
|
|
|
53
60
|
export const SVG: Story = {
|
|
54
61
|
args: {
|
|
55
62
|
src: 'https://dxos.network/bg-kube.svg',
|
|
56
|
-
classNames: '
|
|
63
|
+
classNames: 'is-[20rem]',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Many: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
src: 'https://dxos.network/bg-kube.svg',
|
|
70
|
+
},
|
|
71
|
+
render: () => {
|
|
72
|
+
const images = useMemo(
|
|
73
|
+
() => Array.from({ length: 9 }, (_, i) => `https://picsum.photos/seed/${seed + i}/500/500`),
|
|
74
|
+
[],
|
|
75
|
+
);
|
|
76
|
+
return (
|
|
77
|
+
<div className='is-[60rem] grid grid-cols-3 grid-rows-3 gap-8'>
|
|
78
|
+
{images.map((src, i) => (
|
|
79
|
+
<Image key={i} src={src} classNames='is-[18rem] bs-[12rem]' />
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
57
83
|
},
|
|
58
84
|
};
|
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import React, { type SyntheticEvent, useRef, useState } from 'react';
|
|
5
|
+
import React, { type SyntheticEvent, useCallback, useRef, useState } from 'react';
|
|
6
6
|
|
|
7
7
|
import { type ThemedClassName } from '@dxos/react-ui';
|
|
8
8
|
import { mx } from '@dxos/react-ui-theme';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
const cache = new Map<string, string>();
|
|
11
|
+
|
|
12
|
+
export type ImageProps = ThemedClassName<
|
|
13
|
+
{
|
|
14
|
+
src: string;
|
|
15
|
+
alt?: string;
|
|
16
|
+
crossOrigin?: 'anonymous' | 'use-credentials' | '';
|
|
17
|
+
} & ColorOptions
|
|
18
|
+
>;
|
|
17
19
|
|
|
18
20
|
export const Image = ({
|
|
19
21
|
classNames,
|
|
@@ -21,83 +23,47 @@ export const Image = ({
|
|
|
21
23
|
alt = '',
|
|
22
24
|
crossOrigin = 'anonymous',
|
|
23
25
|
sampleSize = 64,
|
|
24
|
-
contrast = 0.
|
|
26
|
+
contrast = 0.9,
|
|
25
27
|
}: ImageProps) => {
|
|
26
28
|
const [crossOriginState, setCrossOriginState] = useState<ImageProps['crossOrigin']>(crossOrigin);
|
|
27
29
|
const [dominantColor, setDominantColor] = useState<string | undefined>(undefined);
|
|
28
30
|
const [imageLoaded, setImageLoaded] = useState<boolean>(false);
|
|
29
31
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
30
32
|
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!canvas || !ctx) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
33
|
+
// CORS not supported by server.
|
|
34
|
+
const handleImageError = (): void => {
|
|
35
|
+
setCrossOriginState(undefined);
|
|
36
|
+
};
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
|
38
|
+
const handleImageLoad = useCallback(
|
|
39
|
+
({ target }: SyntheticEvent<HTMLImageElement>): void => {
|
|
40
|
+
const rgb = cache.get(src);
|
|
41
|
+
if (rgb) {
|
|
42
|
+
setDominantColor(rgb);
|
|
43
|
+
setImageLoaded(true);
|
|
44
|
+
return;
|
|
73
45
|
}
|
|
74
46
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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})`);
|
|
47
|
+
const img = target as HTMLImageElement;
|
|
48
|
+
if (!canvasRef.current) {
|
|
49
|
+
return;
|
|
85
50
|
}
|
|
86
|
-
} catch {
|
|
87
|
-
setCrossOriginState(undefined);
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
51
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
52
|
+
try {
|
|
53
|
+
const color = extractDominantColor(canvasRef.current, img, { sampleSize, contrast });
|
|
54
|
+
if (color) {
|
|
55
|
+
const rgb = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
|
|
56
|
+
cache.set(src, rgb);
|
|
57
|
+
setDominantColor(rgb);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
setCrossOriginState(undefined);
|
|
61
|
+
}
|
|
95
62
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
};
|
|
63
|
+
setImageLoaded(true);
|
|
64
|
+
},
|
|
65
|
+
[sampleSize, contrast, src],
|
|
66
|
+
);
|
|
101
67
|
|
|
102
68
|
return (
|
|
103
69
|
<div
|
|
@@ -135,3 +101,122 @@ export const Image = ({
|
|
|
135
101
|
</div>
|
|
136
102
|
);
|
|
137
103
|
};
|
|
104
|
+
|
|
105
|
+
type ColorOptions = {
|
|
106
|
+
sampleSize?: number;
|
|
107
|
+
contrast?: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get dominant color from image (esp. from corners).
|
|
112
|
+
*/
|
|
113
|
+
const extractDominantColor = (
|
|
114
|
+
canvas: HTMLCanvasElement,
|
|
115
|
+
img: HTMLImageElement,
|
|
116
|
+
{ sampleSize = 64, contrast = 0.95 }: ColorOptions,
|
|
117
|
+
): [number, number, number] | null => {
|
|
118
|
+
const ctx = canvas.getContext('2d');
|
|
119
|
+
if (!ctx) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Draw the image scaled down.
|
|
124
|
+
canvas.width = sampleSize;
|
|
125
|
+
canvas.height = sampleSize;
|
|
126
|
+
ctx.drawImage(img, 0, 0, sampleSize, sampleSize);
|
|
127
|
+
|
|
128
|
+
// Get image data.
|
|
129
|
+
const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize);
|
|
130
|
+
const pixels = imageData.data;
|
|
131
|
+
|
|
132
|
+
// Check for transparent background.
|
|
133
|
+
if (isTransparent(pixels, sampleSize)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let r = 0;
|
|
138
|
+
let g = 0;
|
|
139
|
+
let b = 0;
|
|
140
|
+
let totalWeight = 0;
|
|
141
|
+
|
|
142
|
+
// Define corner sampling areas (e.g., 25% of each dimension from each corner).
|
|
143
|
+
const cornerSize = Math.floor(sampleSize * 0.125);
|
|
144
|
+
|
|
145
|
+
// Sample only pixels in corner areas.
|
|
146
|
+
for (let y = 0; y < sampleSize; y++) {
|
|
147
|
+
for (let x = 0; x < sampleSize; x++) {
|
|
148
|
+
// Check if pixel is in any corner area.
|
|
149
|
+
const isInTopLeft = x < cornerSize && y < cornerSize;
|
|
150
|
+
const isInTopRight = x >= sampleSize - cornerSize && y < cornerSize;
|
|
151
|
+
const isInBottomLeft = x < cornerSize && y >= sampleSize - cornerSize;
|
|
152
|
+
const isInBottomRight = x >= sampleSize - cornerSize && y >= sampleSize - cornerSize;
|
|
153
|
+
if (!isInTopLeft && !isInTopRight && !isInBottomLeft && !isInBottomRight) {
|
|
154
|
+
continue; // Skip pixels not in corner areas.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const i = (y * sampleSize + x) * 4;
|
|
158
|
+
const red = pixels[i];
|
|
159
|
+
const green = pixels[i + 1];
|
|
160
|
+
const blue = pixels[i + 2];
|
|
161
|
+
const alpha = pixels[i + 3];
|
|
162
|
+
|
|
163
|
+
// Skip transparent pixels.
|
|
164
|
+
if (alpha === 0) continue;
|
|
165
|
+
|
|
166
|
+
// Calculate saturation to weight vibrant colors more.
|
|
167
|
+
const max = Math.max(red, green, blue);
|
|
168
|
+
const min = Math.min(red, green, blue);
|
|
169
|
+
const saturation = max === 0 ? 0 : (max - min) / max;
|
|
170
|
+
const weight = 1 + saturation * 2;
|
|
171
|
+
|
|
172
|
+
r += red * weight;
|
|
173
|
+
g += green * weight;
|
|
174
|
+
b += blue * weight;
|
|
175
|
+
totalWeight += weight;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (totalWeight > 0) {
|
|
180
|
+
// Slightly darken the color for better contrast.
|
|
181
|
+
r = Math.round(Math.round(r / totalWeight) * contrast);
|
|
182
|
+
g = Math.round(Math.round(g / totalWeight) * contrast);
|
|
183
|
+
b = Math.round(Math.round(b / totalWeight) * contrast);
|
|
184
|
+
return [r, g, b];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Detects if an image has a transparent background by examining edge pixels.
|
|
192
|
+
* @param pixels - Image pixel data from canvas
|
|
193
|
+
* @param sampleSize - Size of the sampled image
|
|
194
|
+
* @param threshold - Percentage threshold for considering background transparent (default: 0.5)
|
|
195
|
+
* @returns True if the image has a transparent background
|
|
196
|
+
*/
|
|
197
|
+
const isTransparent = (pixels: Uint8ClampedArray, sampleSize: number, threshold: number = 0.5): boolean => {
|
|
198
|
+
let edgeTransparentPixels = 0;
|
|
199
|
+
const edgePixels = sampleSize * 4 - 4; // Perimeter minus corners counted twice.
|
|
200
|
+
|
|
201
|
+
for (let x = 0; x < sampleSize; x++) {
|
|
202
|
+
// Top edge.
|
|
203
|
+
const topIndex = x * 4;
|
|
204
|
+
if (pixels[topIndex + 3] === 0) edgeTransparentPixels++;
|
|
205
|
+
|
|
206
|
+
// Bottom edge.
|
|
207
|
+
const bottomIndex = ((sampleSize - 1) * sampleSize + x) * 4;
|
|
208
|
+
if (pixels[bottomIndex + 3] === 0) edgeTransparentPixels++;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (let y = 1; y < sampleSize - 1; y++) {
|
|
212
|
+
// Left edge.
|
|
213
|
+
const leftIndex = y * sampleSize * 4;
|
|
214
|
+
if (pixels[leftIndex + 3] === 0) edgeTransparentPixels++;
|
|
215
|
+
|
|
216
|
+
// Right edge.
|
|
217
|
+
const rightIndex = (y * sampleSize + sampleSize - 1) * 4;
|
|
218
|
+
if (pixels[rightIndex + 3] === 0) edgeTransparentPixels++;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return edgeTransparentPixels / edgePixels > threshold;
|
|
222
|
+
};
|
|
@@ -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/
|
|
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;
|
|
@@ -23,6 +23,7 @@ import { type StackContextValue } from '../defs';
|
|
|
23
23
|
import { StackContext } from '../StackContext';
|
|
24
24
|
|
|
25
25
|
export type Orientation = 'horizontal' | 'vertical';
|
|
26
|
+
|
|
26
27
|
/**
|
|
27
28
|
* Size is how Stack and its StackItems coordinate the dimensions of the items with the available space.
|
|
28
29
|
* - `intrinsic` signals to Stack and its StackItems to occupy their intrinsic size
|
|
@@ -32,13 +33,6 @@ export type Orientation = 'horizontal' | 'vertical';
|
|
|
32
33
|
*/
|
|
33
34
|
export type Size = 'intrinsic' | 'contain' | 'split';
|
|
34
35
|
|
|
35
|
-
export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
|
|
36
|
-
Partial<StackContextValue> & {
|
|
37
|
-
itemsCount?: number;
|
|
38
|
-
getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
|
|
39
|
-
separatorOnScroll?: number;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
36
|
export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
|
|
43
37
|
export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
|
|
44
38
|
|
|
@@ -60,6 +54,14 @@ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orient
|
|
|
60
54
|
return el.focus();
|
|
61
55
|
};
|
|
62
56
|
|
|
57
|
+
export type StackProps = Omit<ThemedClassName<ComponentPropsWithRef<'div'>>, 'aria-orientation'> &
|
|
58
|
+
Partial<StackContextValue> & {
|
|
59
|
+
itemsCount?: number;
|
|
60
|
+
getDropElement?: (stackElement: HTMLDivElement) => HTMLDivElement;
|
|
61
|
+
separatorOnScroll?: number;
|
|
62
|
+
circularFocus?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
63
65
|
export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
64
66
|
(
|
|
65
67
|
{
|
|
@@ -67,18 +69,20 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
|
67
69
|
classNames,
|
|
68
70
|
style,
|
|
69
71
|
orientation = 'vertical',
|
|
70
|
-
rail = true,
|
|
72
|
+
rail = true, // TODO(burdon): Change default to false.
|
|
71
73
|
size = 'intrinsic',
|
|
72
74
|
onRearrange,
|
|
73
75
|
itemsCount = Children.count(children),
|
|
74
76
|
getDropElement,
|
|
75
77
|
separatorOnScroll,
|
|
78
|
+
circularFocus,
|
|
76
79
|
...props
|
|
77
80
|
},
|
|
78
81
|
forwardedRef,
|
|
79
82
|
) => {
|
|
80
83
|
const stackId = useId('stack', props.id);
|
|
81
84
|
const [stackElement, stackRef] = useState<HTMLDivElement | null>(null);
|
|
85
|
+
const [lastFocusedItem, setLastFocusedItem] = useState<string>();
|
|
82
86
|
const composedItemRef = composeRefs<HTMLDivElement>(stackRef, forwardedRef);
|
|
83
87
|
|
|
84
88
|
const styles: CSSProperties = {
|
|
@@ -115,9 +119,26 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
|
115
119
|
}, [stackElement, separatorOnScroll, orientation]);
|
|
116
120
|
|
|
117
121
|
/**
|
|
118
|
-
* Handles
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
* Handles blur events to track the last focused item within this stack.
|
|
123
|
+
*/
|
|
124
|
+
const handleBlur = useCallback(
|
|
125
|
+
(event: React.FocusEvent<HTMLDivElement>) => {
|
|
126
|
+
if (event.target) {
|
|
127
|
+
const target = event.target as HTMLElement;
|
|
128
|
+
const closestStackItem = target.closest(`[data-dx-item-id]`) as HTMLElement | null;
|
|
129
|
+
if (closestStackItem?.closest(`[data-dx-stack="${stackId}"]`)) {
|
|
130
|
+
setLastFocusedItem(closestStackItem?.getAttribute('data-dx-item-id') ?? undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
props.onBlur?.(event);
|
|
134
|
+
},
|
|
135
|
+
[stackId, props.onBlur],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handles moving focus using the arrow keys. Focus is only handled by the nearest stack;
|
|
140
|
+
* if the arrow key matches the orientation, focus cycles between items, otherwise focus is passed to an adjacent stack item;
|
|
141
|
+
* or, if there is no such stack item, focus is passed to the adjacent empty stack if one can be found.
|
|
121
142
|
*/
|
|
122
143
|
const handleKeyDown = useCallback(
|
|
123
144
|
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
@@ -152,10 +173,22 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
|
152
173
|
? 1
|
|
153
174
|
: 0;
|
|
154
175
|
if (parallelDelta !== 0) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
176
|
+
const currentIndex = closestStackItems.indexOf(closestOwnedItem);
|
|
177
|
+
const nextIndex = currentIndex + parallelDelta;
|
|
178
|
+
let adjacentItem: HTMLElement | undefined;
|
|
179
|
+
|
|
180
|
+
if (circularFocus) {
|
|
181
|
+
// Circular navigation: wrap around using modulo.
|
|
182
|
+
adjacentItem = closestStackItems[(nextIndex + closestStackItems.length) % closestStackItems.length] as
|
|
183
|
+
| HTMLElement
|
|
184
|
+
| undefined;
|
|
185
|
+
} else {
|
|
186
|
+
// Non-circular navigation: only move if within bounds.
|
|
187
|
+
if (nextIndex >= 0 && nextIndex < closestStackItems.length) {
|
|
188
|
+
adjacentItem = closestStackItems[nextIndex] as HTMLElement | undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
159
192
|
if (adjacentItem) {
|
|
160
193
|
event.preventDefault();
|
|
161
194
|
scrollIntoViewAndFocus(adjacentItem, closestStackOrientation);
|
|
@@ -168,10 +201,21 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
|
168
201
|
`[data-dx-stack-item="${ancestorStack.getAttribute('data-dx-stack')}"] [data-dx-stack]`,
|
|
169
202
|
),
|
|
170
203
|
) as HTMLElement[];
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
const currentStackIndex = siblingStacks.indexOf(closestStack);
|
|
205
|
+
const nextStackIndex = currentStackIndex + perpendicularDelta;
|
|
206
|
+
let adjacentStack: HTMLElement | undefined;
|
|
207
|
+
|
|
208
|
+
if (ancestorStack.getAttribute('data-dx-stack-circular-focus') === 'true') {
|
|
209
|
+
// Circular navigation: wrap around using modulo.
|
|
210
|
+
adjacentStack = siblingStacks[(nextStackIndex + siblingStacks.length) % siblingStacks.length] as
|
|
211
|
+
| HTMLElement
|
|
212
|
+
| undefined;
|
|
213
|
+
} else {
|
|
214
|
+
// Non-circular navigation: only move if within bounds.
|
|
215
|
+
if (nextStackIndex >= 0 && nextStackIndex < siblingStacks.length) {
|
|
216
|
+
adjacentStack = siblingStacks[nextStackIndex] as HTMLElement | undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
175
219
|
const adjacentStackSelfItem = adjacentStack?.closest(
|
|
176
220
|
`[data-dx-stack-item=${ancestorStack.getAttribute('data-dx-stack')}]`,
|
|
177
221
|
) as HTMLElement | undefined;
|
|
@@ -182,26 +226,33 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
|
182
226
|
),
|
|
183
227
|
) as HTMLElement[])
|
|
184
228
|
: [];
|
|
185
|
-
if (adjacentStackItems.length > 0) {
|
|
186
|
-
//
|
|
187
|
-
const ownedItemRect = closestOwnedItem.getBoundingClientRect();
|
|
188
|
-
const targetPosition =
|
|
189
|
-
closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
|
|
190
|
-
|
|
229
|
+
if (adjacentStack && adjacentStackItems.length > 0) {
|
|
230
|
+
// Check if the adjacent stack has a last focused item recorded, otherwise find the closest item by position.
|
|
191
231
|
let closestItem = adjacentStackItems[0];
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
232
|
+
// Try to find an item with matching data-dx-stack-item value.
|
|
233
|
+
const lastFocusedItem = adjacentStack.querySelector(
|
|
234
|
+
`[data-dx-item-id="${adjacentStack.getAttribute('data-dx-last-focused-item') ?? 'never'}"]`,
|
|
235
|
+
);
|
|
236
|
+
if (lastFocusedItem) {
|
|
237
|
+
closestItem = lastFocusedItem as HTMLElement;
|
|
238
|
+
} else {
|
|
239
|
+
// Fall back to positional calculation
|
|
240
|
+
const ownedItemRect = closestOwnedItem.getBoundingClientRect();
|
|
241
|
+
const targetPosition =
|
|
242
|
+
closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
|
|
198
243
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
244
|
+
let closestDistance = Infinity;
|
|
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
|
+
if (distance < closestDistance) {
|
|
250
|
+
closestDistance = distance;
|
|
251
|
+
closestItem = item;
|
|
252
|
+
}
|
|
253
|
+
if (closestDistance <= PERPENDICULAR_FOCUS_THRESHHOLD) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
205
256
|
}
|
|
206
257
|
}
|
|
207
258
|
|
|
@@ -235,13 +286,14 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
|
235
286
|
}
|
|
236
287
|
props.onKeyDown?.(event);
|
|
237
288
|
},
|
|
238
|
-
[props.onKeyDown, stackId],
|
|
289
|
+
[props.onKeyDown, stackId, circularFocus],
|
|
239
290
|
);
|
|
240
291
|
|
|
241
292
|
const gridClasses = useMemo(() => {
|
|
242
293
|
if (!rail) {
|
|
243
294
|
return orientation === 'horizontal' ? 'grid-rows-1 pli-[--stack-gap]' : 'grid-cols-1 plb-[--stack-gap]';
|
|
244
295
|
}
|
|
296
|
+
|
|
245
297
|
if (orientation === 'horizontal') {
|
|
246
298
|
return railGridHorizontal;
|
|
247
299
|
} else {
|
|
@@ -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}
|