@dxos/react-ui 0.8.4-main.03d5cd7b56 → 0.8.4-main.05e74ebcff

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 (55) 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 +560 -257
  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 -1
  11. package/dist/lib/browser/translations.mjs +4 -13
  12. package/dist/lib/browser/translations.mjs.map +4 -4
  13. package/dist/lib/node-esm/{chunk-3JSJK2ZY.mjs → chunk-NGKLIKP3.mjs} +6 -8
  14. package/dist/lib/node-esm/chunk-NGKLIKP3.mjs.map +7 -0
  15. package/dist/lib/node-esm/chunk-XCFLA74M.mjs +26 -0
  16. package/dist/lib/node-esm/chunk-XCFLA74M.mjs.map +7 -0
  17. package/dist/lib/node-esm/index.mjs +560 -257
  18. package/dist/lib/node-esm/index.mjs.map +4 -4
  19. package/dist/lib/node-esm/meta.json +1 -1
  20. package/dist/lib/node-esm/testing/index.mjs +1 -1
  21. package/dist/lib/node-esm/translations.mjs +4 -14
  22. package/dist/lib/node-esm/translations.mjs.map +4 -4
  23. package/dist/types/src/components/Card/Card.d.ts.map +1 -1
  24. package/dist/types/src/components/Carousel/Carousel.d.ts +90 -0
  25. package/dist/types/src/components/Carousel/Carousel.d.ts.map +1 -0
  26. package/dist/types/src/components/Carousel/index.d.ts +2 -0
  27. package/dist/types/src/components/Carousel/index.d.ts.map +1 -0
  28. package/dist/types/src/components/MediaPlayer/MediaPlayer.d.ts +46 -0
  29. package/dist/types/src/components/MediaPlayer/MediaPlayer.d.ts.map +1 -0
  30. package/dist/types/src/components/MediaPlayer/MediaPlayer.stories.d.ts +16 -0
  31. package/dist/types/src/components/MediaPlayer/MediaPlayer.stories.d.ts.map +1 -0
  32. package/dist/types/src/components/MediaPlayer/index.d.ts +2 -0
  33. package/dist/types/src/components/MediaPlayer/index.d.ts.map +1 -0
  34. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  35. package/dist/types/src/components/Tooltip/Tooltip.d.ts +6 -6
  36. package/dist/types/src/components/Tooltip/Tooltip.d.ts.map +1 -1
  37. package/dist/types/src/components/index.d.ts +2 -0
  38. package/dist/types/src/components/index.d.ts.map +1 -1
  39. package/dist/types/src/translations.d.ts +5 -0
  40. package/dist/types/src/translations.d.ts.map +1 -1
  41. package/dist/types/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +18 -18
  43. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +1 -1
  44. package/src/components/Card/Card.tsx +11 -9
  45. package/src/components/Carousel/Carousel.tsx +337 -0
  46. package/src/components/Carousel/index.ts +5 -0
  47. package/src/components/MediaPlayer/MediaPlayer.stories.tsx +50 -0
  48. package/src/components/MediaPlayer/MediaPlayer.tsx +153 -0
  49. package/src/components/MediaPlayer/index.ts +5 -0
  50. package/src/components/Toolbar/Toolbar.tsx +2 -1
  51. package/src/components/Tooltip/Tooltip.tsx +14 -13
  52. package/src/components/index.ts +2 -0
  53. package/src/translations.ts +5 -0
  54. package/dist/lib/browser/chunk-BDBC6H6V.mjs.map +0 -7
  55. 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.03d5cd7b56",
