@hackcove/ui 0.1.0 → 0.2.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.
Files changed (47) hide show
  1. package/README.md +22 -1
  2. package/dist/Breadcrumbs.d.ts +8 -0
  3. package/dist/Breadcrumbs.js +9 -0
  4. package/dist/Button.d.ts +20 -0
  5. package/dist/Button.js +39 -0
  6. package/dist/Callout.d.ts +10 -0
  7. package/dist/Callout.js +13 -0
  8. package/dist/Checkbox.d.ts +5 -0
  9. package/dist/Checkbox.js +6 -0
  10. package/dist/CopyButton.d.ts +7 -0
  11. package/dist/CopyButton.js +13 -0
  12. package/dist/CopyLine.d.ts +6 -0
  13. package/dist/CopyLine.js +8 -0
  14. package/dist/Dialog.d.ts +25 -0
  15. package/dist/Dialog.js +19 -0
  16. package/dist/Field.d.ts +12 -0
  17. package/dist/Field.js +13 -0
  18. package/dist/FieldDialog.d.ts +20 -0
  19. package/dist/FieldDialog.js +32 -0
  20. package/dist/FilterGroup.d.ts +5 -0
  21. package/dist/FilterGroup.js +6 -0
  22. package/dist/IconButton.d.ts +6 -0
  23. package/dist/IconButton.js +7 -0
  24. package/dist/Input.d.ts +5 -0
  25. package/dist/Input.js +10 -0
  26. package/dist/Kbd.d.ts +5 -0
  27. package/dist/Kbd.js +6 -0
  28. package/dist/KeyValueList.d.ts +10 -0
  29. package/dist/KeyValueList.js +10 -0
  30. package/dist/NavTabs.d.ts +16 -0
  31. package/dist/NavTabs.js +11 -0
  32. package/dist/Select.d.ts +3 -0
  33. package/dist/Select.js +7 -0
  34. package/dist/Textarea.d.ts +3 -0
  35. package/dist/Textarea.js +7 -0
  36. package/dist/ToggleChip.d.ts +7 -0
  37. package/dist/ToggleChip.js +9 -0
  38. package/dist/control.d.ts +2 -0
  39. package/dist/control.js +3 -0
  40. package/dist/cx.d.ts +4 -0
  41. package/dist/cx.js +6 -0
  42. package/dist/index.d.ts +18 -0
  43. package/dist/index.js +18 -0
  44. package/dist/typography.js +1 -3
  45. package/dist/ui.d.ts +7 -2
  46. package/dist/ui.js +7 -4
  47. package/package.json +2 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ Hack Cove React components, written against Tailwind v4 utilities. They carry no
4
4
  with a `@hackcove/theme-*` package such as `@hackcove/theme-catppuccin-mocha`, or use them on their
5
5
  own for the default light look.
6
6
 
7
- Status: `0.x`, not yet published to npm. License: MIT.
7
+ Status: `0.x`, published to npm. License: MIT.
8
8
 
9
9
  ## Install
10
10
 
