@hackcove/ui 0.2.4 → 0.2.5
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 +47 -4
- package/dist/Badge.d.ts +9 -0
- package/dist/Badge.js +10 -0
- package/dist/Breadcrumbs.d.ts +22 -3
- package/dist/Breadcrumbs.js +23 -2
- package/dist/Button.d.ts +18 -8
- package/dist/Button.js +54 -22
- package/dist/ButtonGroup.d.ts +14 -0
- package/dist/ButtonGroup.js +10 -0
- package/dist/ButtonLink.d.ts +20 -0
- package/dist/ButtonLink.js +18 -0
- package/dist/Callout.d.ts +10 -2
- package/dist/Callout.js +8 -3
- package/dist/Checkbox.d.ts +10 -4
- package/dist/Checkbox.js +8 -3
- package/dist/ColourSchemeToggle.d.ts +14 -3
- package/dist/ColourSchemeToggle.js +13 -9
- package/dist/DateField.d.ts +15 -2
- package/dist/DateField.js +8 -4
- package/dist/DatePicker.d.ts +1 -1
- package/dist/DatePicker.js +3 -3
- package/dist/Drawer.d.ts +29 -0
- package/dist/Drawer.js +26 -0
- package/dist/FileDrop.d.ts +10 -0
- package/dist/FileDrop.js +11 -0
- package/dist/Input.d.ts +3 -2
- package/dist/Input.js +5 -4
- package/dist/KeyValueList.d.ts +11 -4
- package/dist/KeyValueList.js +14 -5
- package/dist/ListRow.d.ts +36 -0
- package/dist/ListRow.js +31 -0
- package/dist/NavTabs.d.ts +15 -4
- package/dist/NavTabs.js +13 -2
- package/dist/PageFade.d.ts +25 -0
- package/dist/PageFade.js +39 -0
- package/dist/ProgressBar.d.ts +26 -0
- package/dist/ProgressBar.js +32 -0
- package/dist/SegmentedControl.d.ts +11 -0
- package/dist/SegmentedControl.js +22 -0
- package/dist/Select.d.ts +3 -2
- package/dist/Select.js +4 -4
- package/dist/SplitBar.d.ts +21 -0
- package/dist/SplitBar.js +15 -0
- package/dist/StatTile.d.ts +16 -0
- package/dist/StatTile.js +21 -0
- package/dist/Table.d.ts +48 -0
- package/dist/Table.js +42 -0
- package/dist/Tabs.d.ts +13 -0
- package/dist/Tabs.js +23 -0
- package/dist/TextLink.d.ts +19 -4
- package/dist/TextLink.js +19 -4
- package/dist/Textarea.d.ts +3 -2
- package/dist/Textarea.js +4 -4
- package/dist/Timeline.d.ts +21 -0
- package/dist/Timeline.js +19 -0
- package/dist/colourScheme.d.ts +11 -2
- package/dist/colourScheme.js +16 -3
- package/dist/control.d.ts +19 -2
- package/dist/control.js +25 -2
- package/dist/index.d.ts +21 -5
- package/dist/index.js +18 -3
- package/dist/link.d.ts +22 -0
- package/dist/link.js +11 -0
- package/dist/switcher.d.ts +66 -0
- package/dist/switcher.js +53 -0
- package/dist/ui.d.ts +25 -5
- package/dist/ui.js +51 -7
- package/package.json +1 -1
package/dist/DatePicker.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export type DatePickerProps = DateFieldProps & {
|
|
|
5
5
|
defaultOpen?: boolean;
|
|
6
6
|
};
|
|
7
7
|
/** A `DateField` with a calendar button that opens a month view in a popover. */
|
|
8
|
-
export declare function DatePicker({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }: DatePickerProps): ReactElement;
|
|
8
|
+
export declare function DatePicker({ label, description, errorMessage, error, isInvalid: invalidProp, className, value, defaultValue, onChange, name, ...rest }: DatePickerProps): ReactElement;
|
package/dist/DatePicker.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useContext } from "react";
|
|
|
3
3
|
import { Button, DatePicker as AriaDatePicker, DateFieldContext, DateInput, Group, I18nProvider, Label, } from "react-aria-components";
|
|
4
4
|
import { cx } from "./cx.js";
|
|
5
5
|
import { CalendarPopover, calendarIcon } from "./DateCalendar.js";
|
|
6
|
-
import { FieldSegment, LOCALE, Notes, frameClass, iconButtonClass, labelClass, } from "./DateField.js";
|
|
6
|
+
import { FieldSegment, LOCALE, forcedInvalid, Notes, frameClass, iconButtonClass, labelClass, } from "./DateField.js";
|
|
7
7
|
import { ShownInput, useShownDate } from "./shownDate.js";
|
|
8
8
|
/**
|
|
9
9
|
* The picker's input, read-only while `leaving`. Only the field: a read-only picker would also
|
|
@@ -14,7 +14,7 @@ function PickerInput({ leaving, onLeft, children, }) {
|
|
|
14
14
|
return (_jsx(DateFieldContext.Provider, { value: { ...field, isReadOnly: leaving, onBlur: onLeft }, children: children }));
|
|
15
15
|
}
|
|
16
16
|
/** A `DateField` with a calendar button that opens a month view in a popover. */
|
|
17
|
-
export function DatePicker({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }) {
|
|
17
|
+
export function DatePicker({ label, description, errorMessage, error, isInvalid: invalidProp, className, value, defaultValue, onChange, name, ...rest }) {
|
|
18
18
|
const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
|
|
19
19
|
value,
|
|
20
20
|
defaultValue,
|
|
@@ -23,5 +23,5 @@ export function DatePicker({ label, description, errorMessage, className, value,
|
|
|
23
23
|
maxValue: rest.maxValue,
|
|
24
24
|
isRequired: rest.isRequired,
|
|
25
25
|
});
|
|
26
|
-
return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDatePicker, { ...rest, value: inner, onChange: setInner, validate: validate, ...(impossible
|
|
26
|
+
return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDatePicker, { ...rest, value: inner, onChange: setInner, validate: validate, ...forcedInvalid({ impossible, isInvalid: invalidProp, error }), granularity: "day", shouldForceLeadingZeros: true, className: cx("flex flex-col gap-1.5", className), children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsxs(Group, { className: (state) => cx(frameClass(state), "gap-1 pr-1"), children: [_jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(PickerInput, { leaving: leaving, onLeft: onLeft, children: _jsx(DateInput, { className: "flex flex-1", children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }) }), _jsx(Button, { className: iconButtonClass, children: calendarIcon })] }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, error: error, impossible: impossible, isInvalid: isInvalid }), _jsx(CalendarPopover, {})] })) }) }));
|
|
27
27
|
}
|
package/dist/Drawer.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
declare const SIZE: {
|
|
3
|
+
readonly sm: "w-80";
|
|
4
|
+
readonly md: "w-110";
|
|
5
|
+
readonly lg: "w-160";
|
|
6
|
+
};
|
|
7
|
+
export type DrawerProps = {
|
|
8
|
+
/** The drawer's accessible name, shown as its heading. */
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
/** Actions pinned under the scrolling body, right-aligned. */
|
|
12
|
+
footer?: ReactNode;
|
|
13
|
+
/** `md` (the default) holds a form or a record; `sm` is narrower and `lg` wider. */
|
|
14
|
+
size?: keyof typeof SIZE;
|
|
15
|
+
/** Called when the drawer closes: Esc, a click on the scrim or the close button. */
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
/** The accessible name of the close button. */
|
|
18
|
+
closeLabel?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* A modal slide-over anchored to the right edge, on the browser's own `<dialog>`: it is opened
|
|
23
|
+
* with `showModal()` on mount, so render it only while it is open. The page behind goes inert, Esc
|
|
24
|
+
* closes it and focus stays inside; a click on the scrim (the `<dialog>` element itself, not its
|
|
25
|
+
* contents) closes it too. The header holds the title and a close button; the body scrolls between
|
|
26
|
+
* it and an optional pinned `footer`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function Drawer({ title, children, footer, size, onClose, closeLabel, className, }: DrawerProps): ReactElement;
|
|
29
|
+
export {};
|
package/dist/Drawer.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useId, useRef } from "react";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
4
|
+
import { IconButton } from "./IconButton.js";
|
|
5
|
+
import { ModalHeading } from "./typography.js";
|
|
6
|
+
const PANEL = "fixed inset-y-0 right-0 left-auto m-0 h-dvh max-h-none max-w-full flex-col overflow-hidden border-l border-slate-200 bg-white p-0 text-slate-900 shadow-2xl backdrop:bg-slate-900/35 open:flex transition-transform duration-200 starting:open:translate-x-full motion-reduce:transition-none";
|
|
7
|
+
const SIZE = { sm: "w-80", md: "w-110", lg: "w-160" };
|
|
8
|
+
/**
|
|
9
|
+
* A modal slide-over anchored to the right edge, on the browser's own `<dialog>`: it is opened
|
|
10
|
+
* with `showModal()` on mount, so render it only while it is open. The page behind goes inert, Esc
|
|
11
|
+
* closes it and focus stays inside; a click on the scrim (the `<dialog>` element itself, not its
|
|
12
|
+
* contents) closes it too. The header holds the title and a close button; the body scrolls between
|
|
13
|
+
* it and an optional pinned `footer`.
|
|
14
|
+
*/
|
|
15
|
+
export function Drawer({ title, children, footer, size = "md", onClose, closeLabel = "Close", className, }) {
|
|
16
|
+
const dialog = useRef(null);
|
|
17
|
+
const titleId = useId();
|
|
18
|
+
// Closing on unmount (not just on Esc or the scrim) hands focus back to the opener when the
|
|
19
|
+
// parent unmounts the drawer, and keeps StrictMode's second mount from reopening an open dialog.
|
|
20
|
+
useLayoutEffect(() => {
|
|
21
|
+
const el = dialog.current;
|
|
22
|
+
el?.showModal();
|
|
23
|
+
return () => el?.close();
|
|
24
|
+
}, []);
|
|
25
|
+
return (_jsxs("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("header", { className: "flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-5.5 py-3.5", children: [_jsx(ModalHeading, { id: titleId, children: title }), _jsx(IconButton, { label: closeLabel, onClick: () => dialog.current?.close(), children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": "true", className: "h-4 w-4", children: _jsx("path", { d: "M5.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 10l-4.72 4.72a.75.75 0 1 0 1.06 1.06L10 11.06l4.72 4.72a.75.75 0 1 0 1.06-1.06L11.06 10l4.72-4.72a.75.75 0 0 0-1.06-1.06L10 8.94 5.28 4.22Z" }) }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-5.5 py-4", children: children }), footer && (_jsx("footer", { className: "flex shrink-0 justify-end gap-2 border-t border-slate-200 px-5.5 py-3.5", children: footer }))] }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A dashed drop zone around a file input. The input covers the whole zone, invisible, so a click
|
|
4
|
+
* opens the file dialog and a file dragged onto it is taken by the browser as it is by any file
|
|
5
|
+
* input. `children` is the prompt; show the chosen file's name there from `onChange`. `className`
|
|
6
|
+
* goes on the label (which also shows the keyboard focus ring, the input being invisible), every other prop (`accept`, `multiple`, `name`, `disabled`, `onChange`) on the input.
|
|
7
|
+
*/
|
|
8
|
+
export declare function FileDrop({ children, className, ...rest }: {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
} & Omit<ComponentPropsWithoutRef<"input">, "type" | "children" | "size" | "width">): ReactElement;
|
package/dist/FileDrop.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
/**
|
|
4
|
+
* A dashed drop zone around a file input. The input covers the whole zone, invisible, so a click
|
|
5
|
+
* opens the file dialog and a file dragged onto it is taken by the browser as it is by any file
|
|
6
|
+
* input. `children` is the prompt; show the chosen file's name there from `onChange`. `className`
|
|
7
|
+
* goes on the label (which also shows the keyboard focus ring, the input being invisible), every other prop (`accept`, `multiple`, `name`, `disabled`, `onChange`) on the input.
|
|
8
|
+
*/
|
|
9
|
+
export function FileDrop({ children, className, ...rest }) {
|
|
10
|
+
return (_jsxs("label", { className: cx("relative flex items-center justify-center rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500 transition hover:border-violet-400 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-violet-600", rest.disabled && "opacity-50", className), children: [_jsx("input", { type: "file", className: "absolute inset-0 h-full w-full cursor-pointer rounded-lg opacity-0 disabled:cursor-not-allowed", ...rest }), children] }));
|
|
11
|
+
}
|
package/dist/Input.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
|
2
|
+
import { type ControlProps } from "./control.js";
|
|
2
3
|
/** 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
|
+
export declare function Input({ icon, size, width, className, ...rest }: {
|
|
4
5
|
icon?: ReactNode;
|
|
5
|
-
} & ComponentPropsWithoutRef<"input">): ReactElement;
|
|
6
|
+
} & ControlProps & Omit<ComponentPropsWithoutRef<"input">, "size" | "width">): ReactElement;
|
package/dist/Input.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { controlClass } from "./control.js";
|
|
2
|
+
import { CONTROL_PAD, CONTROL_WIDTH, controlClass } from "./control.js";
|
|
3
3
|
import { cx } from "./cx.js";
|
|
4
4
|
/** A text input. `icon` puts a leading icon inside the field (e.g. a search glass). */
|
|
5
|
-
export function Input({ icon, className, ...rest }) {
|
|
5
|
+
export function Input({ icon, size = "md", width = "full", className, ...rest }) {
|
|
6
6
|
if (icon === undefined) {
|
|
7
|
-
return _jsx("input", { ...rest, className: cx(controlClass,
|
|
7
|
+
return (_jsx("input", { ...rest, className: cx(controlClass(size, width), CONTROL_PAD[size].x, className) }));
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
const small = size === "sm";
|
|
10
|
+
return (_jsxs("div", { className: cx("relative", width === "auto" && "inline-block", width !== "full" && CONTROL_WIDTH[width]), children: [_jsx("span", { "aria-hidden": "true", className: cx("pointer-events-none absolute top-1/2 -translate-y-1/2 text-slate-400", small ? "left-2 [&>svg]:h-3.5 [&>svg]:w-3.5" : "left-3 [&>svg]:h-4 [&>svg]:w-4"), children: icon }), _jsx("input", { ...rest, className: cx(controlClass(size, width === "auto" ? "auto" : "full"), CONTROL_PAD[size].icon, className) })] }));
|
|
10
11
|
}
|
package/dist/KeyValueList.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { type HTMLAttributes, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
type KeyValueAlign = "end" | "start";
|
|
3
|
+
/**
|
|
4
|
+
* A definition list of label/value rows. By default each label is on the left and its value
|
|
5
|
+
* right-aligned. `align="start"` puts every value left-aligned in one column beside the labels,
|
|
6
|
+
* which are sized to the widest.
|
|
7
|
+
*/
|
|
8
|
+
export declare function KeyValueList({ align, className, children, ...rest }: {
|
|
9
|
+
align?: KeyValueAlign;
|
|
4
10
|
children: ReactNode;
|
|
5
11
|
} & HTMLAttributes<HTMLDListElement>): ReactElement;
|
|
6
|
-
/** One row of a KeyValueList: a label on the left, a
|
|
12
|
+
/** One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an `align="start"` list). */
|
|
7
13
|
export declare function KeyValue({ label, children, className, ...rest }: {
|
|
8
14
|
label: ReactNode;
|
|
9
15
|
children: ReactNode;
|
|
10
16
|
} & HTMLAttributes<HTMLDivElement>): ReactElement;
|
|
17
|
+
export {};
|
package/dist/KeyValueList.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, } from "react";
|
|
2
3
|
import { cx } from "./cx.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
const AlignContext = createContext("end");
|
|
5
|
+
/**
|
|
6
|
+
* A definition list of label/value rows. By default each label is on the left and its value
|
|
7
|
+
* right-aligned. `align="start"` puts every value left-aligned in one column beside the labels,
|
|
8
|
+
* which are sized to the widest.
|
|
9
|
+
*/
|
|
10
|
+
export function KeyValueList({ align = "end", className, children, ...rest }) {
|
|
11
|
+
return (_jsx("dl", { className: cx(align === "start"
|
|
12
|
+
? "grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5"
|
|
13
|
+
: "flex flex-col gap-1.5", "text-sm", className), ...rest, children: _jsx(AlignContext.Provider, { value: align, children: children }) }));
|
|
6
14
|
}
|
|
7
|
-
/** One row of a KeyValueList: a label on the left, a
|
|
15
|
+
/** One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an `align="start"` list). */
|
|
8
16
|
export function KeyValue({ label, children, className, ...rest }) {
|
|
9
|
-
|
|
17
|
+
const start = useContext(AlignContext) === "start";
|
|
18
|
+
return (_jsxs("div", { className: cx(start ? "col-span-2 grid grid-cols-subgrid" : "flex justify-between gap-3", className), ...rest, children: [_jsx("dt", { className: "text-slate-600", children: label }), _jsx("dd", { className: start ? "tabular-nums" : "text-right tabular-nums", children: children })] }));
|
|
10
19
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
|
2
|
+
import { type LinkRender } from "./link.js";
|
|
3
|
+
type Shared = {
|
|
4
|
+
/** Content at the far end of the row: a value, a count, a Pill. */
|
|
5
|
+
trailing?: ReactNode | undefined;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
type ListRowButtonProps = Shared & {
|
|
9
|
+
/**
|
|
10
|
+
* Makes the row a collapsible header: sets `aria-expanded` and shows a chevron that turns down
|
|
11
|
+
* when open. The caller owns the state and the section; pass `aria-controls` to name it.
|
|
12
|
+
*/
|
|
13
|
+
expanded?: boolean | undefined;
|
|
14
|
+
href?: undefined;
|
|
15
|
+
render?: undefined;
|
|
16
|
+
} & Omit<ComponentPropsWithoutRef<"button">, "children">;
|
|
17
|
+
type ListRowLinkProps = Shared & {
|
|
18
|
+
/** Opens in a new tab: adds `target="_blank"` and `rel="noreferrer"`. */
|
|
19
|
+
external?: boolean | undefined;
|
|
20
|
+
expanded?: undefined;
|
|
21
|
+
} & Omit<ComponentPropsWithoutRef<"a">, "href" | "children"> & ({
|
|
22
|
+
href: string;
|
|
23
|
+
render?: LinkRender | undefined;
|
|
24
|
+
} | {
|
|
25
|
+
href?: string | undefined;
|
|
26
|
+
render: LinkRender;
|
|
27
|
+
});
|
|
28
|
+
export type ListRowProps = ListRowButtonProps | ListRowLinkProps;
|
|
29
|
+
/**
|
|
30
|
+
* A full-width row that is a `<button>` (or, with `href` or `render`, a link) with a hover fill, a
|
|
31
|
+
* left-aligned label and `trailing` content at the far end. With `expanded` it is the header of a
|
|
32
|
+
* collapsible section: `aria-expanded` and a chevron. Rows sit in a `Card flush`; lines between
|
|
33
|
+
* them are the caller's (`border-t border-slate-200/70` on each row after the first).
|
|
34
|
+
*/
|
|
35
|
+
export declare function ListRow(props: ListRowProps): ReactElement;
|
|
36
|
+
export {};
|
package/dist/ListRow.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx, focusRing } from "./cx.js";
|
|
3
|
+
import { renderLink } from "./link.js";
|
|
4
|
+
const ROW = `flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-slate-900 hover:bg-slate-50 disabled:pointer-events-none disabled:opacity-50 ${focusRing} focus-visible:-outline-offset-2`;
|
|
5
|
+
function Chevron({ open }) {
|
|
6
|
+
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" }) }));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A full-width row that is a `<button>` (or, with `href` or `render`, a link) with a hover fill, a
|
|
10
|
+
* left-aligned label and `trailing` content at the far end. With `expanded` it is the header of a
|
|
11
|
+
* collapsible section: `aria-expanded` and a chevron. Rows sit in a `Card flush`; lines between
|
|
12
|
+
* them are the caller's (`border-t border-slate-200/70` on each row after the first).
|
|
13
|
+
*/
|
|
14
|
+
export function ListRow(props) {
|
|
15
|
+
const { trailing, children, className } = props;
|
|
16
|
+
const label = _jsx("span", { className: "min-w-0 flex-1", children: children });
|
|
17
|
+
const end = trailing != null && (_jsx("span", { className: "shrink-0 tabular-nums text-slate-500", children: trailing }));
|
|
18
|
+
if (props.href !== undefined || props.render !== undefined) {
|
|
19
|
+
const { trailing: _t, expanded: _e, href, render, external, className: _c, ...rest } = props;
|
|
20
|
+
return renderLink({
|
|
21
|
+
...rest,
|
|
22
|
+
href,
|
|
23
|
+
render,
|
|
24
|
+
external,
|
|
25
|
+
className: cx(ROW, className),
|
|
26
|
+
children: (_jsxs(_Fragment, { children: [label, end] })),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const { trailing: _t, expanded, href: _h, render: _r, className: _c, ...rest } = props;
|
|
30
|
+
return (_jsxs("button", { type: "button", ...rest, ...(expanded === undefined ? {} : { "aria-expanded": expanded }), className: cx(ROW, className), children: [expanded !== undefined && _jsx(Chevron, { open: expanded }), label, end] }));
|
|
31
|
+
}
|
package/dist/NavTabs.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import type { ReactElement, ReactNode } from "react";
|
|
2
|
-
|
|
2
|
+
import { type LinkRender } from "./link.js";
|
|
3
|
+
/**
|
|
4
|
+
* One pill link in a NavTabs row: a plain `<a href>`, or with `render` the app's own router
|
|
5
|
+
* `<Link>` (`render: (p) => <Link to="/stores" {...p} />`, which gets the tab's classes and
|
|
6
|
+
* `aria-current`; `href` is then not used). `external` opens in a new tab.
|
|
7
|
+
*/
|
|
3
8
|
export type NavTab = {
|
|
4
9
|
label: ReactNode;
|
|
5
|
-
href: string;
|
|
6
10
|
active?: boolean;
|
|
7
|
-
|
|
11
|
+
external?: boolean;
|
|
12
|
+
} & ({
|
|
13
|
+
href: string;
|
|
14
|
+
render?: LinkRender;
|
|
15
|
+
} | {
|
|
16
|
+
href?: string;
|
|
17
|
+
render: LinkRender;
|
|
18
|
+
});
|
|
8
19
|
/** The classes of one tab, for a router's own <Link>. */
|
|
9
20
|
export declare function navTabClass(active: boolean): string;
|
|
10
|
-
/** A row of pill links where the active one is filled.
|
|
21
|
+
/** A row of pill links where the active one is filled. Plain <a> by default; a tab's `render` draws a router's Link. */
|
|
11
22
|
export declare function NavTabs({ items, label, className, }: {
|
|
12
23
|
items: NavTab[];
|
|
13
24
|
/** The accessible name of the nav landmark. */
|
package/dist/NavTabs.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx, focusRing } from "./cx.js";
|
|
3
|
+
import { renderLink } from "./link.js";
|
|
3
4
|
const BASE = `rounded-lg px-3 py-1.5 transition ${focusRing}`;
|
|
4
5
|
/** The classes of one tab, for a router's own <Link>. */
|
|
5
6
|
export function navTabClass(active) {
|
|
6
7
|
return cx(BASE, active ? "bg-slate-100 font-medium text-slate-900" : "text-slate-600 hover:bg-slate-100");
|
|
7
8
|
}
|
|
8
|
-
/** A row of pill links where the active one is filled.
|
|
9
|
+
/** A row of pill links where the active one is filled. Plain <a> by default; a tab's `render` draws a router's Link. */
|
|
9
10
|
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(
|
|
11
|
+
return (_jsx("nav", { "aria-label": label, className: cx("flex flex-wrap gap-1 text-sm", className), children: items.map((item, index) => (_jsx(Tab, { item: item }, item.href ?? index))) }));
|
|
12
|
+
}
|
|
13
|
+
function Tab({ item }) {
|
|
14
|
+
return renderLink({
|
|
15
|
+
href: item.href,
|
|
16
|
+
render: item.render,
|
|
17
|
+
external: item.external,
|
|
18
|
+
className: navTabClass(!!item.active),
|
|
19
|
+
"aria-current": item.active ? "page" : undefined,
|
|
20
|
+
children: item.label,
|
|
21
|
+
});
|
|
11
22
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The page's content area, named for the View Transitions API so a route change cross-fades it
|
|
4
|
+
* while the frame around it (header, footer) stays put. Wrap what changes between pages, once per
|
|
5
|
+
* page: two on one page need a different `name` each, or the browser skips the transition.
|
|
6
|
+
* The fade runs only when the navigation is wrapped in a view transition: `startPageTransition`,
|
|
7
|
+
* or a router's own option (TanStack Router `viewTransition`, React Router's `viewTransition` prop,
|
|
8
|
+
* Astro's `ClientRouter`). Otherwise, or where the browser has no View Transitions, it renders
|
|
9
|
+
* and behaves as a plain `<div>`. A `view-transition-name` makes the div a stacking context, so a
|
|
10
|
+
* dropdown inside it cannot rise above a sticky header outside it: portal overlays to the body. Under `prefers-reduced-motion: reduce` this content swaps with no fade; a router's own
|
|
11
|
+
* transition of the rest of the page is the router's to quiet.
|
|
12
|
+
*/
|
|
13
|
+
export declare function PageFade({ name, className, children, ...rest }: {
|
|
14
|
+
/** The view-transition name, a CSS identifier (letters, digits, `-`, `_`). */
|
|
15
|
+
name?: string;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
} & HTMLAttributes<HTMLDivElement>): ReactElement;
|
|
18
|
+
/**
|
|
19
|
+
* Run `update` inside a view transition, so a `PageFade` cross-fades. `update` is a synchronous
|
|
20
|
+
* state change, flushed at once as the browser needs the new DOM; for a router's navigation, which
|
|
21
|
+
* is async, use the router's own view-transition option instead. Where the browser has
|
|
22
|
+
* no View Transitions, or the user prefers reduced motion, `update` just runs. Call it from an event
|
|
23
|
+
* handler, not during render.
|
|
24
|
+
*/
|
|
25
|
+
export declare function startPageTransition(update: () => void): void;
|
package/dist/PageFade.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { flushSync } from "react-dom";
|
|
3
|
+
const DEFAULT_NAME = "hc-page";
|
|
4
|
+
// The one place the View Transitions pseudo-elements can be styled: a stylesheet, as Tailwind
|
|
5
|
+
// classes cannot reach `::view-transition-*`. The browser's own cross-fade does the fading; this
|
|
6
|
+
// names the element and, under reduced motion, switches the animations off so the content swaps.
|
|
7
|
+
// No colour in it. Where the API is missing the rules are never matched.
|
|
8
|
+
const css = (name) => `[data-page-fade="${name}"]{view-transition-name:${name}}
|
|
9
|
+
@media (prefers-reduced-motion:reduce){::view-transition-group(${name}),::view-transition-old(${name}),::view-transition-new(${name}){animation:none}}`;
|
|
10
|
+
/**
|
|
11
|
+
* The page's content area, named for the View Transitions API so a route change cross-fades it
|
|
12
|
+
* while the frame around it (header, footer) stays put. Wrap what changes between pages, once per
|
|
13
|
+
* page: two on one page need a different `name` each, or the browser skips the transition.
|
|
14
|
+
* The fade runs only when the navigation is wrapped in a view transition: `startPageTransition`,
|
|
15
|
+
* or a router's own option (TanStack Router `viewTransition`, React Router's `viewTransition` prop,
|
|
16
|
+
* Astro's `ClientRouter`). Otherwise, or where the browser has no View Transitions, it renders
|
|
17
|
+
* and behaves as a plain `<div>`. A `view-transition-name` makes the div a stacking context, so a
|
|
18
|
+
* dropdown inside it cannot rise above a sticky header outside it: portal overlays to the body. Under `prefers-reduced-motion: reduce` this content swaps with no fade; a router's own
|
|
19
|
+
* transition of the rest of the page is the router's to quiet.
|
|
20
|
+
*/
|
|
21
|
+
export function PageFade({ name = DEFAULT_NAME, className, children, ...rest }) {
|
|
22
|
+
return (_jsxs(_Fragment, { children: [_jsx("style", { children: css(name) }), _jsx("div", { "data-page-fade": name, className: className, ...rest, children: children })] }));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run `update` inside a view transition, so a `PageFade` cross-fades. `update` is a synchronous
|
|
26
|
+
* state change, flushed at once as the browser needs the new DOM; for a router's navigation, which
|
|
27
|
+
* is async, use the router's own view-transition option instead. Where the browser has
|
|
28
|
+
* no View Transitions, or the user prefers reduced motion, `update` just runs. Call it from an event
|
|
29
|
+
* handler, not during render.
|
|
30
|
+
*/
|
|
31
|
+
export function startPageTransition(update) {
|
|
32
|
+
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
33
|
+
if (reduce || typeof document.startViewTransition !== "function") {
|
|
34
|
+
update();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// A skipped transition (a second one starting, a hidden tab) rejects `ready`; the update still ran.
|
|
38
|
+
document.startViewTransition(() => flushSync(update)).ready.catch(() => { });
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
/** The colour of a bar's fill. */
|
|
3
|
+
export type BarTone = "accent" | "positive" | "warn" | "negative";
|
|
4
|
+
/** 6px, 8px or 12px tall. */
|
|
5
|
+
export type BarSize = "sm" | "md" | "lg";
|
|
6
|
+
export declare const BAR_FILL: Record<BarTone, string>;
|
|
7
|
+
export declare const BAR_TRACK: Record<BarSize, string>;
|
|
8
|
+
/** `part` as a percent of `whole`, held to 0–100; 0 when either is not positive. */
|
|
9
|
+
export declare function sharePercent(part: number, whole: number): number;
|
|
10
|
+
/** The share as a whole percent for a screen reader: never 0 or 100 unless it truly is. */
|
|
11
|
+
export declare function announcedPercent(part: number, whole: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* A thin horizontal bar with a filled share, for progress toward a total. `value` is held to
|
|
14
|
+
* 0–`max`. `label` names what it measures, for a screen reader, which also hears the percentage.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ProgressBar({ value, max, label, tone, size, className, }: {
|
|
17
|
+
value: number;
|
|
18
|
+
/** Defaults to 100. */
|
|
19
|
+
max?: number;
|
|
20
|
+
label: string;
|
|
21
|
+
/** Defaults to "accent". */
|
|
22
|
+
tone?: BarTone;
|
|
23
|
+
/** Defaults to "sm". */
|
|
24
|
+
size?: BarSize;
|
|
25
|
+
className?: string;
|
|
26
|
+
}): ReactElement;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
export const BAR_FILL = {
|
|
4
|
+
accent: "bg-violet-600",
|
|
5
|
+
positive: "bg-emerald-600",
|
|
6
|
+
warn: "bg-amber-600",
|
|
7
|
+
negative: "bg-red-600",
|
|
8
|
+
};
|
|
9
|
+
export const BAR_TRACK = {
|
|
10
|
+
sm: "h-1.5",
|
|
11
|
+
md: "h-2",
|
|
12
|
+
lg: "h-3",
|
|
13
|
+
};
|
|
14
|
+
/** `part` as a percent of `whole`, held to 0–100; 0 when either is not positive. */
|
|
15
|
+
export function sharePercent(part, whole) {
|
|
16
|
+
if (!(whole > 0) || !(part > 0))
|
|
17
|
+
return 0;
|
|
18
|
+
return Math.min(100, (part / whole) * 100);
|
|
19
|
+
}
|
|
20
|
+
/** The share as a whole percent for a screen reader: never 0 or 100 unless it truly is. */
|
|
21
|
+
export function announcedPercent(part, whole) {
|
|
22
|
+
const share = sharePercent(part, whole);
|
|
23
|
+
return share <= 0 || share >= 100 ? share : Math.min(99, Math.max(1, Math.round(share)));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A thin horizontal bar with a filled share, for progress toward a total. `value` is held to
|
|
27
|
+
* 0–`max`. `label` names what it measures, for a screen reader, which also hears the percentage.
|
|
28
|
+
*/
|
|
29
|
+
export function ProgressBar({ value, max = 100, label, tone = "accent", size = "sm", className, }) {
|
|
30
|
+
const share = sharePercent(value, max);
|
|
31
|
+
return (_jsx("div", { role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": announcedPercent(value, max), className: cx("overflow-hidden rounded-full bg-slate-100", BAR_TRACK[size], className), children: _jsx("div", { className: cx("h-full", BAR_FILL[tone]), style: { width: `${share}%` } }) }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { type SwitchItem, type SwitchProps } from "./switcher.js";
|
|
3
|
+
/** One option of a SegmentedControl. See `SwitchItem`. */
|
|
4
|
+
export type Segment = SwitchItem;
|
|
5
|
+
/**
|
|
6
|
+
* A joined pill group where one option is filled: a mode or a range chosen in place. `items` are
|
|
7
|
+
* `{ value, label }` buttons (`value` and `onChange` are the caller's state; the arrow keys, Home
|
|
8
|
+
* and End move the choice) or links (`href`, or `render` to draw the app's own router `<Link>`;
|
|
9
|
+
* `value` marks the current one, which gets `aria-current`). `label` names the group.
|
|
10
|
+
*/
|
|
11
|
+
export declare function SegmentedControl(props: SwitchProps): ReactElement;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
import { Switcher } from "./switcher.js";
|
|
4
|
+
// The focus ring sits inside the option, since the group's rounded edge would clip an outer one.
|
|
5
|
+
const BASE = "border-l border-slate-300 px-3 py-1.5 transition first:rounded-l-full first:border-l-0 last:rounded-r-full focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-violet-600";
|
|
6
|
+
const ON = "bg-violet-600 font-medium text-violet-50";
|
|
7
|
+
const OFF = "bg-white text-slate-600 hover:bg-violet-50 hover:text-violet-700";
|
|
8
|
+
/**
|
|
9
|
+
* A joined pill group where one option is filled: a mode or a range chosen in place. `items` are
|
|
10
|
+
* `{ value, label }` buttons (`value` and `onChange` are the caller's state; the arrow keys, Home
|
|
11
|
+
* and End move the choice) or links (`href`, or `render` to draw the app's own router `<Link>`;
|
|
12
|
+
* `value` marks the current one, which gets `aria-current`). `label` names the group.
|
|
13
|
+
*/
|
|
14
|
+
export function SegmentedControl(props) {
|
|
15
|
+
return (_jsx(Switcher, { ...props, parts: {
|
|
16
|
+
group: "radiogroup",
|
|
17
|
+
item: "radio",
|
|
18
|
+
vertical: true,
|
|
19
|
+
root: "inline-flex rounded-full border border-slate-300 text-sm",
|
|
20
|
+
option: (active) => cx(BASE, active ? ON : OFF),
|
|
21
|
+
} }));
|
|
22
|
+
}
|
package/dist/Select.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactElement } from "react";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { type ControlProps } from "./control.js";
|
|
3
|
+
/** A native `<select>` styled like `Input`, keeping the browser's own arrow. Takes `size` and `width` too. */
|
|
4
|
+
export declare function Select({ size, width, className, children, ...rest }: ControlProps & Omit<ComponentPropsWithoutRef<"select">, "size" | "width">): ReactElement;
|
package/dist/Select.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { controlClass } from "./control.js";
|
|
2
|
+
import { CONTROL_PAD, controlClass } from "./control.js";
|
|
3
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,
|
|
4
|
+
/** A native `<select>` styled like `Input`, keeping the browser's own arrow. Takes `size` and `width` too. */
|
|
5
|
+
export function Select({ size = "md", width = "full", className, children, ...rest }) {
|
|
6
|
+
return (_jsx("select", { ...rest, className: cx(controlClass(size, width), CONTROL_PAD[size].x, className), children: children }));
|
|
7
7
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { type BarSize, type BarTone } from "./ProgressBar.js";
|
|
3
|
+
/**
|
|
4
|
+
* A bar split between two parties, `a` on the left and `b` on the right, each sized by its share of
|
|
5
|
+
* `a + b`. `label` names the split for a screen reader, which hears both percentages; give `names`
|
|
6
|
+
* (left, right) so it hears whose is whose. Put the parties' names under the bar yourself.
|
|
7
|
+
*/
|
|
8
|
+
export declare function SplitBar({ a, b, label, names, toneA, toneB, size, className, }: {
|
|
9
|
+
a: number;
|
|
10
|
+
b: number;
|
|
11
|
+
label: string;
|
|
12
|
+
/** The left and right parties, spoken before their percentages. */
|
|
13
|
+
names?: readonly [string, string];
|
|
14
|
+
/** Defaults to "negative". */
|
|
15
|
+
toneA?: BarTone;
|
|
16
|
+
/** Defaults to "positive". */
|
|
17
|
+
toneB?: BarTone;
|
|
18
|
+
/** Defaults to "lg". */
|
|
19
|
+
size?: BarSize;
|
|
20
|
+
className?: string;
|
|
21
|
+
}): ReactElement;
|
package/dist/SplitBar.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
import { BAR_FILL, BAR_TRACK, announcedPercent, sharePercent, } from "./ProgressBar.js";
|
|
4
|
+
/**
|
|
5
|
+
* A bar split between two parties, `a` on the left and `b` on the right, each sized by its share of
|
|
6
|
+
* `a + b`. `label` names the split for a screen reader, which hears both percentages; give `names`
|
|
7
|
+
* (left, right) so it hears whose is whose. Put the parties' names under the bar yourself.
|
|
8
|
+
*/
|
|
9
|
+
export function SplitBar({ a, b, label, names, toneA = "negative", toneB = "positive", size = "lg", className, }) {
|
|
10
|
+
const shareA = sharePercent(a, a + b);
|
|
11
|
+
const shareB = a + b > 0 ? 100 - shareA : 0;
|
|
12
|
+
const percentA = announcedPercent(a, a + b);
|
|
13
|
+
const percentB = a + b > 0 ? 100 - percentA : 0;
|
|
14
|
+
return (_jsxs("div", { role: "img", "aria-label": `${label}: ${names ? `${names[0]} ${percentA}%, ${names[1]} ${percentB}%` : `${percentA}% / ${percentB}%`}`, className: cx("flex overflow-hidden rounded-full bg-slate-100", BAR_TRACK[size], className), children: [_jsx("div", { className: BAR_FILL[toneA], style: { width: `${shareA}%` } }), _jsx("div", { className: BAR_FILL[toneB], style: { width: `${shareB}%` } })] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
|
2
|
+
/** The colour of a tile's value (and of its accent bar): plain ink, green or red. */
|
|
3
|
+
export type StatTileTone = "neutral" | "positive" | "negative";
|
|
4
|
+
/**
|
|
5
|
+
* One headline figure: an uppercase caption over a large tabular-nums value, with an optional
|
|
6
|
+
* hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
|
|
7
|
+
* tone. Pass the value already formatted.
|
|
8
|
+
*/
|
|
9
|
+
export declare function StatTile({ label, value, hint, tone, accent, className, ...rest }: {
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
value: ReactNode;
|
|
12
|
+
hint?: ReactNode;
|
|
13
|
+
/** Defaults to "neutral". */
|
|
14
|
+
tone?: StatTileTone;
|
|
15
|
+
accent?: boolean;
|
|
16
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, "children">): ReactElement;
|
package/dist/StatTile.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
import { BAR_FILL } from "./ProgressBar.js";
|
|
4
|
+
const VALUE = {
|
|
5
|
+
neutral: "text-slate-900",
|
|
6
|
+
positive: "text-emerald-700",
|
|
7
|
+
negative: "text-red-700",
|
|
8
|
+
};
|
|
9
|
+
const BAR = {
|
|
10
|
+
neutral: BAR_FILL.accent,
|
|
11
|
+
positive: BAR_FILL.positive,
|
|
12
|
+
negative: BAR_FILL.negative,
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* One headline figure: an uppercase caption over a large tabular-nums value, with an optional
|
|
16
|
+
* hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
|
|
17
|
+
* tone. Pass the value already formatted.
|
|
18
|
+
*/
|
|
19
|
+
export function StatTile({ label, value, hint, tone = "neutral", accent = false, className, ...rest }) {
|
|
20
|
+
return (_jsxs("div", { className: cx("relative overflow-hidden rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", className), ...rest, children: [accent && (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 top-0 h-1", BAR[tone]) })), _jsx("p", { className: "text-xs font-semibold uppercase tracking-wider text-slate-500", children: label }), _jsx("p", { className: cx("mt-2 text-3xl font-bold tabular-nums", VALUE[tone]), children: value }), hint != null && _jsx("p", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
|
|
21
|
+
}
|