@dxos/react-ui-stack 0.8.4-main.5ad4a44 → 0.8.4-main.66e292d

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 (38) hide show
  1. package/dist/lib/browser/{chunk-SM27YTH3.mjs → chunk-3F2KBXLP.mjs} +130 -66
  2. package/dist/lib/browser/chunk-3F2KBXLP.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +1 -1
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/playwright/index.mjs +10 -23
  6. package/dist/lib/browser/playwright/index.mjs.map +2 -2
  7. package/dist/lib/browser/testing/index.mjs +1 -1
  8. package/dist/lib/node-esm/{chunk-MMAOXKOM.mjs → chunk-SYKFLQGK.mjs} +130 -66
  9. package/dist/lib/node-esm/chunk-SYKFLQGK.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +1 -1
  11. package/dist/lib/node-esm/meta.json +1 -1
  12. package/dist/lib/node-esm/playwright/index.mjs +10 -23
  13. package/dist/lib/node-esm/playwright/index.mjs.map +2 -2
  14. package/dist/lib/node-esm/testing/index.mjs +1 -1
  15. package/dist/types/src/components/Image/Image.d.ts +5 -2
  16. package/dist/types/src/components/Image/Image.d.ts.map +1 -1
  17. package/dist/types/src/components/Image/Image.stories.d.ts +3 -0
  18. package/dist/types/src/components/Image/Image.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Stack/Stack.d.ts +6 -6
  20. package/dist/types/src/components/Stack/Stack.d.ts.map +1 -1
  21. package/dist/types/src/components/StackItem/StackItem.d.ts +1 -1
  22. package/dist/types/src/components/StackItem/StackItemContent.d.ts +10 -8
  23. package/dist/types/src/components/StackItem/StackItemContent.d.ts.map +1 -1
  24. package/dist/types/src/exemplars/Card/Card.d.ts +12 -8
  25. package/dist/types/src/exemplars/Card/Card.d.ts.map +1 -1
  26. package/dist/types/src/hooks/useStackDropForElements.d.ts +2 -2
  27. package/dist/types/src/hooks/useStackDropForElements.d.ts.map +1 -1
  28. package/dist/types/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +21 -21
  30. package/src/components/Image/Image.stories.tsx +33 -5
  31. package/src/components/Image/Image.tsx +158 -73
  32. package/src/components/Stack/Stack.tsx +14 -14
  33. package/src/components/StackItem/StackItem.stories.tsx +1 -1
  34. package/src/components/StackItem/StackItemContent.tsx +8 -8
  35. package/src/exemplars/Card/Card.tsx +25 -19
  36. package/src/hooks/useStackDropForElements.ts +42 -35
  37. package/dist/lib/browser/chunk-SM27YTH3.mjs.map +0 -7
  38. package/dist/lib/node-esm/chunk-MMAOXKOM.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.5ad4a44",
3
+ "version": "0.8.4-main.66e292d",
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": "0.8.4-main.5ad4a44",
58
- "@dxos/keyboard": "0.8.4-main.5ad4a44",
59
- "@dxos/react-ui-attention": "0.8.4-main.5ad4a44",
60
- "@dxos/live-object": "0.8.4-main.5ad4a44",
61
- "@dxos/storybook-utils": "0.8.4-main.5ad4a44",
62
- "@dxos/util": "0.8.4-main.5ad4a44",
63
- "@dxos/react-ui-dnd": "0.8.4-main.5ad4a44"
57
+ "@dxos/echo": "0.8.4-main.66e292d",
58
+ "@dxos/live-object": "0.8.4-main.66e292d",
59
+ "@dxos/keyboard": "0.8.4-main.66e292d",
60
+ "@dxos/react-ui-attention": "0.8.4-main.66e292d",
61
+ "@dxos/react-ui-dnd": "0.8.4-main.66e292d",
62
+ "@dxos/storybook-utils": "0.8.4-main.66e292d",
63
+ "@dxos/util": "0.8.4-main.66e292d"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/react": "~19.2.2",
67
- "@types/react-dom": "~19.2.1",
67
+ "@types/react-dom": "~19.2.2",
68
68
  "react": "~19.2.0",
69
69
  "react-dom": "~19.2.0",
70
70
  "vite": "7.1.9",