3
+ "version": "0.8.4-main.05e74ebcff",
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/async": "0.8.4-main.03d5cd7b56",
87
- "@dxos/debug": "0.8.4-main.03d5cd7b56",
88
- "@dxos/invariant": "0.8.4-main.03d5cd7b56",
89
- "@dxos/log": "0.8.4-main.03d5cd7b56",
90
- "@dxos/lit-ui": "0.8.4-main.03d5cd7b56",
91
- "@dxos/react-input": "0.8.4-main.03d5cd7b56",
92
- "@dxos/react-hooks": "0.8.4-main.03d5cd7b56",
93
- "@dxos/ui-types": "0.8.4-main.03d5cd7b56",
94
- "@dxos/react-error-boundary": "0.8.4-main.03d5cd7b56",
95
- "@dxos/react-list": "0.8.4-main.03d5cd7b56",
96
- "@dxos/util": "0.8.4-main.03d5cd7b56"
86
+ "@dxos/async": "0.8.4-main.05e74ebcff",
87
+ "@dxos/debug": "0.8.4-main.05e74ebcff",
88
+ "@dxos/invariant": "0.8.4-main.05e74ebcff",
89
+ "@dxos/log": "0.8.4-main.05e74ebcff",
90
+ "@dxos/lit-ui": "0.8.4-main.05e74ebcff",
91
+ "@dxos/react-error-boundary": "0.8.4-main.05e74ebcff",
92
+ "@dxos/react-input": "0.8.4-main.05e74ebcff",
93
+ "@dxos/react-list": "0.8.4-main.05e74ebcff",
94
+ "@dxos/ui-types": "0.8.4-main.05e74ebcff",
95
+ "@dxos/util": "0.8.4-main.05e74ebcff",
96
+ "@dxos/react-hooks": "0.8.4-main.05e74ebcff"
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.03d5cd7b56",
111
- "@dxos/ui-theme": "0.8.4-main.03d5cd7b56",
112
- "@dxos/util": "0.8.4-main.03d5cd7b56"
109
+ "vite": "^8.0.13",
110
+ "@dxos/random": "0.8.4-main.05e74ebcff",
111
+ "@dxos/ui-theme": "0.8.4-main.05e74ebcff",
112
+ "@dxos/util": "0.8.4-main.05e74ebcff"
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.03d5cd7b56"
117
+ "@dxos/ui-theme": "0.8.4-main.05e74ebcff"
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>
@@ -64,15 +64,17 @@ type CardRootProps = {
64
64
  'data-testid'?: string;
65
65
  };
66
66
 
