@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,43 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Dialog open={open} onClose={() => setOpen(false)} title="Delete file?" description="…" footer={…}>…</Dialog>
|
|
5
|
+
* A native <dialog> opened as a modal: the page behind is inert, Escape closes it, focus returns
|
|
6
|
+
* to what opened it, and a click on the backdrop closes it.
|
|
7
|
+
*/
|
|
8
|
+
export function Dialog({ open, onClose, title, description, footer, children, closeLabel = "Close" }) {
|
|
9
|
+
const ref = useRef(null);
|
|
10
|
+
const opener = useRef(null);
|
|
11
|
+
const titleId = useId();
|
|
12
|
+
const descId = useId();
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const d = ref.current;
|
|
16
|
+
if (!d) return;
|
|
17
|
+
if (open && !d.open) { opener.current = document.activeElement; d.showModal(); }
|
|
18
|
+
if (!open && d.open) d.close();
|
|
19
|
+
}, [open]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<dialog
|
|
23
|
+
ref={ref}
|
|
24
|
+
className="ui-dialog"
|
|
25
|
+
aria-labelledby={titleId}
|
|
26
|
+
aria-describedby={description ? descId : undefined}
|
|
27
|
+
onClose={() => { onClose?.(); opener.current?.focus?.(); }}
|
|
28
|
+
onClick={(e) => { if (e.target === ref.current) ref.current.close(); }}
|
|
29
|
+
>
|
|
30
|
+
<div className="ui-dialog-head">
|
|
31
|
+
<div style={{ flex: 1 }}>
|
|
32
|
+
<h2 className="ui-dialog-title" id={titleId}>{title}</h2>
|
|
33
|
+
{description ? <p className="ui-dialog-desc" id={descId}>{description}</p> : null}
|
|
34
|
+
</div>
|
|
35
|
+
<button type="button" className="ui-dialog-close" aria-label={closeLabel} onClick={() => ref.current?.close()}>
|
|
36
|
+
<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>
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
{children ? <div className="ui-dialog-body">{children}</div> : null}
|
|
40
|
+
{footer ? <div className="ui-dialog-foot">{footer}</div> : null}
|
|
41
|
+
</dialog>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Divider /> a plain rule
|
|
5
|
+
* <Divider>or</Divider> a rule with a word or a button in the middle; align="start" puts it near the left
|
|
6
|
+
*/
|
|
7
|
+
export function Divider({ children, align, className }) {
|
|
8
|
+
if (!children) return <hr className={cx("ui-divider", className)} />;
|
|
9
|
+
return <div className={cx("ui-divider", align === "start" && "ui-divider--start", className)} role="separator">{children}</div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const i = (d) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{d}</svg>;
|
|
5
|
+
export const EDITOR_TOOLS = [
|
|
6
|
+
{ cmd: "bold", label: "Bold", icon: i(<path d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />) },
|
|
7
|
+
{ cmd: "italic", label: "Italic", icon: i(<path d="M19 4h-9M14 20H5M15 4 9 20" />) },
|
|
8
|
+
{ cmd: "underline", label: "Underline", icon: i(<path d="M6 4v6a6 6 0 0 0 12 0V4M4 20h16" />) },
|
|
9
|
+
{ sep: true },
|
|
10
|
+
{ cmd: "insertUnorderedList", label: "Bulleted list", icon: i(<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />) },
|
|
11
|
+
{ cmd: "insertOrderedList", label: "Numbered list", icon: i(<path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />) },
|
|
12
|
+
{ cmd: "formatBlock", label: "Quote", icon: i(<path d="M3 21c3 0 7-1 7-8V5H3v7h4c0 3-1 5-4 5zM14 21c3 0 7-1 7-8V5h-7v7h4c0 3-1 5-4 5z" />) },
|
|
13
|
+
{ sep: true },
|
|
14
|
+
{ cmd: "removeFormat", label: "Clear formatting", press: false, icon: i(<path d="M4 7V4h16v3M5 20h6M13 4 8 20M15 15l5 5M20 15l-5 5" />) }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* <Editor label="Announcement" defaultValue="<p>Doors at noon.</p>" onChange={(html) => setDraft(html)} />
|
|
19
|
+
* A toolbar over an editable area, using the browser's editing commands, so ⌘B, ⌘I, ⌘U and undo work.
|
|
20
|
+
* The HTML it hands back is whatever the browser produced: clean it on the server before you store or show it.
|
|
21
|
+
*/
|
|
22
|
+
export function Editor({ label, defaultValue = "", placeholder = "Write something…", onChange, className }) {
|
|
23
|
+
const id = useId();
|
|
24
|
+
const area = useRef(null), bar = useRef(null);
|
|
25
|
+
const [on, setOn] = useState({});
|
|
26
|
+
const [words, setWords] = useState(0);
|
|
27
|
+
const [focus, setFocus] = useState(0);
|
|
28
|
+
|
|
29
|
+
function sync() {
|
|
30
|
+
const next = {};
|
|
31
|
+
for (const t of EDITOR_TOOLS) if (t.cmd && t.press !== false) {
|
|
32
|
+
try { next[t.cmd] = t.cmd === "formatBlock" ? /blockquote/i.test(document.queryCommandValue("formatBlock")) : document.queryCommandState(t.cmd); } catch { next[t.cmd] = false; }
|
|
33
|
+
}
|
|
34
|
+
setOn(next);
|
|
35
|
+
setWords((area.current?.textContent.trim().split(/\s+/).filter(Boolean) || []).length);
|
|
36
|
+
}
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (area.current) area.current.innerHTML = defaultValue;
|
|
39
|
+
sync();
|
|
40
|
+
const sel = () => { if (area.current?.contains(document.getSelection()?.anchorNode)) sync(); };
|
|
41
|
+
document.addEventListener("selectionchange", sel);
|
|
42
|
+
return () => document.removeEventListener("selectionchange", sel);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
function run(t) {
|
|
46
|
+
area.current?.focus();
|
|
47
|
+
if (t.cmd === "formatBlock") document.execCommand("formatBlock", false, on.formatBlock ? "p" : "blockquote");
|
|
48
|
+
else document.execCommand(t.cmd, false, null);
|
|
49
|
+
sync(); onChange?.(area.current.innerHTML);
|
|
50
|
+
}
|
|
51
|
+
const tools = EDITOR_TOOLS.filter((t) => !t.sep);
|
|
52
|
+
function onBarKey(e) {
|
|
53
|
+
const n = tools.length;
|
|
54
|
+
const to = e.key === "ArrowRight" ? (focus + 1) % n : e.key === "ArrowLeft" ? (focus - 1 + n) % n : e.key === "Home" ? 0 : e.key === "End" ? n - 1 : null;
|
|
55
|
+
if (to === null) return;
|
|
56
|
+
e.preventDefault(); setFocus(to);
|
|
57
|
+
bar.current?.querySelectorAll(".ui-editor-tool")[to]?.focus();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let k = -1;
|
|
61
|
+
return (
|
|
62
|
+
<div className={cx("ui-editor", className)}>
|
|
63
|
+
<div ref={bar} className="ui-editor-toolbar" role="toolbar" aria-label={`${label} formatting`} aria-controls={id} onKeyDown={onBarKey}>
|
|
64
|
+
{EDITOR_TOOLS.map((t, n) => {
|
|
65
|
+
if (t.sep) return <span key={n} className="ui-editor-sep" aria-hidden="true" />;
|
|
66
|
+
k += 1;
|
|
67
|
+
const me = k;
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
key={n} type="button" className="ui-editor-tool" aria-label={t.label} tabIndex={me === focus ? 0 : -1}
|
|
71
|
+
aria-pressed={t.press === false ? undefined : !!on[t.cmd]}
|
|
72
|
+
onMouseDown={(e) => e.preventDefault()} onClick={() => { setFocus(me); run(t); }}
|
|
73
|
+
>{t.icon}</button>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
<div
|
|
78
|
+
ref={area} id={id} className="ui-editor-area" contentEditable role="textbox" aria-multiline="true" aria-label={label}
|
|
79
|
+
data-placeholder={placeholder} suppressContentEditableWarning
|
|
80
|
+
onInput={() => { sync(); onChange?.(area.current.innerHTML); }}
|
|
81
|
+
/>
|
|
82
|
+
<div className="ui-editor-foot"><span>{words} {words === 1 ? "word" : "words"}</span></div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/** <EmptyState icon={<SearchIcon />} title="No sets match" text="Try another stage or clear the search." actions={<Button>Clear search</Button>} /> */
|
|
4
|
+
export function EmptyState({ icon, title, text, actions, as: H = "h3", className }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className={cx("ui-empty", className)}>
|
|
7
|
+
{icon ? <span className="ui-empty-icon" aria-hidden="true">{icon}</span> : null}
|
|
8
|
+
<H className="ui-empty-title">{title}</H>
|
|
9
|
+
{text ? <p className="ui-empty-text">{text}</p> : null}
|
|
10
|
+
{actions ? <div className="ui-empty-actions">{actions}</div> : null}
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { forwardRef, useId } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <Field label="Email" hint="We never share it." error={err}> wraps any one control,
|
|
6
|
+
* wiring the label, the hint and the error to it for screen readers.
|
|
7
|
+
* <Input /> and <Textarea /> are the bare controls.
|
|
8
|
+
*/
|
|
9
|
+
export function Field({ label, hint, error, children, className }) {
|
|
10
|
+
const id = useId();
|
|
11
|
+
const hintId = hint ? `${id}-hint` : undefined;
|
|
12
|
+
const errorId = error ? `${id}-error` : undefined;
|
|
13
|
+
const control = typeof children === "function"
|
|
14
|
+
? children({ id, "aria-describedby": [hintId, errorId].filter(Boolean).join(" ") || undefined, "aria-invalid": error ? true : undefined })
|
|
15
|
+
: children;
|
|
16
|
+
return (
|
|
17
|
+
<div className={cx("ui-field", className)}>
|
|
18
|
+
{label ? <label className="ui-label" htmlFor={id}>{label}</label> : null}
|
|
19
|
+
{control}
|
|
20
|
+
{hint ? <span className="ui-hint" id={hintId}>{hint}</span> : null}
|
|
21
|
+
{error ? <span className="ui-error" id={errorId}>{error}</span> : null}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Input = forwardRef(function Input({ className, ...rest }, ref) {
|
|
27
|
+
return <input ref={ref} className={cx("ui-input", className)} {...rest} />;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const Textarea = forwardRef(function Textarea({ className, ...rest }, ref) {
|
|
31
|
+
return <textarea ref={ref} className={cx("ui-input", className)} {...rest} />;
|
|
32
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const UP = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>;
|
|
5
|
+
const FILE = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>;
|
|
6
|
+
const X = <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>;
|
|
7
|
+
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* <FileUpload hint="PDF or images, up to 10 MB" accept=".pdf,image/*" files={files} onChange={setFiles} />
|
|
11
|
+
* A drop zone that is also a file button (keyboard and screen readers get the real input), and the chosen files.
|
|
12
|
+
* It only collects files: uploading them is the app's job.
|
|
13
|
+
*/
|
|
14
|
+
export function FileUpload({ files = [], onChange, accept, multiple = true, hint, className }) {
|
|
15
|
+
const id = useId();
|
|
16
|
+
const [drag, setDrag] = useState(false);
|
|
17
|
+
const add = (list) => onChange?.([...files, ...Array.from(list)]);
|
|
18
|
+
return (
|
|
19
|
+
<div className={className}>
|
|
20
|
+
<label className={cx("ui-dropzone", drag && "is-drag")} htmlFor={id}
|
|
21
|
+
onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)}
|
|
22
|
+
onDrop={(e) => { e.preventDefault(); setDrag(false); add(e.dataTransfer.files); }}>
|
|
23
|
+
<span className="ui-dropzone-icon">{UP}</span>
|
|
24
|
+
<span><strong>Click to upload</strong> or drag and drop</span>
|
|
25
|
+
{hint ? <small>{hint}</small> : null}
|
|
26
|
+
<input id={id} type="file" accept={accept} multiple={multiple} onChange={(e) => { add(e.target.files); e.target.value = ""; }} />
|
|
27
|
+
</label>
|
|
28
|
+
{files.length ? (
|
|
29
|
+
<ul className="ui-files" aria-label="Chosen files">
|
|
30
|
+
{files.map((f, i) => (
|
|
31
|
+
<li key={`${f.name}-${i}`} className="ui-file">{FILE}<span className="ui-file-name">{f.name}</span><span className="ui-file-size">{size(f.size)}</span>
|
|
32
|
+
<button type="button" className="ui-tag-remove" aria-label={`Remove ${f.name}`} onClick={() => onChange?.(files.filter((_, j) => j !== i))}>{X}</button></li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
) : null}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/** <Chip pressed={on} onClick={toggle}>Main</Chip> a toggle that shows its state with aria-pressed */
|
|
4
|
+
export function Chip({ pressed = false, children, className, ...rest }) {
|
|
5
|
+
return <button type="button" className={cx("ui-chip", className)} aria-pressed={pressed} {...rest}>{children}</button>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* <FilterBar label="Filter sets" search={<Input type="search" ... />} chips={<>…Chips…</>} active={<TagList>…</TagList>} />
|
|
10
|
+
* A labelled group: search and chips on one row, the active filters (removable tags) under it.
|
|
11
|
+
*/
|
|
12
|
+
export function FilterBar({ label, search, chips, active, className }) {
|
|
13
|
+
return (
|
|
14
|
+
<div className={cx("ui-filters", className)} role="group" aria-label={label}>
|
|
15
|
+
<div className="ui-filters-row">{search}{chips}</div>
|
|
16
|
+
{active}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Compare label="Tickets sold, this year against last" items={[{ label: "This year", value: 18880 }, { label: "Last year", value: 16740 }]} />
|
|
5
|
+
* The first item is the one that matters; the bars share one scale; the difference is written out.
|
|
6
|
+
* format(n) turns a number into text (default: 18,880).
|
|
7
|
+
*/
|
|
8
|
+
export function Compare({ label, items, format = n => Number(n).toLocaleString("en-US"), className }) {
|
|
9
|
+
const max = Math.max(...items.map(i => i.value), 1), [a, b] = items;
|
|
10
|
+
const diff = b ? a.value - b.value : 0, pct = b && b.value ? Math.round(Math.abs(diff) / b.value * 1000) / 10 : 0;
|
|
11
|
+
return (
|
|
12
|
+
<div className={cx("ui-compare", className)} role="group" aria-label={label}>
|
|
13
|
+
{items.map(it => (
|
|
14
|
+
<div className="ui-compare-row" key={it.label}>
|
|
15
|
+
<span className="ui-compare-label">{it.label}</span>
|
|
16
|
+
<span className="ui-compare-bar" aria-hidden="true"><span style={{ "--value": `${Math.round(it.value / max * 100)}%` }} /></span>
|
|
17
|
+
<strong className="ui-compare-value">{format(it.value)}</strong>
|
|
18
|
+
</div>
|
|
19
|
+
))}
|
|
20
|
+
{b ? <p className="ui-compare-diff">{diff === 0 ? `The same as ${b.label.toLowerCase()}` : `${format(Math.abs(diff))} (${pct}%) ${diff > 0 ? "more" : "less"} than ${b.label.toLowerCase()}`}</p> : null}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATE_WORD = { done: "Done", current: "In progress", upcoming: "Upcoming" };
|
|
26
|
+
/**
|
|
27
|
+
* <Timeline label="Campaign" items={[{ title: "Kickoff", text: "Direction agreed", time: "Jan 14", state: "done" }]} />
|
|
28
|
+
* state: done | current | upcoming. The state is written, not only shown by the dot.
|
|
29
|
+
*/
|
|
30
|
+
export function Timeline({ label, items, className }) {
|
|
31
|
+
return (
|
|
32
|
+
<ol className={cx("ui-timeline", className)} aria-label={label}>
|
|
33
|
+
{items.map(it => (
|
|
34
|
+
<li className="ui-timeline-item" data-state={it.state || "upcoming"} key={it.title} aria-current={it.state === "current" ? "step" : undefined}>
|
|
35
|
+
<span className="ui-timeline-dot" aria-hidden="true" />
|
|
36
|
+
<div>
|
|
37
|
+
<p className="ui-timeline-title">{it.title}<span className={cx("ui-badge", it.state === "done" ? "ui-badge--ok" : it.state === "current" ? "ui-badge--brand" : "ui-badge--outline")}>{STATE_WORD[it.state || "upcoming"]}</span></p>
|
|
38
|
+
{it.text ? <p className="ui-timeline-text">{it.text}</p> : null}
|
|
39
|
+
{it.time ? <span className="ui-timeline-time">{it.time}</span> : null}
|
|
40
|
+
</div>
|
|
41
|
+
</li>
|
|
42
|
+
))}
|
|
43
|
+
</ol>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** <StatGrid label="This week" items={[{ label: "Active projects", value: "9", note: "+2 since Monday" }]} /> */
|
|
48
|
+
export function StatGrid({ label, items, className }) {
|
|
49
|
+
return (
|
|
50
|
+
<dl className={cx("ui-statgrid", className)} aria-label={label}>
|
|
51
|
+
{items.map(it => <div key={it.label}><dt>{it.label}</dt><dd>{it.value}</dd>{it.note ? <small>{it.note}</small> : null}</div>)}
|
|
52
|
+
</dl>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/** <InlineCTA title="Invite your crew" body="Everyone gets the run of show on their phone." actions={<Button variant="primary">Invite</Button>} tone="fill" /> */
|
|
4
|
+
export function InlineCTA({ title, body, actions, tone, as: H = "h3", className }) {
|
|
5
|
+
return (
|
|
6
|
+
<section className={cx("ui-cta", tone === "fill" && "ui-cta--fill", className)}>
|
|
7
|
+
<div className="ui-cta-text">
|
|
8
|
+
<H className="ui-cta-title">{title}</H>
|
|
9
|
+
{body ? <p className="ui-cta-body">{body}</p> : null}
|
|
10
|
+
</div>
|
|
11
|
+
{actions ? <div className="ui-cta-actions">{actions}</div> : null}
|
|
12
|
+
</section>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <LineChart label="Tickets sold per month" values={[120, 180, 160]} labels={["Jan", "Feb", "Mar"]} />
|
|
5
|
+
* The SVG stretches to its box; the values are the chart's text alternative (role="img" + aria-label).
|
|
6
|
+
*/
|
|
7
|
+
export function LineChart({ label, values = [], labels = [], className }) {
|
|
8
|
+
if (values.length < 2) return null;
|
|
9
|
+
const w = 600, h = 200, pad = 6;
|
|
10
|
+
const max = Math.max(...values), min = Math.min(...values), span = max - min || 1;
|
|
11
|
+
const pts = values.map((v, i) => [(i * w) / (values.length - 1), pad + (h - 2 * pad) * (1 - (v - min) / span)]);
|
|
12
|
+
const line = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" ");
|
|
13
|
+
return (
|
|
14
|
+
<div className={cx("ui-chart", className)} role="img" aria-label={`${label}: ${values.map((v, i) => (labels[i] ? `${labels[i]} ${v}` : v)).join(", ")}`}>
|
|
15
|
+
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
|
|
16
|
+
{[0.25, 0.5, 0.75].map((f) => <line key={f} className="ui-chart-grid" x1="0" x2={w} y1={h * f} y2={h * f} vectorEffect="non-scaling-stroke" />)}
|
|
17
|
+
<path className="ui-chart-area" d={`${line} L${w} ${h} L0 ${h} Z`} />
|
|
18
|
+
<path className="ui-chart-line" d={line} vectorEffect="non-scaling-stroke" />
|
|
19
|
+
</svg>
|
|
20
|
+
{labels.length ? <div className="ui-chart-axis" aria-hidden="true">{labels.map((l) => <span key={l}>{l}</span>)}</div> : null}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
// Marketing sections. Each lays itself out by its own width (container queries in components.css), so it works in a
|
|
5
|
+
// column as well as a full page. Content is yours: these take text, links and children, never images they invent.
|
|
6
|
+
|
|
7
|
+
/** <Section alt eyebrow="Lineup" title="Three stages, one weekend" lead="…" center>{children}</Section> */
|
|
8
|
+
export function Section({ eyebrow, title, lead, center = false, alt = false, as: H = "h2", children, className, ...rest }) {
|
|
9
|
+
return (
|
|
10
|
+
<section className={cx("ui-section", alt && "ui-section--alt", className)} {...rest}>
|
|
11
|
+
<div className="ui-section-inner">
|
|
12
|
+
{title ? (
|
|
13
|
+
<div className={cx("ui-section-head", center && "ui-section-head--center")}>
|
|
14
|
+
{eyebrow ? <span className="ui-eyebrow">{eyebrow}</span> : null}
|
|
15
|
+
<H className="ui-section-title">{title}</H>
|
|
16
|
+
{lead ? <p className="ui-section-lead">{lead}</p> : null}
|
|
17
|
+
</div>
|
|
18
|
+
) : null}
|
|
19
|
+
{children}
|
|
20
|
+
</div>
|
|
21
|
+
</section>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** <Media label="Crowd at the main stage" square /> an image's place until a real one goes in (role="img") */
|
|
26
|
+
export function Media({ label, square = false, className }) {
|
|
27
|
+
return <div className={cx("ui-media", square && "ui-media--square", className)} role="img" aria-label={label}><span aria-hidden="true">Image</span></div>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** <SiteNav brand="BKSTG" links={[{ href: "/", label: "Lineup", current: true }]} actions={<Button variant="primary">Tickets</Button>} /> */
|
|
31
|
+
export function SiteNav({ brand, brandMark, links, actions, label = "Main", className }) {
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const id = useId();
|
|
34
|
+
return (
|
|
35
|
+
<header className={cx("ui-sitenav", className)}>
|
|
36
|
+
<div className="ui-sitenav-bar">
|
|
37
|
+
<a className="ui-sitenav-brand" href="/">{brandMark}{brand}</a>
|
|
38
|
+
<nav aria-label={label}><ul className="ui-sitenav-links">{links.map((l) => <li key={l.href}><a href={l.href} aria-current={l.current ? "page" : undefined}>{l.label}</a></li>)}</ul></nav>
|
|
39
|
+
<div className="ui-sitenav-actions">{actions}</div>
|
|
40
|
+
<button type="button" className="ui-btn ui-btn--icon ui-btn--ghost ui-sitenav-toggle" aria-expanded={open} aria-controls={id} aria-label={open ? "Close menu" : "Open menu"} onClick={() => setOpen((o) => !o)}>
|
|
41
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">{open ? <path d="M18 6 6 18M6 6l12 12" /> : <path d="M4 6h16M4 12h16M4 18h16" />}</svg>
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
<nav className="ui-sitenav-panel" id={id} aria-label={`${label}, menu`} hidden={!open}>
|
|
45
|
+
{links.map((l) => <a key={l.href} href={l.href} aria-current={l.current ? "page" : undefined}>{l.label}</a>)}
|
|
46
|
+
{actions}
|
|
47
|
+
</nav>
|
|
48
|
+
</header>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** <Hero eyebrow="April 17–19" title="Spring 2026" lead="…" actions={<Button variant="primary">Get tickets</Button>} media={<Media label="…" />} /> */
|
|
53
|
+
export function Hero({ eyebrow, title, lead, actions, media, center = false, className }) {
|
|
54
|
+
return (
|
|
55
|
+
<div className={cx("ui-hero", center ? "ui-hero--center" : media && "ui-hero--split", className)}>
|
|
56
|
+
<div className="ui-hero-text">
|
|
57
|
+
{eyebrow ? <span className="ui-eyebrow">{eyebrow}</span> : null}
|
|
58
|
+
<h1 className="ui-hero-title">{title}</h1>
|
|
59
|
+
{lead ? <p className="ui-hero-lead">{lead}</p> : null}
|
|
60
|
+
{actions ? <div className="ui-hero-actions">{actions}</div> : null}
|
|
61
|
+
</div>
|
|
62
|
+
{media}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** <FeatureCard icon={<MusicIcon />} title="Live set times" text="…" /> */
|
|
68
|
+
export function FeatureCard({ icon, title, text, children, className }) {
|
|
69
|
+
return <div className={cx("ui-mk-card", className)}>{icon ? <span className="ui-feature-icon" aria-hidden="true">{icon}</span> : null}<h3>{title}</h3>{text ? <p>{text}</p> : null}{children}</div>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** <Stats items={[{ value: "42K", label: "Fans", note: "Across three days" }]} /> */
|
|
73
|
+
export function Stats({ items, className }) {
|
|
74
|
+
return <dl className={cx("ui-stats", className)}>{items.map((s) => <div key={s.label} className="ui-stat"><dt className="ui-stat-label">{s.label}</dt><dd className="ui-stat-value" style={{ margin: 0, order: -1 }}>{s.value}</dd>{s.note ? <dd className="ui-stat-note" style={{ margin: 0 }}>{s.note}</dd> : null}</div>)}</dl>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** <LogoCloud caption="Trusted by" logos={[<img … />, …]} /> */
|
|
78
|
+
export function LogoCloud({ caption, logos, className }) {
|
|
79
|
+
return <div className={cx("ui-logocloud", className)}>{caption ? <p>{caption}</p> : null}<ul>{logos.map((l, i) => <li key={i}>{l}</li>)}</ul></div>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** <Testimonial quote="…" name="Grace Lee" role="Head of Production" avatar={<Avatar name="Grace Lee" />} large /> */
|
|
83
|
+
export function Testimonial({ quote, name, role, avatar, large = false, className }) {
|
|
84
|
+
return <figure className={cx("ui-quote", large && "ui-quote--large", className)}><blockquote><p style={{ margin: 0 }}>{quote}</p></blockquote><figcaption>{avatar}<span><strong>{name}</strong>{role}</span></figcaption></figure>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** <TeamMember name="Ana Ruiz" role="Front of house" bio="…" media={<Media label="Ana Ruiz" square />} /> */
|
|
88
|
+
export function TeamMember({ name, role, bio, media, className }) {
|
|
89
|
+
return <div className={cx("ui-person", className)}>{media}<h3>{name}</h3><p className="ui-person-role">{role}</p>{bio ? <p>{bio}</p> : null}</div>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** <PostCard href="/blog/x" title="…" excerpt="…" meta={<><Badge>News</Badge> · 4 min read</>} media={<Media label="…" />} /> */
|
|
93
|
+
export function PostCard({ href, title, excerpt, meta, media, className }) {
|
|
94
|
+
return <a className={cx("ui-post", className)} href={href}>{media}<span className="ui-post-meta">{meta}</span><h3>{title}</h3>{excerpt ? <p>{excerpt}</p> : null}</a>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** <JobList jobs={[{ href: "/jobs/1", title: "Stage manager", team: "Production", tags: ["Seasonal", "San Diego"] }]} /> */
|
|
98
|
+
export function JobList({ jobs, className }) {
|
|
99
|
+
return <ul className={cx("ui-jobs", className)}>{jobs.map((j) => <li key={j.href} className="ui-job"><div className="ui-job-title"><a href={j.href}>{j.title}</a><span>{j.team}</span></div><div className="ui-job-tags">{(j.tags || []).map((t) => <span key={t} className="ui-badge ui-badge--outline">{t}</span>)}</div></li>)}</ul>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** <FAQ items={[{ q: "Can I re-enter?", a: "Until 6 PM." }]} /> native <details>: no script needed */
|
|
103
|
+
export function FAQ({ items, className }) {
|
|
104
|
+
return <div className={cx("ui-faq", className)}>{items.map((it, i) => <details key={i} open={it.open}><summary>{it.q}</summary><div>{it.a}</div></details>)}</div>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** <Plan name="VIP" price="$389" per="2 days" text="…" features={["…"]} featured badge="Most popular" action={<Button variant="primary">Choose VIP</Button>} /> */
|
|
108
|
+
export function Plan({ name, badge, price, per, text, features, action, featured = false, className }) {
|
|
109
|
+
return (
|
|
110
|
+
<div className={cx("ui-plan", featured && "ui-plan--featured", className)}>
|
|
111
|
+
<h3>{name}{badge ? <span className="ui-badge ui-badge--brand">{badge}</span> : null}</h3>
|
|
112
|
+
<p className="ui-plan-price"><strong>{price}</strong>{per ? <span>{per}</span> : null}</p>
|
|
113
|
+
{text ? <p>{text}</p> : <p />}
|
|
114
|
+
<ul aria-label={`${name} includes`}>{features.map((f) => <li key={f}>{f}</li>)}</ul>
|
|
115
|
+
{action}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** <CTABand title="See you in April" text="…" actions={<><Button>Lineup</Button><Button variant="primary">Get tickets</Button></>} /> */
|
|
121
|
+
export function CTABand({ title, text, actions, className }) {
|
|
122
|
+
return <section className={cx("ui-cta-band", className)}><h2>{title}</h2>{text ? <p>{text}</p> : null}{actions ? <div className="ui-hero-actions">{actions}</div> : null}</section>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* <Newsletter title="Lineup news, first" text="…" onSubscribe={(email) => …} />
|
|
127
|
+
* A real form: the email field is required and typed, and the result is announced.
|
|
128
|
+
*/
|
|
129
|
+
export function Newsletter({ title, text, onSubscribe, note = "One email a month. Unsubscribe anytime.", className }) {
|
|
130
|
+
const id = useId();
|
|
131
|
+
const [done, setDone] = useState("");
|
|
132
|
+
return (
|
|
133
|
+
<div className={cx("ui-newsletter", className)}>
|
|
134
|
+
<div className="ui-section-head"><h2 className="ui-section-title">{title}</h2>{text ? <p className="ui-section-lead">{text}</p> : null}</div>
|
|
135
|
+
<form onSubmit={(e) => { e.preventDefault(); const v = new FormData(e.currentTarget).get("email"); onSubscribe?.(v); setDone(`Thanks. Check ${v} to confirm.`); e.currentTarget.reset(); }}>
|
|
136
|
+
<div className="ui-newsletter-row"><label className="ui-sr-only" htmlFor={id}>Email</label><input className="ui-input" id={id} name="email" type="email" required autoComplete="email" placeholder="you@example.com" /><button type="submit" className="ui-btn ui-btn--primary">Subscribe</button></div>
|
|
137
|
+
<small role="status">{done || note}</small>
|
|
138
|
+
</form>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** <Prose>{article}</Prose> long-form text: headings, lists, quotes and figures in a readable measure */
|
|
144
|
+
export function Prose({ children, className }) {
|
|
145
|
+
return <div className={cx("ui-prose", className)}>{children}</div>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** <Footer brand="BKSTG" blurb="…" columns={[{ title: "Festival", links: [{ href: "/lineup", label: "Lineup" }] }]} legal="© 2026 …" /> */
|
|
149
|
+
export function Footer({ brand, blurb, columns, legal, bottom, className }) {
|
|
150
|
+
return (
|
|
151
|
+
<footer className={cx("ui-footer", className)}>
|
|
152
|
+
<div className="ui-footer-inner">
|
|
153
|
+
<div className="ui-footer-top">
|
|
154
|
+
<div className="ui-footer-brand"><strong>{brand}</strong>{blurb ? <p>{blurb}</p> : null}</div>
|
|
155
|
+
{columns.map((c) => <nav key={c.title} aria-label={c.title}><h2>{c.title}</h2><ul>{c.links.map((l) => <li key={l.href}><a href={l.href}>{l.label}</a></li>)}</ul></nav>)}
|
|
156
|
+
</div>
|
|
157
|
+
<div className="ui-footer-bottom"><small>{legal}</small>{bottom}</div>
|
|
158
|
+
</div>
|
|
159
|
+
</footer>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
|
|
4
|
+
const CHEVRON = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* <Menu label="Options" items={[
|
|
8
|
+
* { label: "Edit", icon: <PencilIcon />, hint: "⌘E", onSelect: edit },
|
|
9
|
+
* { separator: true },
|
|
10
|
+
* { label: "Delete", danger: true, onSelect: remove } ]} />
|
|
11
|
+
* The menu button pattern: Enter, Space or ArrowDown opens on the first item, ArrowUp on the last; arrows,
|
|
12
|
+
* Home and End move; a letter jumps; Escape closes and focus returns to the button. A { heading } item labels a section.
|
|
13
|
+
*/
|
|
14
|
+
export function Menu({ label, items, align, buttonClassName, className }) {
|
|
15
|
+
const id = useId();
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [flip, setFlip] = useState(false);
|
|
18
|
+
const btn = useRef(null), menu = useRef(null), start = useRef("first");
|
|
19
|
+
const enabled = () => [...(menu.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])') || [])];
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!open) return;
|
|
23
|
+
const list = enabled();
|
|
24
|
+
(start.current === "last" ? list[list.length - 1] : list[0])?.focus();
|
|
25
|
+
setFlip(align === "end" || (menu.current && menu.current.getBoundingClientRect().right > window.innerWidth - 8));
|
|
26
|
+
const outside = (e) => { if (!menu.current?.contains(e.target) && !btn.current?.contains(e.target)) setOpen(false); };
|
|
27
|
+
document.addEventListener("mousedown", outside);
|
|
28
|
+
return () => document.removeEventListener("mousedown", outside);
|
|
29
|
+
}, [open]);
|
|
30
|
+
|
|
31
|
+
function close(back) { setOpen(false); if (back) btn.current?.focus(); }
|
|
32
|
+
function onButtonKey(e) {
|
|
33
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); start.current = e.key === "ArrowUp" ? "last" : "first"; setOpen(true); }
|
|
34
|
+
}
|
|
35
|
+
function onMenuKey(e) {
|
|
36
|
+
const list = enabled(), i = list.indexOf(document.activeElement);
|
|
37
|
+
let next = null;
|
|
38
|
+
if (e.key === "ArrowDown") next = list[(i + 1) % list.length];
|
|
39
|
+
else if (e.key === "ArrowUp") next = list[(i - 1 + list.length) % list.length];
|
|
40
|
+
else if (e.key === "Home") next = list[0];
|
|
41
|
+
else if (e.key === "End") next = list[list.length - 1];
|
|
42
|
+
else if (e.key === "Escape") { e.preventDefault(); return close(true); }
|
|
43
|
+
else if (e.key === "Tab") return close(false);
|
|
44
|
+
else if (e.key.length === 1 && /\S/.test(e.key)) {
|
|
45
|
+
const order = [...list.slice(i + 1), ...list.slice(0, i + 1)];
|
|
46
|
+
next = order.find((x) => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase())) || null;
|
|
47
|
+
}
|
|
48
|
+
if (next) { e.preventDefault(); next.focus(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<span className={cx("ui-menu-wrap", className)} data-align={flip ? "end" : undefined}>
|
|
53
|
+
<button
|
|
54
|
+
ref={btn} type="button" className={cx("ui-btn ui-menu-trigger", buttonClassName)}
|
|
55
|
+
aria-haspopup="menu" aria-expanded={open} aria-controls={id}
|
|
56
|
+
onClick={() => { start.current = "first"; setOpen((o) => !o); }} onKeyDown={onButtonKey}
|
|
57
|
+
>
|
|
58
|
+
{label}{CHEVRON}
|
|
59
|
+
</button>
|
|
60
|
+
<div ref={menu} id={id} className="ui-menu" role="menu" aria-label={typeof label === "string" ? label : undefined} hidden={!open} onKeyDown={onMenuKey}>
|
|
61
|
+
{items.map((it, i) => it.separator ? <div key={i} className="ui-menu-sep" role="separator" />
|
|
62
|
+
: it.heading ? <div key={i} className="ui-menu-label" role="presentation">{it.heading}</div>
|
|
63
|
+
: (
|
|
64
|
+
<button
|
|
65
|
+
key={i} type="button" role="menuitem" tabIndex={-1}
|
|
66
|
+
className={cx("ui-menu-item", it.danger && "ui-menu-item--danger")}
|
|
67
|
+
aria-disabled={it.disabled || undefined}
|
|
68
|
+
onClick={() => { if (it.disabled) return; it.onSelect?.(); close(true); }}
|
|
69
|
+
>
|
|
70
|
+
{it.icon}{it.label}{it.hint ? <span className="ui-menu-hint" aria-hidden="true">{it.hint}</span> : null}
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Metric label="Tickets sold" value="18,880" change="+7.4%" /> change starting with "-" reads as down.
|
|
5
|
+
* <Metric label="Refund requests" value="12" change="-2.0%" goodWhen="down" /> for numbers where less is better.
|
|
6
|
+
* The arrow is decoration; the change is spoken as "up 7.4%, better" or "down 2%, better"; its color says good or bad.
|
|
7
|
+
*/
|
|
8
|
+
export function Metric({ label, value, change, goodWhen = "up", className }) {
|
|
9
|
+
const down = typeof change === "string" && change.trim().startsWith("-");
|
|
10
|
+
const good = down === (goodWhen === "down");
|
|
11
|
+
const amount = change ? change.replace(/^[+-]/, "") : "";
|
|
12
|
+
return (
|
|
13
|
+
<div className={cx("ui-metric", className)}>
|
|
14
|
+
<span className="ui-metric-label">{label}</span>
|
|
15
|
+
<span className="ui-metric-row">
|
|
16
|
+
<span className="ui-metric-value">{value}</span>
|
|
17
|
+
{change ? (
|
|
18
|
+
<span className={cx("ui-metric-delta", down && "ui-metric-delta--down", good ? "ui-metric-delta--good" : "ui-metric-delta--bad")}>
|
|
19
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
20
|
+
{down ? <path d="M7 7l10 10M17 7v10H7" /> : <path d="M7 17 17 7M7 7h10v10" />}
|
|
21
|
+
</svg>
|
|
22
|
+
<span className="ui-sr-only">{down ? "down" : "up"} </span>{amount}<span className="ui-sr-only">, {good ? "better" : "worse"}</span>
|
|
23
|
+
</span>
|
|
24
|
+
) : null}
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cx } from "./cx.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <PageHeader breadcrumbs={<Breadcrumbs items={...} />} title="Spring 2026" description="Three stages, two days."
|
|
5
|
+
* actions={<><Button>Share</Button><Button variant="primary">Publish</Button></>} tabs={<Tabs ... />} />
|
|
6
|
+
* The page's one h1.
|
|
7
|
+
*/
|
|
8
|
+
export function PageHeader({ breadcrumbs, title, description, actions, tabs, className }) {
|
|
9
|
+
return (
|
|
10
|
+
<header className={cx("ui-pagehead", className)}>
|
|
11
|
+
{breadcrumbs}
|
|
12
|
+
<div className="ui-pagehead-row">
|
|
13
|
+
<div className="ui-pagehead-text">
|
|
14
|
+
<h1 className="ui-pagehead-title">{title}</h1>
|
|
15
|
+
{description ? <p className="ui-pagehead-desc">{description}</p> : null}
|
|
16
|
+
</div>
|
|
17
|
+
{actions ? <div className="ui-pagehead-actions">{actions}</div> : null}
|
|
18
|
+
</div>
|
|
19
|
+
{tabs}
|
|
20
|
+
</header>
|
|
21
|
+
);
|
|
22
|
+
}
|