@dxos/react-ui 0.8.4-main.cb12b3f963 → 0.8.4-main.d05539e30a

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 (82) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/chunk-A5QCIG5R.mjs +24 -0
  4. package/dist/lib/browser/chunk-A5QCIG5R.mjs.map +7 -0
  5. package/dist/lib/browser/{chunk-BDBC6H6V.mjs → chunk-LY5XDQR5.mjs} +6 -8
  6. package/dist/lib/browser/chunk-LY5XDQR5.mjs.map +7 -0
  7. package/dist/lib/browser/index.mjs +601 -287
  8. package/dist/lib/browser/index.mjs.map +4 -4
  9. package/dist/lib/browser/meta.json +1 -1
  10. package/dist/lib/browser/testing/index.mjs +1 -7
  11. package/dist/lib/browser/testing/index.mjs.map +3 -3
  12. package/dist/lib/browser/translations.mjs +4 -13
  13. package/dist/lib/browser/translations.mjs.map +4 -4
  14. package/dist/lib/node-esm/{chunk-3JSJK2ZY.mjs → chunk-NGKLIKP3.mjs} +6 -8
  15. package/dist/lib/node-esm/chunk-NGKLIKP3.mjs.map +7 -0
  16. package/dist/lib/node-esm/chunk-XCFLA74M.mjs +26 -0
  17. package/dist/lib/node-esm/chunk-XCFLA74M.mjs.map +7 -0
  18. package/dist/lib/node-esm/index.mjs +601 -287
  19. package/dist/lib/node-esm/index.mjs.map +4 -4
  20. package/dist/lib/node-esm/meta.json +1 -1
  21. package/dist/lib/node-esm/testing/index.mjs +1 -7
  22. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  23. package/dist/lib/node-esm/translations.mjs +4 -14
  24. package/dist/lib/node-esm/translations.mjs.map +4 -4
  25. package/dist/types/src/components/Card/Card.d.ts +2 -5
  26. package/dist/types/src/components/Card/Card.d.ts.map +1 -1
  27. package/dist/types/src/components/Carousel/Carousel.d.ts +106 -0
  28. package/dist/types/src/components/Carousel/Carousel.d.ts.map +1 -0
  29. package/dist/types/src/components/Carousel/index.d.ts +2 -0
  30. package/dist/types/src/components/Carousel/index.d.ts.map +1 -0
  31. package/dist/types/src/components/Icon/Icon.d.ts +1 -0
  32. package/dist/types/src/components/Icon/Icon.d.ts.map +1 -1
  33. package/dist/types/src/components/Link/Link.d.ts.map +1 -1
  34. package/dist/types/src/components/List/ListDropIndicator.d.ts.map +1 -1
  35. package/dist/types/src/components/MediaPlayer/MediaPlayer.d.ts +46 -0
  36. package/dist/types/src/components/MediaPlayer/MediaPlayer.d.ts.map +1 -0
  37. package/dist/types/src/components/MediaPlayer/MediaPlayer.stories.d.ts +16 -0
  38. package/dist/types/src/components/MediaPlayer/MediaPlayer.stories.d.ts.map +1 -0
  39. package/dist/types/src/components/MediaPlayer/index.d.ts +2 -0
  40. package/dist/types/src/components/MediaPlayer/index.d.ts.map +1 -0
  41. package/dist/types/src/components/ScrollArea/ScrollArea.stories.d.ts.map +1 -1
  42. package/dist/types/src/components/ScrollContainer/ScrollContainer.d.ts.map +1 -1
  43. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  44. package/dist/types/src/components/Tooltip/Tooltip.d.ts +6 -6
  45. package/dist/types/src/components/Tooltip/Tooltip.d.ts.map +1 -1
  46. package/dist/types/src/components/index.d.ts +2 -0
  47. package/dist/types/src/components/index.d.ts.map +1 -1
  48. package/dist/types/src/exemplars/slot.stories.d.ts.map +1 -1
  49. package/dist/types/src/exemplars/virtualizer.stories.d.ts.map +1 -1
  50. package/dist/types/src/translations.d.ts +5 -0
  51. package/dist/types/src/translations.d.ts.map +1 -1
  52. package/dist/types/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +18 -18
  54. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +1 -1
  55. package/src/components/Button/IconButton.stories.tsx +1 -1
  56. package/src/components/Card/Card.stories.tsx +3 -3
  57. package/src/components/Card/Card.tsx +24 -17
  58. package/src/components/Carousel/Carousel.tsx +379 -0
  59. package/src/components/Carousel/index.ts +5 -0
  60. package/src/components/Clipboard/CopyButton.tsx +2 -2
  61. package/src/components/Icon/Icon.tsx +10 -3
  62. package/src/components/Link/Link.tsx +10 -2
  63. package/src/components/List/List.stories.tsx +1 -1
  64. package/src/components/List/List.tsx +1 -1
  65. package/src/components/List/ListDropIndicator.tsx +0 -1
  66. package/src/components/List/Tree.stories.tsx +1 -1
  67. package/src/components/MediaPlayer/MediaPlayer.stories.tsx +50 -0
  68. package/src/components/MediaPlayer/MediaPlayer.tsx +153 -0
  69. package/src/components/MediaPlayer/index.ts +5 -0
  70. package/src/components/Message/Message.tsx +2 -2
  71. package/src/components/ScrollArea/ScrollArea.stories.tsx +1 -5
  72. package/src/components/ScrollContainer/ScrollContainer.tsx +1 -3
  73. package/src/components/Toolbar/Toolbar.tsx +2 -1
  74. package/src/components/Tooltip/Tooltip.stories.tsx +1 -1
  75. package/src/components/Tooltip/Tooltip.tsx +14 -13
  76. package/src/components/index.ts +2 -0
  77. package/src/exemplars/slot.stories.tsx +2 -4
  78. package/src/exemplars/virtualizer.stories.tsx +0 -1
  79. package/src/testing/decorators/withLayout.tsx +6 -16
  80. package/src/translations.ts +5 -0
  81. package/dist/lib/browser/chunk-BDBC6H6V.mjs.map +0 -7
  82. package/dist/lib/node-esm/chunk-3JSJK2ZY.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui",
