Skip to content

Commit b3f7f39

Browse files
committed
feat(storybook): add Video Control Bar composite and story
Add the VideoControlBar composite, export it from the composites barrel, and document it with a story including a live-feed example whose controls are wired to the YouTube embed via the IFrame Player API. Remove the obsolete LayoutPatterns story.
1 parent 7f939e5 commit b3f7f39

4 files changed

Lines changed: 1380 additions & 229 deletions

File tree

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
import {
2+
Button,
3+
Menu,
4+
MenuItem,
5+
MenuList,
6+
MenuPopover,
7+
MenuTrigger,
8+
Tooltip,
9+
makeStyles,
10+
mergeClasses,
11+
tokens,
12+
} from "@fluentui/react-components";
13+
import { ChevronDownRegular } from "@fluentui/react-icons";
14+
import { type ReactElement, forwardRef } from "react";
15+
16+
type VideoControlBarAppearance = "media" | "subtle";
17+
18+
export interface VideoControlBarMenuItem {
19+
/** Stable identity for the menu entry. */
20+
key: string;
21+
22+
/** Visible menu item label. */
23+
label: string;
24+
25+
/** Optional leading icon. */
26+
icon?: ReactElement | null;
27+
28+
/** Selection handler. */
29+
onClick?: () => void;
30+
31+
/** Disabled state. */
32+
disabled?: boolean;
33+
}
34+
35+
export interface VideoControlBarItem {
36+
/** Stable identity for the control. */
37+
key: string;
38+
39+
/** Accessible name used for both the tooltip and `aria-label`. */
40+
label: string;
41+
42+
/** Icon rendered inside the control. Omit when `text` is provided. */
43+
icon?: ReactElement | null;
44+
45+
/** Short text rendered instead of an icon (e.g. `1:1`). */
46+
text?: string;
47+
48+
/** Click handler. Ignored when `menuItems` is provided. */
49+
onClick?: () => void;
50+
51+
/** Disabled state. */
52+
disabled?: boolean;
53+
54+
/**
55+
* Pressed / toggled state. Renders a persistent highlight and sets
56+
* `aria-pressed` so assistive tech can announce the on state.
57+
*/
58+
active?: boolean;
59+
60+
/** Emphasis. `danger` tints the control (e.g. an active recording dot). */
61+
tone?: "default" | "danger";
62+
63+
/**
64+
* Renders a trailing chevron and, when `menuItems` are supplied, opens a
65+
* Fluent menu on click. Sets `aria-haspopup`.
66+
*/
67+
hasMenu?: boolean;
68+
69+
/** Optional dropdown entries; implies `hasMenu`. */
70+
menuItems?: VideoControlBarMenuItem[];
71+
}
72+
73+
export interface VideoControlBarGroup {
74+
/** Stable identity for the group. */
75+
key: string;
76+
77+
/** Controls belonging to this group, in visual order. */
78+
items: VideoControlBarItem[];
79+
80+
/**
81+
* When true, the group's controls share a single rounded background and are
82+
* separated by vertical dividers (a segmented control cluster).
83+
*/
84+
segmented?: boolean;
85+
}
86+
87+
export interface VideoControlBarProps {
88+
/** Groups aligned to the leading (left) edge. */
89+
start?: VideoControlBarGroup[];
90+
91+
/** Groups aligned to the trailing (right) edge. */
92+
end?: VideoControlBarGroup[];
93+
94+
/**
95+
* Visual context.
96+
* - `media` (default): dark scrim intended to sit over a live video feed.
97+
* - `subtle`: theme-aware bar for placement on a page surface.
98+
*/
99+
appearance?: VideoControlBarAppearance;
100+
101+
/** Accessible name for the toolbar landmark. */
102+
ariaLabel?: string;
103+
104+
/** Optional CSS class applied to the root element. */
105+
className?: string;
106+
}
107+
108+
const useStyles = makeStyles({
109+
root: {
110+
display: "flex",
111+
alignItems: "center",
112+
justifyContent: "space-between",
113+
gap: tokens.spacingHorizontalM,
114+
width: "100%",
115+
minWidth: 0,
116+
boxSizing: "border-box",
117+
paddingLeft: tokens.spacingHorizontalL,
118+
paddingRight: tokens.spacingHorizontalL,
119+
paddingTop: tokens.spacingVerticalS,
120+
paddingBottom: tokens.spacingVerticalS,
121+
},
122+
123+
rootMedia: {
124+
backgroundColor: "#292929",
125+
color: "#ffffff",
126+
borderRadius: tokens.borderRadiusMedium,
127+
},
128+
129+
rootSubtle: {
130+
backgroundColor: tokens.colorNeutralBackground1,
131+
color: tokens.colorNeutralForeground1,
132+
border: `1px solid ${tokens.colorNeutralStroke2}`,
133+
borderRadius: tokens.borderRadiusMedium,
134+
},
135+
136+
side: {
137+
display: "flex",
138+
alignItems: "center",
139+
gap: tokens.spacingHorizontalS,
140+
minWidth: 0,
141+
},
142+
143+
sideEnd: {
144+
justifyContent: "flex-end",
145+
},
146+
147+
group: {
148+
display: "flex",
149+
alignItems: "center",
150+
gap: tokens.spacingHorizontalXXS,
151+
},
152+
153+
segment: {
154+
display: "flex",
155+
alignItems: "center",
156+
gap: 0,
157+
borderRadius: tokens.borderRadiusMedium,
158+
overflow: "hidden",
159+
},
160+
161+
segmentMedia: {
162+
backgroundColor: "rgba(255, 255, 255, 0.08)",
163+
},
164+
165+
segmentSubtle: {
166+
backgroundColor: tokens.colorNeutralBackground3,
167+
},
168+
169+
divider: {
170+
width: "1px",
171+
height: "20px",
172+
flexShrink: 0,
173+
},
174+
175+
dividerMedia: {
176+
backgroundColor: "rgba(255, 255, 255, 0.2)",
177+
},
178+
179+
dividerSubtle: {
180+
backgroundColor: tokens.colorNeutralStroke2,
181+
},
182+
183+
mediaButton: {
184+
color: "#ffffff",
185+
minWidth: "32px",
186+
"&:hover": {
187+
color: "#ffffff",
188+
backgroundColor: "rgba(255, 255, 255, 0.1)",
189+
},
190+
"&:hover:active": {
191+
color: "#ffffff",
192+
backgroundColor: "rgba(255, 255, 255, 0.16)",
193+
},
194+
},
195+
196+
mediaButtonActive: {
197+
backgroundColor: "rgba(255, 255, 255, 0.16)",
198+
},
199+
200+
subtleButton: {
201+
minWidth: "32px",
202+
},
203+
204+
subtleButtonActive: {
205+
backgroundColor: tokens.colorNeutralBackground1Selected,
206+
},
207+
208+
danger: {
209+
color: tokens.colorPaletteRedForeground1,
210+
"&:hover": {
211+
color: tokens.colorPaletteRedForeground1,
212+
},
213+
},
214+
215+
buttonText: {
216+
fontSize: tokens.fontSizeBase300,
217+
fontWeight: tokens.fontWeightSemibold,
218+
lineHeight: tokens.lineHeightBase300,
219+
},
220+
});
221+
222+
interface ControlButtonProps {
223+
item: VideoControlBarItem;
224+
appearance: VideoControlBarAppearance;
225+
}
226+
227+
function ControlButton({ item, appearance }: ControlButtonProps) {
228+
const styles = useStyles();
229+
const isMedia = appearance === "media";
230+
const hasMenu = item.hasMenu || (item.menuItems?.length ?? 0) > 0;
231+
232+
const buttonClassName = mergeClasses(
233+
isMedia ? styles.mediaButton : styles.subtleButton,
234+
item.active &&
235+
(isMedia ? styles.mediaButtonActive : styles.subtleButtonActive),
236+
item.tone === "danger" && styles.danger
237+
);
238+
239+
const button = (
240+
<Button
241+
appearance="transparent"
242+
className={buttonClassName}
243+
icon={item.text ? undefined : (item.icon ?? undefined)}
244+
disabled={item.disabled}
245+
aria-label={item.label}
246+
aria-pressed={item.active ? true : undefined}
247+
aria-haspopup={hasMenu ? "menu" : undefined}
248+
onClick={item.menuItems?.length ? undefined : item.onClick}
249+
>
250+
{item.text ? (
251+
<span className={styles.buttonText}>{item.text}</span>
252+
) : null}
253+
{hasMenu ? <ChevronDownRegular fontSize={16} /> : null}
254+
</Button>
255+
);
256+
257+
if (item.menuItems?.length) {
258+
return (
259+
<Menu>
260+
<MenuTrigger disableButtonEnhancement>
261+
<Tooltip content={item.label} relationship="label" withArrow>
262+
{button}
263+
</Tooltip>
264+
</MenuTrigger>
265+
<MenuPopover>
266+
<MenuList>
267+
{item.menuItems.map((entry) => (
268+
<MenuItem
269+
key={entry.key}
270+
icon={entry.icon ?? undefined}
271+
disabled={entry.disabled}
272+
onClick={entry.onClick}
273+
>
274+
{entry.label}
275+
</MenuItem>
276+
))}
277+
</MenuList>
278+
</MenuPopover>
279+
</Menu>
280+
);
281+
}
282+
283+
return (
284+
<Tooltip content={item.label} relationship="label" withArrow>
285+
{button}
286+
</Tooltip>
287+
);
288+
}
289+
290+
interface ControlGroupProps {
291+
group: VideoControlBarGroup;
292+
appearance: VideoControlBarAppearance;
293+
}
294+
295+
function ControlGroup({ group, appearance }: ControlGroupProps) {
296+
const styles = useStyles();
297+
const isMedia = appearance === "media";
298+
299+
if (group.segmented) {
300+
return (
301+
<div
302+
className={mergeClasses(
303+
styles.segment,
304+
isMedia ? styles.segmentMedia : styles.segmentSubtle
305+
)}
306+
>
307+
{group.items.map((item, index) => (
308+
<div key={item.key} className={styles.group}>
309+
{index > 0 ? (
310+
<span
311+
aria-hidden
312+
className={mergeClasses(
313+
styles.divider,
314+
isMedia ? styles.dividerMedia : styles.dividerSubtle
315+
)}
316+
/>
317+
) : null}
318+
<ControlButton item={item} appearance={appearance} />
319+
</div>
320+
))}
321+
</div>
322+
);
323+
}
324+
325+
return (
326+
<div className={styles.group}>
327+
{group.items.map((item) => (
328+
<ControlButton key={item.key} item={item} appearance={appearance} />
329+
))}
330+
</div>
331+
);
332+
}
333+
334+
/**
335+
* VideoControlBar — a toolbar of grouped icon controls for a live video feed.
336+
*
337+
* Controls are arranged into leading (`start`) and trailing (`end`) groups.
338+
* Groups may be `segmented` to cluster related controls behind a shared
339+
* background with vertical dividers (e.g. record + storage, or the live-view
340+
* resolution / framing cluster). Every control exposes a tooltip and an
341+
* `aria-label`, and the root renders as a `toolbar` landmark.
342+
*/
343+
export const VideoControlBar = forwardRef<HTMLDivElement, VideoControlBarProps>(
344+
(
345+
{
346+
start = [],
347+
end = [],
348+
appearance = "media",
349+
ariaLabel = "Video controls",
350+
className,
351+
...rest
352+
},
353+
ref
354+
) => {
355+
const styles = useStyles();
356+
const isMedia = appearance === "media";
357+
358+
return (
359+
<div
360+
ref={ref}
361+
role="toolbar"
362+
aria-label={ariaLabel}
363+
className={mergeClasses(
364+
styles.root,
365+
isMedia ? styles.rootMedia : styles.rootSubtle,
366+
className
367+
)}
368+
{...rest}
369+
>
370+
<div className={styles.side}>
371+
{start.map((group) => (
372+
<ControlGroup
373+
key={group.key}
374+
group={group}
375+
appearance={appearance}
376+
/>
377+
))}
378+
</div>
379+
<div className={mergeClasses(styles.side, styles.sideEnd)}>
380+
{end.map((group) => (
381+
<ControlGroup
382+
key={group.key}
383+
group={group}
384+
appearance={appearance}
385+
/>
386+
))}
387+
</div>
388+
</div>
389+
);
390+
}
391+
);
392+
393+
VideoControlBar.displayName = "VideoControlBar";

0 commit comments

Comments
 (0)