@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.
- package/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/browser/chunk-A5QCIG5R.mjs +24 -0
- package/dist/lib/browser/chunk-A5QCIG5R.mjs.map +7 -0
- package/dist/lib/browser/{chunk-BDBC6H6V.mjs → chunk-LY5XDQR5.mjs} +6 -8
- package/dist/lib/browser/chunk-LY5XDQR5.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +560 -257
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +1 -1
- package/dist/lib/browser/translations.mjs +4 -13
- package/dist/lib/browser/translations.mjs.map +4 -4
- package/dist/lib/node-esm/{chunk-3JSJK2ZY.mjs → chunk-NGKLIKP3.mjs} +6 -8
- package/dist/lib/node-esm/chunk-NGKLIKP3.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-XCFLA74M.mjs +26 -0
- package/dist/lib/node-esm/chunk-XCFLA74M.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +560 -257
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +1 -1
- package/dist/lib/node-esm/translations.mjs +4 -14
- package/dist/lib/node-esm/translations.mjs.map +4 -4
- package/dist/types/src/components/Card/Card.d.ts.map +1 -1
- package/dist/types/src/components/Carousel/Carousel.d.ts +90 -0
- package/dist/types/src/components/Carousel/Carousel.d.ts.map +1 -0
- package/dist/types/src/components/Carousel/index.d.ts +2 -0
- package/dist/types/src/components/Carousel/index.d.ts.map +1 -0
- package/dist/types/src/components/MediaPlayer/MediaPlayer.d.ts +46 -0
- package/dist/types/src/components/MediaPlayer/MediaPlayer.d.ts.map +1 -0
- package/dist/types/src/components/MediaPlayer/MediaPlayer.stories.d.ts +16 -0
- package/dist/types/src/components/MediaPlayer/MediaPlayer.stories.d.ts.map +1 -0
- package/dist/types/src/components/MediaPlayer/index.d.ts +2 -0
- package/dist/types/src/components/MediaPlayer/index.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/types/src/components/Tooltip/Tooltip.d.ts +6 -6
- package/dist/types/src/components/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +2 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +5 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +18 -18
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +1 -1
- package/src/components/Card/Card.tsx +11 -9
- package/src/components/Carousel/Carousel.tsx +337 -0
- package/src/components/Carousel/index.ts +5 -0
- package/src/components/MediaPlayer/MediaPlayer.stories.tsx +50 -0
- package/src/components/MediaPlayer/MediaPlayer.tsx +153 -0
- package/src/components/MediaPlayer/index.ts +5 -0
- package/src/components/Toolbar/Toolbar.tsx +2 -1
- package/src/components/Tooltip/Tooltip.tsx +14 -13
- package/src/components/index.ts +2 -0
- package/src/translations.ts +5 -0
- package/dist/lib/browser/chunk-BDBC6H6V.mjs.map +0 -7
- 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.
|
|
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": "
|
|
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.
|
|
87
|
-
"@dxos/debug": "0.8.4-main.
|
|
88
|
-
"@dxos/invariant": "0.8.4-main.
|
|
89
|
-
"@dxos/log": "0.8.4-main.
|
|
90
|
-
"@dxos/lit-ui": "0.8.4-main.
|
|
91
|
-
"@dxos/react-
|
|
92
|
-
"@dxos/react-
|
|
93
|
-
"@dxos/
|
|
94
|
-
"@dxos/
|
|
95
|
-
"@dxos/
|
|
96
|
-
"@dxos/
|
|
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.
|
|
110
|
-
"@dxos/random": "0.8.4-main.
|
|
111
|
-
"@dxos/ui-theme": "0.8.4-main.
|
|
112
|
-
"@dxos/util": "0.8.4-main.
|
|
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.
|
|
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-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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,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
|
+
};
|
|
@@ -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
|
/>
|