71
- "@dxos/app-graph": "0.8.4-main.5ad4a44",
72
- "@dxos/echo": "0.8.4-main.5ad4a44",
73
- "@dxos/client": "0.8.4-main.5ad4a44",
74
- "@dxos/react-ui": "0.8.4-main.5ad4a44",
75
- "@dxos/random": "0.8.4-main.5ad4a44",
76
- "@dxos/react-ui-theme": "0.8.4-main.5ad4a44",
77
- "@dxos/storybook-utils": "0.8.4-main.5ad4a44",
78
- "@dxos/test-utils": "0.8.4-main.5ad4a44"
71
+ "@dxos/app-graph": "0.8.4-main.66e292d",
72
+ "@dxos/random": "0.8.4-main.66e292d",
73
+ "@dxos/react-ui": "0.8.4-main.66e292d",
74
+ "@dxos/client": "0.8.4-main.66e292d",
75
+ "@dxos/react-ui-theme": "0.8.4-main.66e292d",
76
+ "@dxos/storybook-utils": "0.8.4-main.66e292d",
77
+ "@dxos/test-utils": "0.8.4-main.66e292d",
78
+ "@dxos/echo": "0.8.4-main.66e292d"
79
79
  },
80
80
  "peerDependencies": {
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
- "@dxos/client": "0.8.4-main.5ad4a44",
84
- "@dxos/react-ui": "0.8.4-main.5ad4a44",
85
- "@dxos/react-ui-theme": "0.8.4-main.5ad4a44",
86
- "@dxos/random": "0.8.4-main.5ad4a44"
83
+ "@dxos/client": "0.8.4-main.66e292d",
84
+ "@dxos/random": "0.8.4-main.66e292d",
85
+ "@dxos/react-ui-theme": "0.8.4-main.66e292d",
86
+ "@dxos/react-ui": "0.8.4-main.66e292d"
87
87
  },