@@ -40,6 +40,27 @@ export const Example = () => (
40
40
  - Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
41
41
  `PageHeading`, `SectionHeading`, `SubsectionHeading`, `ModalHeading`, `Subtitle`, `Overline`,
42
42
  `Body`, `BodyStrong`, `Caption`.
43
+ - Controls: `Button`, `buttonClass` (the classes of a `Button`, for a router's own `<Link>`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`), `Input` (a text input; `icon` adds a leading icon inside the field), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Checkbox` (a native checkbox with its label; `className` goes on the label, other props on the input). `CopyButton` (a small text button that writes `text` to the clipboard and swaps `label` ("copy") for `copiedLabel` ("copied"); every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body). `NavTabs` (a row of pill links in a `<nav>`; `items` are `{ label, href, active? }`, `label` names the landmark, the active one is filled and gets `aria-current="page"`). `navTabClass` (the classes of one tab, for a router's own `<Link>`). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, the last is the current page and gets `aria-current="page"`, and each crumb truncates). `crumbLinkClass` (the classes of a crumb that links, for a router's own `<Link>`). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `ToggleChip` (an on/off filter pill; `pressed` sets `aria-pressed` and the filled look, `dot` adds a leading colour dot, and the caller toggles it in `onClick`). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `ToggleChip`s; `className` goes on the fieldset).
44
+
45
+ ## Palette
46
+
47
+ `palette.json` (in the repo, not in the package) lists the colour utilities the components may use,
48
+ by role.
49
+
50
+ | Role | For |
51
+ | ---------- | --------------------------------------------------------------------- |
52
+ | `surface` | Cards and panels: the main background. |
53
+ | `subtle` | A step off the surface: table stripes, hover rows. |
54
+ | `inset` | A step deeper: wells, chips, pressed fills. |
55
+ | `line` | Borders and dividers. |
56
+ | `ink` | Text, from primary down to muted, plus input placeholders. |
57
+ | `accent` | Primary actions, links, focus rings and controls; text on the accent. |
58
+ | `positive` | Success: fill, text and border. |
59
+ | `warn` | Caution: fill, text and border. |
60
+ | `danger` | Errors and destructive actions: fill, text and border. |
61
+ | `info` | Neutral notes: fill, text and border. |
62
+
63
+ A theme remaps every utility in `palette.json`; a component uses no colour utility outside it.
43
64
 
44
65
  ## Develop
45
66
 
@@ -0,0 +1,8 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
3
+ export declare const crumbLinkClass: string;
4
+ /** `org / project / build / snapshot`: each crumb truncates, and the last is the current page. */
5
+ export declare function Breadcrumbs({ crumbs, className, }: {
6
+ crumbs: ReactNode[];
7
+ className?: string;
8
+ }): ReactElement;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
4
+ export const crumbLinkClass = cx("rounded text-slate-500 hover:underline", focusRing);
5
+ /** `org / project / build / snapshot`: each crumb truncates, and the last is the current page. */
6
+ export function Breadcrumbs({ crumbs, className, }) {
7
+ const last = crumbs.length - 1;
8
+ return (_jsx("nav", { "aria-label": "Breadcrumb", className: cx("min-w-0", className), children: _jsx("ol", { className: "flex min-w-0 items-center gap-2 text-sm", children: crumbs.map((crumb, index) => (_jsxs("li", { "aria-current": index === last ? "page" : undefined, className: "flex min-w-0 items-center gap-2", children: [index > 0 && (_jsx("span", { "aria-hidden": "true", className: "text-slate-400", children: "/" })), _jsx("span", { className: cx("min-w-0 truncate", index === last ? "font-medium text-slate-900" : "text-slate-500"), children: crumb })] }, index))) }) }));
9
+ }
@@ -0,0 +1,20 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ export type ButtonVariant = "primary" | "outline" | "ghost";
3
+ /** The classes of a Button, for a router's own <Link> that should look like one. */
4
+ export declare function buttonClass(variant?: ButtonVariant): string;
5
+ type ButtonProps = {
6
+ variant?: ButtonVariant;
7
+ /** Why the button cannot be pressed: disables it, sets it as the `title`, and greys it flat. */
8
+ disabledReason?: string | undefined;
9
+ /** The work is in flight: disabled, `aria-busy`, keeps its colour. Wins over `disabledReason`. */
10
+ busy?: boolean | undefined;
11
+ /** The label while `busy`, in place of the children. */
12
+ busyLabel?: string | undefined;
13
+ } & ComponentPropsWithoutRef<"button">;
14
+ /**
15
+ * A button in the primary, outline or ghost style. Defaults to `type="button"`. `disabledReason`
16
+ * and `busy` are the two disabled states that say more than a plain `disabled`, which keeps its
17
+ * half-opacity look.
18
+ */
19
+ export declare function Button({ variant, disabledReason, busy, busyLabel, className, children, title, disabled, ...rest }: ButtonProps): ReactElement;
20
+ export {};
package/dist/Button.js ADDED
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ const SHAPE = "inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition";
4
+ // A plain `disabled` washes out and ignores the pointer. Blocked and busy buttons are disabled too,
5
+ // but they keep pointer events (the title shows, the cursor says why) and do not take this.
6
+ const DISABLED_PLAIN = "disabled:pointer-events-none disabled:opacity-50";
7
+ const VARIANT = {
8
+ primary: {
9
+ color: "border-violet-600 bg-violet-600 text-violet-50",
10
+ hover: "hover:bg-violet-700",
11
+ },
12
+ outline: { color: "border-violet-600 text-violet-600", hover: "hover:bg-violet-50" },
13
+ ghost: { color: "border-transparent text-slate-600", hover: "hover:bg-slate-100" },
14
+ };
15
+ // Blocked replaces the variant's colour and hover rather than overriding them, so no `!` is needed.
16
+ const BLOCKED = "cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400";
17
+ const BUSY = "cursor-progress opacity-85";
18
+ /** The classes of a Button, for a router's own <Link> that should look like one. */
19
+ export function buttonClass(variant = "primary") {
20
+ return cx(SHAPE, DISABLED_PLAIN, focusRing, VARIANT[variant].color, VARIANT[variant].hover);
21
+ }
22
+ /** The classes of a Button in a given state: blocked swaps the colour out, busy keeps it. */
23
+ function stateClass(variant, blocked, busy) {
24
+ const { color } = VARIANT[variant];
25
+ if (blocked)
26
+ return cx(SHAPE, focusRing, BLOCKED);
27
+ if (busy)
28
+ return cx(SHAPE, focusRing, color, BUSY);
29
+ return buttonClass(variant);
30
+ }
31
+ /**
32
+ * A button in the primary, outline or ghost style. Defaults to `type="button"`. `disabledReason`
33
+ * and `busy` are the two disabled states that say more than a plain `disabled`, which keeps its
34
+ * half-opacity look.
35
+ */
36
+ export function Button({ variant = "primary", disabledReason, busy = false, busyLabel, className, children, title, disabled, ...rest }) {
37
+ const blocked = disabledReason !== undefined && !busy;
38
+ return (_jsx("button", { type: "button", className: cx(stateClass(variant, blocked, busy), className), disabled: disabled || busy || blocked, "aria-busy": busy || undefined, title: blocked ? disabledReason : title, ...rest, children: busy && busyLabel ? busyLabel : children }));
39
+ }
@@ -0,0 +1,10 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
+ /** The colour family of a callout. */
3
+ export type CalloutTone = "neutral" | "info" | "positive" | "warn" | "danger";
4
+ /** A tinted notice box, with an optional bold title above its body. */
5
+ export declare function Callout({ tone, title, children, className, ...rest }: {
6
+ /** Defaults to "info". */
7
+ tone?: CalloutTone;
8
+ title?: ReactNode;
9
+ children: ReactNode;
10
+ } & Omit<HTMLAttributes<HTMLDivElement>, "title">): ReactElement;
@@ -0,0 +1,13 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ const TONES = {
4
+ neutral: "border-slate-200 bg-slate-50 text-slate-700",
5
+ info: "border-blue-200 bg-blue-50 text-blue-800",
6
+ positive: "border-emerald-200 bg-emerald-50 text-emerald-800",
7
+ warn: "border-amber-200 bg-amber-50 text-amber-800",
8
+ danger: "border-red-200 bg-red-50 text-red-800",
9
+ };
10
+ /** A tinted notice box, with an optional bold title above its body. */
11
+ export function Callout({ tone = "info", title, children, className, ...rest }) {
12
+ return (_jsx("div", { className: cx("rounded-xl border p-4 text-sm", TONES[tone], className), ...rest, children: title ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-semibold", children: title }), _jsx("div", { className: "mt-1", children: children })] })) : (children) }));
13
+ }
@@ -0,0 +1,5 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ /** A native checkbox with its label. `className` goes on the label; every other prop goes on the input. */
3
+ export declare function Checkbox({ label, className, ...rest }: {
4
+ label: ReactNode;
5
+ } & Omit<ComponentPropsWithoutRef<"input">, "type">): ReactElement;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ /** A native checkbox with its label. `className` goes on the label; every other prop goes on the input. */
4
+ export function Checkbox({ label, className, ...rest }) {
5
+ return (_jsxs("label", { className: cx("inline-flex cursor-pointer items-center gap-2 text-sm text-slate-700", className), children: [_jsx("input", { type: "checkbox", className: cx("h-4 w-4 accent-violet-600", focusRing), ...rest }), label] }));
6
+ }
@@ -0,0 +1,7 @@
1
+ import { type ComponentPropsWithoutRef, type ReactElement } from "react";
2
+ /** Writes `text` to the clipboard and swaps the label to `copiedLabel`. Defaults to `type="button"`. A refused or missing clipboard leaves the label on `label`. Every other button prop, `disabled` included, goes on the `<button>`. */
3
+ export declare function CopyButton({ text, label, copiedLabel, onClick, className, ...rest }: {
4
+ text: string;
5
+ label?: string;
6
+ copiedLabel?: string;
7
+ } & Omit<ComponentPropsWithoutRef<"button">, "children">): ReactElement;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { cx, focusRing } from "./cx.js";
4
+ const BASE = `shrink-0 rounded px-1.5 py-0.5 font-sans text-xs text-slate-400 hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 ${focusRing}`;
5
+ /** Writes `text` to the clipboard and swaps the label to `copiedLabel`. Defaults to `type="button"`. A refused or missing clipboard leaves the label on `label`. Every other button prop, `disabled` included, goes on the `<button>`. */
6
+ export function CopyButton({ text, label = "copy", copiedLabel = "copied", onClick, className, ...rest }) {
7
+ const [copied, setCopied] = useState(false);
8
+ return (_jsx("button", { type: "button", ...rest, className: cx(BASE, className), onClick: (event) => {
9
+ onClick?.(event);
10
+ // The rejection is swallowed on purpose: a denied write is not an error the page can act on.
11
+ navigator.clipboard?.writeText(text).then(() => setCopied(true), () => { });
12
+ }, children: copied ? copiedLabel : label }));
13
+ }
@@ -0,0 +1,6 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ /** One line of mono text in a box, with a `CopyButton`: a command or a token to paste elsewhere. `className` and every other div prop go on the root `<div>`, so `data-testid` lands there; the `<code>` holds only `text`. */
3
+ export declare function CopyLine({ text, className, ...rest }: {
4
+ text: string;
5
+ className?: string;
6
+ } & Omit<ComponentPropsWithoutRef<"div">, "children">): ReactElement;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CopyButton } from "./CopyButton.js";
3
+ import { cx } from "./cx.js";
4
+ const BOX = "flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-2.5 py-2 font-mono text-[12.5px] text-slate-900";
5
+ /** One line of mono text in a box, with a `CopyButton`: a command or a token to paste elsewhere. `className` and every other div prop go on the root `<div>`, so `data-testid` lands there; the `<code>` holds only `text`. */
6
+ export function CopyLine({ text, className, ...rest }) {
7
+ return (_jsxs("div", { ...rest, className: cx(BOX, className), children: [_jsx("code", { className: "min-w-0 break-words", children: text }), _jsx(CopyButton, { text: text })] }));
8
+ }
@@ -0,0 +1,25 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ declare const SIZE: {
3
+ readonly sm: "w-80";
4
+ readonly md: "w-110";
5
+ };
6
+ export type DialogProps = {
7
+ /** The dialog's accessible name, shown as its heading. */
8
+ title: ReactNode;
9
+ description?: ReactNode;
10
+ children?: ReactNode;
11
+ /** Actions under the body, right-aligned. A submit button here can reach a form in the body with its `form` attribute. */
12
+ footer?: ReactNode;
13
+ /** `sm` is a list; `md` (the default) holds a form. */
14
+ size?: keyof typeof SIZE;
15
+ /** Called when the dialog closes: Esc, a click on the backdrop, or a `<form method="dialog">` submit. */
16
+ onClose: () => void;
17
+ className?: string;
18
+ };
19
+ /**
20
+ * A modal on the browser's own `<dialog>`: it is opened with `showModal()` on mount, so render it
21
+ * only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click
22
+ * on the backdrop (the `<dialog>` element itself, not its contents) closes it too.
23
+ */
24
+ export declare function Dialog({ title, description, children, footer, size, onClose, className, }: DialogProps): ReactElement;
25
+ export {};
package/dist/Dialog.js ADDED
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { Subtitle, ModalHeading } from "./typography.js";
5
+ const PANEL = "mx-auto mt-28 max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-200 bg-white p-0 text-slate-900 shadow-2xl backdrop:bg-slate-900/35";
6
+ const SIZE = { sm: "w-80", md: "w-110" };
7
+ /**
8
+ * A modal on the browser's own `<dialog>`: it is opened with `showModal()` on mount, so render it
9
+ * only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click
10
+ * on the backdrop (the `<dialog>` element itself, not its contents) closes it too.
11
+ */
12
+ export function Dialog({ title, description, children, footer, size = "md", onClose, className, }) {
13
+ const dialog = useRef(null);
14
+ const titleId = useId();
15
+ useEffect(() => {
16
+ dialog.current?.showModal();
17
+ }, []);
18
+ return (_jsx("dialog", { ref: dialog, "aria-labelledby": titleId, onClose: onClose, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(PANEL, SIZE[size], className), children: _jsxs("div", { className: "p-5.5", children: [_jsx(ModalHeading, { id: titleId, children: title }), description && _jsx(Subtitle, { className: "mt-1", children: description }), children && _jsx("div", { className: "mt-4", children: children }), footer && _jsx("div", { className: "mt-5 flex justify-end gap-2", children: footer })] }) }));
19
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /** A label above a control, with a hint or an error line under it. An error replaces the hint. */
3
+ export declare function Field({ label, htmlFor, hint, error, children, className, }: {
4
+ label: ReactNode;
5
+ /** The id of the control inside; the label's htmlFor. */
6
+ htmlFor: string;
7
+ hint?: ReactNode;
8
+ /** Replaces the hint when set. */
9
+ error?: ReactNode;
10
+ children: ReactNode;
11
+ className?: string;
12
+ }): ReactElement;
package/dist/Field.js ADDED
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** A label above a control, with a hint or an error line under it. An error replaces the hint. */
4
+ export function Field({ label, htmlFor, hint, error, children, className, }) {
5
+ let note = null;
6
+ if (error) {
7
+ note = (_jsx("p", { role: "alert", className: "text-xs text-red-700", children: error }));
8
+ }
9
+ else if (hint) {
10
+ note = _jsx("p", { className: "text-xs text-slate-500", children: hint });
11
+ }
12
+ return (_jsxs("div", { className: cx("flex flex-col gap-1.5", className), children: [_jsx("label", { htmlFor: htmlFor, className: "text-sm font-medium text-slate-700", children: label }), children, note] }));
13
+ }
@@ -0,0 +1,20 @@
1
+ import { type ReactElement } from "react";
2
+ export type FieldDialogProps = {
3
+ title: string;
4
+ description?: string;
5
+ /** The visible label above the field. */
6
+ label: string;
7
+ /** The field's accessible name, when it says more than the visible label. */
8
+ inputLabel: string;
9
+ placeholder: string;
10
+ hint: string;
11
+ /** Said when the field is empty and the submit button is blocked. */
12
+ emptyReason: string;
13
+ submit: string;
14
+ busyLabel: string;
15
+ /** Gets the trimmed value. Resolving closes the dialog; a rejection shows its message in it. */
16
+ onSubmit: (value: string) => Promise<unknown>;
17
+ onClose: () => void;
18
+ };
19
+ /** A `Dialog` with one text field and Cancel / submit buttons. Render it only while open. */
20
+ export declare function FieldDialog({ title, description, label, inputLabel, placeholder, hint, emptyReason, submit, busyLabel, onSubmit, onClose, }: FieldDialogProps): ReactElement;
@@ -0,0 +1,32 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useState } from "react";
3
+ import { Button } from "./Button.js";
4
+ import { Dialog } from "./Dialog.js";
5
+ import { Field } from "./Field.js";
6
+ import { Input } from "./Input.js";
7
+ /** The field's value and its in-flight state; `send` is the form's submit handler. */
8
+ function useFieldSubmit(onSubmit, onClose) {
9
+ const [value, setValue] = useState("");
10
+ const [busy, setBusy] = useState(false);
11
+ const [error, setError] = useState(null);
12
+ const empty = value.trim().length === 0;
13
+ const send = (event) => {
14
+ event.preventDefault();
15
+ if (empty || busy)
16
+ return;
17
+ setError(null);
18
+ setBusy(true);
19
+ void onSubmit(value.trim()).then(onClose, (e) => {
20
+ setBusy(false);
21
+ setError(e instanceof Error ? e.message : String(e));
22
+ });
23
+ };
24
+ return { value, setValue, busy, error, empty, send };
25
+ }
26
+ /** A `Dialog` with one text field and Cancel / submit buttons. Render it only while open. */
27
+ export function FieldDialog({ title, description, label, inputLabel, placeholder, hint, emptyReason, submit, busyLabel, onSubmit, onClose, }) {
28
+ const id = useId();
29
+ const formId = `${id}-form`;
30
+ const { value, setValue, busy, error, empty, send } = useFieldSubmit(onSubmit, onClose);
31
+ return (_jsx(Dialog, { title: title, description: description, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: (event) => event.currentTarget.closest("dialog")?.close(), children: "Cancel" }), _jsx(Button, { type: "submit", form: formId, disabledReason: empty ? emptyReason : undefined, busy: busy, busyLabel: busyLabel, children: submit })] }), children: _jsx("form", { id: formId, onSubmit: send, children: _jsx(Field, { label: label, htmlFor: id, hint: empty ? emptyReason : hint, error: error ?? undefined, children: _jsx(Input, { autoFocus: true, id: id, "aria-label": inputLabel, placeholder: placeholder, value: value, onChange: (e) => setValue(e.target.value), autoComplete: "off" }) }) }) }));
32
+ }
@@ -0,0 +1,5 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ /** A labelled filter section: a small uppercase legend over a wrapped row of chips (e.g. ToggleChip). Other props go on the fieldset. */
3
+ export declare function FilterGroup({ legend, children, className, ...rest }: {
4
+ legend: ReactNode;
5
+ } & ComponentPropsWithoutRef<"fieldset">): ReactElement;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** A labelled filter section: a small uppercase legend over a wrapped row of chips (e.g. ToggleChip). Other props go on the fieldset. */
4
+ export function FilterGroup({ legend, children, className, ...rest }) {
5
+ return (_jsxs("fieldset", { className: cx("min-w-0", className), ...rest, children: [_jsx("legend", { className: "mb-2 text-xs font-medium tracking-wide text-slate-500 uppercase", children: legend }), _jsx("div", { className: "flex flex-wrap gap-1.5", children: children })] }));
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ /** A square bordered button holding one icon. `label` is its accessible name and tooltip. Defaults to `type="button"`. */
3
+ export declare function IconButton({ label, className, children, ...rest }: {
4
+ label: string;
5
+ children: ReactNode;
6
+ } & Omit<ComponentPropsWithoutRef<"button">, "children">): ReactElement;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ const BASE = `inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:pointer-events-none disabled:opacity-50 ${focusRing}`;
4
+ /** A square bordered button holding one icon. `label` is its accessible name and tooltip. Defaults to `type="button"`. */
5
+ export function IconButton({ label, className, children, ...rest }) {
6
+ return (_jsx("button", { type: "button", ...rest, "aria-label": label, title: label, className: cx(BASE, className), children: children }));
7
+ }
@@ -0,0 +1,5 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ /** A text input. `icon` puts a leading icon inside the field (e.g. a search glass). */
3
+ export declare function Input({ icon, className, ...rest }: {
4
+ icon?: ReactNode;
5
+ } & ComponentPropsWithoutRef<"input">): ReactElement;
package/dist/Input.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { controlClass } from "./control.js";
3
+ import { cx } from "./cx.js";
4
+ /** A text input. `icon` puts a leading icon inside the field (e.g. a search glass). */
5
+ export function Input({ icon, className, ...rest }) {
6
+ if (icon === undefined) {
7
+ return _jsx("input", { ...rest, className: cx(controlClass, "px-3", className) });
8
+ }
9
+ return (_jsxs("div", { className: "relative", children: [_jsx("span", { "aria-hidden": "true", className: "pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-slate-400 [&>svg]:h-4 [&>svg]:w-4", children: icon }), _jsx("input", { ...rest, className: cx(controlClass, "pr-3 pl-9", className) })] }));
10
+ }
package/dist/Kbd.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
+ /** A keyboard key. Its border takes the surrounding text colour, so it reads on a filled button and on plain text. */
3
+ export declare function Kbd({ children, className, ...rest }: {
4
+ children: ReactNode;
5
+ } & HTMLAttributes<HTMLElement>): ReactElement;
package/dist/Kbd.js ADDED
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** A keyboard key. Its border takes the surrounding text colour, so it reads on a filled button and on plain text. */
4
+ export function Kbd({ children, className, ...rest }) {
5
+ return (_jsx("kbd", { className: cx("rounded border border-current/30 px-1 font-mono text-xs leading-4", className), ...rest, children: children }));
6
+ }
@@ -0,0 +1,10 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
+ /** A definition list of label/value rows: each label on the left, its value right-aligned. */
3
+ export declare function KeyValueList({ className, children, ...rest }: {
4
+ children: ReactNode;
5
+ } & HTMLAttributes<HTMLDListElement>): ReactElement;
6
+ /** One row of a KeyValueList: a label on the left, a right-aligned tabular value on the right. */
7
+ export declare function KeyValue({ label, children, className, ...rest }: {
8
+ label: ReactNode;
9
+ children: ReactNode;
10
+ } & HTMLAttributes<HTMLDivElement>): ReactElement;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** A definition list of label/value rows: each label on the left, its value right-aligned. */
4
+ export function KeyValueList({ className, children, ...rest }) {
5
+ return (_jsx("dl", { className: cx("flex flex-col gap-1.5 text-sm", className), ...rest, children: children }));
6
+ }
7
+ /** One row of a KeyValueList: a label on the left, a right-aligned tabular value on the right. */
8
+ export function KeyValue({ label, children, className, ...rest }) {
9
+ return (_jsxs("div", { className: cx("flex justify-between gap-3", className), ...rest, children: [_jsx("dt", { className: "text-slate-600", children: label }), _jsx("dd", { className: "text-right tabular-nums", children: children })] }));
10
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /** One pill link in a NavTabs row. */
3
+ export type NavTab = {
4
+ label: ReactNode;
5
+ href: string;
6
+ active?: boolean;
7
+ };
8
+ /** The classes of one tab, for a router's own <Link>. */
9
+ export declare function navTabClass(active: boolean): string;
10
+ /** A row of pill links where the active one is filled. Renders plain <a>; use navTabClass with a router's Link. */
11
+ export declare function NavTabs({ items, label, className, }: {
12
+ items: NavTab[];
13
+ /** The accessible name of the nav landmark. */
14
+ label: string;
15
+ className?: string;
16
+ }): ReactElement;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ const BASE = `rounded-lg px-3 py-1.5 transition ${focusRing}`;
4
+ /** The classes of one tab, for a router's own <Link>. */
5
+ export function navTabClass(active) {
6
+ return cx(BASE, active ? "bg-slate-100 font-medium text-slate-900" : "text-slate-600 hover:bg-slate-100");
7
+ }
8
+ /** A row of pill links where the active one is filled. Renders plain <a>; use navTabClass with a router's Link. */
9
+ export function NavTabs({ items, label, className, }) {
10
+ return (_jsx("nav", { "aria-label": label, className: cx("flex flex-wrap gap-1 text-sm", className), children: items.map((item) => (_jsx("a", { href: item.href, className: navTabClass(!!item.active), "aria-current": item.active ? "page" : undefined, children: item.label }, item.href))) }));
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ /** A native `<select>` styled like `Input`, keeping the browser's own arrow. */
3
+ export declare function Select({ className, children, ...rest }: ComponentPropsWithoutRef<"select">): ReactElement;
package/dist/Select.js ADDED
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { controlClass } from "./control.js";
3
+ import { cx } from "./cx.js";
4
+ /** A native `<select>` styled like `Input`, keeping the browser's own arrow. */
5
+ export function Select({ className, children, ...rest }) {
6
+ return (_jsx("select", { ...rest, className: cx(controlClass, "px-3", className), children: children }));
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ /** A multi-line text input, styled like `Input`. `rows` defaults to 3. */
3
+ export declare function Textarea({ className, ...rest }: ComponentPropsWithoutRef<"textarea">): ReactElement;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { controlClass } from "./control.js";
3
+ import { cx } from "./cx.js";
4
+ /** A multi-line text input, styled like `Input`. `rows` defaults to 3. */
5
+ export function Textarea({ className, ...rest }) {
6
+ return _jsx("textarea", { rows: 3, ...rest, className: cx(controlClass, "px-3", className) });
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ /** An on/off pill button for filters. `pressed` sets its state; `dot` adds a leading colour dot. The caller toggles it in onClick. */
3
+ export declare function ToggleChip({ pressed, dot, className, children, ...rest }: {
4
+ pressed: boolean;
5
+ /** A CSS colour for a leading dot (e.g. a category colour); no dot when omitted. */
6
+ dot?: string;
7
+ } & ComponentPropsWithoutRef<"button">): ReactElement;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx, focusRing } from "./cx.js";
3
+ const BASE = `inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition ${focusRing}`;
4
+ const OFF = "border-slate-200 bg-white text-slate-600 hover:border-violet-400";
5
+ const ON = "border-violet-600 bg-violet-600 text-violet-50";
6
+ /** An on/off pill button for filters. `pressed` sets its state; `dot` adds a leading colour dot. The caller toggles it in onClick. */
7
+ export function ToggleChip({ pressed, dot, className, children, ...rest }) {
8
+ return (_jsxs("button", { type: "button", "aria-pressed": pressed, className: cx(BASE, pressed ? ON : OFF, className), ...rest, children: [dot && (_jsx("span", { "aria-hidden": "true", className: cx("h-1.5 w-1.5 rounded-full", pressed && "ring-1 ring-current"), style: { backgroundColor: dot } })), children] }));
9
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared look of text controls; each control adds its own horizontal padding. */
2
+ export declare const controlClass = "w-full rounded-lg border border-slate-300 bg-white py-2 text-sm text-slate-900 placeholder:text-slate-400 transition hover:border-violet-400 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600";
@@ -0,0 +1,3 @@
1
+ import { focusRing } from "./cx.js";
2
+ /** Shared look of text controls; each control adds its own horizontal padding. */
3
+ export const controlClass = `w-full rounded-lg border border-slate-300 bg-white py-2 text-sm text-slate-900 placeholder:text-slate-400 transition hover:border-violet-400 disabled:opacity-50 ${focusRing}`;
package/dist/cx.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Join class names, dropping falsy parts. */
2
+ export declare function cx(...parts: Array<string | false | null | undefined>): string;
3
+ /** The keyboard focus ring every interactive control shares. */
4
+ export declare const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600";
package/dist/cx.js ADDED
@@ -0,0 +1,6 @@
1
+ /** Join class names, dropping falsy parts. */
2
+ export function cx(...parts) {
3
+ return parts.filter(Boolean).join(" ");
4
+ }
5
+ /** The keyboard focus ring every interactive control shares. */
6
+ export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600";
package/dist/index.d.ts CHANGED
@@ -1,3 +1,21 @@
1
1
  export { Card, EpicHeader, Empty, PageTitle, Pill, groupByEpic } from "./ui.js";
2
2
  export { Markdown } from "./Markdown.js";
3
+ export { Button, buttonClass, type ButtonVariant } from "./Button.js";
4
+ export { Breadcrumbs, crumbLinkClass } from "./Breadcrumbs.js";
5
+ export { Callout, type CalloutTone } from "./Callout.js";
6
+ export { Dialog, type DialogProps } from "./Dialog.js";
7
+ export { IconButton } from "./IconButton.js";
8
+ export { NavTabs, navTabClass, type NavTab } from "./NavTabs.js";
9
+ export { ToggleChip } from "./ToggleChip.js";
10
+ export { FilterGroup } from "./FilterGroup.js";
11
+ export { Checkbox } from "./Checkbox.js";
12
+ export { CopyButton } from "./CopyButton.js";
13
+ export { CopyLine } from "./CopyLine.js";
14
+ export { Field } from "./Field.js";
15
+ export { FieldDialog, type FieldDialogProps } from "./FieldDialog.js";
16
+ export { Input } from "./Input.js";
17
+ export { Kbd } from "./Kbd.js";
18
+ export { KeyValue, KeyValueList } from "./KeyValueList.js";
19
+ export { Select } from "./Select.js";
20
+ export { Textarea } from "./Textarea.js";
3
21
  export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, Subtitle, type TypographyColor, type TypographyProps, } from "./typography.js";
package/dist/index.js CHANGED
@@ -1,3 +1,21 @@
1
1
  export { Card, EpicHeader, Empty, PageTitle, Pill, groupByEpic } from "./ui.js";
2
2
  export { Markdown } from "./Markdown.js";
3
+ export { Button, buttonClass } from "./Button.js";
4
+ export { Breadcrumbs, crumbLinkClass } from "./Breadcrumbs.js";
5
+ export { Callout } from "./Callout.js";
6
+ export { Dialog } from "./Dialog.js";
7
+ export { IconButton } from "./IconButton.js";
8
+ export { NavTabs, navTabClass } from "./NavTabs.js";
9
+ export { ToggleChip } from "./ToggleChip.js";
10
+ export { FilterGroup } from "./FilterGroup.js";
11
+ export { Checkbox } from "./Checkbox.js";
12
+ export { CopyButton } from "./CopyButton.js";
13
+ export { CopyLine } from "./CopyLine.js";
14
+ export { Field } from "./Field.js";
15
+ export { FieldDialog } from "./FieldDialog.js";
16
+ export { Input } from "./Input.js";
17
+ export { Kbd } from "./Kbd.js";
18
+ export { KeyValue, KeyValueList } from "./KeyValueList.js";
19
+ export { Select } from "./Select.js";
20
+ export { Textarea } from "./Textarea.js";
3
21
  export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, Subtitle, } from "./typography.js";
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
2
3
  /** Colour token → slate/violet utility (remapped to Catppuccin Mocha in dark mode by theme.css). */
3
4
  const COLOR = {
4
5
  default: "text-slate-900", // Catppuccin text #cdd6f4
@@ -7,9 +8,6 @@ const COLOR = {
7
8
  muted: "text-slate-400", // Catppuccin overlay2 #9399b2
8
9
  brand: "text-violet-600", // Catppuccin mauve #cba6f7
9
10
  };
10
- function cx(...parts) {
11
- return parts.filter(Boolean).join(" ");
12
- }
13
11
  /**
14
12
  * Build a semantic component that pins `variantClass` (size + weight + tracking)
15
13
  * and a default colour. The scale/weight for each role therefore lives in exactly
package/dist/ui.d.ts CHANGED
@@ -3,9 +3,12 @@ export declare function PageTitle({ title, subtitle }: {
3
3
  title: string;
4
4
  subtitle?: string;
5
5
  }): import("react").JSX.Element;
6
- export declare function Card({ title, count, children, }: {
6
+ /** A bordered panel with an optional title and count; `as` picks the element (default `section`). */
7
+ export declare function Card({ title, count, as: Tag, className, children, }: {
7
8
  title?: string;
8
9
  count?: number;
10
+ as?: "section" | "article" | "div";
11
+ className?: string;
9
12
  children: ReactNode;
10
13
  }): import("react").JSX.Element;
11
14
  /** A coloured epic header row: a dot in the epic's colour + its label. */
@@ -13,9 +16,11 @@ export declare function EpicHeader({ color, label }: {
13
16
  color: string;
14
17
  label: string;
15
18
  }): import("react").JSX.Element;
16
- export declare function Pill({ children, tone, }: {
19
+ /** A small tinted label; `dot` adds a leading dot in the pill's own text colour. */
20
+ export declare function Pill({ children, tone, dot, }: {
17
21
  children: ReactNode;
18
22
  tone?: "slate" | "amber" | "emerald" | "blue" | "red";
23
+ dot?: boolean;
19
24
  }): import("react").JSX.Element;
20
25
  export declare function Empty({ children }: {
21
26
  children: ReactNode;
package/dist/ui.js CHANGED
@@ -1,17 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
2
3
  // Small presentational primitives written against Tailwind's slate-based class vocabulary
3
4
  // (remapped to Catppuccin Mocha in dark mode by theme.css).
4
5
  export function PageTitle({ title, subtitle }) {
5
6
  return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight text-slate-900", children: title }), subtitle && _jsx("p", { className: "mt-1 text-sm text-slate-500", children: subtitle })] }));
6
7
  }
7
- export function Card({ title, count, children, }) {
8
- return (_jsxs("section", { className: "rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", children: [title && (_jsxs("div", { className: "mb-3 flex items-baseline gap-2", children: [_jsx("h2", { className: "text-xs font-semibold uppercase tracking-wide text-slate-400", children: title }), count !== undefined && (_jsx("span", { className: "text-xs font-medium text-slate-400", children: count }))] })), children] }));
8
+ /** A bordered panel with an optional title and count; `as` picks the element (default `section`). */
9
+ export function Card({ title, count, as: Tag = "section", className, children, }) {
10
+ return (_jsxs(Tag, { className: cx("rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", className), children: [title && (_jsxs("div", { className: "mb-3 flex items-baseline gap-2", children: [_jsx("h2", { className: "text-xs font-semibold uppercase tracking-wide text-slate-400", children: title }), count !== undefined && (_jsx("span", { className: "text-xs font-medium text-slate-400", children: count }))] })), children] }));
9
11
  }
10
12
  /** A coloured epic header row: a dot in the epic's colour + its label. */
11
13
  export function EpicHeader({ color, label }) {
12
14
  return (_jsxs("div", { className: "mb-1.5 mt-3 flex items-center gap-2 first:mt-0", children: [_jsx("span", { className: "inline-block h-2.5 w-2.5 rounded-full", style: { backgroundColor: color } }), _jsx("span", { className: "text-sm font-semibold text-slate-700", children: label })] }));
13
15
  }
14
- export function Pill({ children, tone = "slate", }) {
16
+ /** A small tinted label; `dot` adds a leading dot in the pill's own text colour. */
17
+ export function Pill({ children, tone = "slate", dot = false, }) {
15
18
  const tones = {
16
19
  slate: "bg-slate-100 text-slate-600",
17
20
  amber: "bg-amber-50 text-amber-700",
@@ -19,7 +22,7 @@ export function Pill({ children, tone = "slate", }) {
19
22
  blue: "bg-blue-50 text-blue-700",
20
23
  red: "bg-red-50 text-red-900",
21
24
  };
22
- return (_jsx("span", { className: `rounded-md px-1.5 py-0.5 text-xs font-medium ${tones[tone]}`, children: children }));
25
+ return (_jsxs("span", { className: cx("rounded-md px-1.5 py-0.5 text-xs font-medium", tones[tone], dot && "inline-flex items-center gap-1"), children: [dot && _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-1.5 rounded-full bg-current" }), children] }));
23
26
  }
24
27
  export function Empty({ children }) {
25
28
  return _jsx("p", { className: "text-sm text-slate-400", children: children });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a @hackcove/theme-* package.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "@types/bun": "1.4.3",
34
34
  "@types/react": "^19.3.0",
35
35
  "@types/react-dom": "^19.3.0",
36
+ "jsdom": "^30.1.3",
36
37
  "react": "^19.3.1",
37
38
  "react-dom": "^19.3.1",
38
39
  "tailwindcss": "^4.3.3",