3
- "version": "0.8.4-main.cb12b3f963",
3
+ "version": "0.8.4-main.d05539e30a",
4
4
  "description": "Low-level React components for DXOS, applying a theme to a core group of primitives",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": false,
14
14
  "type": "module",
@@ -83,17 +83,17 @@
83
83
  "react-error-boundary": "^4.0.13",
84
84
  "react-i18next": "^11.18.6",
85
85
  "react-remove-scroll": "^2.6.0",
86
- "@dxos/debug": "0.8.4-main.cb12b3f963",
87
- "@dxos/async": "0.8.4-main.cb12b3f963",
88
- "@dxos/lit-ui": "0.8.4-main.cb12b3f963",
89
- "@dxos/invariant": "0.8.4-main.cb12b3f963",
90
- "@dxos/log": "0.8.4-main.cb12b3f963",
91
- "@dxos/react-hooks": "0.8.4-main.cb12b3f963",
92
- "@dxos/react-input": "0.8.4-main.cb12b3f963",
93
- "@dxos/react-error-boundary": "0.8.4-main.cb12b3f963",
94
- "@dxos/react-list": "0.8.4-main.cb12b3f963",
95
- "@dxos/util": "0.8.4-main.cb12b3f963",
96
- "@dxos/ui-types": "0.8.4-main.cb12b3f963"
86
+ "@dxos/async": "0.8.4-main.d05539e30a",
87
+ "@dxos/invariant": "0.8.4-main.d05539e30a",
88
+ "@dxos/log": "0.8.4-main.d05539e30a",
89
+ "@dxos/lit-ui": "0.8.4-main.d05539e30a",
90
+ "@dxos/react-error-boundary": "0.8.4-main.d05539e30a",
91
+ "@dxos/react-hooks": "0.8.4-main.d05539e30a",
92
+ "@dxos/debug": "0.8.4-main.d05539e30a",
93
+ "@dxos/react-list": "0.8.4-main.d05539e30a",
94
+ "@dxos/react-input": "0.8.4-main.d05539e30a",
95
+ "@dxos/util": "0.8.4-main.d05539e30a",
96
+ "@dxos/ui-types": "0.8.4-main.d05539e30a"
97
97
  },