88
88
  "publishConfig": {
89
89
  "access": "public"
@@ -3,14 +3,16 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React from 'react';
6
+ import React, { useMemo } from 'react';
7
7
 
8
8
  import { faker } from '@dxos/random';
9
9
  import { withTheme } from '@dxos/react-ui/testing';
10
10
 
11
11
  import { Image } from './Image';
12
12
 
13
- faker.seed(1);
13
+ const seed = Math.random();
14
+
15
+ faker.seed(seed);
14
16
 
15
17
  const meta = {
16
18
  title: 'ui/react-ui-stack/Image',
@@ -22,7 +24,7 @@ const meta = {
22
24
  ),
23
25
  decorators: [withTheme],
24
26
  parameters: {
25
- layout: 'fullscreen',
27
+ layout: 'centered',
26
28
  },
27
29
  } satisfies Meta<typeof Image>;
28
30
 
@@ -44,13 +46,39 @@ export const Default: Story = {
44
46
  export const Cors: Story = {
45
47
  args: {
46
48
  src: 'https://dxos.network/dxos-logotype-blue.png',
47
- classNames: 'w-[20rem]',
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]',
48
57
  },
49
58
  };
50
59
 
51
60
  export const SVG: Story = {
52
61
  args: {
53
62
  src: 'https://dxos.network/bg-kube.svg',
54
- classNames: 'w-[20rem]',
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
+ );
55
83
  },
56
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
- export type ImageProps = ThemedClassName<{
11
- src: string;
12
- alt?: string;
13
- crossOrigin?: 'anonymous' | 'use-credentials' | '';
14
- sampleSize?: number;
15
- contrast?: number;
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.95,
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
- // 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
- }
33
+ // CORS not supported by server.
34
+ const handleImageError = (): void => {
35
+ setCrossOriginState(undefined);
36
+ };
38
37
 
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;
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
- 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})`);
47
+ const img = target as HTMLImageElement;
48
+ if (!canvasRef.current) {
49
+ return;
85
50
  }
86
- } catch {
87
- setCrossOriginState(undefined);
88
- }
89
- };
90
51
 
91
- // CORS not supported by server.
92
- const handleImageError = (): void => {
93
- setCrossOriginState(undefined);
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
- const handleImageLoad = (ev: SyntheticEvent<HTMLImageElement>): void => {
97
- const img = ev.target as HTMLImageElement;
98
- extractDominantColor(img);
99
- setImageLoaded(true);
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
+ };
@@ -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,14 +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
- circularFocus?: boolean;
41
- };
42
-
43
36
  export const railGridHorizontal = 'grid-rows-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
44
37
  export const railGridVertical = 'grid-cols-[[rail-start]_var(--rail-size)_[content-start]_1fr_[content-end]]';
45
38
 
@@ -61,6 +54,14 @@ const scrollIntoViewAndFocus = (el: HTMLElement, orientation: StackProps['orient
61
54
  return el.focus();
62
55
  };
63
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
+
64
65
  export const Stack = forwardRef<HTMLDivElement, StackProps>(
65
66
  (
66
67
  {
@@ -68,7 +69,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
68
69
  classNames,
69
70
  style,
70
71
  orientation = 'vertical',
71
- rail = true,
72
+ rail = true, // TODO(burdon): Change default to false.
72
73
  size = 'intrinsic',
73
74
  onRearrange,
74
75
  itemsCount = Children.count(children),
@@ -135,9 +136,9 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
135
136
  );
136
137
 
137
138
  /**
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.
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.
141
142
  */
142
143
  const handleKeyDown = useCallback(
143
144
  (event: KeyboardEvent<HTMLDivElement>) => {
@@ -241,12 +242,10 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
241
242
  closestStackOrientation === 'vertical' ? ownedItemRect.top : ownedItemRect.left;
242
243
 
243
244
  let closestDistance = Infinity;
244
-
245
245
  for (const item of adjacentStackItems) {
246
246
  const itemRect = item.getBoundingClientRect();
247
247
  const itemPosition = closestStackOrientation === 'vertical' ? itemRect.top : itemRect.left;
248
248
  const distance = Math.abs(itemPosition - targetPosition);
249
-
250
249
  if (distance < closestDistance) {
251
250
  closestDistance = distance;
252
251
  closestItem = item;
@@ -294,6 +293,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(
294
293
  if (!rail) {
295
294
  return orientation === 'horizontal' ? 'grid-rows-1 pli-[--stack-gap]' : 'grid-cols-1 plb-[--stack-gap]';
296
295
  }
296
+
297
297
  if (orientation === 'horizontal') {
298
298
  return railGridHorizontal;
299
299
  } else {
@@ -12,7 +12,7 @@ import { StackItem, type StackItemRootProps } from './StackItem';
12
12
 
13
13
  const DefaultStory = (props: StackItemRootProps) => {
14
14
  return (
15
- <StackItem.Root role='section' {...props} classNames='w-[20rem] border border-separator'>
15
+ <StackItem.Root role='section' {...props} classNames='is-[20rem] border border-separator'>
16
16
  <StackItem.Heading>
17
17
  <span className='sr-only'>Title</span>
18
18
  <div role='none' className='sticky -block-start-px bg-[--sticky-bg] p-1 is-full'>
@@ -27,17 +27,17 @@ export type StackItemContentProps = ThemedClassName<Omit<ComponentPropsWithoutRe
27
27
  */
28
28
  scrollable?: boolean;
29
29
 
30
- /**
31
- * Whether the consumer intends to do something custom and typical affordances should not apply.
32
- */
33
- // TODO(burdon): This is cryptic; can we remove (only used by plugin-inbox?) Normalize toolbar?
34
- layoutManaged?: boolean;
35
-
36
30
  /**
37
31
  * Whether to set a certain aspect ratio on the content, including the toolbar and statusbar.
38
32
  * This is provided for convenience and consistency; it can instead be specified by the `classNames` or `style` props as needed.
39
33
  */
40
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;
41
41
  };
42
42
 
43
43
  /**
@@ -71,13 +71,13 @@ export const StackItemContent = forwardRef<HTMLDivElement, StackItemContentProps
71
71
  {...props}
72
72
  className={mx(
73
73
  'group grid grid-cols-[100%] density-coarse',
74
- stackItemSize === 'contain' && 'min-bs-0 overflow-hidden',
75
74
  size === 'video' ? 'aspect-video' : size === 'square' && 'aspect-square',
76
- toolbar && '[&>.dx-toolbar]:relative [&>.dx-toolbar]:border-be [&>.dx-toolbar]:border-subduedSeparator',
75
+ stackItemSize === 'contain' && 'min-bs-0 overflow-hidden',
77
76
  scrollable ? 'min-bs-0 overflow-y-auto scrollbar-thin contain-layout' : 'overflow-hidden',
78
77
  role === 'section' &&
79
78
  toolbar &&
80
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',
81
81
  classNames,
82
82
  )}
83
83
  style={style}
@@ -13,31 +13,31 @@ import React, {
13
13
  } from 'react';
14
14
 
15
15
  import { Icon, IconButton, type ThemedClassName, Toolbar, type ToolbarRootProps, useTranslation } from '@dxos/react-ui';
16
- import { hoverableControls, mx } from '@dxos/react-ui-theme';
16
+ import { cardMinInlineSize, hoverableControls, mx } from '@dxos/react-ui-theme';
17
17
 
18
18
  import { Image, StackItem } from '../../components';
19
19
  import { translationKey } from '../../translations';
20
20
 
21
21
  import { cardChrome, cardHeading, cardRoot, cardSpacing, cardText } from './fragments';
22
22
 
23
- type SharedCardProps = ThemedClassName<ComponentPropsWithoutRef<'div'>> & { asChild?: boolean };
24
-
25
23
  /**
26
24
  * The default width of cards. It should be no larger than 320px per WCAG 2.1 SC 1.4.10.
27
25
  */
28
- const cardDefaultInlineSize = 18;
26
+ const cardDefaultInlineSize = cardMinInlineSize;
27
+
29
28
  /**
30
- * This is `cardDefaultInlineSize` plus 2 times the sum of the inner and outer spacing applied by CardStack on the
31
- * inline axis.
29
+ * This is `cardDefaultInlineSize` plus 2 times the sum of the inner and outer spacing applied by CardStack on the inline axis.
32
30
  */
33
31
  const cardStackDefaultInlineSizeRem = cardDefaultInlineSize + 2.125;
34
32
 
35
- const CardStaticRoot = forwardRef<HTMLDivElement, SharedCardProps>(
36
- ({ children, classNames, asChild, role = 'group', ...props }, forwardedRef) => {
33
+ type SharedCardProps = ThemedClassName<ComponentPropsWithoutRef<'div'>> & { asChild?: boolean };
34
+
35
+ const CardStaticRoot = forwardRef<HTMLDivElement, SharedCardProps & { id?: string }>(
36
+ ({ children, classNames, id, asChild, role = 'group', ...props }, forwardedRef) => {
37
37
  const Root = asChild ? Slot : 'div';
38
38
  const rootProps = asChild ? { classNames: [cardRoot, classNames] } : { className: mx(cardRoot, classNames), role };
39
39
  return (
40
- <Root {...props} {...rootProps} ref={forwardedRef}>
40
+ <Root {...(id && { 'data-object-id': id })} {...props} {...rootProps} ref={forwardedRef}>
41
41
  {children}
42
42
  </Root>
43
43
  );
@@ -45,18 +45,19 @@ const CardStaticRoot = forwardRef<HTMLDivElement, SharedCardProps>(
45
45
  );
46
46
 
47
47
  /**
48
- * This should be used by Surface fulfillments in cases where the content may or may not already be encapsulated (e.g.
49
- * in a Popover) and knows this based on the `role` it receives. This will render a `Card.StaticRoot` by default, otherwise
50
- * it will render a `div` primitive with the appropriate styling for specific handled situations.
48
+ * This should be used by Surface fulfillments in cases where the content may or may not already be encapsulated (e.g., in a Popover) and knows this based on the `role` it receives.
49
+ * This will render a `Card.StaticRoot` by default, otherwise it will render a `div` primitive with the appropriate styling for specific handled situations.
51
50
  */
52
51
  const CardSurfaceRoot = ({
52
+ id,
53
53
  role = 'never',
54
54
  children,
55
55
  classNames,
56
- }: ThemedClassName<PropsWithChildren<{ role?: string }>>) => {
56
+ }: ThemedClassName<PropsWithChildren<{ id?: string; role?: string }>>) => {
57
57
  if (['card--popover', 'card--intrinsic', 'card--extrinsic'].includes(role)) {
58
58
  return (
59
59
  <div
60
+ {...(id && { 'data-object-id': id })}
60
61
  className={mx(
61
62
  role === 'card--popover'
62
63
  ? 'popover-card-width'
@@ -72,6 +73,7 @@ const CardSurfaceRoot = ({
72
73
  } else {
73
74
  return (
74
75
  <CardStaticRoot
76
+ id={id}
75
77
  classNames={[
76
78
  role === 'card--transclusion' && 'mlb-1',
77
79
  role === 'card--transclusion' && hoverableControls,
@@ -128,22 +130,26 @@ const CardDragPreview = StackItem.DragPreview;
128
130
 
129
131
  const CardMenu = Primitive.div as FC<ComponentPropsWithRef<'div'>>;
130
132
 
131
- type CardPosterProps = {
132
- alt: string;
133
- aspect?: 'video' | 'auto';
134
- } & Partial<{ image: string; icon: string }>;
133
+ type CardPosterProps = ThemedClassName<
134
+ {
135
+ alt: string;
136
+ aspect?: 'video' | 'auto';
137
+ } & Partial<{ image: string; icon: string }>
138
+ >;
135
139
 
136
140
  const CardPoster = (props: CardPosterProps) => {
137
141
  const aspect = props.aspect === 'auto' ? 'aspect-auto' : 'aspect-video';
138
142
  if (props.image) {
139
- return <Image classNames={[`dx-card__poster is-full __bs-auto`, aspect]} src={props.image} alt={props.alt} />;
143
+ return (
144
+ <Image classNames={[`dx-card__poster is-full`, aspect, props.classNames]} src={props.image} alt={props.alt} />
145
+ );
140
146
  }
141
147
 
142
148
  if (props.icon) {
143
149
  return (
144
150
  <div
145
151
  role='image'
146
- className={mx(`dx-card__poster grid place-items-center bg-inputSurface text-subdued`, aspect)}
152
+ className={mx(`dx-card__poster grid place-items-center bg-inputSurface text-subdued`, aspect, props.classNames)}
147
153
  aria-label={props.alt}
148
154
  >
149
155
  <Icon icon={props.icon} size={10} />