@hackcove/ui-react 0.0.0-stage → 0.8.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/LICENSE +21 -0
- package/MIGRATING.md +116 -0
- package/README.md +400 -2
- package/dist/AppShell.d.ts +97 -0
- package/dist/AppShell.js +106 -0
- package/dist/Avatar.d.ts +49 -0
- package/dist/Avatar.js +65 -0
- package/dist/AvatarStack.d.ts +29 -0
- package/dist/AvatarStack.js +26 -0
- package/dist/Badge.d.ts +14 -0
- package/dist/Badge.js +11 -0
- package/dist/Breadcrumbs.d.ts +28 -0
- package/dist/Breadcrumbs.js +30 -0
- package/dist/BulletList.d.ts +4 -0
- package/dist/BulletList.js +5 -0
- package/dist/Button.d.ts +78 -0
- package/dist/Button.js +133 -0
- package/dist/ButtonGroup.d.ts +15 -0
- package/dist/ButtonGroup.js +10 -0
- package/dist/Callout.d.ts +24 -0
- package/dist/Callout.js +28 -0
- package/dist/Card.d.ts +95 -0
- package/dist/Card.js +134 -0
- package/dist/CardBadge.d.ts +7 -0
- package/dist/CardBadge.js +12 -0
- package/dist/CardParts.d.ts +25 -0
- package/dist/CardParts.js +25 -0
- package/dist/Checkbox.d.ts +29 -0
- package/dist/Checkbox.js +36 -0
- package/dist/Chevron.d.ts +5 -0
- package/dist/Chevron.js +6 -0
- package/dist/Cluster.d.ts +51 -0
- package/dist/Cluster.js +38 -0
- package/dist/Code.d.ts +4 -0
- package/dist/Code.js +5 -0
- package/dist/CodeBlock.d.ts +27 -0
- package/dist/CodeBlock.js +12 -0
- package/dist/CodeExample.d.ts +17 -0
- package/dist/CodeExample.js +13 -0
- package/dist/CodeText.d.ts +7 -0
- package/dist/CodeText.js +21 -0
- package/dist/ColourSchemeToggle.d.ts +34 -0
- package/dist/ColourSchemeToggle.js +50 -0
- package/dist/Container.d.ts +22 -0
- package/dist/Container.js +13 -0
- package/dist/CopyButton.d.ts +17 -0
- package/dist/CopyButton.js +13 -0
- package/dist/CopyLine.d.ts +9 -0
- package/dist/CopyLine.js +7 -0
- package/dist/DateCalendar.d.ts +10 -0
- package/dist/DateCalendar.js +45 -0
- package/dist/DateField.d.ts +87 -0
- package/dist/DateField.js +100 -0
- package/dist/DateFieldCalendar.d.ts +6 -0
- package/dist/DateFieldCalendar.js +35 -0
- package/dist/Dialog.d.ts +26 -0
- package/dist/Dialog.js +18 -0
- package/dist/Disclosure.d.ts +15 -0
- package/dist/Disclosure.js +12 -0
- package/dist/Divider.d.ts +4 -0
- package/dist/Divider.js +5 -0
- package/dist/Drawer.d.ts +29 -0
- package/dist/Drawer.js +22 -0
- package/dist/EmptyState.d.ts +18 -0
- package/dist/EmptyState.js +14 -0
- package/dist/Field.d.ts +21 -0
- package/dist/Field.js +19 -0
- package/dist/FieldDialog.d.ts +28 -0
- package/dist/FieldDialog.js +34 -0
- package/dist/FileDrop.d.ts +17 -0
- package/dist/FileDrop.js +12 -0
- package/dist/FilterGroup.d.ts +7 -0
- package/dist/FilterGroup.js +6 -0
- package/dist/FormRow.d.ts +42 -0
- package/dist/FormRow.js +33 -0
- package/dist/Icon.d.ts +20 -0
- package/dist/Icon.js +13 -0
- package/dist/IconButton.d.ts +57 -0
- package/dist/IconButton.js +51 -0
- package/dist/Input.d.ts +22 -0
- package/dist/Input.js +22 -0
- package/dist/InputShell.d.ts +16 -0
- package/dist/InputShell.js +26 -0
- package/dist/Kbd.d.ts +6 -0
- package/dist/Kbd.js +5 -0
- package/dist/KeyValueList.d.ts +23 -0
- package/dist/KeyValueList.js +19 -0
- package/dist/Lightbox.d.ts +28 -0
- package/dist/Lightbox.js +44 -0
- package/dist/ListRow.d.ts +109 -0
- package/dist/ListRow.js +92 -0
- package/dist/Markdown.d.ts +9 -0
- package/dist/Markdown.js +43 -0
- package/dist/MetaList.d.ts +23 -0
- package/dist/MetaList.js +14 -0
- package/dist/OutlineRegion.d.ts +43 -0
- package/dist/OutlineRegion.js +27 -0
- package/dist/PageFade.d.ts +26 -0
- package/dist/PageFade.js +39 -0
- package/dist/PageHeader.d.ts +24 -0
- package/dist/PageHeader.js +14 -0
- package/dist/PageLayouts.d.ts +130 -0
- package/dist/PageLayouts.js +86 -0
- package/dist/Pill.d.ts +53 -0
- package/dist/Pill.js +34 -0
- package/dist/ProgressBar.d.ts +32 -0
- package/dist/ProgressBar.js +32 -0
- package/dist/RadioGroup.d.ts +48 -0
- package/dist/RadioGroup.js +39 -0
- package/dist/SearchInput.d.ts +8 -0
- package/dist/SearchInput.js +40 -0
- package/dist/SegmentedControl.d.ts +20 -0
- package/dist/SegmentedControl.js +31 -0
- package/dist/Select.d.ts +5 -0
- package/dist/Select.js +9 -0
- package/dist/SplitBar.d.ts +27 -0
- package/dist/SplitBar.js +15 -0
- package/dist/Stack.d.ts +70 -0
- package/dist/Stack.js +47 -0
- package/dist/StackedBar.d.ts +56 -0
- package/dist/StackedBar.js +35 -0
- package/dist/StatTile.d.ts +21 -0
- package/dist/StatTile.js +22 -0
- package/dist/StatusPages.d.ts +38 -0
- package/dist/StatusPages.js +57 -0
- package/dist/Steps.d.ts +17 -0
- package/dist/Steps.js +14 -0
- package/dist/StickyBar.d.ts +12 -0
- package/dist/StickyBar.js +10 -0
- package/dist/Table.d.ts +142 -0
- package/dist/Table.js +138 -0
- package/dist/Tabs.d.ts +40 -0
- package/dist/Tabs.js +49 -0
- package/dist/TextLink.d.ts +51 -0
- package/dist/TextLink.js +37 -0
- package/dist/Textarea.d.ts +12 -0
- package/dist/Textarea.js +8 -0
- package/dist/Timeline.d.ts +27 -0
- package/dist/Timeline.js +29 -0
- package/dist/Video.d.ts +13 -0
- package/dist/Video.js +9 -0
- package/dist/colourScheme.d.ts +21 -0
- package/dist/colourScheme.js +53 -0
- package/dist/control.d.ts +31 -0
- package/dist/control.js +38 -0
- package/dist/cx.d.ts +12 -0
- package/dist/cx.js +14 -0
- package/dist/highlight.d.ts +16 -0
- package/dist/highlight.js +292 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +66 -0
- package/dist/isoDate.d.ts +5 -0
- package/dist/isoDate.js +16 -0
- package/dist/lift.harness.d.ts +2 -0
- package/dist/lift.harness.js +3 -0
- package/dist/link.d.ts +50 -0
- package/dist/link.js +25 -0
- package/dist/modal.d.ts +11 -0
- package/dist/modal.js +41 -0
- package/dist/props.d.ts +14 -0
- package/dist/props.js +1 -0
- package/dist/shownDate.d.ts +45 -0
- package/dist/shownDate.js +88 -0
- package/dist/size.d.ts +6 -0
- package/dist/size.js +1 -0
- package/dist/switcher.d.ts +88 -0
- package/dist/switcher.js +71 -0
- package/dist/typography.d.ts +123 -0
- package/dist/typography.js +120 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.js +27 -0
- package/package.json +91 -4
package/dist/Button.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx, focusRing } from "./cx.js";
|
|
3
|
+
import { renderLink } from "./link.js";
|
|
4
|
+
const SHAPE = "inline-flex items-center justify-center rounded-lg border font-medium transition";
|
|
5
|
+
// Size sets the padding and the text. The link variant has no box, so it takes the text size only.
|
|
6
|
+
const SIZE = {
|
|
7
|
+
sm: { box: "gap-1.5 px-2.5 py-1", text: "text-xs" },
|
|
8
|
+
md: { box: "gap-2 px-4 py-2", text: "text-sm" },
|
|
9
|
+
lg: { box: "gap-2 px-5 py-2.5", text: "text-base" },
|
|
10
|
+
};
|
|
11
|
+
// A plain `disabled` washes out and ignores the pointer. Blocked and busy buttons are disabled too,
|
|
12
|
+
// but they keep pointer events (the title shows, the cursor says why) and do not take this.
|
|
13
|
+
const DISABLED_PLAIN = "disabled:pointer-events-none disabled:opacity-50";
|
|
14
|
+
const LINK = "underline-offset-2";
|
|
15
|
+
const TONE = {
|
|
16
|
+
default: {
|
|
17
|
+
primary: {
|
|
18
|
+
color: "border-violet-600 bg-violet-600 text-violet-50",
|
|
19
|
+
hover: "hover:bg-violet-700",
|
|
20
|
+
},
|
|
21
|
+
outline: { color: "border-violet-600 text-violet-600", hover: "hover:bg-violet-50" },
|
|
22
|
+
ghost: { color: "border-transparent text-slate-600", hover: "hover:bg-slate-100" },
|
|
23
|
+
link: {
|
|
24
|
+
color: `border-transparent text-violet-600 ${LINK}`,
|
|
25
|
+
hover: "hover:text-violet-700 hover:underline",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
positive: {
|
|
29
|
+
primary: {
|
|
30
|
+
color: "border-emerald-600 bg-emerald-600 text-emerald-50",
|
|
31
|
+
hover: "hover:bg-emerald-700",
|
|
32
|
+
},
|
|
33
|
+
outline: { color: "border-emerald-200 text-emerald-700", hover: "hover:bg-emerald-50" },
|
|
34
|
+
ghost: { color: "border-transparent text-emerald-700", hover: "hover:bg-emerald-50" },
|
|
35
|
+
link: { color: `border-transparent text-emerald-700 ${LINK}`, hover: "hover:underline" },
|
|
36
|
+
},
|
|
37
|
+
danger: {
|
|
38
|
+
primary: { color: "border-red-600 bg-red-600 text-red-50", hover: "hover:bg-red-700" },
|
|
39
|
+
outline: { color: "border-red-200 text-red-700", hover: "hover:bg-red-50" },
|
|
40
|
+
ghost: { color: "border-transparent text-red-700", hover: "hover:bg-red-50" },
|
|
41
|
+
link: { color: `border-transparent text-red-700 ${LINK}`, hover: "hover:underline" },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
// The add row: Card dashed's outline and muted text, lit with the accent on hover. One look, so
|
|
45
|
+
// `tone` does not apply to it.
|
|
46
|
+
const DASHED = {
|
|
47
|
+
color: "border-slate-300 text-slate-500",
|
|
48
|
+
hover: "hover:border-violet-400 hover:text-violet-700",
|
|
49
|
+
};
|
|
50
|
+
// An outline or ghost Button with `pressed` set is a toggle, such as a filter chip: off is neutral
|
|
51
|
+
// (the surface, a hairline, muted text, lit with the accent on hover), on is the tone's primary
|
|
52
|
+
// fill, so the selected one stands out in every theme. A primary is already filled, and link and
|
|
53
|
+
// dashed have no selected state; with `pressed` left out, outline and ghost keep their own look.
|
|
54
|
+
const TOGGLE_OFF = {
|
|
55
|
+
color: "border-slate-200 bg-white text-slate-600",
|
|
56
|
+
hover: "hover:border-violet-400",
|
|
57
|
+
};
|
|
58
|
+
function lookOf(variant, tone, pressed) {
|
|
59
|
+
if (variant === "dashed")
|
|
60
|
+
return DASHED;
|
|
61
|
+
if (pressed !== undefined && (variant === "outline" || variant === "ghost")) {
|
|
62
|
+
return pressed ? TONE[tone].primary : TOGGLE_OFF;
|
|
63
|
+
}
|
|
64
|
+
return TONE[tone][variant];
|
|
65
|
+
}
|
|
66
|
+
// Blocked replaces the variant's colour and hover rather than overriding them, so no `!` is needed.
|
|
67
|
+
const BLOCKED = "cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400";
|
|
68
|
+
const BLOCKED_DASHED = "cursor-not-allowed border-slate-200 text-slate-400";
|
|
69
|
+
const BLOCKED_LINK = "cursor-not-allowed border-transparent text-slate-400";
|
|
70
|
+
const BLOCKED_BY_VARIANT = {
|
|
71
|
+
link: BLOCKED_LINK,
|
|
72
|
+
dashed: BLOCKED_DASHED,
|
|
73
|
+
};
|
|
74
|
+
const BUSY = "cursor-progress opacity-85";
|
|
75
|
+
function shape(variant, size) {
|
|
76
|
+
const { box, text } = SIZE[size];
|
|
77
|
+
return cx(SHAPE, variant !== "link" && box, variant === "link" && "gap-1.5", variant === "dashed" && "w-full border-dashed", text, focusRing);
|
|
78
|
+
}
|
|
79
|
+
/** The classes of an idle Button: what `href` draws, and what the tests compare against. */
|
|
80
|
+
export function buttonClass(variant = "primary", { size = "md", tone = "default" } = {}) {
|
|
81
|
+
return idleClass(variant, size, tone);
|
|
82
|
+
}
|
|
83
|
+
function idleClass(variant, size, tone, pressed) {
|
|
84
|
+
const look = lookOf(variant, tone, pressed);
|
|
85
|
+
return cx(shape(variant, size), DISABLED_PLAIN, look.color, look.hover);
|
|
86
|
+
}
|
|
87
|
+
function isLink(props) {
|
|
88
|
+
return props.href !== undefined || props.render !== undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Busy wins over blocked. */
|
|
91
|
+
function stateOf(busy, disabledReason) {
|
|
92
|
+
if (busy)
|
|
93
|
+
return "busy";
|
|
94
|
+
return disabledReason === undefined ? "idle" : "blocked";
|
|
95
|
+
}
|
|
96
|
+
/** The classes of a Button in a given state: blocked swaps the colour out, busy keeps it. */
|
|
97
|
+
function stateClass({ variant = "primary", size = "md", tone = "default" }, state, pressed) {
|
|
98
|
+
if (state === "blocked") {
|
|
99
|
+
return cx(shape(variant, size), BLOCKED_BY_VARIANT[variant] ?? BLOCKED);
|
|
100
|
+
}
|
|
101
|
+
if (state === "busy")
|
|
102
|
+
return cx(shape(variant, size), lookOf(variant, tone, pressed).color, BUSY);
|
|
103
|
+
return idleClass(variant, size, tone, pressed);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A button in the primary, outline, ghost, link or dashed style (the link has no box: text with an
|
|
107
|
+
* underline on hover, for an in-line action; `dashed` is the full-width "add" row, a dashed
|
|
108
|
+
* outline with muted text that fills its container, with an optional leading icon in the
|
|
109
|
+
* children). `size="sm"` is compact and `size="lg"` larger; `tone="positive"` is green and `tone="danger"` is red;
|
|
110
|
+
* `pressed` makes it a toggle: neutral when off, the tone's fill when on.
|
|
111
|
+
* Defaults to `type="button"`. `disabledReason` and `busy` are the two disabled states that say
|
|
112
|
+
* more than a plain `disabled`, which keeps its half-opacity look. `href` makes it a link instead,
|
|
113
|
+
* with the same look (a plain `<a>`, or with `render` the app's own router `<Link>`:
|
|
114
|
+
* `render={(p) => <Link to="/quotes" {...p} />}`; `external` opens a new tab).
|
|
115
|
+
*/
|
|
116
|
+
export function Button(props) {
|
|
117
|
+
return isLink(props) ? _jsx(LinkButton, { ...props }) : _jsx(PlainButton, { ...props });
|
|
118
|
+
}
|
|
119
|
+
function LinkButton({ variant = "primary", size = "md", tone = "default", href, render, external = false, children, ...rest }) {
|
|
120
|
+
return renderLink({
|
|
121
|
+
...rest,
|
|
122
|
+
href,
|
|
123
|
+
render,
|
|
124
|
+
external,
|
|
125
|
+
className: buttonClass(variant, { size, tone }),
|
|
126
|
+
children,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function PlainButton({ variant, size, tone, disabledReason, busy = false, busyLabel, pressed, children, title, disabled, ...rest }) {
|
|
130
|
+
const state = stateOf(busy, disabledReason);
|
|
131
|
+
const blocked = state === "blocked";
|
|
132
|
+
return (_jsx("button", { type: "button", className: stateClass({ variant, size, tone }, state, pressed), disabled: disabled || busy || blocked, "aria-busy": busy || undefined, "aria-pressed": pressed, title: blocked ? disabledReason : title, ...rest, children: busy && busyLabel ? busyLabel : children }));
|
|
133
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { Unstyled } from "./props.js";
|
|
3
|
+
/**
|
|
4
|
+
* A wrapping row of buttons with one optional item pushed to the far end (a Back link, say). On a
|
|
5
|
+
* narrow viewport the row wraps and `end` stays at the end of the last line. `label` makes it a
|
|
6
|
+
* named `role="group"`. Directly in a `Card` footer it spans the footer, so `end` reaches the far
|
|
7
|
+
* edge.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ButtonGroup({ children, end, label, ...rest }: {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** One item, pushed to the end of the row. */
|
|
12
|
+
end?: ReactNode;
|
|
13
|
+
/** The accessible name of the group: sets `role="group"` and `aria-label`. */
|
|
14
|
+
label?: string;
|
|
15
|
+
} & Unstyled<Omit<HTMLAttributes<HTMLDivElement>, "children">>): ReactElement;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A wrapping row of buttons with one optional item pushed to the far end (a Back link, say). On a
|
|
4
|
+
* narrow viewport the row wraps and `end` stays at the end of the last line. `label` makes it a
|
|
5
|
+
* named `role="group"`. Directly in a `Card` footer it spans the footer, so `end` reaches the far
|
|
6
|
+
* edge.
|
|
7
|
+
*/
|
|
8
|
+
export function ButtonGroup({ children, end, label, ...rest }) {
|
|
9
|
+
return (_jsxs("div", { ...rest, ...(label === undefined ? {} : { role: "group", "aria-label": label }), "data-button-group": "", className: "flex flex-wrap items-center gap-2", children: [children, end ? _jsx("div", { className: "ml-auto", children: end }) : null] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { Unstyled } from "./props.js";
|
|
3
|
+
/** The colour family of a callout. */
|
|
4
|
+
export type CalloutTone = "neutral" | "info" | "positive" | "warn" | "danger";
|
|
5
|
+
/** The soft fill and border of each tone; a toned `Card` uses the same. */
|
|
6
|
+
export declare const TONE_SURFACE: Record<CalloutTone, string>;
|
|
7
|
+
/**
|
|
8
|
+
* A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
|
|
9
|
+
* at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
|
|
10
|
+
* top of a `Card flush` and nowhere else: a padded card or a padded `FullBleedLayout` column
|
|
11
|
+
* would inset it. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
|
|
12
|
+
* stretch` there makes the whole callout its click target, like a link in a table row.
|
|
13
|
+
*/
|
|
14
|
+
export declare function Callout({ tone, title, actions, banner, children, ...rest }: {
|
|
15
|
+
/** Defaults to "info". */
|
|
16
|
+
tone?: CalloutTone;
|
|
17
|
+
/** A bold line above the body. */
|
|
18
|
+
title?: ReactNode;
|
|
19
|
+
/** A flat strip: `border-b` only and no radius, for the top of a `Card flush` only. */
|
|
20
|
+
banner?: boolean;
|
|
21
|
+
/** A button or link at the end of the row, beside the title and body. */
|
|
22
|
+
actions?: ReactNode;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
} & Unstyled<Omit<HTMLAttributes<HTMLDivElement>, "title">>): ReactElement;
|
package/dist/Callout.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
/** The soft fill and border of each tone; a toned `Card` uses the same. */
|
|
4
|
+
export const TONE_SURFACE = {
|
|
5
|
+
neutral: "border-slate-200 bg-slate-50",
|
|
6
|
+
info: "border-blue-200 bg-blue-50",
|
|
7
|
+
positive: "border-emerald-200 bg-emerald-50",
|
|
8
|
+
warn: "border-amber-200 bg-amber-50",
|
|
9
|
+
danger: "border-red-200 bg-red-50",
|
|
10
|
+
};
|
|
11
|
+
const TONE_TEXT = {
|
|
12
|
+
neutral: "text-slate-700",
|
|
13
|
+
info: "text-blue-800",
|
|
14
|
+
positive: "text-emerald-800",
|
|
15
|
+
warn: "text-amber-800",
|
|
16
|
+
danger: "text-red-800",
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
|
|
20
|
+
* at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
|
|
21
|
+
* top of a `Card flush` and nowhere else: a padded card or a padded `FullBleedLayout` column
|
|
22
|
+
* would inset it. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
|
|
23
|
+
* stretch` there makes the whole callout its click target, like a link in a table row.
|
|
24
|
+
*/
|
|
25
|
+
export function Callout({ tone = "info", title, actions, banner = false, children, ...rest }) {
|
|
26
|
+
const content = title ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-semibold", children: title }), _jsx("div", { className: "mt-1", children: children })] })) : (children);
|
|
27
|
+
return (_jsx("div", { className: cx(Boolean(actions) && "relative", banner ? "border-b px-5 py-3 text-sm" : "rounded-xl border p-4 text-sm", TONE_SURFACE[tone], TONE_TEXT[tone]), ...rest, children: actions ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "min-w-0", children: content }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: actions })] })) : (content) }));
|
|
28
|
+
}
|
package/dist/Card.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { type CalloutTone } from "./Callout.js";
|
|
3
|
+
import { type LinkRender } from "./link.js";
|
|
4
|
+
import type { Unstyled } from "./props.js";
|
|
5
|
+
import type { Size } from "./size.js";
|
|
6
|
+
type CardBase = {
|
|
7
|
+
/** The card's heading, a small uppercase `h2`; on a `section` or `article` it names the card. */
|
|
8
|
+
title?: string;
|
|
9
|
+
/** A small number beside the title (how many rows the card holds). Needs a `title`. */
|
|
10
|
+
count?: number;
|
|
11
|
+
/** A `Pill` (or any node) directly beside the title and count, not at the far end like `actions`. Needs a `title`. */
|
|
12
|
+
status?: ReactNode;
|
|
13
|
+
/** Put the title, count and actions in a tinted strip above the body, edge to edge. */
|
|
14
|
+
bar?: boolean;
|
|
15
|
+
/** No padding and `overflow-hidden`: the body (a table, a list) fills the card. */
|
|
16
|
+
flush?: boolean;
|
|
17
|
+
/** An empty-state shell: dashed outline, no shadow, no fill, centred muted text. `"filled"` puts it on a surface fill with more padding. */
|
|
18
|
+
dashed?: boolean | "filled";
|
|
19
|
+
/** A soft fill and border in the Callout's tones, for a data panel with a status. Ignored with `bar` and `dashed`. */
|
|
20
|
+
tone?: CalloutTone;
|
|
21
|
+
/** A `Pill` (or any small node) pinned in the top-right corner, over the content and inside the radius, with no change to the layout. On a card that is a link it is part of the link's name, and a link or button in it is clickable on its own. */
|
|
22
|
+
badge?: ReactNode;
|
|
23
|
+
/** The warn tone's strong border in place of the card's own, for the one card in a set that needs a look (a run that differed). It keeps the card's fill. Ignored with `dashed`. */
|
|
24
|
+
attention?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* `sm` is dense padding on the body, heading and bar, `lg` one step roomier than `md`; a
|
|
27
|
+
* `dashed` shell keeps its own.
|
|
28
|
+
* @default md
|
|
29
|
+
*/
|
|
30
|
+
size?: Size;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
};
|
|
33
|
+
type CardPanelProps = CardBase & {
|
|
34
|
+
/** Buttons or links at the end of the header: in the strip with `bar`, else beside the title. */
|
|
35
|
+
actions?: ReactNode;
|
|
36
|
+
/** A tinted strip across the bottom of the card, under a hairline, for the card's own actions or a total. It stays at the foot when the card is stretched in a `CardGrid`. Ignored with `dashed`. */
|
|
37
|
+
footer?: ReactNode;
|
|
38
|
+
/**
|
|
39
|
+
* The root element. A `div` takes no name from the title. Not on a card that is a link.
|
|
40
|
+
* @default section
|
|
41
|
+
*/
|
|
42
|
+
as?: "section" | "article" | "div";
|
|
43
|
+
href?: undefined;
|
|
44
|
+
render?: undefined;
|
|
45
|
+
external?: undefined;
|
|
46
|
+
} & Omit<ComponentPropsWithoutRef<"section">, "title" | "className" | "style" | "children">;
|
|
47
|
+
type CardLinkProps = CardBase & {
|
|
48
|
+
actions?: undefined;
|
|
49
|
+
footer?: undefined;
|
|
50
|
+
as?: undefined;
|
|
51
|
+
/** Opens in a new tab: adds `target="_blank"` and `rel="noreferrer"`. */
|
|
52
|
+
external?: boolean | undefined;
|
|
53
|
+
} & Unstyled<Omit<ComponentPropsWithoutRef<"a">, "href" | "title" | "children">> & ({
|
|
54
|
+
/** Makes the whole card one link to this URL, with no `actions`, `footer` or `as`. */
|
|
55
|
+
href: string;
|
|
56
|
+
/** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/shots/1" {...p} />`. */
|
|
57
|
+
render?: LinkRender | undefined;
|
|
58
|
+
} | {
|
|
59
|
+
/** Makes the whole card one link to this URL, with no `actions`, `footer` or `as`. */
|
|
60
|
+
href?: string | undefined;
|
|
61
|
+
/** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/shots/1" {...p} />`. */
|
|
62
|
+
render: LinkRender;
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* A bordered panel with an optional title, count and status; `as` picks the element (default `section`).
|
|
66
|
+
* Three opt-in forms for data views: `flush` drops the padding and clips the corners, for a table
|
|
67
|
+
* that runs edge to edge; `bar` puts the title, count and `actions` in a tinted strip across the
|
|
68
|
+
* top, over the body; `dashed` is an empty-state shell, a dashed outline with centred muted text.
|
|
69
|
+
* `dashed` stands alone: it ignores `flush` and `bar`; `dashed="filled"` is the same shell on a
|
|
70
|
+
* surface fill with more padding, for an empty state that must read as a panel. A flush card has no
|
|
71
|
+
* padding for a `title`, so give it a `bar`. `size="sm"` is the dense padding and `size="lg"` the
|
|
72
|
+
* roomy one, the same on a `ListRow` and a `FileDrop`. `tone` is the Callout's set, for a data panel that carries a status
|
|
73
|
+
* (a soft fill and border, no shadow); a message is still a `Callout`, and `bar` and `dashed` have
|
|
74
|
+
* no tone. A titled panel (`section`, `article`) is named by its title: the root gets
|
|
75
|
+
* `aria-labelledby` pointing at the heading, unless the caller passes its own `aria-label` or
|
|
76
|
+
* `aria-labelledby`, which wins. Other props (`id`, `data-*`, `aria-*`) go on the root. The root is `relative`, so a
|
|
77
|
+
* `TextLink stretch` inside it covers the card. A card with an `id` is an anchor target: `#id`
|
|
78
|
+
* scrolls it clear of the sticky header (and a `subnav`) and outlines it in the accent colour while
|
|
79
|
+
* it is the target.
|
|
80
|
+
*
|
|
81
|
+
* `footer` is a tinted strip across the bottom, under a hairline, for the card's own actions or a
|
|
82
|
+
* total; in a `CardGrid` it stays at the foot of a card that is stretched to the row's height. It
|
|
83
|
+
* suits a panel, not a card that is a link, and `dashed` has none.
|
|
84
|
+
*
|
|
85
|
+
* `href` makes the whole card one link: a hover border and the focus ring, with the link stretched
|
|
86
|
+
* over the card, so it takes no `actions`, `footer` or `as`. A `TextLink`, `Button` or `Checkbox` in
|
|
87
|
+
* the body sits above the link and is clickable on its own. With `render` it draws the app's own router `<Link>`
|
|
88
|
+
* (`render={(p) => <Link to="/shots/1" {...p} />}`). `external` opens it in a new tab. On a link card
|
|
89
|
+
* `id`, `data-*` and the describing `aria-*` (`aria-describedby`, `aria-details`, …) go on the card
|
|
90
|
+
* root, not the link; the link's own `aria-label`, `aria-labelledby` and `aria-current` stay on the
|
|
91
|
+
* link. The link is named by the card's text (title, body, `badge`) through the root's id, which is
|
|
92
|
+
* generated when the caller gives none.
|
|
93
|
+
*/
|
|
94
|
+
export declare function Card(props: CardPanelProps | CardLinkProps): ReactElement;
|
|
95
|
+
export {};
|
package/dist/Card.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { TONE_SURFACE } from "./Callout.js";
|
|
4
|
+
import { CardBadge } from "./CardBadge.js";
|
|
5
|
+
import { CardBar, CardFooter, CardHeader, CardHeading } from "./CardParts.js";
|
|
6
|
+
import { cx, focusRing, liftInteractive } from "./cx.js";
|
|
7
|
+
import { renderLink } from "./link.js";
|
|
8
|
+
/** A card with a `footer`: a `dashed` card stands alone and has none. */
|
|
9
|
+
function withFooter(form, footer) {
|
|
10
|
+
return { ...form, footer: !form.dashed && Boolean(footer) };
|
|
11
|
+
}
|
|
12
|
+
/** `dashed` stands alone; `bar` needs a title or actions to fill it and has no tone. */
|
|
13
|
+
function cardForm(asked, hasHeader) {
|
|
14
|
+
const dashed = Boolean(asked.dashed);
|
|
15
|
+
const bar = (asked.bar ?? false) && !dashed && hasHeader;
|
|
16
|
+
return {
|
|
17
|
+
flush: (asked.flush ?? false) && !dashed,
|
|
18
|
+
bar,
|
|
19
|
+
dashed,
|
|
20
|
+
filled: asked.dashed === "filled",
|
|
21
|
+
tone: dashed || bar ? undefined : asked.tone,
|
|
22
|
+
attention: (asked.attention ?? false) && !dashed,
|
|
23
|
+
size: asked.size,
|
|
24
|
+
footer: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The padding of a card's body by `size`. */
|
|
28
|
+
const BODY_PAD = { sm: "p-3", md: "p-5", lg: "p-6" };
|
|
29
|
+
function cardBorder(form) {
|
|
30
|
+
if (form.dashed)
|
|
31
|
+
return "border-dashed border-slate-300";
|
|
32
|
+
const surface = form.tone ? TONE_SURFACE[form.tone] : "border-slate-200/70 bg-white";
|
|
33
|
+
if (!form.attention)
|
|
34
|
+
return surface;
|
|
35
|
+
// The warn tone's strong edge in place of the border colour; the fill stays.
|
|
36
|
+
const fill = surface.split(" ").filter((c) => c.startsWith("bg-"));
|
|
37
|
+
return cx("border-amber-600", ...fill);
|
|
38
|
+
}
|
|
39
|
+
function cardPadding(form) {
|
|
40
|
+
if (form.dashed) {
|
|
41
|
+
return cx(form.filled ? "p-10" : "p-8", "text-center text-sm text-slate-500");
|
|
42
|
+
}
|
|
43
|
+
return !form.flush && !form.bar && !form.footer && BODY_PAD[form.size];
|
|
44
|
+
}
|
|
45
|
+
function cardClass(form) {
|
|
46
|
+
return cx("relative scroll-mt-28 rounded-xl border target:outline-2 target:outline-violet-600", liftInteractive, cardBorder(form), form.filled && "bg-white", cardPadding(form), !form.dashed && !form.tone && "shadow-sm", (form.flush || form.bar || form.footer) && "overflow-hidden", form.footer && "flex flex-col");
|
|
47
|
+
}
|
|
48
|
+
function isCardLink(props) {
|
|
49
|
+
return props.href !== undefined || props.render !== undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A bordered panel with an optional title, count and status; `as` picks the element (default `section`).
|
|
53
|
+
* Three opt-in forms for data views: `flush` drops the padding and clips the corners, for a table
|
|
54
|
+
* that runs edge to edge; `bar` puts the title, count and `actions` in a tinted strip across the
|
|
55
|
+
* top, over the body; `dashed` is an empty-state shell, a dashed outline with centred muted text.
|
|
56
|
+
* `dashed` stands alone: it ignores `flush` and `bar`; `dashed="filled"` is the same shell on a
|
|
57
|
+
* surface fill with more padding, for an empty state that must read as a panel. A flush card has no
|
|
58
|
+
* padding for a `title`, so give it a `bar`. `size="sm"` is the dense padding and `size="lg"` the
|
|
59
|
+
* roomy one, the same on a `ListRow` and a `FileDrop`. `tone` is the Callout's set, for a data panel that carries a status
|
|
60
|
+
* (a soft fill and border, no shadow); a message is still a `Callout`, and `bar` and `dashed` have
|
|
61
|
+
* no tone. A titled panel (`section`, `article`) is named by its title: the root gets
|
|
62
|
+
* `aria-labelledby` pointing at the heading, unless the caller passes its own `aria-label` or
|
|
63
|
+
* `aria-labelledby`, which wins. Other props (`id`, `data-*`, `aria-*`) go on the root. The root is `relative`, so a
|
|
64
|
+
* `TextLink stretch` inside it covers the card. A card with an `id` is an anchor target: `#id`
|
|
65
|
+
* scrolls it clear of the sticky header (and a `subnav`) and outlines it in the accent colour while
|
|
66
|
+
* it is the target.
|
|
67
|
+
*
|
|
68
|
+
* `footer` is a tinted strip across the bottom, under a hairline, for the card's own actions or a
|
|
69
|
+
* total; in a `CardGrid` it stays at the foot of a card that is stretched to the row's height. It
|
|
70
|
+
* suits a panel, not a card that is a link, and `dashed` has none.
|
|
71
|
+
*
|
|
72
|
+
* `href` makes the whole card one link: a hover border and the focus ring, with the link stretched
|
|
73
|
+
* over the card, so it takes no `actions`, `footer` or `as`. A `TextLink`, `Button` or `Checkbox` in
|
|
74
|
+
* the body sits above the link and is clickable on its own. With `render` it draws the app's own router `<Link>`
|
|
75
|
+
* (`render={(p) => <Link to="/shots/1" {...p} />}`). `external` opens it in a new tab. On a link card
|
|
76
|
+
* `id`, `data-*` and the describing `aria-*` (`aria-describedby`, `aria-details`, …) go on the card
|
|
77
|
+
* root, not the link; the link's own `aria-label`, `aria-labelledby` and `aria-current` stay on the
|
|
78
|
+
* link. The link is named by the card's text (title, body, `badge`) through the root's id, which is
|
|
79
|
+
* generated when the caller gives none.
|
|
80
|
+
*/
|
|
81
|
+
export function Card(props) {
|
|
82
|
+
return isCardLink(props) ? _jsx(LinkCard, { ...props }) : _jsx(PanelCard, { ...props });
|
|
83
|
+
}
|
|
84
|
+
/** A card that is not a link: `as` (a `section` by default) named by its heading. */
|
|
85
|
+
function PanelCard(props) {
|
|
86
|
+
const { title, count, status, actions, footer, bar, flush, dashed, tone, badge, attention, size = "md", children, ...panel } = props;
|
|
87
|
+
const { as: Tag = "section", ...rest } = panel;
|
|
88
|
+
const headingId = useId();
|
|
89
|
+
const form = withFooter(cardForm({ flush, bar, dashed, tone, attention, size }, Boolean(title || actions)), footer);
|
|
90
|
+
// The title names the section or article; a caller's own name wins, and a bare `div` takes none.
|
|
91
|
+
const named = title &&
|
|
92
|
+
Tag !== "div" &&
|
|
93
|
+
rest["aria-label"] === undefined &&
|
|
94
|
+
rest["aria-labelledby"] === undefined;
|
|
95
|
+
return (_jsxs(Tag, { ...(named ? { "aria-labelledby": headingId } : {}), ...rest, className: cardClass(form), children: [cardBody(form, { title, count, status, actions, children, headingId }), form.footer && _jsx(CardFooter, { size: form.size, children: footer }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
|
|
96
|
+
}
|
|
97
|
+
/** What names or marks the link itself: these stay on the overlay `<a>`; `id`, `data-*` and every other `aria-*` go on the card. */
|
|
98
|
+
const LINK_OWN = new Set(["aria-label", "aria-labelledby", "aria-current"]);
|
|
99
|
+
/** Splits a link card's props into the attributes for the root and the rest, for the `<a>`. */
|
|
100
|
+
function splitRootAttrs(props) {
|
|
101
|
+
const root = {};
|
|
102
|
+
const link = {};
|
|
103
|
+
for (const [key, value] of Object.entries(props)) {
|
|
104
|
+
const onRoot = key === "id" || key.startsWith("data-") || (key.startsWith("aria-") && !LINK_OWN.has(key));
|
|
105
|
+
(onRoot ? root : link)[key] = value;
|
|
106
|
+
}
|
|
107
|
+
return [root, link];
|
|
108
|
+
}
|
|
109
|
+
/** A card that is a link: a `div` with the link stretched over it as an empty overlay, so the card's own links and buttons are not nested in it. */
|
|
110
|
+
function LinkCard(props) {
|
|
111
|
+
const { title, count, status, actions, bar, flush, dashed, tone, badge, attention, size = "md", children, external = false, ...rest } = props;
|
|
112
|
+
const form = cardForm({ flush, bar, dashed, tone, attention, size }, Boolean(title));
|
|
113
|
+
const generated = useId();
|
|
114
|
+
const [root, link] = splitRootAttrs(rest);
|
|
115
|
+
// The link is named by the whole card, so the root needs an id: the caller's, else a generated one.
|
|
116
|
+
const cardId = typeof root["id"] === "string" ? root["id"] : generated;
|
|
117
|
+
return (_jsxs("div", { ...root, id: cardId, className: cx(cardClass(form), "transition hover:border-violet-400"), children: [renderLink({
|
|
118
|
+
...(link["aria-label"] === undefined ? { "aria-labelledby": cardId } : {}),
|
|
119
|
+
...link,
|
|
120
|
+
external,
|
|
121
|
+
className: cx("link-stretch absolute inset-0 rounded-xl", focusRing, "focus-visible:-outline-offset-2"),
|
|
122
|
+
children: null,
|
|
123
|
+
}), cardBody(form, { title, count, status, actions, children, headingId: undefined }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
|
|
124
|
+
}
|
|
125
|
+
function cardBody(form, { title, count, status, actions, children, headingId, }) {
|
|
126
|
+
const heading = title && (_jsx(CardHeading, { id: headingId, title: title, count: count, status: status, bar: form.bar, spaced: !form.bar && !actions, size: form.size }));
|
|
127
|
+
const grow = form.footer && "flex-1";
|
|
128
|
+
if (form.bar) {
|
|
129
|
+
return (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: actions, size: form.size }), _jsx("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]) || undefined, children: children })] }));
|
|
130
|
+
}
|
|
131
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(CardHeader, { heading: heading, actions: actions, size: form.size }), children] }));
|
|
132
|
+
// With a footer the root has no padding, so the body pads itself and takes the room left over.
|
|
133
|
+
return form.footer ? (_jsx("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]), children: content })) : (content);
|
|
134
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { Size } from "./size.js";
|
|
3
|
+
/** A `Card`'s `badge`: pinned over the content; a click falls through to a link card, except on a link or button in it. */
|
|
4
|
+
export declare function CardBadge({ size, children }: {
|
|
5
|
+
size: Size;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}): ReactElement;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
/** The corner a `badge` sits in, by `size`: inside the radius, clear of the edge. */
|
|
4
|
+
const BADGE_CORNER = {
|
|
5
|
+
sm: "top-2 right-2",
|
|
6
|
+
md: "top-3 right-3",
|
|
7
|
+
lg: "top-4 right-4",
|
|
8
|
+
};
|
|
9
|
+
/** A `Card`'s `badge`: pinned over the content; a click falls through to a link card, except on a link or button in it. */
|
|
10
|
+
export function CardBadge({ size, children }) {
|
|
11
|
+
return (_jsx("span", { className: cx("pointer-events-none absolute z-10 flex [&_:is(a,button)]:pointer-events-auto", BADGE_CORNER[size]), children: children }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Size } from "./size.js";
|
|
3
|
+
export declare function CardHeading({ id, title, count, status, bar, spaced, size, }: {
|
|
4
|
+
id: string | undefined;
|
|
5
|
+
title: string;
|
|
6
|
+
count: number | undefined;
|
|
7
|
+
status: ReactNode;
|
|
8
|
+
bar: boolean;
|
|
9
|
+
spaced: boolean;
|
|
10
|
+
size: Size;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
export declare function CardBar({ heading, actions, size, }: {
|
|
13
|
+
heading: ReactNode;
|
|
14
|
+
actions: ReactNode;
|
|
15
|
+
size: Size;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
export declare function CardFooter({ children, size }: {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
size: Size;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
21
|
+
export declare function CardHeader({ heading, actions, size, }: {
|
|
22
|
+
heading: ReactNode;
|
|
23
|
+
actions: ReactNode;
|
|
24
|
+
size: Size;
|
|
25
|
+
}): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
import { Overline } from "./typography.js";
|
|
4
|
+
/** The space under a card's heading, by `size`. */
|
|
5
|
+
const HEADING_GAP = { sm: "mb-2", md: "mb-3", lg: "mb-4" };
|
|
6
|
+
/** The padding of a card's bar and footer strips, by `size`. */
|
|
7
|
+
const STRIP_PAD = { sm: "px-3 py-2", md: "px-5 py-3", lg: "px-6 py-4" };
|
|
8
|
+
export function CardHeading({ id, title, count, status, bar, spaced, size, }) {
|
|
9
|
+
const ink = bar ? "text-slate-500" : "text-slate-400";
|
|
10
|
+
return (_jsxs("div", { className: cx("flex gap-2", status ? "items-center" : "items-baseline", spaced && HEADING_GAP[size]), children: [_jsx(Overline, { as: "h2", id: id, color: bar ? "tertiary" : "muted", children: title }), count !== undefined && _jsx("span", { className: cx("text-xs font-medium", ink), children: count }), status] }));
|
|
11
|
+
}
|
|
12
|
+
function CardActions({ children }) {
|
|
13
|
+
return _jsx("div", { className: "ml-auto flex items-center gap-2", children: children });
|
|
14
|
+
}
|
|
15
|
+
export function CardBar({ heading, actions, size, }) {
|
|
16
|
+
return (_jsxs("div", { className: cx("flex items-center justify-between gap-3 border-b border-slate-200/70 bg-slate-50", STRIP_PAD[size]), children: [heading, actions && _jsx(CardActions, { children: actions })] }));
|
|
17
|
+
}
|
|
18
|
+
export function CardFooter({ children, size }) {
|
|
19
|
+
return (_jsx("div", { className: cx("flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-slate-200/70 bg-slate-50 [&>[data-button-group]]:grow", STRIP_PAD[size]), children: children }));
|
|
20
|
+
}
|
|
21
|
+
export function CardHeader({ heading, actions, size, }) {
|
|
22
|
+
if (!actions)
|
|
23
|
+
return heading;
|
|
24
|
+
return (_jsxs("div", { className: cx("flex items-center justify-between gap-3", HEADING_GAP[size]), children: [heading, _jsx(CardActions, { children: actions })] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { Unstyled } from "./props.js";
|
|
3
|
+
import type { Size } from "./size.js";
|
|
4
|
+
/**
|
|
5
|
+
* A native checkbox with its label. Every prop but the label goes on the input.
|
|
6
|
+
* `size="sm"` is `text-xs` and a smaller box, for a dense row; `size="lg"` is `text-base` and a
|
|
7
|
+
* larger box. Leave `label` out for a bare box in
|
|
8
|
+
* a table cell, and name it with `aria-label`. `align="start"` puts the box on the label's first
|
|
9
|
+
* line, for a label that wraps onto several; the default centres it on the whole label. The box
|
|
10
|
+
* keeps its size either way. `description` is a muted line under the label that says more, like a
|
|
11
|
+
* `Field`'s hint; it is the box's `aria-describedby`, and with it the box sits on the label's first
|
|
12
|
+
* line.
|
|
13
|
+
*/
|
|
14
|
+
export declare function Checkbox({ label, description, size, align, "aria-describedby": describedBy, ...rest }: {
|
|
15
|
+
/** The text beside the box. Leave it out for a bare box (a table cell) and name it with `aria-label`. */
|
|
16
|
+
label?: ReactNode;
|
|
17
|
+
/** A muted line under the label that says more. It is the box's `aria-describedby`. */
|
|
18
|
+
description?: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* The label's text size and the box with it.
|
|
21
|
+
* @default md
|
|
22
|
+
*/
|
|
23
|
+
size?: Size | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* `start` puts the box on the label's first line, for a label that wraps; `center` centres it on the whole label. A `description` makes it `start`.
|
|
26
|
+
* @default center
|
|
27
|
+
*/
|
|
28
|
+
align?: "center" | "start" | undefined;
|
|
29
|
+
} & Unstyled<Omit<ComponentPropsWithoutRef<"input">, "type" | "size" | "width">>): ReactElement;
|
package/dist/Checkbox.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { cx, focusRing } from "./cx.js";
|
|
4
|
+
/** The label's gap and text, the box, and the box's drop onto the first line, by `size`. */
|
|
5
|
+
const SIZE = {
|
|
6
|
+
sm: { label: "gap-1.5 text-xs", box: "h-3.5 w-3.5", start: "mt-px" },
|
|
7
|
+
md: { label: "gap-2 text-sm", box: "h-4 w-4", start: "mt-0.5" },
|
|
8
|
+
lg: { label: "gap-2.5 text-base", box: "h-5 w-5", start: "mt-0.5" },
|
|
9
|
+
};
|
|
10
|
+
/** The label, with its muted description under it when there is one. */
|
|
11
|
+
function Words({ label, description, id, }) {
|
|
12
|
+
if (id === undefined)
|
|
13
|
+
return label;
|
|
14
|
+
return (_jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { children: label }), _jsx("span", { id: id, className: "text-xs text-slate-500", children: description })] }));
|
|
15
|
+
}
|
|
16
|
+
/** The description's id (none without a description) and the box's whole `aria-describedby`. */
|
|
17
|
+
function useDescription(description, describedBy) {
|
|
18
|
+
const generatedId = useId();
|
|
19
|
+
const descriptionId = description != null && description !== false ? generatedId : undefined;
|
|
20
|
+
return { descriptionId, describedIds: [describedBy, descriptionId].filter(Boolean).join(" ") };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A native checkbox with its label. Every prop but the label goes on the input.
|
|
24
|
+
* `size="sm"` is `text-xs` and a smaller box, for a dense row; `size="lg"` is `text-base` and a
|
|
25
|
+
* larger box. Leave `label` out for a bare box in
|
|
26
|
+
* a table cell, and name it with `aria-label`. `align="start"` puts the box on the label's first
|
|
27
|
+
* line, for a label that wraps onto several; the default centres it on the whole label. The box
|
|
28
|
+
* keeps its size either way. `description` is a muted line under the label that says more, like a
|
|
29
|
+
* `Field`'s hint; it is the box's `aria-describedby`, and with it the box sits on the label's first
|
|
30
|
+
* line.
|
|
31
|
+
*/
|
|
32
|
+
export function Checkbox({ label, description, size = "md", align = "center", "aria-describedby": describedBy, ...rest }) {
|
|
33
|
+
const { descriptionId, describedIds } = useDescription(description, describedBy);
|
|
34
|
+
const start = align === "start" || descriptionId !== undefined;
|
|
35
|
+
return (_jsxs("label", { className: cx("inline-flex cursor-pointer text-slate-700", start ? "items-start" : "items-center", SIZE[size].label), children: [_jsx("input", { type: "checkbox", className: cx(SIZE[size].box, "shrink-0", start && SIZE[size].start, "accent-violet-600", focusRing), ...(describedIds ? { "aria-describedby": describedIds } : {}), ...rest }), _jsx(Words, { label: label, description: description, id: descriptionId })] }));
|
|
36
|
+
}
|
package/dist/Chevron.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
/** The right-pointing chevron of a collapsible header; it turns down when `open`. */
|
|
4
|
+
export function Chevron({ open }) {
|
|
5
|
+
return (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 20 20", fill: "currentColor", className: cx("h-4 w-4 shrink-0 text-slate-400", open && "rotate-90"), children: _jsx("path", { fillRule: "evenodd", d: "M7.2 4.8a.75.75 0 0 1 1.06 0l4.7 4.67a.75.75 0 0 1 0 1.06l-4.7 4.67a.75.75 0 1 1-1.06-1.06L11.37 10 7.2 5.86a.75.75 0 0 1 0-1.06Z", clipRule: "evenodd" }) }));
|
|
6
|
+
}
|