98
98
  "devDependencies": {
99
99
  "@dnd-kit/core": "^6.0.5",
@@ -106,15 +106,15 @@
106
106
  "react": "~19.2.3",
107
107
  "react-dom": "~19.2.3",
108
108
  "tabster": "^8.5.5",
109
- "vite": "^8.0.10",
110
- "@dxos/random": "0.8.4-main.cb12b3f963",
111
- "@dxos/util": "0.8.4-main.cb12b3f963",
112
- "@dxos/ui-theme": "0.8.4-main.cb12b3f963"
109
+ "vite": "^8.0.13",
110
+ "@dxos/ui-theme": "0.8.4-main.d05539e30a",
111
+ "@dxos/random": "0.8.4-main.d05539e30a",
112
+ "@dxos/util": "0.8.4-main.d05539e30a"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "react": "~19.2.3",
116
116
  "react-dom": "~19.2.3",
117
- "@dxos/ui-theme": "0.8.4-main.cb12b3f963"
117
+ "@dxos/ui-theme": "0.8.4-main.d05539e30a"
118
118
  },
119
119
  "publishConfig": {
120
120
  "access": "public"
@@ -15,7 +15,7 @@ const DefaultStory = (props: BreadcrumbRootProps) => {
15
15
  <Breadcrumb.List>
16
16
  <Breadcrumb.ListItem>
17
17
  <Breadcrumb.Link asChild>
18
- <Button variant='ghost' classNames='px-0 text-base-surface-text font-normal'>
18
+ <Button variant='ghost' classNames='px-0 text-base-foreground font-normal'>
19
19
  Grocery
20
20
  </Button>
21
21
  </Breadcrumb.Link>
@@ -13,7 +13,7 @@ import { IconButton, type IconButtonProps } from './IconButton';
13
13
  const DefaultStory = (props: IconButtonProps) => {
14
14
  return (
15
15
  <Tooltip.Provider>
16
- <div role='none' className='flex gap-4'>
16
+ <div className='flex gap-4'>
17
17
  <IconButton {...props} />
18
18
  <IconButton iconOnly {...props} />
19
19
  <Button>{props.label}</Button>
@@ -129,18 +129,18 @@ export const Description: Story = {
129
129
  export const Mock = () => (
130
130
  <div className='grid grid-cols-[2rem_1fr_2rem] w-full dx-card-min-width dx-card-max-width border border-separator rounded-xs'>
131
131
  <div className='grid grid-cols-subgrid col-span-full'>
132
- <div role='none' className='grid h-[var(--dx-rail-item)] w-[var(--dx-rail-item)] place-items-center'>
132
+ <div className='grid h-[var(--dx-rail-item)] w-[var(--dx-rail-item)] place-items-center'>
133
133
  <Icon icon='ph--dots-six-vertical--regular' />
134
134
  </div>
135
135
  <div className='p-1 whitespace-normal break-words text-description items-center'>
136
136
  This line is very very long and it should wrap.
137
137
  </div>
138
- <div role='none' className='grid h-[var(--dx-rail-item)] w-[var(--dx-rail-item)] place-items-center'>
138
+ <div className='grid h-[var(--dx-rail-item)] w-[var(--dx-rail-item)] place-items-center'>
139
139
  <Icon icon='ph--x--regular' />
140
140
  </div>
141
141
  </div>
142
142
  <div className='grid grid-cols-subgrid col-span-3'>
143
- <div role='none' className='grid h-[var(--dx-rail-item)] w-[var(--dx-rail-item)] place-items-center'>
143
+ <div className='grid h-[var(--dx-rail-item)] w-[var(--dx-rail-item)] place-items-center'>
144
144
  <Icon icon='ph--dots-six-vertical--regular' />
145
145
  </div>
146
146
  <div className='p-1 text-description items-center col-span-2'>
@@ -52,7 +52,7 @@ const CardContext = createContext<CardContextValue>({});
52
52
 
53
53
  const CARD_ROOT_NAME = 'Card.Root';
54
54
 
55
- type CardRootOwnProps = {
55
+ type CardRootProps = {
56
56
  id?: string;
57
57
  border?: boolean;
58
58
  fullWidth?: boolean;
@@ -64,26 +64,33 @@ type CardRootOwnProps = {
64
64
  'data-testid'?: string;
65
65
  };
66
66
 
67
- type CardRootProps = CardRootOwnProps;
68
-
69
- const CardRoot = slottable<HTMLDivElement, CardRootOwnProps>(
70
- ({ children, id, asChild, role, border = true, fullWidth, density, ...props }, forwardedRef) => {
67
+ /**
68
+ * `Card.Root` does not support `asChild`. The Column grid is the root element
69
+ * (one `<div>` carrying both the `dx-card` and `dx-column-root` classes
70
+ * instead of the previous outer-card + inner-column pair), so caller-provided
71
+ * HTML attributes — `onClick`, `tabIndex`, `style`, `data-*`, `grid-template-rows`
72
+ * overrides via `classNames` — land directly on the grid container.
73
+ * Slot-parents (`Focus.Item asChild`, `Mosaic.Tile asChild`, etc.) continue to
74
+ * work because `composable()` preserves the COMPOSABLE marker that slottable parents
75
+ * check before warning, and Radix `Slot` merges the parent's props onto the inner
76
+ * `<div>` exactly the way `slottable`'s `Slot`/`Primitive.div` branch did.
77
+ */
78
+ const CardRoot = composable<HTMLDivElement, CardRootProps>(
79
+ ({ children, id, role, border = true, fullWidth, density, ...props }, forwardedRef) => {
71
80
  const { className, ...rest } = composableProps(props);
72
- const Comp = asChild ? Slot : Primitive.div;
73
81
  const { tx } = useThemeContext();
74
82
 
75
83
  return (
76
- <Comp
77
- {...rest}
78
- {...(id && { 'data-object-id': id })}
84
+ <Column.Root
85
+ asChild
86
+ gutter={density === 'coarse' ? 'lg' : 'md'}
87
+ classNames={tx('card.root', { border, fullWidth }, className)}
79
88
  role={role ?? 'group'}
80
- className={tx('card.root', { border, fullWidth }, className)}
81
- ref={forwardedRef}
82
89
  >
83
- <Column.Root classNames='overflow-hidden' gutter={density === 'coarse' ? 'lg' : 'md'}>
90
+ <div {...rest} {...(id && { 'data-object-id': id })} ref={forwardedRef}>
84
91
  {children}
85
- </Column.Root>
86
- </Comp>
92
+ </div>
93
+ </Column.Root>
87
94
  );
88
95
  },
89
96
  );
@@ -196,7 +203,7 @@ const CardIconBlock = forwardRef<HTMLDivElement, ThemedClassName<PropsWithChildr
196
203
  const { tx } = useThemeContext();
197
204
 
198
205
  return (
199
- <div {...props} role='none' className={tx('card.icon-block', { padding }, classNames)} ref={forwardedRef}>
206
+ <div {...props} className={tx('card.icon-block', { padding }, classNames)} ref={forwardedRef}>
200
207
  {children}
201
208
  </div>
202
209
  );
@@ -253,6 +260,7 @@ const CARD_ROW_NAME = 'Card.Row';
253
260
 
254
261
  type CardRowProps = { icon?: string; fullWidth?: boolean };
255
262
 
263
+ // TODO(burdon): fullWidth should mean no columns.
256
264
  const CardRow = slottable<HTMLDivElement, CardRowProps>(({ children, asChild, icon, ...props }, forwardedRef) => {
257
265
  const { className, ...rest } = composableProps(props);
258
266
  const Comp = asChild ? Slot : Primitive.div;
@@ -365,7 +373,6 @@ const CardHtml = ({ html, variant = 'default', ...props }: CardHtmlProps & Theme
365
373
  return (
366
374
  <div
367
375
  {...props}
368
- role='none'
369
376
  className={tx('card.text', { variant })}
370
377
  // eslint-disable-next-line react/no-danger
371
378
  dangerouslySetInnerHTML={{ __html: sanitized }}
@@ -401,7 +408,7 @@ const CardPoster = (props: CardPosterProps) => {
401
408
 
402
409
  if (props.image) {
403
410
  return (
404
- <div role='none' className='col-span-full'>
411
+ <div className='col-span-full'>
405
412
  <Image
406
413
  classNames={[tx('card.poster', {}), aspect, props.classNames]}
407
414
  src={props.image}
@@ -0,0 +1,379 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useArrowNavigationGroup } from '@fluentui/react-tabster';
6
+ import React, {
7
+ createContext,
8
+ type KeyboardEvent,
9
+ type PropsWithChildren,
10
+ type ReactNode,
11
+ useCallback,
12
+ useContext,
13
+ useEffect,
14
+ useMemo,
15
+ useState,
16
+ } from 'react';
17
+
18
+ import { mx } from '@dxos/ui-theme';
19
+
20
+ import { translationKey } from '../../translations';
21
+ import { type ThemedClassName } from '../../util';
22
+ import { IconButton } from '../Button';
23
+ import { MediaPlayer, type MediaKind } from '../MediaPlayer';
24
+ import { useTranslation } from '../ThemeProvider';
25
+
26
+ // TODO(burdon): Move per-element class strings to `@dxos/ui-theme` (theme tokens)
27
+ // so callers can re-theme via the same mechanism the rest of `react-ui` uses.
28
+
29
+ //
30
+ // Context
31
+ //
32
+
33
+ type CarouselContextValue = {
34
+ index: number;
35
+ count: number;
36
+ setIndex: (index: number) => void;
37
+ next: () => void;
38
+ prev: () => void;
39
+ };
40
+
41
+ const CarouselContext = createContext<CarouselContextValue | null>(null);
42
+
43
+ /** Returns the current carousel state. Must be used within {@link Carousel.Root}. */
44
+ export const useCarousel = (): CarouselContextValue => {
45
+ const context = useContext(CarouselContext);
46
+ if (!context) {
47
+ throw new Error('useCarousel must be used within Carousel.Root');
48
+ }
49
+ return context;
50
+ };
51
+
52
+ //
53
+ // Root
54
+ //
55
+
56
+ export type CarouselRootProps = ThemedClassName<
57
+ PropsWithChildren<{
58
+ /** Total number of slides; drives auto-advance and indicator counts. */
59
+ count: number;
60
+ /** Whether to auto-advance slides on mount. Defaults to `false`. */
61
+ autorun?: boolean;
62
+ /** Auto-advance interval in milliseconds. Set 0 to disable. */
63
+ intervalMs?: number;
64
+ defaultIndex?: number;
65
+ }>
66
+ >;
67
+
68
+ const CarouselRoot = ({
69
+ classNames,
70
+ children,
71
+ count,
72
+ autorun = false,
73
+ intervalMs = 5_000,
74
+ defaultIndex = 0,
75
+ }: CarouselRootProps) => {
76
+ const [index, setIndexState] = useState(defaultIndex);
77
+ const [autoAdvance, setAutoAdvance] = useState(autorun);
78
+
79
+ // Reset to first slide if the slide count shrinks below the current index.
80
+ useEffect(() => {
81
+ if (index >= count) {
82
+ setIndexState(0);
83
+ }
84
+ }, [count, index]);
85
+
86
+ // Auto-advance — stops permanently once the user interacts with any control.
87
+ useEffect(() => {
88
+ if (!autoAdvance || count <= 1 || intervalMs <= 0) {
89
+ return;
90
+ }
91
+ const handle = setInterval(() => setIndexState((i) => (i + 1) % count), intervalMs);
92
+ return () => clearInterval(handle);
93
+ }, [autoAdvance, count, intervalMs]);
94
+
95
+ const setIndex = useCallback((next: number) => {
96
+ setAutoAdvance(false);
97
+ setIndexState(next);
98
+ }, []);
99
+ const next = useCallback(() => {
100
+ setAutoAdvance(false);
101
+ setIndexState((i) => (i + 1) % count);
102
+ }, [count]);
103
+ const prev = useCallback(() => {
104
+ setAutoAdvance(false);
105
+ setIndexState((i) => (i - 1 + count) % count);
106
+ }, [count]);
107
+
108
+ const value = useMemo(() => ({ index, count, setIndex, next, prev }), [index, count, setIndex, next, prev]);
109
+
110
+ if (count === 0) {
111
+ return null;
112
+ }
113
+
114
+ return (
115
+ <CarouselContext.Provider value={value}>
116
+ {/*
117
+ * Rows are `[1fr, auto]`: row 1 (Previous|Viewport|Next) stretches when the parent
118
+ * gives the carousel a definite height, and row 2 (Indicators / Caption) sticks to
119
+ * its content height. With no parent height constraint, the `1fr` row simply tracks
120
+ * row-1 content — preserving the existing aspect-video behaviour for unbounded use.
121
+ */}
122
+ {/* TODO(burdon): Move to ui-theme. */}
123
+ <div
124
+ className={mx(
125
+ 'w-full grid grid-cols-[min-content_1fr_min-content] grid-rows-[minmax(0,1fr)_auto] gap-4 items-center',
126
+ classNames,
127
+ )}
128
+ >
129
+ {children}
130
+ </div>
131
+ </CarouselContext.Provider>
132
+ );
133
+ };
134
+
135
+ CarouselRoot.displayName = 'Carousel.Root';
136
+
137
+ //
138
+ // Viewport
139
+ //
140
+
141
+ export type CarouselViewportProps = ThemedClassName<PropsWithChildren<{}>>;
142
+
143
+ const CarouselViewport = ({ children, classNames }: CarouselViewportProps) => {
144
+ const { t } = useTranslation(translationKey);
145
+ const { count, next, prev } = useCarousel();
146
+ const handleKeyDown = useCallback(
147
+ (event: KeyboardEvent<HTMLDivElement>) => {
148
+ if (count <= 1) {
149
+ return;
150
+ }
151
+ if (event.key === 'ArrowLeft') {
152
+ event.preventDefault();
153
+ prev();
154
+ } else if (event.key === 'ArrowRight') {
155
+ event.preventDefault();
156
+ next();
157
+ }
158
+ },
159
+ [count, next, prev],
160
+ );
161
+
162
+ return (
163
+ <div
164
+ // TODO(burdon): Move to ui-theme.
165
+ className={mx(
166
+ 'relative w-full aspect-video overflow-hidden',
167
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
168
+ classNames,
169
+ )}
170
+ tabIndex={0}
171
+ role='region'
172
+ aria-roledescription='carousel'
173
+ aria-label={t('carousel-viewport.label')}
174
+ onKeyDown={handleKeyDown}
175
+ >
176
+ {children}
177
+ </div>
178
+ );
179
+ };
180
+
181
+ CarouselViewport.displayName = 'Carousel.Viewport';
182
+
183
+ //
184
+ // Slide
185
+ //
186
+
187
+ export type CarouselSlideProps = ThemedClassName<{
188
+ index: number;
189
+ /** Media source URL — rendered via the embedded {@link MediaPlayer}. */
190
+ src: string;
191
+ /** Override media auto-detection (`'video' | 'audio'`). */
192
+ kind?: MediaKind;
193
+ /** Accessible label / `<img alt>` fallback. */
194
+ alt?: string;
195
+ /** Class names forwarded to the inner `<img>` when MediaPlayer resolves to an image. */
196
+ imgClassNames?: string;
197
+ /** Class names forwarded to the inner `<video>` / `<audio>` / `<iframe>`. */
198
+ mediaClassNames?: string;
199
+ controls?: boolean;
200
+ autoPlay?: boolean;
201
+ loop?: boolean;
202
+ muted?: boolean;
203
+ crossOrigin?: 'anonymous' | 'use-credentials' | '';
204
+ }>;
205
+
206
+ const CarouselSlide = ({
207
+ index,
208
+ classNames,
209
+ src,
210
+ kind,
211
+ alt,
212
+ imgClassNames,
213
+ mediaClassNames,
214
+ controls,
215
+ autoPlay,
216
+ loop,
217
+ muted,
218
+ crossOrigin,
219
+ }: CarouselSlideProps) => {
220
+ const { index: active } = useCarousel();
221
+ if (active !== index) {
222
+ return null;
223
+ }
224
+
225
+ return (
226
+ <div className={mx('absolute inset-0 w-full h-full bg-baseSurface', classNames)}>
227
+ <MediaPlayer
228
+ src={src}
229
+ kind={kind}
230
+ alt={alt}
231
+ classNames='w-full h-full'
232
+ imgClassNames={mx('object-cover', imgClassNames)}
233
+ mediaClassNames={mediaClassNames}
234
+ controls={controls}
235
+ autoPlay={autoPlay}
236
+ loop={loop}
237
+ muted={muted}
238
+ crossOrigin={crossOrigin}
239
+ />
240
+ </div>
241
+ );
242
+ };
243
+
244
+ CarouselSlide.displayName = 'Carousel.Slide';
245
+
246
+ //
247
+ // Previous / Next
248
+ //
249
+
250
+ export type CarouselButtonProps = ThemedClassName<{}>;
251
+
252
+ const CarouselPrevious = ({ classNames }: CarouselButtonProps) => {
253
+ const { t } = useTranslation(translationKey);
254
+ const { count, prev } = useCarousel();
255
+ if (count <= 1) {
256
+ return <div />;
257
+ }
258
+
259
+ return (
260
+ <IconButton
261
+ classNames={mx('self-center', classNames)}
262
+ square
263
+ variant='ghost'
264
+ icon='ph--caret-left--regular'
265
+ iconOnly
266
+ label={t('carousel-prev.label')}
267
+ onClick={prev}
268
+ />
269
+ );
270
+ };
271
+
272
+ CarouselPrevious.displayName = 'Carousel.Previous';
273
+
274
+ const CarouselNext = ({ classNames }: CarouselButtonProps) => {
275
+ const { t } = useTranslation(translationKey);
276
+ const { count, next } = useCarousel();
277
+ if (count <= 1) {
278
+ return <div />;
279
+ }
280
+
281
+ return (
282
+ <IconButton
283
+ classNames={mx('self-center', classNames)}
284
+ square
285
+ variant='ghost'
286
+ icon='ph--caret-right--regular'
287
+ iconOnly
288
+ label={t('carousel-next.label')}
289
+ onClick={next}
290
+ />
291
+ );
292
+ };
293
+
294
+ CarouselNext.displayName = 'Carousel.Next';
295
+
296
+ //
297
+ // Indicators
298
+ //
299
+
300
+ export type CarouselIndicatorsProps = ThemedClassName<{}>;
301
+
302
+ /** Tab-strip of slide indicators. Sits in the centre column so it matches the viewport's width. */
303
+ const CarouselIndicators = ({ classNames }: CarouselIndicatorsProps) => {
304
+ const { t } = useTranslation(translationKey);
305
+ const { count, index, setIndex } = useCarousel();
306
+ const arrowNavigationAttrs = useArrowNavigationGroup({ axis: 'horizontal', memorizeCurrent: true });
307
+ if (count <= 1) {
308
+ return null;
309
+ }
310
+
311
+ return (
312
+ <div className='col-start-2 overflow-hidden'>
313
+ <div
314
+ {...arrowNavigationAttrs}
315
+ className={mx('flex items-center justify-center', classNames)}
316
+ role='tablist'
317
+ aria-label={t('carousel-indicators.label')}
318
+ >
319
+ {Array.from({ length: count }).map((_, i) => (
320
+ <IconButton
321
+ key={i}
322
+ role='tab'
323
+ aria-selected={i === index}
324
+ classNames={i === index ? 'text-primary-500' : 'text-description'}
325
+ icon={i === index ? 'ph--circle--fill' : 'ph--circle--regular'}
326
+ iconOnly
327
+ label={t('carousel-go-to.label', { index: i + 1 })}
328
+ onClick={() => setIndex(i)}
329
+ onFocus={() => setIndex(i)}
330
+ size={3}
331
+ variant='ghost'
332
+ />
333
+ ))}
334
+ </div>
335
+ </div>
336
+ );
337
+ };
338
+
339
+ CarouselIndicators.displayName = 'Carousel.Indicators';
340
+
341
+ //
342
+ // Caption
343
+ //
344
+
345
+ export type CarouselCaptionProps = ThemedClassName<{
346
+ /** Render prop receiving the active slide index. */
347
+ children: (index: number) => ReactNode;
348
+ }>;
349
+
350
+ /** Caption sized to the viewport's column. */
351
+ const CarouselCaption = ({ children, classNames }: CarouselCaptionProps) => {
352
+ const { index } = useCarousel();
353
+ const content = children(index);
354
+ if (content == null || content === false || content === '') {
355
+ return null;
356
+ }
357
+ return (
358
+ // TODO(burdon): Move to ui-theme.
359
+ <div className='col-start-2'>
360
+ <p className={mx('text-center text-description', classNames)}>{content}</p>
361
+ </div>
362
+ );
363
+ };
364
+
365
+ CarouselCaption.displayName = 'Carousel.Caption';
366
+
367
+ //
368
+ // Carousel
369
+ //
370
+
371
+ export const Carousel = {
372
+ Root: CarouselRoot,
373
+ Viewport: CarouselViewport,
374
+ Slide: CarouselSlide,
375
+ Previous: CarouselPrevious,
376
+ Next: CarouselNext,
377
+ Indicators: CarouselIndicators,
378
+ Caption: CarouselCaption,
379
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Carousel';
@@ -30,11 +30,11 @@ export const CopyButton = ({ classNames, value, size = 5, ...props }: CopyButton
30
30
  onClick={() => setTextValue(value)}
31
31
  data-testid='copy-invitation'
32
32
  >
33
- <div role='none' className={mx('flex gap-1 items-center', isCopied && inactiveLabelStyles)}>
33
+ <div className={mx('flex gap-1 items-center', isCopied && inactiveLabelStyles)}>
34
34
  <span className='px-1'>{t('copy.label')}</span>
35
35
  <Icon icon='ph--copy--regular' size={size} />
36
36
  </div>
37
- <div role='none' className={mx('flex gap-1 items-center', !isCopied && inactiveLabelStyles)}>
37
+ <div className={mx('flex gap-1 items-center', !isCopied && inactiveLabelStyles)}>
38
38
  <span className='px-1'>{t('copy-success.label')}</span>
39
39
  <Icon icon='ph--check--regular' size={size} />
40
40
  </div>
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import { type Primitive } from '@radix-ui/react-primitive';
6
- import React, { type ComponentPropsWithRef, forwardRef, memo } from 'react';
6
+ import React, { type ComponentPropsWithRef, forwardRef, memo, useMemo } from 'react';
7
7
 
8
8
  import { type Size } from '@dxos/ui-types';
9
9
 
@@ -13,18 +13,25 @@ import { type ThemedClassName } from '../../util';
13
13
  export type IconProps = ThemedClassName<ComponentPropsWithRef<typeof Primitive.svg>> & {
14
14
  icon: string;
15
15
  size?: Size;
16
+ synchronized?: boolean;
16
17
  };
17
18
 
18
19
  /**
19
20
  * The Icon's size can be set directly or inherited from the `--dx-icon-size` CSS variable.
20
21
  */
21
22
  export const Icon = memo(
22
- forwardRef<SVGSVGElement, IconProps>(({ icon, classNames, size, ...props }, forwardedRef) => {
23
+ forwardRef<SVGSVGElement, IconProps>(({ classNames, icon, size, synchronized, style, ...props }, forwardedRef) => {
23
24
  const { tx } = useThemeContext();
25
+ const spinDelay = useMemo(() => (synchronized ? `${-(Date.now() % 1_000)}ms` : undefined), [synchronized]);
24
26
  const href = useIconHref(icon);
25
27
 
26
28
  return (
27
- <svg {...props} className={tx('icon.root', { size }, classNames)} ref={forwardedRef}>
29
+ <svg
30
+ {...props}
31
+ style={{ ...style, animationDelay: spinDelay }}
32
+ className={tx('icon.root', { size }, classNames)}
33
+ ref={forwardedRef}
34
+ >
28
35
  <use href={href} />
29
36
  </svg>
30
37
  );
@@ -16,9 +16,17 @@ export type LinkProps = ThemedClassName<ComponentPropsWithRef<typeof Primitive.a
16
16
  }>;
17
17
 
18
18
  export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
19
- ({ asChild, variant, classNames, ...props }, forwardedRef) => {
19
+ ({ classNames, asChild, variant, target = '_blank', rel = 'noreferrer', ...props }, forwardedRef) => {
20
20
  const { tx } = useThemeContext();
21
21
  const Comp = asChild ? Slot : Primitive.a;
22
- return <Comp {...props} className={tx('link.root', { variant }, classNames)} ref={forwardedRef} />;
22
+ return (
23
+ <Comp
24
+ {...props}
25
+ target={target}
26
+ rel={rel}
27
+ className={tx('link.root', { variant }, classNames)}
28
+ ref={forwardedRef}
29
+ />
30
+ );
23
31
  },
24
32
  );
@@ -176,7 +176,7 @@ export const Collapsible: Story = {
176
176
  <List {...args}>
177
177
  {items.map(({ id, text, body }, index) => (
178
178
  <ListItem.Root key={id} id={id} collapsible={index !== 2} defaultOpen={index % 2 === 0}>
179
- <div role='none' className='grow flex'>
179
+ <div className='grow flex'>
180
180
  {index !== 2 ? <ListItem.OpenTrigger /> : <ListItem.MockOpenTrigger />}
181
181
  <ListItem.Heading classNames='grow pt-2'>{text}</ListItem.Heading>
182
182
  <ListItem.Endcap>
@@ -75,7 +75,7 @@ const MockListItemOpenTrigger = ({
75
75
  }: ThemedClassName<Omit<ComponentPropsWithoutRef<'div'>, 'children'>>) => {
76
76
  const density = useDensityContext();
77
77
  const { tx } = useThemeContext();
78
- return <div role='none' {...props} className={tx('list.item.openTrigger', { density }, classNames)} />;
78
+ return <div {...props} className={tx('list.item.openTrigger', { density }, classNames)} />;
79
79
  };
80
80
 
81
81
  type ListItemHeadingProps = ThemedClassName<ListPrimitiveItemHeadingProps>;