@bkstg/design-systems 1.0.0
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/README.md +2 -0
- package/bkstg/latest/README.md +46 -0
- package/bkstg/latest/bkstg.css +1668 -0
- package/bkstg/latest/bkstg.js +703 -0
- package/bkstg/latest/charts-core.mjs +349 -0
- package/bkstg/latest/charts.js +58 -0
- package/bkstg/latest/motion.js +168 -0
- package/bkstg/latest/react/ActivityFeed.jsx +18 -0
- package/bkstg/latest/react/Alert.jsx +28 -0
- package/bkstg/latest/react/Annotations.jsx +24 -0
- package/bkstg/latest/react/AppShell.jsx +87 -0
- package/bkstg/latest/react/Avatar.jsx +19 -0
- package/bkstg/latest/react/Badge.jsx +14 -0
- package/bkstg/latest/react/Banner.jsx +17 -0
- package/bkstg/latest/react/Breadcrumbs.jsx +23 -0
- package/bkstg/latest/react/Button.jsx +31 -0
- package/bkstg/latest/react/ButtonGroup.jsx +29 -0
- package/bkstg/latest/react/Calendar.jsx +57 -0
- package/bkstg/latest/react/CardHeader.jsx +14 -0
- package/bkstg/latest/react/Chart.jsx +42 -0
- package/bkstg/latest/react/Chat.jsx +34 -0
- package/bkstg/latest/react/Checkbox.jsx +32 -0
- package/bkstg/latest/react/CodeSnippet.jsx +17 -0
- package/bkstg/latest/react/ColorPicker.jsx +35 -0
- package/bkstg/latest/react/CommandMenu.jsx +54 -0
- package/bkstg/latest/react/DatePicker.jsx +34 -0
- package/bkstg/latest/react/DeviceFrame.jsx +12 -0
- package/bkstg/latest/react/Dialog.jsx +43 -0
- package/bkstg/latest/react/Divider.jsx +10 -0
- package/bkstg/latest/react/Editor.jsx +85 -0
- package/bkstg/latest/react/EmptyState.jsx +13 -0
- package/bkstg/latest/react/Field.jsx +32 -0
- package/bkstg/latest/react/FileUpload.jsx +38 -0
- package/bkstg/latest/react/Filters.jsx +19 -0
- package/bkstg/latest/react/Infographics.jsx +54 -0
- package/bkstg/latest/react/InlineCTA.jsx +14 -0
- package/bkstg/latest/react/LineChart.jsx +23 -0
- package/bkstg/latest/react/Marketing.jsx +161 -0
- package/bkstg/latest/react/Menu.jsx +76 -0
- package/bkstg/latest/react/Metric.jsx +28 -0
- package/bkstg/latest/react/PageHeader.jsx +22 -0
- package/bkstg/latest/react/Pagination.jsx +32 -0
- package/bkstg/latest/react/Progress.jsx +26 -0
- package/bkstg/latest/react/RadioGroup.jsx +27 -0
- package/bkstg/latest/react/Reveal.jsx +17 -0
- package/bkstg/latest/react/SectionHeader.jsx +24 -0
- package/bkstg/latest/react/Select.jsx +19 -0
- package/bkstg/latest/react/Sheet.jsx +25 -0
- package/bkstg/latest/react/SideNav.jsx +37 -0
- package/bkstg/latest/react/Skeleton.jsx +16 -0
- package/bkstg/latest/react/Slider.jsx +27 -0
- package/bkstg/latest/react/Spinner.jsx +14 -0
- package/bkstg/latest/react/Steps.jsx +22 -0
- package/bkstg/latest/react/Switch.jsx +11 -0
- package/bkstg/latest/react/Table.jsx +24 -0
- package/bkstg/latest/react/Tabs.jsx +80 -0
- package/bkstg/latest/react/Tag.jsx +34 -0
- package/bkstg/latest/react/Tooltip.jsx +25 -0
- package/bkstg/latest/react/Tree.jsx +41 -0
- package/bkstg/latest/react/VideoPlayer.jsx +57 -0
- package/bkstg/latest/react/cx.js +4 -0
- package/bkstg/latest/react/index.js +59 -0
- package/bkstg/latest/system.json +24 -0
- package/bkstg/v1/README.md +46 -0
- package/bkstg/v1/bkstg.css +1668 -0
- package/bkstg/v1/bkstg.js +703 -0
- package/bkstg/v1/charts-core.mjs +349 -0
- package/bkstg/v1/charts.js +58 -0
- package/bkstg/v1/motion.js +168 -0
- package/bkstg/v1/react/ActivityFeed.jsx +18 -0
- package/bkstg/v1/react/Alert.jsx +28 -0
- package/bkstg/v1/react/Annotations.jsx +24 -0
- package/bkstg/v1/react/AppShell.jsx +87 -0
- package/bkstg/v1/react/Avatar.jsx +19 -0
- package/bkstg/v1/react/Badge.jsx +14 -0
- package/bkstg/v1/react/Banner.jsx +17 -0
- package/bkstg/v1/react/Breadcrumbs.jsx +23 -0
- package/bkstg/v1/react/Button.jsx +31 -0
- package/bkstg/v1/react/ButtonGroup.jsx +29 -0
- package/bkstg/v1/react/Calendar.jsx +57 -0
- package/bkstg/v1/react/CardHeader.jsx +14 -0
- package/bkstg/v1/react/Chart.jsx +42 -0
- package/bkstg/v1/react/Chat.jsx +34 -0
- package/bkstg/v1/react/Checkbox.jsx +32 -0
- package/bkstg/v1/react/CodeSnippet.jsx +17 -0
- package/bkstg/v1/react/ColorPicker.jsx +35 -0
- package/bkstg/v1/react/CommandMenu.jsx +54 -0
- package/bkstg/v1/react/DatePicker.jsx +34 -0
- package/bkstg/v1/react/DeviceFrame.jsx +12 -0
- package/bkstg/v1/react/Dialog.jsx +43 -0
- package/bkstg/v1/react/Divider.jsx +10 -0
- package/bkstg/v1/react/Editor.jsx +85 -0
- package/bkstg/v1/react/EmptyState.jsx +13 -0
- package/bkstg/v1/react/Field.jsx +32 -0
- package/bkstg/v1/react/FileUpload.jsx +38 -0
- package/bkstg/v1/react/Filters.jsx +19 -0
- package/bkstg/v1/react/Infographics.jsx +54 -0
- package/bkstg/v1/react/InlineCTA.jsx +14 -0
- package/bkstg/v1/react/LineChart.jsx +23 -0
- package/bkstg/v1/react/Marketing.jsx +161 -0
- package/bkstg/v1/react/Menu.jsx +76 -0
- package/bkstg/v1/react/Metric.jsx +28 -0
- package/bkstg/v1/react/PageHeader.jsx +22 -0
- package/bkstg/v1/react/Pagination.jsx +32 -0
- package/bkstg/v1/react/Progress.jsx +26 -0
- package/bkstg/v1/react/RadioGroup.jsx +27 -0
- package/bkstg/v1/react/Reveal.jsx +17 -0
- package/bkstg/v1/react/SectionHeader.jsx +24 -0
- package/bkstg/v1/react/Select.jsx +19 -0
- package/bkstg/v1/react/Sheet.jsx +25 -0
- package/bkstg/v1/react/SideNav.jsx +37 -0
- package/bkstg/v1/react/Skeleton.jsx +16 -0
- package/bkstg/v1/react/Slider.jsx +27 -0
- package/bkstg/v1/react/Spinner.jsx +14 -0
- package/bkstg/v1/react/Steps.jsx +22 -0
- package/bkstg/v1/react/Switch.jsx +11 -0
- package/bkstg/v1/react/Table.jsx +24 -0
- package/bkstg/v1/react/Tabs.jsx +80 -0
- package/bkstg/v1/react/Tag.jsx +34 -0
- package/bkstg/v1/react/Tooltip.jsx +25 -0
- package/bkstg/v1/react/Tree.jsx +41 -0
- package/bkstg/v1/react/VideoPlayer.jsx +57 -0
- package/bkstg/v1/react/cx.js +4 -0
- package/bkstg/v1/react/index.js +59 -0
- package/bkstg/v1/system.json +24 -0
- package/package.json +14 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
const ARROW = (d) => <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>;
|
|
4
|
+
|
|
5
|
+
/** The page numbers to show: always the first and last, the current one and its neighbors, gaps as null */
|
|
6
|
+
export function pageList(page, count) {
|
|
7
|
+
const want = new Set([1, count, page - 1, page, page + 1].filter((n) => n >= 1 && n <= count));
|
|
8
|
+
const out = [];
|
|
9
|
+
[...want].sort((a, b) => a - b).forEach((n, i, all) => { if (i && n - all[i - 1] > 1) out.push(null); out.push(n); });
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* <Pagination page={page} count={12} onChange={setPage} />
|
|
15
|
+
* Previous and Next, then the numbers; in a narrow container the numbers give way to "Page 2 of 12".
|
|
16
|
+
*/
|
|
17
|
+
export function Pagination({ page, count, onChange, label = "Pagination", className }) {
|
|
18
|
+
const go = (n) => () => onChange?.(n);
|
|
19
|
+
return (
|
|
20
|
+
<nav className={cx("ui-pagination", className)} aria-label={label}>
|
|
21
|
+
<button type="button" className="ui-btn ui-btn--sm" onClick={go(page - 1)} disabled={page <= 1}>{ARROW("m15 18-6-6 6-6")}Previous</button>
|
|
22
|
+
<ul className="ui-pagination-pages">
|
|
23
|
+
{pageList(page, count).map((n, i) => (
|
|
24
|
+
<li key={i}>{n === null ? <span className="ui-page-gap" aria-hidden="true">…</span>
|
|
25
|
+
: <button type="button" className="ui-page" aria-current={n === page ? "page" : undefined} aria-label={`Page ${n}`} onClick={go(n)}>{n}</button>}</li>
|
|
26
|
+
))}
|
|
27
|
+
</ul>
|
|
28
|
+
<span className="ui-pagination-info" aria-live="polite">{`Page ${page} of ${count}`}</span>
|
|
29
|
+
<button type="button" className="ui-btn ui-btn--sm" onClick={go(page + 1)} disabled={page >= count}>Next{ARROW("m9 18 6-6-6-6")}</button>
|
|
30
|
+
</nav>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Progress value={62} label="Uploading" /> 0 to 100
|
|
5
|
+
* <Progress label="Loading" /> no value: indeterminate
|
|
6
|
+
* label is required: it is the progress bar's accessible name.
|
|
7
|
+
*/
|
|
8
|
+
export function Progress({ value, label, showValue = true, className }) {
|
|
9
|
+
const known = typeof value === "number";
|
|
10
|
+
const v = known ? Math.max(0, Math.min(100, value)) : undefined;
|
|
11
|
+
return (
|
|
12
|
+
<div className={className}>
|
|
13
|
+
{showValue ? <div className="ui-progress-label"><span>{label}</span>{known ? <span>{Math.round(v)}%</span> : null}</div> : null}
|
|
14
|
+
<div
|
|
15
|
+
className={cx("ui-progress", !known && "ui-progress--indeterminate")}
|
|
16
|
+
role="progressbar"
|
|
17
|
+
aria-label={label}
|
|
18
|
+
aria-valuemin={known ? 0 : undefined}
|
|
19
|
+
aria-valuemax={known ? 100 : undefined}
|
|
20
|
+
aria-valuenow={known ? Math.round(v) : undefined}
|
|
21
|
+
>
|
|
22
|
+
<div className="ui-progress-bar" style={known ? { "--value": `${v}%` } : undefined} />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <RadioGroup legend="Ticket type" value={type} onChange={setType} options={[
|
|
6
|
+
* { value: "ga", label: "General admission", hint: "Both days" }, { value: "vip", label: "VIP" } ]} />
|
|
7
|
+
* Native radios in a fieldset: the arrow keys move the choice, Tab leaves the group.
|
|
8
|
+
*/
|
|
9
|
+
export function RadioGroup({ legend, name, value, defaultValue, onChange, options, className }) {
|
|
10
|
+
const auto = useId();
|
|
11
|
+
const group = name || auto;
|
|
12
|
+
return (
|
|
13
|
+
<fieldset className={cx("ui-choices", className)}>
|
|
14
|
+
{legend ? <legend className="ui-label">{legend}</legend> : null}
|
|
15
|
+
{options.map((o) => (
|
|
16
|
+
<label key={o.value} className="ui-check-row">
|
|
17
|
+
<input
|
|
18
|
+
type="radio" className="ui-radio" name={group} value={o.value} disabled={o.disabled}
|
|
19
|
+
{...(value !== undefined ? { checked: value === o.value } : { defaultChecked: defaultValue === o.value })}
|
|
20
|
+
onChange={(e) => onChange?.(e.target.value, e)}
|
|
21
|
+
/>
|
|
22
|
+
{o.hint ? <span className="ui-check-text">{o.label}<small>{o.hint}</small></span> : o.label}
|
|
23
|
+
</label>
|
|
24
|
+
))}
|
|
25
|
+
</fieldset>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Reveal effect="fade-up" stagger={60}>…</Reveal> the kit's own engine (motion.js, no library) from React.
|
|
5
|
+
* Load motion.js once in the app. Props mirror the no-code attributes: effect, trigger ("scroll" | "load" | "hover"),
|
|
6
|
+
* duration, delay, ease, distance, stagger, split ("words" | "chars" | "lines"), repeat. It follows the visitor's
|
|
7
|
+
* reduce-motion setting and the system's Motion level. For app-style animation (layout, gestures, exit), use Motion.
|
|
8
|
+
*/
|
|
9
|
+
export function Reveal({ as: As = "div", effect = "fade-up", trigger, duration, delay, ease, distance, stagger, split, repeat, children, ...rest }) {
|
|
10
|
+
const ref = useRef(null);
|
|
11
|
+
useEffect(() => { if (ref.current && window.UIMotion) window.UIMotion.init(ref.current.parentElement || document); }, []);
|
|
12
|
+
return (
|
|
13
|
+
<As ref={ref} data-reveal={effect} data-reveal-trigger={trigger} data-reveal-duration={duration} data-reveal-delay={delay}
|
|
14
|
+
data-reveal-ease={ease} data-reveal-distance={distance} data-reveal-stagger={stagger} data-reveal-split={split}
|
|
15
|
+
data-reveal-repeat={repeat ? "" : undefined} {...rest}>{children}</As>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/** <SectionHeader title="Team" description="Who can see and change this event." actions={<Button size="sm">Invite</Button>} /> */
|
|
4
|
+
export function SectionHeader({ title, description, actions, as: H = "h2", className }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className={cx("ui-sectionhead", className)}>
|
|
7
|
+
<div className="ui-sectionhead-text">
|
|
8
|
+
<H className="ui-sectionhead-title">{title}</H>
|
|
9
|
+
{description ? <p className="ui-sectionhead-desc">{description}</p> : null}
|
|
10
|
+
</div>
|
|
11
|
+
{actions ? <div className="ui-sectionhead-actions">{actions}</div> : null}
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** <SectionFooter note="Last saved 2 minutes ago" actions={<><Button>Cancel</Button><Button variant="primary">Save</Button></>} /> */
|
|
17
|
+
export function SectionFooter({ note, actions, className }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className={cx("ui-sectionfoot", className)}>
|
|
20
|
+
{note ? <p className="ui-sectionfoot-note">{note}</p> : null}
|
|
21
|
+
{actions ? <div className="ui-sectionfoot-actions">{actions}</div> : null}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Select value={v} onChange={...} options={[{ value: "a", label: "A" }]} />
|
|
6
|
+
* A native <select>: the phone's own picker, full keyboard support, works in forms.
|
|
7
|
+
*/
|
|
8
|
+
export const Select = forwardRef(function Select({ options = [], className, children, ...rest }, ref) {
|
|
9
|
+
return (
|
|
10
|
+
<span className={cx("ui-select", className)}>
|
|
11
|
+
<select ref={ref} className="ui-input" {...rest}>
|
|
12
|
+
{options.map((o) => (
|
|
13
|
+
<option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
|
|
14
|
+
))}
|
|
15
|
+
{children}
|
|
16
|
+
</select>
|
|
17
|
+
</span>
|
|
18
|
+
);
|
|
19
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const X = <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* <Sheet open={open} onClose={() => setOpen(false)} title="Set details" footer={<Button onClick={save}>Save</Button>}>…</Sheet>
|
|
8
|
+
* A slide-out panel on the right: a modal dialog, so focus stays inside; Escape and the backdrop close it.
|
|
9
|
+
*/
|
|
10
|
+
export function Sheet({ open, onClose, title, children, footer, className }) {
|
|
11
|
+
const ref = useRef(null), id = useId();
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const d = ref.current; if (!d) return;
|
|
14
|
+
if (open && !d.open) d.showModal();
|
|
15
|
+
if (!open && d.open) d.close();
|
|
16
|
+
}, [open]);
|
|
17
|
+
return (
|
|
18
|
+
<dialog ref={ref} className={cx("ui-sheet", className)} aria-labelledby={`${id}-t`} onClose={onClose} onClick={(e) => { if (e.target === ref.current) onClose?.(); }}>
|
|
19
|
+
<div className="ui-sheet-head"><h2 className="ui-sheet-title" id={`${id}-t`}>{title}</h2>
|
|
20
|
+
<button type="button" className="ui-dialog-close" aria-label="Close" onClick={onClose}>{X}</button></div>
|
|
21
|
+
<div className="ui-sheet-body">{children}</div>
|
|
22
|
+
{footer ? <div className="ui-sheet-foot">{footer}</div> : null}
|
|
23
|
+
</dialog>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <SideNav label="Main" brand={<Logo />} items={[{ href: "/", label: "Overview", icon: <HomeIcon />, current: true, count: 8 }]}
|
|
5
|
+
* footer={[...]} user={{ name: "Sean Marshall", email: "…" }} />
|
|
6
|
+
* The current page gets aria-current="page". Icons are decoration (aria-hidden); the label names the link.
|
|
7
|
+
*/
|
|
8
|
+
export function SideNav({ label = "Main", brand, items = [], footer = [], user, children, className }) {
|
|
9
|
+
const list = (links) => (
|
|
10
|
+
<ul className="ui-sidenav-list">
|
|
11
|
+
{links.map((it) => (
|
|
12
|
+
<li key={it.href + it.label}>
|
|
13
|
+
<a className="ui-sidenav-link" href={it.href} aria-current={it.current ? "page" : undefined}>
|
|
14
|
+
{it.icon ? <span aria-hidden="true">{it.icon}</span> : null}
|
|
15
|
+
{it.label}
|
|
16
|
+
{it.count != null ? <span className="ui-badge">{it.count}</span> : null}
|
|
17
|
+
</a>
|
|
18
|
+
</li>
|
|
19
|
+
))}
|
|
20
|
+
</ul>
|
|
21
|
+
);
|
|
22
|
+
return (
|
|
23
|
+
<nav className={cx("ui-sidenav", className)} aria-label={label}>
|
|
24
|
+
{brand ? <div className="ui-sidenav-brand">{brand}</div> : null}
|
|
25
|
+
{list(items)}
|
|
26
|
+
<div className="ui-sidenav-spacer" />
|
|
27
|
+
{footer.length ? list(footer) : null}
|
|
28
|
+
{children}
|
|
29
|
+
{user ? (
|
|
30
|
+
<div className="ui-sidenav-user">
|
|
31
|
+
<span className="ui-avatar ui-avatar--sm" aria-hidden="true">{user.name.split(" ").map((w) => w[0]).join("").slice(0, 2)}</span>
|
|
32
|
+
<span><strong>{user.name}</strong>{user.email ? <small>{user.email}</small> : null}</span>
|
|
33
|
+
</div>
|
|
34
|
+
) : null}
|
|
35
|
+
</nav>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Skeleton width="60%" height="1rem" /> <Skeleton circle width={40} height={40} />
|
|
5
|
+
* A placeholder while content loads. Hidden from screen readers: announce loading elsewhere (a Spinner,
|
|
6
|
+
* or aria-busy on the region). Carries .ambient so the Calm motion level stops the shimmer.
|
|
7
|
+
*/
|
|
8
|
+
export function Skeleton({ width, height, circle = false, className, style }) {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
className={cx("ui-skeleton", circle && "ui-skeleton--circle", "ambient", className)}
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
style={{ width, height, ...style }}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Slider label="Capacity" min={0} max={5000} step={100} value={cap} onChange={setCap} format={(v) => `${v} people`} />
|
|
6
|
+
* A native range input: arrow keys, Page Up/Down, Home and End all work. The value is shown beside the label
|
|
7
|
+
* and spoken through aria-valuetext when format is given.
|
|
8
|
+
*/
|
|
9
|
+
export function Slider({ label, min = 0, max = 100, step = 1, value, defaultValue, onChange, format, scale, className, ...rest }) {
|
|
10
|
+
const id = useId();
|
|
11
|
+
const [own, setOwn] = useState(defaultValue ?? min);
|
|
12
|
+
const v = value ?? own;
|
|
13
|
+
const pct = ((v - min) / (max - min || 1)) * 100;
|
|
14
|
+
const text = format ? format(v) : Number(v).toLocaleString();
|
|
15
|
+
return (
|
|
16
|
+
<div className={className}>
|
|
17
|
+
{label ? <div className="ui-slider-head"><label className="ui-label" htmlFor={id}>{label}</label><output htmlFor={id}>{text}</output></div> : null}
|
|
18
|
+
<input
|
|
19
|
+
id={id} type="range" className="ui-slider" min={min} max={max} step={step} value={v}
|
|
20
|
+
aria-valuetext={format ? text : undefined} style={{ "--value": `${pct}%` }}
|
|
21
|
+
onChange={(e) => { const n = Number(e.target.value); setOwn(n); onChange?.(n, e); }}
|
|
22
|
+
{...rest}
|
|
23
|
+
/>
|
|
24
|
+
{scale ? <div className="ui-slider-scale" aria-hidden="true">{scale.map((s) => <span key={s}>{s}</span>)}</div> : null}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Spinner label="Loading results" size="sm" />
|
|
5
|
+
* role="status" so a screen reader announces the label; the ring itself is decoration.
|
|
6
|
+
*/
|
|
7
|
+
export function Spinner({ label = "Loading", size = "md", className }) {
|
|
8
|
+
return (
|
|
9
|
+
<span role="status" className={className}>
|
|
10
|
+
<span className={cx("ui-spinner", size !== "md" && `ui-spinner--${size}`)} aria-hidden="true" />
|
|
11
|
+
<span className="ui-sr-only">{label}</span>
|
|
12
|
+
</span>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Steps current={1} steps={[{ label: "Details", desc: "Name and dates" }, { label: "Stages" }, { label: "Publish" }]} />
|
|
5
|
+
* Steps before current are complete, current is marked aria-current="step". vertical stacks them.
|
|
6
|
+
*/
|
|
7
|
+
export function Steps({ steps, current = 0, vertical = false, label = "Progress", className }) {
|
|
8
|
+
return (
|
|
9
|
+
<ol className={cx("ui-steps", vertical && "ui-steps--vertical", className)} aria-label={label}>
|
|
10
|
+
{steps.map((s, i) => {
|
|
11
|
+
const state = i < current ? "complete" : i === current ? "current" : "upcoming";
|
|
12
|
+
return (
|
|
13
|
+
<li key={i} className="ui-step" data-state={state} aria-current={state === "current" ? "step" : undefined}>
|
|
14
|
+
<span className="ui-step-marker" aria-hidden="true" />
|
|
15
|
+
<span className="ui-step-label">{s.label}{state === "complete" ? <span className="ui-sr-only"> (done)</span> : null}</span>
|
|
16
|
+
{s.desc ? <span className="ui-step-desc">{s.desc}</span> : null}
|
|
17
|
+
</li>
|
|
18
|
+
);
|
|
19
|
+
})}
|
|
20
|
+
</ol>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Switch checked={on} onChange={(e) => setOn(e.target.checked)}>Notifications</Switch>
|
|
6
|
+
* A real checkbox with role="switch": Space toggles it, forms submit it.
|
|
7
|
+
*/
|
|
8
|
+
export const Switch = forwardRef(function Switch({ children, className, ...rest }, ref) {
|
|
9
|
+
const input = <input ref={ref} type="checkbox" role="switch" className={cx("ui-switch", className)} {...rest} />;
|
|
10
|
+
return children ? <label className="ui-switch-row">{input}{children}</label> : input;
|
|
11
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Table caption="Upcoming set times" columns={[{ key: "artist", label: "Artist", strong: true }, { key: "time", label: "Time", numeric: true }]} rows={rows} />
|
|
5
|
+
* A real <table> with a caption and column headers, in a wrapper that scrolls sideways on narrow screens.
|
|
6
|
+
* A cell can be a string or a React node (a Badge, for example). rows need an id.
|
|
7
|
+
*/
|
|
8
|
+
export function Table({ caption, columns = [], rows = [], className }) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={cx("ui-table-wrap", className)}>
|
|
11
|
+
<table className="ui-table">
|
|
12
|
+
{caption ? <caption>{caption}</caption> : null}
|
|
13
|
+
<thead><tr>{columns.map((c) => <th key={c.key} scope="col" className={c.numeric ? "ui-num" : undefined}>{c.label}</th>)}</tr></thead>
|
|
14
|
+
<tbody>
|
|
15
|
+
{rows.map((r) => (
|
|
16
|
+
<tr key={r.id}>
|
|
17
|
+
{columns.map((c) => <td key={c.key} className={cx(c.strong && "ui-table-strong", c.numeric && "ui-num") || undefined}>{r[c.key]}</td>)}
|
|
18
|
+
</tr>
|
|
19
|
+
))}
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Tabs label="Settings" tabs={[{ id: "a", label: "Account", content: <p>…</p> }]} />
|
|
6
|
+
* variant: "underline" (default) | "pill". Controlled with value + onChange, or uncontrolled.
|
|
7
|
+
* Arrows, Home and End move between tabs; the indicator slides to the selected one.
|
|
8
|
+
*/
|
|
9
|
+
export function Tabs({ tabs, label, variant = "underline", value, defaultValue, onChange, className }) {
|
|
10
|
+
const base = useId();
|
|
11
|
+
const [inner, setInner] = useState(defaultValue ?? tabs.find((t) => !t.disabled)?.id);
|
|
12
|
+
const current = value ?? inner;
|
|
13
|
+
const refs = useRef({});
|
|
14
|
+
const indicator = useRef(null);
|
|
15
|
+
const listRef = useRef(null);
|
|
16
|
+
|
|
17
|
+
function choose(id, focus) {
|
|
18
|
+
if (value === undefined) setInner(id);
|
|
19
|
+
onChange?.(id);
|
|
20
|
+
if (focus) refs.current[id]?.focus();
|
|
21
|
+
}
|
|
22
|
+
function onKey(e, id) {
|
|
23
|
+
const on = tabs.filter((t) => !t.disabled);
|
|
24
|
+
const i = on.findIndex((t) => t.id === id);
|
|
25
|
+
const next = { ArrowRight: on[(i + 1) % on.length], ArrowLeft: on[(i - 1 + on.length) % on.length], Home: on[0], End: on[on.length - 1] }[e.key];
|
|
26
|
+
if (next) { e.preventDefault(); choose(next.id, true); }
|
|
27
|
+
}
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
function move() {
|
|
30
|
+
const t = refs.current[current];
|
|
31
|
+
if (!t || !indicator.current) return;
|
|
32
|
+
indicator.current.style.setProperty("--x", `${t.offsetLeft}px`);
|
|
33
|
+
indicator.current.style.setProperty("--w", `${t.offsetWidth}px`);
|
|
34
|
+
}
|
|
35
|
+
move();
|
|
36
|
+
if (typeof ResizeObserver === "undefined" || !listRef.current) return undefined;
|
|
37
|
+
const ro = new ResizeObserver(move);
|
|
38
|
+
ro.observe(listRef.current);
|
|
39
|
+
return () => ro.disconnect();
|
|
40
|
+
}, [current, tabs]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={cx("ui-tabs", variant === "pill" && "ui-tabs--pill", className)}>
|
|
44
|
+
<div className="ui-tablist" role="tablist" aria-label={label} ref={listRef}>
|
|
45
|
+
{tabs.map((t) => (
|
|
46
|
+
<button
|
|
47
|
+
key={t.id}
|
|
48
|
+
ref={(el) => { refs.current[t.id] = el; }}
|
|
49
|
+
type="button"
|
|
50
|
+
role="tab"
|
|
51
|
+
id={`${base}-tab-${t.id}`}
|
|
52
|
+
className="ui-tab"
|
|
53
|
+
aria-selected={t.id === current}
|
|
54
|
+
aria-controls={`${base}-panel-${t.id}`}
|
|
55
|
+
tabIndex={t.id === current ? 0 : -1}
|
|
56
|
+
disabled={t.disabled}
|
|
57
|
+
onClick={() => choose(t.id, false)}
|
|
58
|
+
onKeyDown={(e) => onKey(e, t.id)}
|
|
59
|
+
>
|
|
60
|
+
{t.label}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
<span className="ui-tab-indicator" aria-hidden="true" ref={indicator} />
|
|
64
|
+
</div>
|
|
65
|
+
{tabs.map((t) => (
|
|
66
|
+
<div
|
|
67
|
+
key={t.id}
|
|
68
|
+
className="ui-tabpanel"
|
|
69
|
+
role="tabpanel"
|
|
70
|
+
id={`${base}-panel-${t.id}`}
|
|
71
|
+
aria-labelledby={`${base}-tab-${t.id}`}
|
|
72
|
+
tabIndex={0}
|
|
73
|
+
hidden={t.id !== current}
|
|
74
|
+
>
|
|
75
|
+
{t.content}
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <TagList label="Genres">{genres.map(g => <Tag key={g} onRemove={() => drop(g)}>{g}</Tag>)}</TagList>
|
|
6
|
+
* With onRemove, a tag shows a remove button labelled "Remove <text>". After a removal, focus moves to the
|
|
7
|
+
* next remove button, or the previous one, or the list, so a keyboard user is never dropped.
|
|
8
|
+
*/
|
|
9
|
+
export function TagList({ label, className, children }) {
|
|
10
|
+
return <div className={cx("ui-tags", className)} role="group" aria-label={label} tabIndex={-1}>{children}</div>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Tag({ children, onRemove, removeLabel, className, ...rest }) {
|
|
14
|
+
const ref = useRef(null);
|
|
15
|
+
function remove() {
|
|
16
|
+
const list = ref.current?.parentElement;
|
|
17
|
+
const all = list ? [...list.querySelectorAll(".ui-tag-remove")] : [];
|
|
18
|
+
const i = all.indexOf(ref.current?.querySelector(".ui-tag-remove"));
|
|
19
|
+
const next = all[i + 1] || all[i - 1] || list;
|
|
20
|
+
onRemove();
|
|
21
|
+
// after React removes this tag
|
|
22
|
+
requestAnimationFrame(() => next?.isConnected ? next.focus() : list?.focus());
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<span ref={ref} className={cx("ui-tag", className)} {...rest}>
|
|
26
|
+
{children}
|
|
27
|
+
{onRemove ? (
|
|
28
|
+
<button type="button" className="ui-tag-remove" aria-label={removeLabel || `Remove ${typeof children === "string" ? children : "tag"}`} onClick={remove}>
|
|
29
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
30
|
+
</button>
|
|
31
|
+
) : null}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cloneElement, useId, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Tooltip text="Copy link"><button aria-label="Copy">…</button></Tooltip>
|
|
6
|
+
* Shows on hover and keyboard focus, after --tooltip-delay. Escape hides it until the pointer or focus leaves.
|
|
7
|
+
* side: "top" (default) | "bottom". The child must be focusable.
|
|
8
|
+
*/
|
|
9
|
+
export function Tooltip({ text, side = "top", children, className }) {
|
|
10
|
+
const id = useId();
|
|
11
|
+
const [dismissed, setDismissed] = useState(false);
|
|
12
|
+
const child = cloneElement(children, { "aria-describedby": id });
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className={cx("ui-tooltip-wrap", dismissed && "is-dismissed", className)}
|
|
16
|
+
data-side={side === "bottom" ? "bottom" : undefined}
|
|
17
|
+
onKeyDown={(e) => { if (e.key === "Escape") setDismissed(true); }}
|
|
18
|
+
onMouseLeave={() => setDismissed(false)}
|
|
19
|
+
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDismissed(false); }}
|
|
20
|
+
>
|
|
21
|
+
{child}
|
|
22
|
+
<span role="tooltip" id={id} className="ui-tooltip">{text}</span>
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Tree label="Files" items={[{ id: "a", label: "Artwork", icon, children: [{ id: "a1", label: "Poster.pdf" }] }]} selected={id} onSelect={setId} />
|
|
6
|
+
* Up and Down move, Right opens or steps in, Left closes or steps out, Home and End, Enter selects. One tab stop.
|
|
7
|
+
*/
|
|
8
|
+
export function Tree({ label, items, selected, onSelect, defaultOpen = [], className }) {
|
|
9
|
+
const [open, setOpen] = useState(() => new Set(defaultOpen));
|
|
10
|
+
const [focus, setFocus] = useState(selected || items[0]?.id);
|
|
11
|
+
const root = useRef(null);
|
|
12
|
+
const visible = [];
|
|
13
|
+
const walk = (list, parent) => list.forEach((it) => { visible.push({ it, parent }); if (it.children && open.has(it.id)) walk(it.children, it.id); });
|
|
14
|
+
walk(items, null);
|
|
15
|
+
const go = (id) => { setFocus(id); requestAnimationFrame(() => root.current?.querySelector(`[data-id="${id}"]`)?.focus()); };
|
|
16
|
+
const toggle = (id, on) => setOpen((s) => { const n = new Set(s); (on ?? !n.has(id)) ? n.add(id) : n.delete(id); return n; });
|
|
17
|
+
function onKey(e) {
|
|
18
|
+
const i = visible.findIndex((v) => v.it.id === focus), cur = visible[i]; if (!cur) return;
|
|
19
|
+
const has = !!cur.it.children, isOpen = open.has(cur.it.id);
|
|
20
|
+
const k = e.key;
|
|
21
|
+
if (k === "ArrowDown" && visible[i + 1]) go(visible[i + 1].it.id);
|
|
22
|
+
else if (k === "ArrowUp" && visible[i - 1]) go(visible[i - 1].it.id);
|
|
23
|
+
else if (k === "Home") go(visible[0].it.id);
|
|
24
|
+
else if (k === "End") go(visible[visible.length - 1].it.id);
|
|
25
|
+
else if (k === "ArrowRight" && has) { if (!isOpen) toggle(cur.it.id, true); else go(cur.it.children[0].id); }
|
|
26
|
+
else if (k === "ArrowLeft") { if (has && isOpen) toggle(cur.it.id, false); else if (cur.parent) go(cur.parent); }
|
|
27
|
+
else if (k === "Enter" || k === " ") { onSelect?.(cur.it.id); if (has) toggle(cur.it.id); }
|
|
28
|
+
else return;
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
}
|
|
31
|
+
const render = (list, level) => list.map((it) => (
|
|
32
|
+
<li key={it.id} data-id={it.id} role="treeitem" aria-level={level} aria-selected={it.id === selected} aria-expanded={it.children ? open.has(it.id) : undefined}
|
|
33
|
+
tabIndex={it.id === focus ? 0 : -1}>
|
|
34
|
+
<div className="ui-tree-row" onClick={() => { setFocus(it.id); onSelect?.(it.id); if (it.children) toggle(it.id); }}>
|
|
35
|
+
{it.children ? <span className="ui-tree-caret" aria-hidden="true" /> : <span className="ui-tree-leaf" aria-hidden="true" />}{it.icon}{it.label}
|
|
36
|
+
</div>
|
|
37
|
+
{it.children ? <ul role="group">{render(it.children, level + 1)}</ul> : null}
|
|
38
|
+
</li>
|
|
39
|
+
));
|
|
40
|
+
return <ul ref={root} className={cx("ui-tree", className)} role="tree" aria-label={label} onKeyDown={onKey}>{render(items, 1)}</ul>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const PLAY = <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5Z" /></svg>;
|
|
5
|
+
const PAUSE = <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" /></svg>;
|
|
6
|
+
const o = { fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" };
|
|
7
|
+
const SOUND = <svg viewBox="0 0 24 24" {...o} aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14" /></svg>;
|
|
8
|
+
const MUTED = <svg viewBox="0 0 24 24" {...o} aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z" /><path d="m22 9-6 6M16 9l6 6" /></svg>;
|
|
9
|
+
const FULL = <svg viewBox="0 0 24 24" {...o} aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3" /></svg>;
|
|
10
|
+
const clock = (s) => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* <VideoPlayer label="Day 1 recap" sources={[{ src: "/recap.mp4", type: "video/mp4" }]} poster="/recap.jpg"
|
|
14
|
+
* captions={{ src: "/recap.vtt", lang: "en" }} />
|
|
15
|
+
* A native <video> with the kit's controls: play and pause, seek, mute, full screen; k or Space plays and pauses.
|
|
16
|
+
* A video with speech needs captions (WCAG 1.2.2): pass a WebVTT file.
|
|
17
|
+
*/
|
|
18
|
+
export function VideoPlayer({ label, sources, poster, captions, muted = false, className }) {
|
|
19
|
+
const root = useRef(null), v = useRef(null), idle = useRef(null);
|
|
20
|
+
const [s, setS] = useState({ playing: false, muted, t: 0, d: 0, idle: false });
|
|
21
|
+
const paint = () => { const el = v.current; if (el) setS((p) => ({ ...p, playing: !el.paused, muted: el.muted, t: el.currentTime, d: el.duration || 0 })); };
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const el = v.current;
|
|
24
|
+
const evs = ["play", "pause", "timeupdate", "loadedmetadata", "volumechange", "ended"];
|
|
25
|
+
evs.forEach((n) => el.addEventListener(n, paint));
|
|
26
|
+
return () => { evs.forEach((n) => el.removeEventListener(n, paint)); clearTimeout(idle.current); };
|
|
27
|
+
}, []);
|
|
28
|
+
const toggle = () => { const el = v.current; if (el.paused) el.play(); else el.pause(); };
|
|
29
|
+
const wake = () => { setS((p) => ({ ...p, idle: false })); clearTimeout(idle.current); idle.current = setTimeout(() => setS((p) => ({ ...p, idle: true })), 2000); };
|
|
30
|
+
const pct = s.d ? (s.t / s.d) * 100 : 0;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
ref={root} className={cx("ui-video", className)} role="group" aria-label={label}
|
|
35
|
+
data-playing={s.playing ? "" : undefined} data-idle={s.idle ? "" : undefined}
|
|
36
|
+
onPointerMove={wake} onFocus={wake}
|
|
37
|
+
onKeyDown={(e) => { if (e.target.closest("button, input")) return; if (e.key === " " || e.key === "k") { e.preventDefault(); toggle(); } }}
|
|
38
|
+
>
|
|
39
|
+
<video ref={v} poster={poster} muted={muted} playsInline preload="metadata" onClick={toggle} tabIndex={-1}>
|
|
40
|
+
{sources.map((x) => <source key={x.src} src={x.src} type={x.type} />)}
|
|
41
|
+
{captions ? <track kind="captions" src={captions.src} srcLang={captions.lang || "en"} label={captions.label || "English"} default /> : null}
|
|
42
|
+
</video>
|
|
43
|
+
<button type="button" className="ui-video-big" aria-label="Play" tabIndex={s.playing ? -1 : 0} onClick={toggle}>{PLAY}</button>
|
|
44
|
+
<div className="ui-video-bar">
|
|
45
|
+
<button type="button" className="ui-video-btn" aria-label={s.playing ? "Pause" : "Play"} onClick={toggle}>{s.playing ? PAUSE : PLAY}</button>
|
|
46
|
+
<input
|
|
47
|
+
type="range" className="ui-slider" aria-label="Seek" min={0} max={Math.round(s.d * 10) || 0} value={Math.round(s.t * 10)}
|
|
48
|
+
aria-valuetext={`${clock(s.t)} of ${clock(s.d)}`} style={{ "--value": `${pct}%` }}
|
|
49
|
+
onChange={(e) => { v.current.currentTime = e.target.value / 10; }}
|
|
50
|
+
/>
|
|
51
|
+
<span className="ui-video-time">{clock(s.t)} / {clock(s.d)}</span>
|
|
52
|
+
<button type="button" className="ui-video-btn" aria-label={s.muted ? "Unmute" : "Mute"} onClick={() => { v.current.muted = !v.current.muted; }}>{s.muted ? MUTED : SOUND}</button>
|
|
53
|
+
<button type="button" className="ui-video-btn" aria-label="Full screen" onClick={() => (document.fullscreenElement ? document.exitFullscreen() : root.current.requestFullscreen?.())}>{FULL}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|