67
- // `Card.Root` does not support `asChild`. The Column grid is the root element
68
- // (one `<div>` carrying both the `dx-card` and `dx-column-root` classes
69
- // instead of the previous outer-card + inner-column pair), so caller-provided
70
- // HTML attributes `onClick`, `tabIndex`, `style`, `data-*`, `grid-template-rows`
71
- // overrides via `classNames`land directly on the grid container. Slot-parents
72
- // (`Focus.Item asChild`, `Mosaic.Tile asChild`, etc.) continue to work because
73
- // `composable()` preserves the COMPOSABLE marker that slottable parents check
74
- // before warning, and Radix `Slot` merges the parent's props onto the inner
75
- // `<div>` exactly the way `slottable`'s `Slot`/`Primitive.div` branch did.
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
+ */
76
78
  const CardRoot = composable<HTMLDivElement, CardRootProps>(
77
79
  ({ children, id, role, border = true, fullWidth, density, ...props }, forwardedRef) => {
78
80
  const { className, ...rest } = composableProps(props);
@@ -0,0 +1,337 @@
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 { useTranslation } from '../ThemeProvider';
24
+
25
+ // TODO(burdon): Move per-element class strings to `@dxos/ui-theme` (theme tokens)
26
+ // so callers can re-theme via the same mechanism the rest of `react-ui` uses.
27
+
28
+ //
29
+ // Context
30
+ //
31
+
32
+ type CarouselContextValue = {
33
+ index: number;
34
+ count: number;
35
+ setIndex: (index: number) => void;
36
+ next: () => void;
37
+ prev: () => void;
38
+ };
39
+
40
+ const CarouselContext = createContext<CarouselContextValue | null>(null);
41
+
42
+ /** Returns the current carousel state. Must be used within {@link Carousel.Root}. */
43
+ export const useCarousel = (): CarouselContextValue => {
44
+ const context = useContext(CarouselContext);
45
+ if (!context) {
46
+ throw new Error('useCarousel must be used within Carousel.Root');
47
+ }
48
+ return context;
49
+ };
50
+
51
+ //
52
+ // Root
53
+ //
54
+
55
+ export type CarouselRootProps = ThemedClassName<
56
+ PropsWithChildren<{
57
+ /** Total number of slides; drives auto-advance and indicator counts. */
58
+ count: number;
59
+ /** Whether to auto-advance slides on mount. Defaults to `false`. */
60
+ autorun?: boolean;
61
+ /** Auto-advance interval in milliseconds. Set 0 to disable. */
62
+ intervalMs?: number;
63
+ defaultIndex?: number;
64
+ }>
65
+ >;
66
+
67
+ const CarouselRoot = ({
68
+ classNames,
69
+ children,
70
+ count,
71
+ autorun = false,
72
+ intervalMs = 5_000,
73
+ defaultIndex = 0,
74
+ }: CarouselRootProps) => {
75
+ const [index, setIndexState] = useState(defaultIndex);
76
+ const [autoAdvance, setAutoAdvance] = useState(autorun);
77
+
78
+ // Reset to first slide if the slide count shrinks below the current index.
79
+ useEffect(() => {
80
+ if (index >= count) {
81
+ setIndexState(0);
82
+ }
83
+ }, [count, index]);
84
+
85
+ // Auto-advance — stops permanently once the user interacts with any control.
86
+ useEffect(() => {
87
+ if (!autoAdvance || count <= 1 || intervalMs <= 0) {
88
+ return;
89
+ }
90
+ const handle = setInterval(() => setIndexState((i) => (i + 1) % count), intervalMs);
91
+ return () => clearInterval(handle);
92
+ }, [autoAdvance, count, intervalMs]);
93
+
94
+ const setIndex = useCallback((next: number) => {
95
+ setAutoAdvance(false);
96
+ setIndexState(next);
97
+ }, []);
98
+ const next = useCallback(() => {
99
+ setAutoAdvance(false);
100
+ setIndexState((i) => (i + 1) % count);
101
+ }, [count]);
102
+ const prev = useCallback(() => {
103
+ setAutoAdvance(false);
104
+ setIndexState((i) => (i - 1 + count) % count);
105
+ }, [count]);
106
+
107
+ const value = useMemo(() => ({ index, count, setIndex, next, prev }), [index, count, setIndex, next, prev]);
108
+
109
+ if (count === 0) {
110
+ return null;
111
+ }
112
+
113
+ return (
114
+ <CarouselContext.Provider value={value}>
115
+ {/*
116
+ * Rows are `[1fr, auto]`: row 1 (Previous|Viewport|Next) stretches when the parent
117
+ * gives the carousel a definite height, and row 2 (Indicators / Caption) sticks to
118
+ * its content height. With no parent height constraint, the `1fr` row simply tracks
119
+ * row-1 content — preserving the existing aspect-video behaviour for unbounded use.
120
+ */}
121
+ {/* TODO(burdon): Move to ui-theme. */}
122
+ <div
123
+ className={mx(
124
+ 'w-full grid grid-cols-[min-content_1fr_min-content] grid-rows-[minmax(0,1fr)_auto] gap-4 items-center',
125
+ classNames,
126
+ )}
127
+ >
128
+ {children}
129
+ </div>
130
+ </CarouselContext.Provider>
131
+ );
132
+ };
133
+
134
+ CarouselRoot.displayName = 'Carousel.Root';
135
+
136
+ //
137
+ // Viewport
138
+ //
139
+
140
+ export type CarouselViewportProps = ThemedClassName<PropsWithChildren<{}>>;
141
+
142
+ const CarouselViewport = ({ children, classNames }: CarouselViewportProps) => {
143
+ const { t } = useTranslation(translationKey);
144
+ const { count, next, prev } = useCarousel();
145
+ const handleKeyDown = useCallback(
146
+ (event: KeyboardEvent<HTMLDivElement>) => {
147
+ if (count <= 1) {
148
+ return;
149
+ }
150
+ if (event.key === 'ArrowLeft') {
151
+ event.preventDefault();
152
+ prev();
153
+ } else if (event.key === 'ArrowRight') {
154
+ event.preventDefault();
155
+ next();
156
+ }
157
+ },
158
+ [count, next, prev],
159
+ );
160
+
161
+ return (
162
+ <div
163
+ // TODO(burdon): Move to ui-theme.
164
+ className={mx(
165
+ 'relative w-full aspect-video overflow-hidden',
166
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
167
+ classNames,
168
+ )}
169
+ tabIndex={0}
170
+ role='region'
171
+ aria-roledescription='carousel'
172
+ aria-label={t('carousel-viewport.label')}
173
+ onKeyDown={handleKeyDown}
174
+ >
175
+ {children}
176
+ </div>
177
+ );
178
+ };
179
+
180
+ CarouselViewport.displayName = 'Carousel.Viewport';
181
+
182
+ //
183
+ // Slide
184
+ //
185
+
186
+ export type CarouselSlideProps = ThemedClassName<
187
+ PropsWithChildren<{
188
+ index: number;
189
+ }>
190
+ >;
191
+
192
+ const CarouselSlide = ({ children, index, classNames }: CarouselSlideProps) => {
193
+ const { index: active } = useCarousel();
194
+ if (active !== index) {
195
+ return null;
196
+ }
197
+
198
+ // TODO(burdon): Move to ui-theme.
199
+ return <div className={mx('absolute inset-0 w-full h-full bg-baseSurface', classNames)}>{children}</div>;
200
+ };
201
+
202
+ CarouselSlide.displayName = 'Carousel.Slide';
203
+
204
+ //
205
+ // Previous / Next
206
+ //
207
+
208
+ export type CarouselButtonProps = ThemedClassName<{}>;
209
+
210
+ const CarouselPrevious = ({ classNames }: CarouselButtonProps) => {
211
+ const { t } = useTranslation(translationKey);
212
+ const { count, prev } = useCarousel();
213
+ if (count <= 1) {
214
+ return <div />;
215
+ }
216
+
217
+ return (
218
+ <IconButton
219
+ classNames={classNames}
220
+ square
221
+ variant='ghost'
222
+ icon='ph--caret-left--regular'
223
+ iconOnly
224
+ label={t('carousel-prev.label')}
225
+ onClick={prev}
226
+ />
227
+ );
228
+ };
229
+
230
+ CarouselPrevious.displayName = 'Carousel.Previous';
231
+
232
+ const CarouselNext = ({ classNames }: CarouselButtonProps) => {
233
+ const { t } = useTranslation(translationKey);
234
+ const { count, next } = useCarousel();
235
+ if (count <= 1) {
236
+ return <div />;
237
+ }
238
+
239
+ return (
240
+ <IconButton
241
+ classNames={classNames}
242
+ square
243
+ variant='ghost'
244
+ icon='ph--caret-right--regular'
245
+ iconOnly
246
+ label={t('carousel-next.label')}
247
+ onClick={next}
248
+ />
249
+ );
250
+ };
251
+
252
+ CarouselNext.displayName = 'Carousel.Next';
253
+
254
+ //
255
+ // Indicators
256
+ //
257
+
258
+ export type CarouselIndicatorsProps = ThemedClassName<{}>;
259
+
260
+ /** Tab-strip of slide indicators. Sits in the centre column so it matches the viewport's width. */
261
+ const CarouselIndicators = ({ classNames }: CarouselIndicatorsProps) => {
262
+ const { t } = useTranslation(translationKey);
263
+ const { count, index, setIndex } = useCarousel();
264
+ const arrowNavigationAttrs = useArrowNavigationGroup({ axis: 'horizontal', memorizeCurrent: true });
265
+ if (count <= 1) {
266
+ return null;
267
+ }
268
+
269
+ return (
270
+ <div className='col-start-2 overflow-hidden'>
271
+ <div
272
+ {...arrowNavigationAttrs}
273
+ className={mx('flex items-center justify-center', classNames)}
274
+ role='tablist'
275
+ aria-label={t('carousel-indicators.label')}
276
+ >
277
+ {Array.from({ length: count }).map((_, i) => (
278
+ <IconButton
279
+ key={i}
280
+ role='tab'
281
+ aria-selected={i === index}
282
+ classNames={i === index ? 'text-primary-500' : 'text-description'}
283
+ icon={i === index ? 'ph--circle--fill' : 'ph--circle--regular'}
284
+ iconOnly
285
+ label={t('carousel-go-to.label', { index: i + 1 })}
286
+ onClick={() => setIndex(i)}
287
+ onFocus={() => setIndex(i)}
288
+ size={3}
289
+ variant='ghost'
290
+ />
291
+ ))}
292
+ </div>
293
+ </div>
294
+ );
295
+ };
296
+
297
+ CarouselIndicators.displayName = 'Carousel.Indicators';
298
+
299
+ //
300
+ // Caption
301
+ //
302
+
303
+ export type CarouselCaptionProps = ThemedClassName<{
304
+ /** Render prop receiving the active slide index. */
305
+ children: (index: number) => ReactNode;
306
+ }>;
307
+
308
+ /** Caption sized to the viewport's column. */
309
+ const CarouselCaption = ({ children, classNames }: CarouselCaptionProps) => {
310
+ const { index } = useCarousel();
311
+ const content = children(index);
312
+ if (content == null || content === false || content === '') {
313
+ return null;
314
+ }
315
+ return (
316
+ // TODO(burdon): Move to ui-theme.
317
+ <div className='col-start-2'>
318
+ <p className={mx('text-center text-description', classNames)}>{content}</p>
319
+ </div>
320
+ );
321
+ };
322
+
323
+ CarouselCaption.displayName = 'Carousel.Caption';
324
+
325
+ //
326
+ // Carousel
327
+ //
328
+
329
+ export const Carousel = {
330
+ Root: CarouselRoot,
331
+ Viewport: CarouselViewport,
332
+ Slide: CarouselSlide,
333
+ Previous: CarouselPrevious,
334
+ Next: CarouselNext,
335
+ Indicators: CarouselIndicators,
336
+ Caption: CarouselCaption,
337
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Carousel';
@@ -0,0 +1,50 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+
7
+ import { withTheme } from '@dxos/react-ui/testing';
8
+
9
+ import { MediaPlayer } from './MediaPlayer';
10
+
11
+ const meta = {
12
+ title: 'ui/react-ui-core/components/MediaPlayer',
13
+ component: MediaPlayer,
14
+ decorators: [withTheme()],
15
+ parameters: { layout: 'centered' },
16
+ } satisfies Meta<typeof MediaPlayer>;
17
+
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ export const Video: Story = {
23
+ args: {
24
+ // TODO(burdon): CORS issue.
25
+ src: 'https://customer-5rxcjpyab08avpmn.cloudflarestream.com/f58459bcdf3a6f3e93644a4e0f39b22a/downloads/default.mp4',
26
+ classNames: 'max-w-[640px]',
27
+ },
28
+ };
29
+
30
+ export const Audio: Story = {
31
+ args: {
32
+ src: 'https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3',
33
+ classNames: 'min-w-[480px]',
34
+ },
35
+ };
36
+
37
+ export const ExplicitKind: Story = {
38
+ args: {
39
+ src: 'https://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3',
40
+ kind: 'audio',
41
+ classNames: 'min-w-[480px]',
42
+ },
43
+ };
44
+
45
+ export const Streaming: Story = {
46
+ args: {
47
+ src: 'https://customer-5rxcjpyab08avpmn.cloudflarestream.com/f58459bcdf3a6f3e93644a4e0f39b22a/iframe?poster=https%3A%2F%2Fcustomer-5rxcjpyab08avpmn.cloudflarestream.com%2Ff58459bcdf3a6f3e93644a4e0f39b22a%2Fthumbnails%2Fthumbnail.jpg%3Ftime%3D%26height%3D600',
48
+ classNames: 'min-w-[480px]',
49
+ },
50
+ };
@@ -0,0 +1,153 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { mx } from '@dxos/ui-theme';
8
+
9
+ import { type ThemedClassName } from '../../util';
10
+
11
+ export type MediaKind = 'video' | 'audio';
12
+
13
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogv', '.mov', '.m4v'];
14
+ const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'];
15
+
16
+ /** iframe sandbox flags compatible with typical oEmbed-style players. */
17
+ const DEFAULT_IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-presentation';
18
+
19
+ /**
20
+ * Best-effort detection of `video` vs `audio` from a media URL.
21
+ * Inspects the pathname's extension (ignoring query/hash). Returns `undefined`
22
+ * when the URL doesn't look like a recognised media file — callers should
23
+ * default to 'video' or render a fallback (e.g. iframe / img).
24
+ */
25
+ export const detectMediaKind = (src: string): MediaKind | undefined => {
26
+ // Strip query and hash, then take the last path segment's extension.
27
+ const pathname = src.split(/[?#]/, 1)[0]!;
28
+ const lower = pathname.toLowerCase();
29
+ if (VIDEO_EXTENSIONS.some((extension) => lower.endsWith(extension))) {
30
+ return 'video';
31
+ }
32
+ if (AUDIO_EXTENSIONS.some((extension) => lower.endsWith(extension))) {
33
+ return 'audio';
34
+ }
35
+
36
+ return undefined;
37
+ };
38
+
39
+ /**
40
+ * Heuristic match for URLs that should render as native `<video>` / `<audio>`
41
+ * (i.e. URLs ending in a recognised media extension).
42
+ *
43
+ * NB: legacy embed URLs (Cloudflare Stream etc. — paths containing `iframe`)
44
+ * serve an HTML player page, **not** a media stream, so they cannot be loaded
45
+ * via `<video>`. Those are detected by {@link isLegacyIframeUrl} and rendered
46
+ * via `<iframe>` instead.
47
+ */
48
+ export const isEmbedUrl = (src: string): boolean => detectMediaKind(src) !== undefined;
49
+
50
+ /** Match URLs whose pathname has an `/iframe` segment (e.g. Cloudflare Stream embeds). */
51
+ const LEGACY_IFRAME_PATH_PATTERN = /\/iframe(?:[/?#]|$)/i;
52
+
53
+ const isLegacyIframeUrl = (src: string): boolean => {
54
+ const pathAndQuery = src.split('#', 1)[0]!;
55
+ return LEGACY_IFRAME_PATH_PATTERN.test(pathAndQuery);
56
+ };
57
+
58
+ export type MediaPlayerProps = ThemedClassName<{
59
+ src: string;
60
+ /** Override auto-detection. When omitted, `detectMediaKind(src)` is used and falls back to 'video'. */
61
+ kind?: MediaKind;
62
+ controls?: boolean;
63
+ autoPlay?: boolean;
64
+ loop?: boolean;
65
+ muted?: boolean;
66
+ /** Accessible label for the `<video>` / `<audio>` element. */
67
+ alt?: string;
68
+ /** Defaults to 'anonymous' for cross-origin sources (e.g. signed S3 URLs). */
69
+ crossOrigin?: 'anonymous' | 'use-credentials' | '';
70
+ /** Additional classes applied only when rendering `<img>`. */
71
+ imgClassNames?: string;
72
+ /** Additional classes applied only when rendering native media or `<iframe>`. */
73
+ mediaClassNames?: string;
74
+ }>;
75
+
76
+ /**
77
+ * Renders a media URL using the appropriate element:
78
+ * - Direct media URLs (mp4, mp3, …) → native `<video>` / `<audio>`.
79
+ * - Legacy `iframe`-style embed URLs (Cloudflare Stream, oEmbed players) → `<iframe>`.
80
+ * - Everything else → `<img>` that hides itself on load failure (broken images
81
+ * are common in feeds and the placeholder is uglier than nothing).
82
+ */
83
+ export const MediaPlayer = ({
84
+ classNames,
85
+ src,
86
+ kind,
87
+ controls = true,
88
+ autoPlay = false,
89
+ loop = false,
90
+ muted = false,
91
+ alt,
92
+ crossOrigin = 'anonymous',
93
+ imgClassNames,
94
+ mediaClassNames,
95
+ }: MediaPlayerProps) => {
96
+ if (isEmbedUrl(src)) {
97
+ const resolved = kind ?? detectMediaKind(src) ?? 'video';
98
+ if (resolved === 'audio') {
99
+ return (
100
+ <audio
101
+ className={mx('w-full', classNames, mediaClassNames)}
102
+ src={src}
103
+ controls={controls}
104
+ autoPlay={autoPlay}
105
+ loop={loop}
106
+ muted={muted}
107
+ crossOrigin={crossOrigin}
108
+ aria-label={alt}
109
+ />
110
+ );
111
+ }
112
+
113
+ return (
114
+ <video
115
+ className={mx('aspect-video max-w-full max-h-full', classNames, mediaClassNames)}
116
+ src={src}
117
+ controls={controls}
118
+ autoPlay={autoPlay}
119
+ loop={loop}
120
+ muted={muted}
121
+ crossOrigin={crossOrigin}
122
+ aria-label={alt}
123
+ />
124
+ );
125
+ }
126
+
127
+ if (isLegacyIframeUrl(src)) {
128
+ return (
129
+ <iframe
130
+ src={src}
131
+ title={alt ?? 'Embedded media'}
132
+ loading='lazy'
133
+ className={mx('border-none', classNames, mediaClassNames)}
134
+ sandbox={DEFAULT_IFRAME_SANDBOX}
135
+ referrerPolicy='no-referrer'
136
+ allow='accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;'
137
+ allowFullScreen
138
+ />
139
+ );
140
+ }
141
+
142
+ return (
143
+ <img
144
+ src={src}
145
+ alt={alt ?? ''}
146
+ loading='lazy'
147
+ className={mx(classNames, imgClassNames)}
148
+ onError={(event) => {
149
+ event.currentTarget.style.display = 'none';
150
+ }}
151
+ />
152
+ );
153
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './MediaPlayer';
@@ -161,7 +161,7 @@ const ToolbarToggleGroupItem = forwardRef<HTMLButtonElement, ToolbarToggleGroupI
161
161
  type ToolbarToggleGroupIconItemProps = Omit<ToggleGroupItemPrimitiveProps, 'className'> & IconButtonProps;
162
162
 
163
163
  const ToolbarToggleGroupIconItem = forwardRef<HTMLButtonElement, ToolbarToggleGroupIconItemProps>(
164
- ({ variant, density, elevation, classNames, icon, label, iconOnly, ...props }, forwardedRef) => {
164
+ ({ variant, density, elevation, classNames, icon, label, iconOnly, iconClassNames, ...props }, forwardedRef) => {
165
165
  return (
166
166
  <ToolbarPrimitive.ToolbarToggleItem {...props} asChild>
167
167
  <IconButton
@@ -173,6 +173,7 @@ const ToolbarToggleGroupIconItem = forwardRef<HTMLButtonElement, ToolbarToggleGr
173
173
  icon,
174
174
  label,
175
175
  iconOnly,
176
+ iconClassNames,
176
177
  }}
177
178
  ref={forwardedRef}
178
179
  />