@bkstg/design-systems 1.0.0 → 1.0.1
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/package.json +1 -1
- package/tether/latest/README.md +44 -0
- package/tether/latest/charts-core.mjs +349 -0
- package/tether/latest/charts.js +58 -0
- package/tether/latest/motion.js +168 -0
- package/tether/latest/react/ActivityFeed.jsx +18 -0
- package/tether/latest/react/Alert.jsx +28 -0
- package/tether/latest/react/Annotations.jsx +24 -0
- package/tether/latest/react/AppShell.jsx +87 -0
- package/tether/latest/react/Avatar.jsx +19 -0
- package/tether/latest/react/Badge.jsx +14 -0
- package/tether/latest/react/Banner.jsx +17 -0
- package/tether/latest/react/Breadcrumbs.jsx +23 -0
- package/tether/latest/react/Button.jsx +31 -0
- package/tether/latest/react/ButtonGroup.jsx +29 -0
- package/tether/latest/react/Calendar.jsx +57 -0
- package/tether/latest/react/CardHeader.jsx +14 -0
- package/tether/latest/react/Chart.jsx +42 -0
- package/tether/latest/react/Chat.jsx +34 -0
- package/tether/latest/react/Checkbox.jsx +32 -0
- package/tether/latest/react/CodeSnippet.jsx +17 -0
- package/tether/latest/react/ColorPicker.jsx +35 -0
- package/tether/latest/react/CommandMenu.jsx +54 -0
- package/tether/latest/react/DatePicker.jsx +34 -0
- package/tether/latest/react/DeviceFrame.jsx +12 -0
- package/tether/latest/react/Dialog.jsx +43 -0
- package/tether/latest/react/Divider.jsx +10 -0
- package/tether/latest/react/Editor.jsx +85 -0
- package/tether/latest/react/EmptyState.jsx +13 -0
- package/tether/latest/react/Field.jsx +32 -0
- package/tether/latest/react/FileUpload.jsx +38 -0
- package/tether/latest/react/Filters.jsx +19 -0
- package/tether/latest/react/Infographics.jsx +54 -0
- package/tether/latest/react/InlineCTA.jsx +14 -0
- package/tether/latest/react/LineChart.jsx +23 -0
- package/tether/latest/react/Marketing.jsx +161 -0
- package/tether/latest/react/Menu.jsx +76 -0
- package/tether/latest/react/Metric.jsx +28 -0
- package/tether/latest/react/PageHeader.jsx +22 -0
- package/tether/latest/react/Pagination.jsx +32 -0
- package/tether/latest/react/Progress.jsx +26 -0
- package/tether/latest/react/RadioGroup.jsx +27 -0
- package/tether/latest/react/Reveal.jsx +17 -0
- package/tether/latest/react/SectionHeader.jsx +24 -0
- package/tether/latest/react/Select.jsx +19 -0
- package/tether/latest/react/Sheet.jsx +25 -0
- package/tether/latest/react/SideNav.jsx +37 -0
- package/tether/latest/react/Skeleton.jsx +16 -0
- package/tether/latest/react/Slider.jsx +27 -0
- package/tether/latest/react/Spinner.jsx +14 -0
- package/tether/latest/react/Steps.jsx +22 -0
- package/tether/latest/react/Switch.jsx +11 -0
- package/tether/latest/react/Table.jsx +24 -0
- package/tether/latest/react/Tabs.jsx +80 -0
- package/tether/latest/react/Tag.jsx +34 -0
- package/tether/latest/react/Tooltip.jsx +25 -0
- package/tether/latest/react/Tree.jsx +41 -0
- package/tether/latest/react/VideoPlayer.jsx +57 -0
- package/tether/latest/react/cx.js +4 -0
- package/tether/latest/react/index.js +59 -0
- package/tether/latest/system.json +44 -0
- package/tether/latest/tether.css +1661 -0
- package/tether/latest/tether.js +703 -0
- package/tether/v1/README.md +44 -0
- package/tether/v1/charts-core.mjs +349 -0
- package/tether/v1/charts.js +58 -0
- package/tether/v1/motion.js +168 -0
- package/tether/v1/react/ActivityFeed.jsx +18 -0
- package/tether/v1/react/Alert.jsx +28 -0
- package/tether/v1/react/Annotations.jsx +24 -0
- package/tether/v1/react/AppShell.jsx +87 -0
- package/tether/v1/react/Avatar.jsx +19 -0
- package/tether/v1/react/Badge.jsx +14 -0
- package/tether/v1/react/Banner.jsx +17 -0
- package/tether/v1/react/Breadcrumbs.jsx +23 -0
- package/tether/v1/react/Button.jsx +31 -0
- package/tether/v1/react/ButtonGroup.jsx +29 -0
- package/tether/v1/react/Calendar.jsx +57 -0
- package/tether/v1/react/CardHeader.jsx +14 -0
- package/tether/v1/react/Chart.jsx +42 -0
- package/tether/v1/react/Chat.jsx +34 -0
- package/tether/v1/react/Checkbox.jsx +32 -0
- package/tether/v1/react/CodeSnippet.jsx +17 -0
- package/tether/v1/react/ColorPicker.jsx +35 -0
- package/tether/v1/react/CommandMenu.jsx +54 -0
- package/tether/v1/react/DatePicker.jsx +34 -0
- package/tether/v1/react/DeviceFrame.jsx +12 -0
- package/tether/v1/react/Dialog.jsx +43 -0
- package/tether/v1/react/Divider.jsx +10 -0
- package/tether/v1/react/Editor.jsx +85 -0
- package/tether/v1/react/EmptyState.jsx +13 -0
- package/tether/v1/react/Field.jsx +32 -0
- package/tether/v1/react/FileUpload.jsx +38 -0
- package/tether/v1/react/Filters.jsx +19 -0
- package/tether/v1/react/Infographics.jsx +54 -0
- package/tether/v1/react/InlineCTA.jsx +14 -0
- package/tether/v1/react/LineChart.jsx +23 -0
- package/tether/v1/react/Marketing.jsx +161 -0
- package/tether/v1/react/Menu.jsx +76 -0
- package/tether/v1/react/Metric.jsx +28 -0
- package/tether/v1/react/PageHeader.jsx +22 -0
- package/tether/v1/react/Pagination.jsx +32 -0
- package/tether/v1/react/Progress.jsx +26 -0
- package/tether/v1/react/RadioGroup.jsx +27 -0
- package/tether/v1/react/Reveal.jsx +17 -0
- package/tether/v1/react/SectionHeader.jsx +24 -0
- package/tether/v1/react/Select.jsx +19 -0
- package/tether/v1/react/Sheet.jsx +25 -0
- package/tether/v1/react/SideNav.jsx +37 -0
- package/tether/v1/react/Skeleton.jsx +16 -0
- package/tether/v1/react/Slider.jsx +27 -0
- package/tether/v1/react/Spinner.jsx +14 -0
- package/tether/v1/react/Steps.jsx +22 -0
- package/tether/v1/react/Switch.jsx +11 -0
- package/tether/v1/react/Table.jsx +24 -0
- package/tether/v1/react/Tabs.jsx +80 -0
- package/tether/v1/react/Tag.jsx +34 -0
- package/tether/v1/react/Tooltip.jsx +25 -0
- package/tether/v1/react/Tree.jsx +41 -0
- package/tether/v1/react/VideoPlayer.jsx +57 -0
- package/tether/v1/react/cx.js +4 -0
- package/tether/v1/react/index.js +59 -0
- package/tether/v1/system.json +44 -0
- package/tether/v1/tether.css +1661 -0
- package/tether/v1/tether.js +703 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|