makersclaw 0.1.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 +68 -0
- package/bin/makersclaw.mjs +10 -0
- package/package.json +34 -0
- package/registry/blocks/board/README.md +130 -0
- package/registry/blocks/board/block.json +10 -0
- package/registry/blocks/board/board.jsx +435 -0
- package/registry/blocks/board/demo.jsx +50 -0
- package/registry/blocks/board/index.js +4 -0
- package/registry/blocks/job-panel/README.md +68 -0
- package/registry/blocks/job-panel/block.json +10 -0
- package/registry/blocks/job-panel/demo.jsx +75 -0
- package/registry/blocks/job-panel/index.js +3 -0
- package/registry/blocks/job-panel/job-panel.jsx +110 -0
- package/src/cli.mjs +301 -0
- package/src/fsutil.mjs +76 -0
- package/src/install.mjs +457 -0
- package/src/registry.mjs +191 -0
- package/templates/next/files/README.md +626 -0
- package/templates/next/files/app/auto-refresh.jsx +26 -0
- package/templates/next/files/app/error-relay.jsx +53 -0
- package/templates/next/files/app/error.jsx +44 -0
- package/templates/next/files/app/global-error.jsx +36 -0
- package/templates/next/files/app/globals.css +178 -0
- package/templates/next/files/app/layout.jsx +51 -0
- package/templates/next/files/app/not-found.jsx +23 -0
- package/templates/next/files/app/page.jsx +22 -0
- package/templates/next/files/app/s/[key]/page.jsx +47 -0
- package/templates/next/files/app/shell.jsx +64 -0
- package/templates/next/files/check/run.mjs +683 -0
- package/templates/next/files/components/parts/fixture.jsx +60 -0
- package/templates/next/files/components/parts/index.js +5 -0
- package/templates/next/files/components/parts/record-switcher.jsx +49 -0
- package/templates/next/files/components/ui/button.jsx +81 -0
- package/templates/next/files/components/ui/cx.js +4 -0
- package/templates/next/files/components/ui/depiction.jsx +66 -0
- package/templates/next/files/components/ui/field.jsx +126 -0
- package/templates/next/files/components/ui/icon.jsx +87 -0
- package/templates/next/files/components/ui/index.js +12 -0
- package/templates/next/files/components/ui/menu.jsx +123 -0
- package/templates/next/files/components/ui/notice.jsx +86 -0
- package/templates/next/files/components/ui/sheet.jsx +89 -0
- package/templates/next/files/components/ui/tabs.jsx +81 -0
- package/templates/next/files/lib/actions.js +187 -0
- package/templates/next/files/lib/claw.js +219 -0
- package/templates/next/files/lib/data.js +171 -0
- package/templates/next/files/lib/db.js +72 -0
- package/templates/next/files/lib/hooks.js +218 -0
- package/templates/next/files/lib/platform.js +59 -0
- package/templates/next/files/lib/sample.js +12 -0
- package/templates/next/files/middleware.js +42 -0
- package/templates/next/files/next.config.mjs +41 -0
- package/templates/next/files/package.json +19 -0
- package/templates/next/files/postcss.config.mjs +3 -0
- package/templates/next/files/schema.sql +22 -0
- package/templates/next/files/surfaces/README.md +32 -0
- package/templates/next/files/surfaces.js +53 -0
- package/templates/next/template.json +118 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { markSample } from "../../lib/sample";
|
|
5
|
+
import { Button, Notice } from "../ui";
|
|
6
|
+
|
|
7
|
+
// The worked example, never persisted. A surface opens populated (README
|
|
8
|
+
// §3: the template test) — but populated is not persisted. The example is
|
|
9
|
+
// rows in the app's code, shown while the real collection is empty and
|
|
10
|
+
// never INSERTed: the workspace database is shared by every app, so an
|
|
11
|
+
// invented row there is a real row somewhere else. Clearing writes
|
|
12
|
+
// nothing; the first real write is the user's.
|
|
13
|
+
//
|
|
14
|
+
// const { rows, isFixture, clear } = useFixture(realRows, SAMPLE);
|
|
15
|
+
// {isFixture ? <FixtureNotice onClear={clear} /> : null}
|
|
16
|
+
//
|
|
17
|
+
// A fixture row carries `_sample: true` and a `sample-N` id. Editing one
|
|
18
|
+
// promotes it: the surface copies its values into a new draft and saves
|
|
19
|
+
// THAT — it never updateRow()s a sample id (the row does not exist).
|
|
20
|
+
//
|
|
21
|
+
// `rows` keeps its identity between renders (an effect may depend on it);
|
|
22
|
+
// declare SAMPLE at module level, not inside the component, so it does too.
|
|
23
|
+
|
|
24
|
+
const NONE = [];
|
|
25
|
+
|
|
26
|
+
export function useFixture(rows, sample, { idColumn = "id" } = {}) {
|
|
27
|
+
const [cleared, setCleared] = useState(false);
|
|
28
|
+
const isFixture = !cleared && (!rows || rows.length === 0) && Array.isArray(sample) && sample.length > 0;
|
|
29
|
+
const fixtureRows = useMemo(() => markSample(sample, idColumn), [sample, idColumn]);
|
|
30
|
+
return {
|
|
31
|
+
rows: isFixture ? fixtureRows : rows ?? NONE,
|
|
32
|
+
isFixture,
|
|
33
|
+
clear: () => setCleared(true),
|
|
34
|
+
restore: () => setCleared(false),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isSample(row) {
|
|
39
|
+
return Boolean(row && row._sample);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The line above a fixture, with the one control it earns. */
|
|
43
|
+
export function FixtureNotice({ onClear, children, className }) {
|
|
44
|
+
return (
|
|
45
|
+
<Notice
|
|
46
|
+
tone="info"
|
|
47
|
+
title="Example only"
|
|
48
|
+
className={className}
|
|
49
|
+
action={
|
|
50
|
+
onClear ? (
|
|
51
|
+
<Button variant="quiet" size="sm" onClick={onClear}>
|
|
52
|
+
Clear the example
|
|
53
|
+
</Button>
|
|
54
|
+
) : null
|
|
55
|
+
}
|
|
56
|
+
>
|
|
57
|
+
{children ?? "Edit it or start your own. Nothing here is saved until you save it."}
|
|
58
|
+
</Notice>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Parts: the kit's pieces bound to this template's data layer. Each
|
|
2
|
+
// exists because an app got it wrong once; each carries the behaviour
|
|
3
|
+
// that makes it right. README §3b.
|
|
4
|
+
export { useFixture, isSample, FixtureNotice } from "./fixture";
|
|
5
|
+
export { RecordSwitcher } from "./record-switcher";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { Button, Select } from "../ui";
|
|
5
|
+
|
|
6
|
+
// Pick a saved record, or make a new one. With nothing saved there is
|
|
7
|
+
// nothing to pick, so it shows the one control that has a job: New. A
|
|
8
|
+
// disabled dropdown reading "No saved…" is a control that cannot be used
|
|
9
|
+
// drawn at full weight — the thing this part exists to prevent.
|
|
10
|
+
//
|
|
11
|
+
// <RecordSwitcher rows={drafts} value={draft.id} onChange={choose}
|
|
12
|
+
// labelOf={(r) => r.title} onNew={newDraft} newLabel="New draft" />
|
|
13
|
+
|
|
14
|
+
export function RecordSwitcher({ rows, value, onChange, labelOf = (r) => r.title ?? r.name ?? r.id, onNew, newLabel = "New", label = "Saved", idColumn = "id", className }) {
|
|
15
|
+
const id = useId();
|
|
16
|
+
const live = (rows ?? []).filter((r) => !r._sample);
|
|
17
|
+
if (live.length === 0) {
|
|
18
|
+
return onNew ? (
|
|
19
|
+
<div className={className}>
|
|
20
|
+
<Button variant="secondary" icon="plus" onClick={onNew}>
|
|
21
|
+
{newLabel}
|
|
22
|
+
</Button>
|
|
23
|
+
</div>
|
|
24
|
+
) : null;
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<div className={["flex items-end gap-2", className].filter(Boolean).join(" ")}>
|
|
28
|
+
<label htmlFor={id} className="sr-only">
|
|
29
|
+
{label}
|
|
30
|
+
</label>
|
|
31
|
+
<div className="min-w-0 flex-1">
|
|
32
|
+
<Select
|
|
33
|
+
id={id}
|
|
34
|
+
value={value ?? ""}
|
|
35
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
36
|
+
options={[
|
|
37
|
+
...(value ? [] : [{ value: "", label: `${label}…` }]),
|
|
38
|
+
...live.map((r) => ({ value: String(r[idColumn]), label: labelOf(r) })),
|
|
39
|
+
]}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
{onNew ? (
|
|
43
|
+
<Button variant="secondary" icon="plus" onClick={onNew}>
|
|
44
|
+
{newLabel}
|
|
45
|
+
</Button>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { cx } from "./cx";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
|
|
5
|
+
// Button — every action in the app. Four looks, one shape, 44px targets
|
|
6
|
+
// (mobile first: a thumb hits it). `href` makes it a next/link (base
|
|
7
|
+
// path applied); `icon` names one from ./icon. Never style a <button>
|
|
8
|
+
// by hand in a surface: the look is the workspace's, the behaviour is
|
|
9
|
+
// the browser's, and this is the only place both meet.
|
|
10
|
+
//
|
|
11
|
+
// variant primary (the one main action per surface) · secondary ·
|
|
12
|
+
// quiet (no fill) · danger (destructive, red text) · link
|
|
13
|
+
// size md (44px) · sm (36px, for dense rows and rails)
|
|
14
|
+
|
|
15
|
+
const VARIANTS = {
|
|
16
|
+
primary: "bg-primary text-primary-foreground hover:opacity-90 border-transparent",
|
|
17
|
+
secondary: "bg-secondary text-secondary-foreground border-border hover:bg-accent",
|
|
18
|
+
quiet: "bg-transparent text-foreground border-transparent hover:bg-accent",
|
|
19
|
+
danger: "bg-transparent text-destructive border-border hover:bg-accent",
|
|
20
|
+
link: "bg-transparent text-primary underline-offset-4 hover:underline border-transparent px-0 min-h-0",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SIZES = {
|
|
24
|
+
md: "min-h-11 px-4 text-sm",
|
|
25
|
+
sm: "min-h-9 px-3 text-sm",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Button({
|
|
29
|
+
variant = "secondary",
|
|
30
|
+
size = "md",
|
|
31
|
+
icon,
|
|
32
|
+
href,
|
|
33
|
+
className,
|
|
34
|
+
children,
|
|
35
|
+
type = "button",
|
|
36
|
+
...rest
|
|
37
|
+
}) {
|
|
38
|
+
const classes = cx(
|
|
39
|
+
"inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap",
|
|
40
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
41
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
42
|
+
VARIANTS[variant] ?? VARIANTS.secondary,
|
|
43
|
+
SIZES[size] ?? SIZES.md,
|
|
44
|
+
className,
|
|
45
|
+
);
|
|
46
|
+
const body = (
|
|
47
|
+
<>
|
|
48
|
+
{icon ? <Icon name={icon} /> : null}
|
|
49
|
+
{children}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
if (href) {
|
|
53
|
+
return (
|
|
54
|
+
<Link href={href} className={classes} {...rest}>
|
|
55
|
+
{body}
|
|
56
|
+
</Link>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return (
|
|
60
|
+
<button type={type} className={classes} {...rest}>
|
|
61
|
+
{body}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A square button holding only an icon; `label` is required and is what
|
|
67
|
+
* a screen reader says. */
|
|
68
|
+
export function IconButton({ icon, label, size = "md", variant = "quiet", className, ...rest }) {
|
|
69
|
+
return (
|
|
70
|
+
<Button
|
|
71
|
+
variant={variant}
|
|
72
|
+
size={size}
|
|
73
|
+
aria-label={label}
|
|
74
|
+
title={label}
|
|
75
|
+
className={cx(size === "sm" ? "w-9 px-0" : "w-11 px-0", className)}
|
|
76
|
+
{...rest}
|
|
77
|
+
>
|
|
78
|
+
<Icon name={icon} size={size === "sm" ? 16 : 18} />
|
|
79
|
+
</Button>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
// Depiction — the frame around a picture of something that exists
|
|
6
|
+
// elsewhere: a tweet as X will show it, a LinkedIn post in the feed, the
|
|
7
|
+
// row an email makes in Gmail's inbox. The picture itself is a file in
|
|
8
|
+
// components/depictions/<thing>.jsx and wears THAT place's colours, face
|
|
9
|
+
// and widths (README §3c); this frame is the boundary where the
|
|
10
|
+
// workspace theme stops. app/globals.css resets the inherited face, size,
|
|
11
|
+
// letter-spacing and density on [data-depiction] and sets color-scheme
|
|
12
|
+
// from data-scheme, so nothing of the workspace reaches the picture.
|
|
13
|
+
//
|
|
14
|
+
// <Depiction of="x-post" scheme="dark" width={600} label="On X · Dim">
|
|
15
|
+
// <XPost text={draft.text} name={me.name} handle={me.handle} mode="dim" />
|
|
16
|
+
// </Depiction>
|
|
17
|
+
//
|
|
18
|
+
// of the depiction file's basename; `npm run check` ties them
|
|
19
|
+
// scheme the TARGET's light or dark — never the workspace's
|
|
20
|
+
// width the target's real column in px (X 600, LinkedIn 555, phone 375)
|
|
21
|
+
// label the caption, chrome, in the workspace's tokens
|
|
22
|
+
//
|
|
23
|
+
// Scale, never reflow: line breaks and the "…more" fold ARE the preview,
|
|
24
|
+
// so a narrow column shrinks the picture (transform) instead of
|
|
25
|
+
// re-wrapping it. One ResizeObserver, no dependency. The caption sits
|
|
26
|
+
// outside the reset boundary; the frame paints no ground inside it — the
|
|
27
|
+
// depiction's root paints its target's page colour.
|
|
28
|
+
|
|
29
|
+
export function Depiction({ of, scheme, width, label, children }) {
|
|
30
|
+
const outer = useRef(null);
|
|
31
|
+
const inner = useRef(null);
|
|
32
|
+
const [fit, setFit] = useState({ k: 1, height: undefined });
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const o = outer.current;
|
|
36
|
+
const i = inner.current;
|
|
37
|
+
if (!o || !i || typeof ResizeObserver === "undefined") return;
|
|
38
|
+
const measure = () => {
|
|
39
|
+
const k = Math.min(1, o.clientWidth / width);
|
|
40
|
+
setFit({ k, height: Math.round(i.offsetHeight * k) });
|
|
41
|
+
};
|
|
42
|
+
measure();
|
|
43
|
+
const ro = new ResizeObserver(measure);
|
|
44
|
+
ro.observe(o);
|
|
45
|
+
ro.observe(i);
|
|
46
|
+
return () => ro.disconnect();
|
|
47
|
+
}, [width]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<figure className="min-w-0">
|
|
51
|
+
<figcaption className="mb-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
52
|
+
{label ?? `How it will look · ${of}`}
|
|
53
|
+
</figcaption>
|
|
54
|
+
<div ref={outer} className="overflow-hidden" style={{ height: fit.height }}>
|
|
55
|
+
<div
|
|
56
|
+
ref={inner}
|
|
57
|
+
data-depiction={of}
|
|
58
|
+
data-scheme={scheme}
|
|
59
|
+
style={{ width, transform: fit.k === 1 ? undefined : `scale(${fit.k})`, transformOrigin: "top left" }}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</figure>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { cx } from "./cx";
|
|
2
|
+
|
|
3
|
+
// Form parts. Each is the native element with the workspace's look and a
|
|
4
|
+
// 44px target; the browser supplies the behaviour (focus, keyboard,
|
|
5
|
+
// validation, autofill). Wrap one in <Field> for its label, hint and
|
|
6
|
+
// error — never a bare <input> with a <div> label beside it.
|
|
7
|
+
|
|
8
|
+
const CONTROL =
|
|
9
|
+
"w-full min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground " +
|
|
10
|
+
"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring " +
|
|
11
|
+
"disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive";
|
|
12
|
+
|
|
13
|
+
/** Label, control, hint and error in one stack. Pass `id`; the label
|
|
14
|
+
* points at it. `error` (a string) marks the control invalid. */
|
|
15
|
+
export function Field({ id, label, hint, error, required, className, children }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={cx("grid gap-1.5", className)}>
|
|
18
|
+
{label ? (
|
|
19
|
+
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
|
20
|
+
{label}
|
|
21
|
+
{required ? <span aria-hidden="true" className="text-destructive"> *</span> : null}
|
|
22
|
+
</label>
|
|
23
|
+
) : null}
|
|
24
|
+
{children}
|
|
25
|
+
{error ? (
|
|
26
|
+
<p id={id ? `${id}-error` : undefined} role="alert" className="text-xs text-destructive">
|
|
27
|
+
{error}
|
|
28
|
+
</p>
|
|
29
|
+
) : hint ? (
|
|
30
|
+
<p id={id ? `${id}-hint` : undefined} className="text-xs text-muted-foreground">
|
|
31
|
+
{hint}
|
|
32
|
+
</p>
|
|
33
|
+
) : null}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Input({ className, invalid, ...rest }) {
|
|
39
|
+
return <input className={cx(CONTROL, className)} aria-invalid={invalid ? true : undefined} {...rest} />;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Textarea({ className, invalid, rows = 4, ...rest }) {
|
|
43
|
+
return (
|
|
44
|
+
<textarea
|
|
45
|
+
rows={rows}
|
|
46
|
+
className={cx(CONTROL, "min-h-24 resize-y py-2.5 leading-normal", className)}
|
|
47
|
+
aria-invalid={invalid ? true : undefined}
|
|
48
|
+
{...rest}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The native <select>, styled. `options` is [{ value, label }]; children
|
|
55
|
+
* work too. A select with nothing to choose from is not a control — when
|
|
56
|
+
* `options` is empty it renders `empty` (a short muted line, or nothing
|
|
57
|
+
* when `empty` is null) rather than a disabled dropdown. For "pick a
|
|
58
|
+
* saved record or make a new one" use RecordSwitcher (components/parts).
|
|
59
|
+
*/
|
|
60
|
+
export function Select({ options, empty = "Nothing to choose yet.", className, invalid, children, ...rest }) {
|
|
61
|
+
if (options && options.length === 0 && !children) {
|
|
62
|
+
return empty ? <p className="text-sm text-muted-foreground">{empty}</p> : null;
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<span className="relative block">
|
|
66
|
+
<select
|
|
67
|
+
className={cx(CONTROL, "appearance-none pr-9", className)}
|
|
68
|
+
aria-invalid={invalid ? true : undefined}
|
|
69
|
+
{...rest}
|
|
70
|
+
>
|
|
71
|
+
{options ? options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>) : children}
|
|
72
|
+
</select>
|
|
73
|
+
<svg
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
76
|
+
width="16"
|
|
77
|
+
height="16"
|
|
78
|
+
viewBox="0 0 24 24"
|
|
79
|
+
fill="none"
|
|
80
|
+
stroke="currentColor"
|
|
81
|
+
strokeWidth="1.8"
|
|
82
|
+
strokeLinecap="round"
|
|
83
|
+
strokeLinejoin="round"
|
|
84
|
+
>
|
|
85
|
+
<path d="m6 9 6 6 6-6" />
|
|
86
|
+
</svg>
|
|
87
|
+
</span>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** An on/off. A checkbox underneath (role=switch), so forms, labels and
|
|
92
|
+
* keyboards work; the track is drawn from tokens. */
|
|
93
|
+
export function Switch({ id, checked, onChange, label, disabled, className }) {
|
|
94
|
+
return (
|
|
95
|
+
<label htmlFor={id} className={cx("inline-flex min-h-11 cursor-pointer items-center gap-3 text-sm", disabled && "cursor-not-allowed opacity-50", className)}>
|
|
96
|
+
<span className="relative inline-flex h-6 w-10 shrink-0">
|
|
97
|
+
<input
|
|
98
|
+
id={id}
|
|
99
|
+
type="checkbox"
|
|
100
|
+
role="switch"
|
|
101
|
+
className="peer sr-only"
|
|
102
|
+
checked={checked}
|
|
103
|
+
onChange={onChange}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
/>
|
|
106
|
+
<span className="absolute inset-0 rounded-full border border-input bg-muted transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring" />
|
|
107
|
+
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-foreground transition-transform peer-checked:translate-x-4 peer-checked:bg-primary-foreground" />
|
|
108
|
+
</span>
|
|
109
|
+
{label ? <span>{label}</span> : null}
|
|
110
|
+
</label>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function Checkbox({ id, label, className, ...rest }) {
|
|
115
|
+
return (
|
|
116
|
+
<label htmlFor={id} className={cx("inline-flex min-h-11 cursor-pointer items-center gap-2 text-sm", className)}>
|
|
117
|
+
<input
|
|
118
|
+
id={id}
|
|
119
|
+
type="checkbox"
|
|
120
|
+
className="h-4 w-4 rounded-sm border-input accent-[var(--color-primary)] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
121
|
+
{...rest}
|
|
122
|
+
/>
|
|
123
|
+
{label ? <span>{label}</span> : null}
|
|
124
|
+
</label>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The kit's icons: one inline sprite, no icon package to install. Every
|
|
2
|
+
// glyph is a 24-box stroke path; add one here, never an <svg> in a surface.
|
|
3
|
+
// `name` is the key below; unknown names render nothing (and `npm run
|
|
4
|
+
// check` names them).
|
|
5
|
+
|
|
6
|
+
const PATHS = {
|
|
7
|
+
plus: "M12 5v14M5 12h14",
|
|
8
|
+
x: "M18 6 6 18M6 6l12 12",
|
|
9
|
+
check: "m5 12 5 5L20 7",
|
|
10
|
+
"chevron-down": "m6 9 6 6 6-6",
|
|
11
|
+
"chevron-up": "m18 15-6-6-6 6",
|
|
12
|
+
"chevron-left": "m15 18-6-6 6-6",
|
|
13
|
+
"chevron-right": "m9 18 6-6-6-6",
|
|
14
|
+
"arrow-left": "M19 12H5m7-7-7 7 7 7",
|
|
15
|
+
"arrow-right": "M5 12h14m-7-7 7 7-7 7",
|
|
16
|
+
search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM21 21l-4.3-4.3",
|
|
17
|
+
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
|
|
18
|
+
pencil: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z",
|
|
19
|
+
copy: "M8 8h12v12H8zM4 16V4h12",
|
|
20
|
+
more: "M5 12h.01M12 12h.01M19 12h.01",
|
|
21
|
+
external: "M14 3h7v7m0-7L10 14M21 14v7H3V3h7",
|
|
22
|
+
refresh: "M21 12a9 9 0 1 1-2.6-6.4M21 3v6h-6",
|
|
23
|
+
filter: "M3 5h18l-7 8v6l-4 2v-8Z",
|
|
24
|
+
calendar: "M3 5h18v16H3zM16 3v4M8 3v4M3 10h18",
|
|
25
|
+
clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5l3 3",
|
|
26
|
+
user: "M20 21a8 8 0 0 0-16 0M12 3a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z",
|
|
27
|
+
users: "M17 21a4 4 0 0 0-8 0M13 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM23 21a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8M3 21a4 4 0 0 1 3-3.9M8 3.1a4 4 0 0 0 0 7.8",
|
|
28
|
+
mail: "M3 5h18v14H3zM3 7l9 6 9-6",
|
|
29
|
+
link: "M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1",
|
|
30
|
+
grip: "M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01",
|
|
31
|
+
info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 5h.01M12 12v4",
|
|
32
|
+
warning: "M12 3 2 20h20L12 3Zm0 7v4m0 3h.01",
|
|
33
|
+
sparkles: "m12 3 2 6 6 2-6 2-2 6-2-6-6-2 6-2 2-6ZM19 15l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2Z",
|
|
34
|
+
play: "m6 4 14 8-14 8V4Z",
|
|
35
|
+
pause: "M6 4h4v16H6zM14 4h4v16h-4z",
|
|
36
|
+
download: "M12 3v12m-5-5 5 5 5-5M4 21h16",
|
|
37
|
+
upload: "M12 21V9m-5 5 5-5 5 5M4 3h16",
|
|
38
|
+
eye: "M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Zm10-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z",
|
|
39
|
+
send: "m22 2-7 20-4-9-9-4 20-7Z",
|
|
40
|
+
home: "m3 11 9-8 9 8v10h-6v-6H9v6H3V11Z",
|
|
41
|
+
settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8 4-1.5-.5.3-1.6 1.4-.9-1-1.7-1.6.5-1.2-1.2.5-1.6-1.7-1-.9 1.4-1.6-.3L12 4l-.5 1.5-1.6.3-.9-1.4-1.7 1 .5 1.6-1.2 1.2-1.6-.5-1 1.7 1.4.9.3 1.6L4 12l1.5.5-.3 1.6-1.4.9 1 1.7 1.6-.5 1.2 1.2-.5 1.6 1.7 1 .9-1.4 1.6.3L12 20l.5-1.5 1.6-.3.9 1.4 1.7-1-.5-1.6 1.2-1.2 1.6.5 1-1.7-1.4-.9-.3-1.6L20 12Z",
|
|
42
|
+
reply: "M9 17 4 12l5-5M4 12h10a6 6 0 0 1 6 6v1",
|
|
43
|
+
repeat: "m17 2 4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4M21 13v2a3 3 0 0 1-3 3H3",
|
|
44
|
+
heart: "M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z",
|
|
45
|
+
chart: "M3 3v18h18M7 16l4-5 4 3 5-7",
|
|
46
|
+
share: "M12 3v12M7 8l5-5 5 5M5 14v6h14v-6",
|
|
47
|
+
star: "m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3 6.4 20.2l1.1-6.2L3 9.6l6.2-.9L12 3Z",
|
|
48
|
+
bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0",
|
|
49
|
+
tag: "M20 12 12 20l-9-9V3h8l9 9ZM7 7h.01",
|
|
50
|
+
folder: "M3 7V5h6l2 2h10v13H3V7Z",
|
|
51
|
+
file: "M14 3H6v18h12V7l-4-4ZM14 3v4h4",
|
|
52
|
+
image: "M3 5h18v14H3zM8 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM21 15l-5-5-9 9",
|
|
53
|
+
phone: "M22 16.9v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7l.8 3.6a2 2 0 0 1-.5 1.9L8.1 10.5a16 16 0 0 0 5.4 5.4l1.3-1.3a2 2 0 0 1 1.9-.5l3.6.8a2 2 0 0 1 1.7 2Z",
|
|
54
|
+
globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
|
|
55
|
+
lock: "M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4",
|
|
56
|
+
zap: "M13 2 3 14h8l-1 8 10-12h-8l1-8Z",
|
|
57
|
+
dollar: "M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6",
|
|
58
|
+
map: "m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3V6ZM9 3v15M15 6v15",
|
|
59
|
+
minus: "M5 12h14",
|
|
60
|
+
archive: "M3 4h18v4H3zM5 8v12h14V8M10 12h4",
|
|
61
|
+
inbox: "M3 13h5l2 3h4l2-3h5M3 13V5h18v8M3 13v6h18v-6",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const ICON_NAMES = Object.keys(PATHS);
|
|
65
|
+
|
|
66
|
+
export function Icon({ name, size = 16, className, label }) {
|
|
67
|
+
const d = PATHS[name];
|
|
68
|
+
if (!d) return null;
|
|
69
|
+
return (
|
|
70
|
+
<svg
|
|
71
|
+
width={size}
|
|
72
|
+
height={size}
|
|
73
|
+
viewBox="0 0 24 24"
|
|
74
|
+
fill="none"
|
|
75
|
+
stroke="currentColor"
|
|
76
|
+
strokeWidth="1.8"
|
|
77
|
+
strokeLinecap="round"
|
|
78
|
+
strokeLinejoin="round"
|
|
79
|
+
aria-hidden={label ? undefined : true}
|
|
80
|
+
role={label ? "img" : undefined}
|
|
81
|
+
aria-label={label}
|
|
82
|
+
className={className}
|
|
83
|
+
>
|
|
84
|
+
<path d={d} />
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The kit. Import from "../components/ui" (a surface) or "./ui" (a
|
|
2
|
+
// component). Every part is the workspace's look over the browser's own
|
|
3
|
+
// behaviour; none of them takes a colour. README §3b.
|
|
4
|
+
export { cx } from "./cx";
|
|
5
|
+
export { Icon, ICON_NAMES } from "./icon";
|
|
6
|
+
export { Button, IconButton } from "./button";
|
|
7
|
+
export { Field, Input, Textarea, Select, Switch, Checkbox } from "./field";
|
|
8
|
+
export { Card, Notice, Badge, Kicker, Empty } from "./notice";
|
|
9
|
+
export { Sheet, Confirm } from "./sheet";
|
|
10
|
+
export { Tabs, TabPanel } from "./tabs";
|
|
11
|
+
export { Menu } from "./menu";
|
|
12
|
+
export { Depiction } from "./depiction";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { cx } from "./cx";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
|
|
7
|
+
// Menu — the "…" of a row, the kebab of a card. The Popover API does the
|
|
8
|
+
// hard part (top layer, light dismiss, Escape); this positions it under
|
|
9
|
+
// its button and moves focus with the arrow keys. `items` with nothing in
|
|
10
|
+
// them render nothing: a menu is earned by having actions.
|
|
11
|
+
//
|
|
12
|
+
// items [{ label, onSelect, icon?, tone?: "danger", disabled? }]
|
|
13
|
+
// trigger a node to use as the button's content; default is the ⋯ icon
|
|
14
|
+
|
|
15
|
+
export function Menu({ items, label = "More", trigger, align = "end", className }) {
|
|
16
|
+
const id = useId();
|
|
17
|
+
const btn = useRef(null);
|
|
18
|
+
const pop = useRef(null);
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const live = (items ?? []).filter(Boolean);
|
|
21
|
+
|
|
22
|
+
// A fixed-positioned popover does not follow its button; close it when
|
|
23
|
+
// the page moves rather than leave it floating where the button was.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open) return;
|
|
26
|
+
const close = () => pop.current?.hidePopover();
|
|
27
|
+
window.addEventListener("resize", close);
|
|
28
|
+
window.addEventListener("scroll", close, true);
|
|
29
|
+
return () => {
|
|
30
|
+
window.removeEventListener("resize", close);
|
|
31
|
+
window.removeEventListener("scroll", close, true);
|
|
32
|
+
};
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
if (live.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
function place() {
|
|
38
|
+
const b = btn.current?.getBoundingClientRect();
|
|
39
|
+
const p = pop.current;
|
|
40
|
+
if (!b || !p) return;
|
|
41
|
+
p.style.position = "fixed";
|
|
42
|
+
p.style.top = `${Math.round(b.bottom + 4)}px`;
|
|
43
|
+
const width = p.offsetWidth || 200;
|
|
44
|
+
const left = align === "end" ? b.right - width : b.left;
|
|
45
|
+
p.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toggle() {
|
|
49
|
+
const p = pop.current;
|
|
50
|
+
if (!p) return;
|
|
51
|
+
if (p.matches(":popover-open")) p.hidePopover();
|
|
52
|
+
else {
|
|
53
|
+
p.showPopover();
|
|
54
|
+
place();
|
|
55
|
+
p.querySelector('[role="menuitem"]:not([disabled])')?.focus();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onKeyDown(e) {
|
|
60
|
+
const nodes = [...(pop.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? [])];
|
|
61
|
+
const i = nodes.indexOf(document.activeElement);
|
|
62
|
+
if (e.key === "ArrowDown") nodes[(i + 1) % nodes.length]?.focus();
|
|
63
|
+
else if (e.key === "ArrowUp") nodes[(i - 1 + nodes.length) % nodes.length]?.focus();
|
|
64
|
+
else if (e.key === "Home") nodes[0]?.focus();
|
|
65
|
+
else if (e.key === "End") nodes[nodes.length - 1]?.focus();
|
|
66
|
+
else if (e.key === "Tab" || e.key === "Escape") {
|
|
67
|
+
pop.current?.hidePopover();
|
|
68
|
+
btn.current?.focus();
|
|
69
|
+
} else return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<button
|
|
76
|
+
ref={btn}
|
|
77
|
+
type="button"
|
|
78
|
+
aria-haspopup="menu"
|
|
79
|
+
aria-expanded={open}
|
|
80
|
+
aria-controls={id}
|
|
81
|
+
aria-label={trigger ? undefined : label}
|
|
82
|
+
title={trigger ? undefined : label}
|
|
83
|
+
onClick={toggle}
|
|
84
|
+
className={cx(
|
|
85
|
+
"inline-flex min-h-9 min-w-9 items-center justify-center gap-2 rounded-md px-2 text-sm text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
|
86
|
+
className,
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{trigger ?? <Icon name="more" />}
|
|
90
|
+
</button>
|
|
91
|
+
<div
|
|
92
|
+
ref={pop}
|
|
93
|
+
id={id}
|
|
94
|
+
popover="auto"
|
|
95
|
+
role="menu"
|
|
96
|
+
aria-label={label}
|
|
97
|
+
onToggle={(e) => setOpen(e.newState === "open")}
|
|
98
|
+
onKeyDown={onKeyDown}
|
|
99
|
+
className="min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
|
100
|
+
>
|
|
101
|
+
{live.map((item, i) => (
|
|
102
|
+
<button
|
|
103
|
+
key={i}
|
|
104
|
+
type="button"
|
|
105
|
+
role="menuitem"
|
|
106
|
+
disabled={item.disabled}
|
|
107
|
+
onClick={() => {
|
|
108
|
+
pop.current?.hidePopover();
|
|
109
|
+
item.onSelect?.();
|
|
110
|
+
}}
|
|
111
|
+
className={cx(
|
|
112
|
+
"flex w-full min-h-10 items-center gap-2 rounded-sm px-2 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent disabled:opacity-50",
|
|
113
|
+
item.tone === "danger" && "text-destructive",
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
{item.icon ? <Icon name={item.icon} /> : null}
|
|
117
|
+
{item.label}
|
|
118
|
+
</button>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|