@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,28 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
const ICONS = {
|
|
4
|
+
info: <><circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" /></>,
|
|
5
|
+
ok: <><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></>,
|
|
6
|
+
warn: <><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4M12 17h.01" /></>,
|
|
7
|
+
critical: <><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6M9 9l6 6" /></>,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* <Alert tone="warn" title="Set times move Thursday">Check the schedule before you publish.</Alert>
|
|
12
|
+
* tone: "info" (default) | "ok" | "warn" | "critical". Critical uses role="alert" (announced at once),
|
|
13
|
+
* the others role="status". onClose adds a close button.
|
|
14
|
+
*/
|
|
15
|
+
export function Alert({ tone = "info", title, children, onClose, closeLabel = "Dismiss", className }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={cx("ui-alert", tone !== "info" && `ui-alert--${tone}`, className)} role={tone === "critical" ? "alert" : "status"}>
|
|
18
|
+
<svg className="ui-alert-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{ICONS[tone] || ICONS.info}</svg>
|
|
19
|
+
{title ? <p className="ui-alert-title">{title}</p> : null}
|
|
20
|
+
{children ? <p className="ui-alert-body">{children}</p> : null}
|
|
21
|
+
{onClose ? (
|
|
22
|
+
<button type="button" className="ui-alert-close" aria-label={closeLabel} onClick={onClose}>
|
|
23
|
+
<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>
|
|
24
|
+
</button>
|
|
25
|
+
) : null}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Annotations notes={[{ x: "12%", y: "16%", title: "One primary action", text: "…" }]}>{the design}</Annotations>
|
|
6
|
+
* Numbered pins over the design, each a button that lights up its note in the list beside it.
|
|
7
|
+
*/
|
|
8
|
+
export function Annotations({ notes, children, label = "Notes", className }) {
|
|
9
|
+
const [on, setOn] = useState(null);
|
|
10
|
+
return (
|
|
11
|
+
<div className={cx("ui-annot", className)}>
|
|
12
|
+
<div className="ui-annot-canvas">
|
|
13
|
+
{children}
|
|
14
|
+
{notes.map((n, i) => (
|
|
15
|
+
<button key={i} type="button" className="ui-annot-pin" aria-pressed={on === i} aria-label={`Note ${i + 1}: ${n.title}`}
|
|
16
|
+
style={{ left: n.x, top: n.y }} onClick={() => setOn(on === i ? null : i)}>{i + 1}</button>
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
<ol className="ui-annot-notes" aria-label={label}>
|
|
20
|
+
{notes.map((n, i) => <li key={i} data-active={on === i ? "" : undefined}><strong>{n.title}</strong><span>{n.text}</span></li>)}
|
|
21
|
+
</ol>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const NARROW = 720; // keep in step with the @container query in components.css
|
|
5
|
+
const Icon = ({ d }) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{d}</svg>;
|
|
6
|
+
const PANEL = <Icon d={<><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18" /></>} />;
|
|
7
|
+
const MENU = <Icon d={<path d="M4 6h16M4 12h16M4 18h16" />} />;
|
|
8
|
+
const CHEV = <Icon d={<path d="m6 9 6 6 6-6" />} />;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The app sidebar with a flyout, after the BKSTG workbench's own. Wide, it collapses to an icon rail; under
|
|
12
|
+
* 720px it slides over the page as a drawer (Escape or a tap outside closes it).
|
|
13
|
+
* <AppShell brand="BKSTG" title="Overview" label="Main" items={[
|
|
14
|
+
* { href: "/", label: "Overview", icon: <HomeIcon />, current: true },
|
|
15
|
+
* { href: "/lineup", label: "Lineup", icon: <MusicIcon />, count: 24 },
|
|
16
|
+
* { label: "Production", icon: <TruckIcon />, open: true, items: [{ href: "/stages", label: "Stages" }] },
|
|
17
|
+
* { heading: "Departments" },
|
|
18
|
+
* { href: "/booking", label: "Booking", icon: <MicIcon /> },
|
|
19
|
+
* ]} footer={[{ href: "/settings", label: "Settings", icon: <GearIcon /> }]}>…the page…</AppShell>
|
|
20
|
+
*/
|
|
21
|
+
export function AppShell({ brand, title, label = "Main", items = [], footer = [], defaultCollapsed = false, children, className }) {
|
|
22
|
+
const id = useId().replace(/:/g, ""), sbId = `sb-${id}`;
|
|
23
|
+
const root = useRef(null), side = useRef(null), opener = useRef(null);
|
|
24
|
+
const [narrow, setNarrow] = useState(false);
|
|
25
|
+
const [open, setOpen] = useState(!defaultCollapsed);
|
|
26
|
+
const [groups, setGroups] = useState(() => new Set(items.filter((it) => it.items && it.open).map((it) => it.label)));
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!root.current || typeof ResizeObserver === "undefined") return undefined;
|
|
29
|
+
const ro = new ResizeObserver(([e]) => { const n = e.contentRect.width <= NARROW; setNarrow(n); if (n) setOpen(false); });
|
|
30
|
+
ro.observe(root.current);
|
|
31
|
+
return () => ro.disconnect();
|
|
32
|
+
}, []);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!(narrow && open)) return undefined;
|
|
35
|
+
side.current?.querySelector(".ui-sidebar-toggle, .ui-sidebar-item")?.focus();
|
|
36
|
+
const key = (e) => { if (e.key === "Escape") { setOpen(false); opener.current?.focus(); } };
|
|
37
|
+
const tap = (e) => { if (side.current && !side.current.contains(e.target) && !opener.current?.contains(e.target)) setOpen(false); };
|
|
38
|
+
document.addEventListener("keydown", key); document.addEventListener("mousedown", tap);
|
|
39
|
+
return () => { document.removeEventListener("keydown", key); document.removeEventListener("mousedown", tap); };
|
|
40
|
+
}, [narrow, open]);
|
|
41
|
+
const toggleGroup = (name) => {
|
|
42
|
+
if (!open && !narrow) { setOpen(true); setGroups((s) => new Set(s).add(name)); return; }
|
|
43
|
+
setGroups((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
|
44
|
+
};
|
|
45
|
+
const link = (it, sub) => (
|
|
46
|
+
<a key={it.href + it.label} className="ui-sidebar-item" href={it.href} title={sub ? undefined : it.label} aria-current={it.current ? "page" : undefined}>
|
|
47
|
+
{it.icon ? <span aria-hidden="true" style={{ display: "contents" }}>{it.icon}</span> : null}
|
|
48
|
+
<span className="ui-sidebar-label">{it.label}</span>
|
|
49
|
+
{it.count != null ? <span className="ui-sidebar-count">{it.count}</span> : null}
|
|
50
|
+
</a>
|
|
51
|
+
);
|
|
52
|
+
const entry = (it) => {
|
|
53
|
+
if (it.heading) return <p key={"h-" + it.heading} className="ui-sidebar-head">{it.heading}</p>;
|
|
54
|
+
if (!it.items) return link(it);
|
|
55
|
+
const bodyId = `${sbId}-${it.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, isOpen = groups.has(it.label) && (open || narrow);
|
|
56
|
+
return (
|
|
57
|
+
<div key={"g-" + it.label} className="ui-sidebar-group">
|
|
58
|
+
<button type="button" className="ui-sidebar-item" aria-expanded={isOpen} aria-controls={bodyId} title={it.label} onClick={() => toggleGroup(it.label)}>
|
|
59
|
+
{it.icon ? <span aria-hidden="true" style={{ display: "contents" }}>{it.icon}</span> : null}
|
|
60
|
+
<span className="ui-sidebar-label">{it.label}</span><span className="ui-sidebar-chev">{CHEV}</span>
|
|
61
|
+
</button>
|
|
62
|
+
<div className="ui-sidebar-group-body" id={bodyId} hidden={!isOpen}>{it.items.map((s) => link(s, true))}</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
const toggleLabel = narrow ? "Close menu" : open ? "Collapse sidebar" : "Expand sidebar";
|
|
67
|
+
return (
|
|
68
|
+
<div ref={root} className={cx("ui-app", className)}>
|
|
69
|
+
<nav ref={side} className="ui-sidebar" id={sbId} data-state={open ? "expanded" : "collapsed"} aria-label={label}>
|
|
70
|
+
<div className="ui-sidebar-top">
|
|
71
|
+
{brand ? <span className="ui-sidebar-brand">{brand}</span> : null}
|
|
72
|
+
<button type="button" className="ui-sidebar-toggle" aria-controls={sbId} aria-expanded={open} aria-label={toggleLabel}
|
|
73
|
+
onClick={() => { setOpen(!open); if (open && narrow) opener.current?.focus(); }}>{PANEL}</button>
|
|
74
|
+
</div>
|
|
75
|
+
{items.map(entry)}
|
|
76
|
+
{footer.length ? <div className="ui-sidebar-foot">{footer.map((it) => link(it))}</div> : null}
|
|
77
|
+
</nav>
|
|
78
|
+
<div className="ui-app-main">
|
|
79
|
+
<div className="ui-app-bar">
|
|
80
|
+
<button ref={opener} type="button" className="ui-sidebar-open" aria-controls={sbId} aria-expanded={open} aria-label="Open menu" onClick={() => setOpen(true)}>{MENU}</button>
|
|
81
|
+
{title ? <strong>{title}</strong> : null}
|
|
82
|
+
</div>
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Avatar name="Grace Lee" src={url} size="lg" online />
|
|
5
|
+
* Without src, shows initials. name is the accessible name either way.
|
|
6
|
+
*/
|
|
7
|
+
export function Avatar({ name, src, size = "md", online = false, className }) {
|
|
8
|
+
const initials = (name || "").split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join("");
|
|
9
|
+
return (
|
|
10
|
+
<span className={cx("ui-avatar", size !== "md" && `ui-avatar--${size}`, className)} role="img" aria-label={name + (online ? ", online" : "")}>
|
|
11
|
+
{src ? <img src={src} alt="" /> : <span aria-hidden="true">{initials}</span>}
|
|
12
|
+
{online ? <span className="ui-avatar-status" aria-hidden="true" /> : null}
|
|
13
|
+
</span>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AvatarGroup({ label, className, children }) {
|
|
18
|
+
return <span className={cx("ui-avatar-group", className)} role="group" aria-label={label}>{children}</span>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Badge tone="ok" dot>Live</Badge>
|
|
5
|
+
* tone: "neutral" (default) | "ok" | "warn" | "critical" | "brand" | "outline"
|
|
6
|
+
*/
|
|
7
|
+
export function Badge({ tone = "neutral", dot = false, className, children, ...rest }) {
|
|
8
|
+
return (
|
|
9
|
+
<span className={cx("ui-badge", tone !== "neutral" && `ui-badge--${tone}`, className)} {...rest}>
|
|
10
|
+
{dot ? <span className="ui-badge-dot" aria-hidden="true" /> : null}
|
|
11
|
+
{children}
|
|
12
|
+
</span>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
const X = <svg 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>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* <Banner tone="fill" link={{ href: "/schedule", label: "Open the schedule" }} onClose={hide}>Set times are live.</Banner>
|
|
7
|
+
* tone: default (inverted) | "fill" | "subtle"; card puts it in a rounded box. A named region, so it can be found.
|
|
8
|
+
*/
|
|
9
|
+
export function Banner({ children, tone, card = false, link, onClose, label = "Announcement", className }) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={cx("ui-banner", tone && `ui-banner--${tone}`, card && "ui-banner--card", className)} role="region" aria-label={label}>
|
|
12
|
+
<span>{children}</span>
|
|
13
|
+
{link ? <a href={link.href}>{link.label}</a> : null}
|
|
14
|
+
{onClose ? <button type="button" className="ui-banner-close" aria-label={`Dismiss ${label.toLowerCase()}`} onClick={onClose}>{X}</button> : null}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Breadcrumbs items={[{ href: "/", label: "Home", icon: <HomeIcon /> }, { href: "/events", label: "Events" }, { label: "Spring 2026" }]} />
|
|
5
|
+
* The last item is the current page: plain text with aria-current="page", never a link.
|
|
6
|
+
*/
|
|
7
|
+
export function Breadcrumbs({ items, label = "Breadcrumb", className }) {
|
|
8
|
+
return (
|
|
9
|
+
<nav className={cx("ui-breadcrumb", className)} aria-label={label}>
|
|
10
|
+
<ol>
|
|
11
|
+
{items.map((it, i) => {
|
|
12
|
+
const last = i === items.length - 1;
|
|
13
|
+
return (
|
|
14
|
+
<li key={i}>
|
|
15
|
+
{last ? <span aria-current="page">{it.icon}{it.label}</span>
|
|
16
|
+
: <a href={it.href} aria-label={it.icon && !it.label ? it.ariaLabel : undefined}>{it.icon}{it.label}</a>}
|
|
17
|
+
</li>
|
|
18
|
+
);
|
|
19
|
+
})}
|
|
20
|
+
</ol>
|
|
21
|
+
</nav>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Button variant="primary" size="sm">Save</Button>
|
|
6
|
+
* variant: "secondary" (default) | "primary" | "ghost" | "destructive" | "link"
|
|
7
|
+
* size: "md" (default) | "sm" | "lg" | "icon" (icon needs an aria-label)
|
|
8
|
+
* loading: shows a spinner, sets aria-busy, blocks clicks
|
|
9
|
+
* Same markup and classes as the HTML version; the look lives in components.css.
|
|
10
|
+
*/
|
|
11
|
+
export const Button = forwardRef(function Button(
|
|
12
|
+
{ variant = "secondary", size = "md", loading = false, className, type = "button", children, ...rest },
|
|
13
|
+
ref
|
|
14
|
+
) {
|
|
15
|
+
return (
|
|
16
|
+
<button
|
|
17
|
+
ref={ref}
|
|
18
|
+
type={type}
|
|
19
|
+
aria-busy={loading || undefined}
|
|
20
|
+
className={cx(
|
|
21
|
+
"ui-btn",
|
|
22
|
+
variant !== "secondary" && `ui-btn--${variant}`,
|
|
23
|
+
size !== "md" && `ui-btn--${size}`,
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
26
|
+
{...rest}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <ButtonGroup label="View" value={view} onChange={setView} items={[
|
|
5
|
+
* { value: "list", label: "List" }, { value: "board", label: "Board" } ]} />
|
|
6
|
+
* One pressed at a time. With multiple, value is an array and each button toggles on its own.
|
|
7
|
+
* Each button reports its state with aria-pressed.
|
|
8
|
+
*/
|
|
9
|
+
export function ButtonGroup({ label, items, value, onChange, multiple = false, className }) {
|
|
10
|
+
const on = (v) => (multiple ? (value || []).includes(v) : value === v);
|
|
11
|
+
function press(v) {
|
|
12
|
+
if (!onChange) return;
|
|
13
|
+
if (!multiple) return onChange(v);
|
|
14
|
+
const list = value || [];
|
|
15
|
+
onChange(list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
|
|
16
|
+
}
|
|
17
|
+
return (
|
|
18
|
+
<div className={cx("ui-btngroup", className)} role="group" aria-label={label}>
|
|
19
|
+
{items.map((it) => (
|
|
20
|
+
<button
|
|
21
|
+
key={it.value} type="button" className="ui-btngroup-item" disabled={it.disabled}
|
|
22
|
+
aria-pressed={on(it.value)} aria-label={it.ariaLabel} onClick={() => press(it.value)}
|
|
23
|
+
>
|
|
24
|
+
{it.icon}{it.label}
|
|
25
|
+
</button>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
5
|
+
const DOW = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], DOW_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
6
|
+
export const toIso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
7
|
+
export const fromIso = (s) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s || ""); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; };
|
|
8
|
+
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>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* <Calendar value="2026-04-18" onChange={setDate} events={["2026-04-11"]} />
|
|
12
|
+
* A month as a grid: arrows move a day, Page Up/Down a month, Home/End the week. Dates are "YYYY-MM-DD" strings.
|
|
13
|
+
*/
|
|
14
|
+
export function Calendar({ value, onChange, events = [], today = new Date(), autoFocus = false, label, className }) {
|
|
15
|
+
const [cursor, setCursor] = useState(() => fromIso(value) || today);
|
|
16
|
+
const focus = useRef(autoFocus), grid = useRef(null);
|
|
17
|
+
useEffect(() => { if (focus.current) grid.current?.querySelector('[tabindex="0"]')?.focus(); focus.current = false; });
|
|
18
|
+
const y = cursor.getFullYear(), mo = cursor.getMonth(), start = new Date(y, mo, 1 - new Date(y, mo, 1).getDay());
|
|
19
|
+
const move = (days, months) => { focus.current = true; setCursor((c) => (months ? new Date(c.getFullYear(), c.getMonth() + months, Math.min(c.getDate(), 28)) : new Date(c.getFullYear(), c.getMonth(), c.getDate() + days))); };
|
|
20
|
+
function onKey(e) {
|
|
21
|
+
const m = { ArrowLeft: [-1], ArrowRight: [1], ArrowUp: [-7], ArrowDown: [7], Home: [-cursor.getDay()], End: [6 - cursor.getDay()], PageUp: [0, -1], PageDown: [0, 1] }[e.key];
|
|
22
|
+
if (m) { e.preventDefault(); move(m[0], m[1]); }
|
|
23
|
+
}
|
|
24
|
+
const titleId = `cal-${y}-${mo}`;
|
|
25
|
+
return (
|
|
26
|
+
<div className={cx("ui-calendar", className)}>
|
|
27
|
+
<div className="ui-calendar-head">
|
|
28
|
+
<button type="button" className="ui-btn ui-btn--sm ui-btn--icon ui-btn--ghost" aria-label="Previous month" onClick={() => setCursor(new Date(y, mo - 1, 1))}><Arrow d="m15 18-6-6 6-6" /></button>
|
|
29
|
+
<h3 className="ui-calendar-title" id={titleId} aria-live="polite">{`${MONTHS[mo]} ${y}`}</h3>
|
|
30
|
+
<button type="button" className="ui-btn ui-btn--sm ui-btn--icon ui-btn--ghost" aria-label="Next month" onClick={() => setCursor(new Date(y, mo + 1, 1))}><Arrow d="m9 18 6-6-6-6" /></button>
|
|
31
|
+
</div>
|
|
32
|
+
<table role="grid" aria-labelledby={titleId} aria-label={label} ref={grid} onKeyDown={onKey}>
|
|
33
|
+
<thead><tr>{DOW.map((d, i) => <th key={d} scope="col" abbr={DOW_LONG[i]}>{d}</th>)}</tr></thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
{Array.from({ length: 6 }, (_, w) => (
|
|
36
|
+
<tr key={w}>
|
|
37
|
+
{Array.from({ length: 7 }, (_, d) => {
|
|
38
|
+
const day = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d), s = toIso(day), ev = events.includes(s);
|
|
39
|
+
return (
|
|
40
|
+
<td key={s} role="gridcell">
|
|
41
|
+
<button
|
|
42
|
+
type="button" className="ui-day" tabIndex={s === toIso(cursor) ? 0 : -1}
|
|
43
|
+
aria-label={`${DOW_LONG[day.getDay()]}, ${MONTHS[day.getMonth()]} ${day.getDate()}${ev ? ", has events" : ""}`}
|
|
44
|
+
aria-current={s === toIso(today) ? "date" : undefined} aria-selected={s === value}
|
|
45
|
+
data-outside={day.getMonth() !== mo ? "" : undefined} data-event={ev ? "" : undefined}
|
|
46
|
+
onClick={() => { setCursor(day); onChange?.(s); }}
|
|
47
|
+
>{day.getDate()}</button>
|
|
48
|
+
</td>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/** <CardHeader title="Ticket sales" badge={<Badge>Live</Badge>} description="Updated every minute." actions={<Button size="sm">Export</Button>} /> */
|
|
4
|
+
export function CardHeader({ title, badge, description, actions, as: H = "h3", className }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className={cx("ui-cardhead", className)}>
|
|
7
|
+
<div className="ui-cardhead-text">
|
|
8
|
+
<H className="ui-cardhead-title">{title}{badge}</H>
|
|
9
|
+
{description ? <p className="ui-cardhead-desc">{description}</p> : null}
|
|
10
|
+
</div>
|
|
11
|
+
{actions ? <div className="ui-cardhead-actions">{actions}</div> : null}
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createElement, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { chartNodes, toReact, animate, motionLevel, showTip, hideTip } from "../charts-core.mjs";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The kit's charts in React: the same geometry as charts.js (charts-core.mjs), so both draw the same chart.
|
|
7
|
+
* <Chart type="bar" label="Monthly expenses" labels={["Jan", "Feb"]} values={[4200, 7100]} prefix="$" />
|
|
8
|
+
* <Chart type="bar" stacked label="File transfer" labels={months} series={[{ name: "Desktop", values }, { name: "Mobile", values }]} />
|
|
9
|
+
* <Chart type="donut" label="Leads by source" center="Leads" labels={sources} values={counts} />
|
|
10
|
+
* <Chart type="ring" label="Target reached" value={48} center="of target" />
|
|
11
|
+
* type: bar | line | area | donut | pie | spark | ring | meter. horizontal, stacked (bar); bars (spark); max (ring, meter);
|
|
12
|
+
* prefix, suffix, decimals; tone good | warn | neg for one series that means good or bad.
|
|
13
|
+
* animate={false} when Motion (or anything else) plays it in instead.
|
|
14
|
+
* Draws at its real width, plays in once when it scrolls into view (as far as the motion level allows), and keeps a
|
|
15
|
+
* data table for screen readers.
|
|
16
|
+
*/
|
|
17
|
+
export function Chart({ type, label = "Chart", labels = [], values, series, stacked = false, horizontal = false, bars = false, value, max,
|
|
18
|
+
prefix = "", suffix = "", decimals = 0, tone = "", center = "", height, animate: play = true, className }) {
|
|
19
|
+
const ref = useRef(null);
|
|
20
|
+
const [width, setWidth] = useState(600);
|
|
21
|
+
const spec = { type, label, labels, series: series || (values ? [{ name: label, values }] : []), stacked, horizontal, bars, tone, center,
|
|
22
|
+
prefix, suffix, decimals, value, max: max ?? (type === "ring" ? 100 : undefined) };
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const fig = ref.current; if (!fig || typeof ResizeObserver !== "function") return undefined;
|
|
25
|
+
const ro = new ResizeObserver(([e]) => { const w = Math.round(e.contentRect.width); setWidth(prev => Math.abs(w - prev) > 8 ? w : prev); });
|
|
26
|
+
ro.observe(fig);
|
|
27
|
+
return () => ro.disconnect();
|
|
28
|
+
}, []);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const fig = ref.current; if (!play || !fig || typeof IntersectionObserver !== "function") return undefined;
|
|
31
|
+
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); animate(fig, motionLevel()); } }, { threshold: 0.25 });
|
|
32
|
+
io.observe(fig);
|
|
33
|
+
return () => io.disconnect();
|
|
34
|
+
}, []);
|
|
35
|
+
const h = height ?? (type === "spark" ? 40 : 220);
|
|
36
|
+
return (
|
|
37
|
+
<figure ref={ref} className={cx("ui-viz", `ui-viz--${type}`, horizontal && "ui-viz--horizontal", className)} aria-label={label}
|
|
38
|
+
onPointerMove={e => showTip(ref.current, spec, e)} onPointerLeave={() => hideTip(ref.current)}>
|
|
39
|
+
{toReact(chartNodes(spec, width, h), createElement)}
|
|
40
|
+
</figure>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const SEND = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m22 2-7 20-4-9-9-4z" /><path d="M22 2 11 13" /></svg>;
|
|
5
|
+
const initials = (n) => n.split(" ").map((w) => w[0]).join("").slice(0, 2);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* <Chat label="Production chat" messages={[{ id: 1, from: "Grace Lee", text: "Doors at noon?", time: "9:41" }, { id: 2, mine: true, text: "Yes.", time: "9:42" }]} onSend={send} />
|
|
9
|
+
* Messages read out as they arrive (role="log"). Enter sends, Shift+Enter adds a line.
|
|
10
|
+
*/
|
|
11
|
+
export function Chat({ label, messages, onSend, placeholder = "Write a message", className }) {
|
|
12
|
+
const [text, setText] = useState("");
|
|
13
|
+
const log = useRef(null);
|
|
14
|
+
useEffect(() => { if (log.current) log.current.scrollTop = log.current.scrollHeight; }, [messages.length]);
|
|
15
|
+
const send = () => { const t = text.trim(); if (!t) return; onSend?.(t); setText(""); };
|
|
16
|
+
return (
|
|
17
|
+
<section className={cx("ui-chat", className)} aria-label={label}>
|
|
18
|
+
<ol className="ui-chat-log" role="log" aria-live="polite" ref={log}>
|
|
19
|
+
{messages.map((m) => (
|
|
20
|
+
<li key={m.id} className={cx("ui-msg", m.mine && "ui-msg--out")}>
|
|
21
|
+
{!m.mine ? <span className="ui-avatar ui-avatar--sm" role="img" aria-label={m.from}><span aria-hidden="true">{initials(m.from)}</span></span> : null}
|
|
22
|
+
<div className="ui-msg-bubble">{m.text}</div>
|
|
23
|
+
<span className="ui-msg-meta">{m.mine ? "You" : m.from} · {m.time}</span>
|
|
24
|
+
</li>
|
|
25
|
+
))}
|
|
26
|
+
</ol>
|
|
27
|
+
<div className="ui-chat-compose">
|
|
28
|
+
<textarea className="ui-input" rows={1} aria-label="Message" placeholder={placeholder} value={text} onChange={(e) => setText(e.target.value)}
|
|
29
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(); } }} />
|
|
30
|
+
<button type="button" className="ui-btn ui-btn--primary ui-btn--icon" aria-label="Send" onClick={send}>{SEND}</button>
|
|
31
|
+
</div>
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Checkbox checked={on} onChange={(e) => setOn(e.target.checked)} hint="We send one a week.">Newsletter</Checkbox>
|
|
6
|
+
* A real checkbox: Space toggles it, its label is clickable, forms submit it.
|
|
7
|
+
* indeterminate shows the mixed state, for a "select all" box when only some are checked.
|
|
8
|
+
*/
|
|
9
|
+
export const Checkbox = forwardRef(function Checkbox({ children, hint, indeterminate = false, className, ...rest }, ref) {
|
|
10
|
+
const inner = useRef(null);
|
|
11
|
+
useImperativeHandle(ref, () => inner.current);
|
|
12
|
+
// indeterminate is a property, never an attribute, so it is set on the element; the browser then reports "mixed"
|
|
13
|
+
useEffect(() => { if (inner.current) inner.current.indeterminate = indeterminate; }, [indeterminate]);
|
|
14
|
+
const input = <input ref={inner} type="checkbox" className={cx("ui-check", className)} {...rest} />;
|
|
15
|
+
if (!children) return input;
|
|
16
|
+
return (
|
|
17
|
+
<label className="ui-check-row">
|
|
18
|
+
{input}
|
|
19
|
+
{hint ? <span className="ui-check-text">{children}<small>{hint}</small></span> : children}
|
|
20
|
+
</label>
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** <Choices legend="Notify me about">…checkboxes or radios…</Choices>: a fieldset, so the group has a name */
|
|
25
|
+
export function Choices({ legend, children, className, ...rest }) {
|
|
26
|
+
return (
|
|
27
|
+
<fieldset className={cx("ui-choices", className)} {...rest}>
|
|
28
|
+
{legend ? <legend className="ui-label">{legend}</legend> : null}
|
|
29
|
+
{children}
|
|
30
|
+
</fieldset>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/** <CodeSnippet language="html" code={'<link rel="stylesheet" href="bkstg.css">'} /> A copy button copies the code. */
|
|
5
|
+
export function CodeSnippet({ code, language, className }) {
|
|
6
|
+
const [said, setSaid] = useState("Copy");
|
|
7
|
+
const copy = () => navigator.clipboard?.writeText(code).then(() => setSaid("Copied"), () => setSaid("Copy failed")).finally(() => setTimeout(() => setSaid("Copy"), 2000));
|
|
8
|
+
return (
|
|
9
|
+
<figure className={cx("ui-code", className)}>
|
|
10
|
+
<figcaption className="ui-code-head">
|
|
11
|
+
<span className="ui-code-lang">{language}</span>
|
|
12
|
+
<button type="button" className="ui-btn ui-btn--sm ui-btn--ghost" onClick={copy}><span>{said}</span></button>
|
|
13
|
+
</figcaption>
|
|
14
|
+
<pre><code>{code}</code></pre>
|
|
15
|
+
</figure>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <ColorPicker label="Stage color" value={color} onChange={setColor} swatches={["#c6f135", "#4f46e5", "#db2777"]} />
|
|
6
|
+
* Swatches (a radio group: arrows move), the browser's own picker, and a hex field, all in step.
|
|
7
|
+
*/
|
|
8
|
+
export function ColorPicker({ label, value, onChange, swatches = [], className }) {
|
|
9
|
+
const id = useId();
|
|
10
|
+
const valid = /^#[0-9a-f]{6}$/i.test(value || "");
|
|
11
|
+
return (
|
|
12
|
+
<div className={cx("ui-colorpick", className)}>
|
|
13
|
+
<span className="ui-label" id={`${id}-l`}>{label}</span>
|
|
14
|
+
{swatches.length ? (
|
|
15
|
+
<div className="ui-swatches" role="radiogroup" aria-labelledby={`${id}-l`}
|
|
16
|
+
onKeyDown={(e) => {
|
|
17
|
+
const i = swatches.findIndex((s) => s.toLowerCase() === (value || "").toLowerCase());
|
|
18
|
+
const n = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
|
|
19
|
+
if (n) { e.preventDefault(); const next = swatches[(i + n + swatches.length) % swatches.length]; onChange?.(next); e.currentTarget.querySelectorAll(".ui-swatch")[swatches.indexOf(next)]?.focus(); }
|
|
20
|
+
}}>
|
|
21
|
+
{swatches.map((s, i) => {
|
|
22
|
+
const on = s.toLowerCase() === (value || "").toLowerCase();
|
|
23
|
+
return <button key={s} type="button" role="radio" className="ui-swatch" style={{ background: s }} aria-label={s} aria-checked={on}
|
|
24
|
+
tabIndex={on || (!swatches.some((x) => x.toLowerCase() === (value || "").toLowerCase()) && i === 0) ? 0 : -1} onClick={() => onChange?.(s)} />;
|
|
25
|
+
})}
|
|
26
|
+
</div>
|
|
27
|
+
) : null}
|
|
28
|
+
<div className="ui-colorpick-row">
|
|
29
|
+
<input type="color" aria-label={`${label}, picker`} value={valid ? value : "#000000"} onChange={(e) => onChange?.(e.target.value)} />
|
|
30
|
+
<input type="text" className="ui-input" aria-label={`${label}, hex`} defaultValue={value} key={value} maxLength={7} spellCheck={false}
|
|
31
|
+
aria-invalid={!valid || undefined} onChange={(e) => { const v = e.target.value.trim(); if (/^#?[0-9a-f]{6}$/i.test(v)) onChange?.(v[0] === "#" ? v : `#${v}`); }} />
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const SEARCH = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* <CommandMenu open={open} onOpenChange={setOpen} groups={[{ label: "Go to", items: [{ id: "lineup", label: "Lineup", icon, hint: "G L", keywords: "artists", onRun }] }]} />
|
|
8
|
+
* A dialog with a search box over a list: type to filter, arrows to move, Enter to run. ⌘K or Ctrl+K opens it.
|
|
9
|
+
*/
|
|
10
|
+
export function CommandMenu({ open, onOpenChange, groups, placeholder = "Type a command or search…", className }) {
|
|
11
|
+
const id = useId(), dlg = useRef(null), input = useRef(null);
|
|
12
|
+
const [q, setQ] = useState(""), [active, setActive] = useState(0);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const key = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); onOpenChange?.(!open); } };
|
|
15
|
+
document.addEventListener("keydown", key);
|
|
16
|
+
return () => document.removeEventListener("keydown", key);
|
|
17
|
+
}, [open, onOpenChange]);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const d = dlg.current; if (!d) return;
|
|
20
|
+
if (open && !d.open) { setQ(""); setActive(0); d.showModal(); input.current?.focus(); }
|
|
21
|
+
if (!open && d.open) d.close();
|
|
22
|
+
}, [open]);
|
|
23
|
+
const shown = useMemo(() => groups.map((g) => ({ ...g, items: g.items.filter((it) => !q || `${it.label} ${it.keywords || ""}`.toLowerCase().includes(q.toLowerCase())) })).filter((g) => g.items.length), [groups, q]);
|
|
24
|
+
const flat = shown.flatMap((g) => g.items);
|
|
25
|
+
const run = (it) => { if (!it) return; onOpenChange?.(false); it.onRun?.(); };
|
|
26
|
+
function onKey(e) {
|
|
27
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => (a + 1) % Math.max(flat.length, 1)); }
|
|
28
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (a - 1 + flat.length) % Math.max(flat.length, 1)); }
|
|
29
|
+
else if (e.key === "Enter") { e.preventDefault(); run(flat[active]); }
|
|
30
|
+
}
|
|
31
|
+
let n = -1;
|
|
32
|
+
return (
|
|
33
|
+
<dialog ref={dlg} className={cx("ui-dialog ui-command", className)} aria-label="Command menu" onClose={() => onOpenChange?.(false)} onClick={(e) => { if (e.target === dlg.current) onOpenChange?.(false); }}>
|
|
34
|
+
<div className="ui-command-search">{SEARCH}
|
|
35
|
+
<input ref={input} role="combobox" aria-expanded="true" aria-controls={`${id}-list`} aria-autocomplete="list" aria-label="Search commands"
|
|
36
|
+
aria-activedescendant={flat[active] ? `${id}-${flat[active].id}` : undefined} placeholder={placeholder} value={q}
|
|
37
|
+
onChange={(e) => { setQ(e.target.value); setActive(0); }} onKeyDown={onKey} />
|
|
38
|
+
</div>
|
|
39
|
+
<div className="ui-command-list" id={`${id}-list`} role="listbox" aria-label="Commands">
|
|
40
|
+
{shown.map((g) => (
|
|
41
|
+
<div key={g.label} role="group" aria-label={g.label}>
|
|
42
|
+
<div className="ui-command-group" aria-hidden="true">{g.label}</div>
|
|
43
|
+
{g.items.map((it) => { n += 1; const me = n; return (
|
|
44
|
+
<div key={it.id} id={`${id}-${it.id}`} role="option" className="ui-command-item" aria-selected={me === active}
|
|
45
|
+
onMouseMove={() => setActive(me)} onClick={() => run(it)}>{it.icon}{it.label}{it.hint ? <kbd>{it.hint}</kbd> : null}</div>
|
|
46
|
+
); })}
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
{!flat.length ? <p className="ui-command-empty">Nothing matches "{q}".</p> : null}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="ui-command-foot" aria-hidden="true"><span><kbd className="ui-kbd">↑</kbd> <kbd className="ui-kbd">↓</kbd> move</span><span><kbd className="ui-kbd">↵</kbd> run</span><span><kbd className="ui-kbd">esc</kbd> close</span></div>
|
|
52
|
+
</dialog>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
import { Calendar, fromIso } from "./Calendar.jsx";
|
|
4
|
+
|
|
5
|
+
const CAL = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* <DatePicker label="Show date" value={date} onChange={setDate} placeholder="Pick a date" />
|
|
9
|
+
* A button showing the date; it opens a calendar under it. A choice, Escape or a click outside closes it.
|
|
10
|
+
*/
|
|
11
|
+
export function DatePicker({ value, onChange, placeholder = "Pick a date", label, className }) {
|
|
12
|
+
const [open, setOpen] = useState(false);
|
|
13
|
+
const root = useRef(null), btn = useRef(null);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!open) return undefined;
|
|
16
|
+
const out = (e) => { if (!root.current?.contains(e.target)) setOpen(false); };
|
|
17
|
+
document.addEventListener("mousedown", out);
|
|
18
|
+
return () => document.removeEventListener("mousedown", out);
|
|
19
|
+
}, [open]);
|
|
20
|
+
const d = fromIso(value);
|
|
21
|
+
return (
|
|
22
|
+
<div ref={root} className={cx("ui-datepicker", className)} onKeyDown={(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); setOpen(false); btn.current?.focus(); } }}>
|
|
23
|
+
<button ref={btn} type="button" className="ui-input ui-datepicker-btn" aria-haspopup="dialog" aria-expanded={open} aria-label={label ? `${label}: ${d ? d.toDateString() : "none"}` : undefined}
|
|
24
|
+
data-empty={d ? undefined : ""} onClick={() => setOpen((o) => !o)}>
|
|
25
|
+
{CAL}<span>{d ? d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" }) : placeholder}</span>
|
|
26
|
+
</button>
|
|
27
|
+
{open ? (
|
|
28
|
+
<div className="ui-datepicker-pop" role="dialog" aria-label={label || "Choose a date"}>
|
|
29
|
+
<Calendar value={value} autoFocus onChange={(s) => { onChange?.(s); setOpen(false); btn.current?.focus(); }} />
|
|
30
|
+
</div>
|
|
31
|
+
) : null}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/** <DeviceFrame kind="browser" | "laptop" | "phone" caption="Ticketing, desktop"><img src="…" alt="…" /></DeviceFrame> */
|
|
4
|
+
export function DeviceFrame({ kind = "browser", caption, children, className }) {
|
|
5
|
+
return (
|
|
6
|
+
<figure className={cx("ui-device", `ui-device--${kind}`, className)}>
|
|
7
|
+
{kind === "browser" ? <div className="ui-device-chrome" aria-hidden="true"><i /><i /><i /><span /></div> : null}
|
|
8
|
+
<div className="ui-device-screen">{children}</div>
|
|
9
|
+
{caption ? <figcaption>{caption}</figcaption> : null}
|
|
10
|
+
</figure>
|
|
11
|
+
);
|
|
12
|
+
}
|