@hackcove/ui-react 0.8.3 → 0.9.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/README.md +18 -9
- package/dist/AppShell.d.ts +15 -3
- package/dist/AppShell.js +29 -11
- package/dist/AppShellMenu.d.ts +13 -0
- package/dist/AppShellMenu.js +42 -0
- package/dist/Avatar.d.ts +8 -4
- package/dist/Avatar.js +11 -3
- package/dist/AvatarStack.js +1 -0
- package/dist/Callout.d.ts +8 -2
- package/dist/Callout.js +3 -3
- package/dist/Card.d.ts +2 -0
- package/dist/Card.js +14 -8
- package/dist/CardParts.d.ts +6 -0
- package/dist/CardParts.js +6 -0
- package/dist/Cluster.d.ts +9 -4
- package/dist/Cluster.js +12 -5
- package/dist/Dialog.d.ts +4 -2
- package/dist/Dialog.js +9 -3
- package/dist/EmptyState.d.ts +7 -1
- package/dist/EmptyState.js +3 -2
- package/dist/Field.d.ts +8 -1
- package/dist/Field.js +4 -2
- package/dist/Image.d.ts +46 -0
- package/dist/Image.js +42 -0
- package/dist/KeyValueList.d.ts +4 -2
- package/dist/KeyValueList.js +3 -3
- package/dist/ListRow.d.ts +3 -1
- package/dist/ListRow.js +6 -6
- package/dist/MediaTile.d.ts +37 -0
- package/dist/MediaTile.js +19 -0
- package/dist/PageLayouts.d.ts +10 -3
- package/dist/PageLayouts.js +15 -4
- package/dist/Pill.d.ts +24 -6
- package/dist/Pill.js +72 -16
- package/dist/ScrollStrip.d.ts +21 -0
- package/dist/ScrollStrip.js +12 -0
- package/dist/Stack.d.ts +15 -1
- package/dist/Stack.js +12 -2
- package/dist/StatTile.d.ts +2 -2
- package/dist/StatTile.js +2 -0
- package/dist/Table.d.ts +29 -12
- package/dist/Table.js +25 -11
- package/dist/TableEditCell.d.ts +19 -0
- package/dist/TableEditCell.js +58 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/size.d.ts +1 -1
- package/dist/typography.d.ts +34 -10
- package/dist/typography.js +43 -13
- package/package.json +1 -1
package/dist/Cluster.js
CHANGED
|
@@ -28,11 +28,18 @@ const JUSTIFY = {
|
|
|
28
28
|
export function Cluster({ as: Tag = "div", gap = "xs", align = "center", justify = "start", wrap = false, children, ...rest }) {
|
|
29
29
|
return (_jsx(Tag, { ...rest, className: cx("flex", STACK_GAP[gap], ALIGN[align], JUSTIFY[justify], wrap && "flex-wrap"), children: children }));
|
|
30
30
|
}
|
|
31
|
+
/** The narrowest a `ClusterGrow` gets before it wraps under, the same two steps as `CardGrid`'s `minWidth`. */
|
|
32
|
+
const GROW_MIN_WIDTH = {
|
|
33
|
+
sm: "min-w-0 flex-[1_1_12rem]",
|
|
34
|
+
md: "min-w-0 flex-[1_1_17rem]",
|
|
35
|
+
};
|
|
31
36
|
/**
|
|
32
|
-
* The one child of a `Cluster` that grows to the room left in the
|
|
33
|
-
* (`min-w-0`), so the items beside it stay on the line and
|
|
34
|
-
* pushing them out.
|
|
37
|
+
* The one child of a `Cluster` (or of a `Stack` with `rowFrom`) that grows to the room left in the
|
|
38
|
+
* row. By default it can shrink to nothing (`min-w-0`), so the items beside it stay on the line and
|
|
39
|
+
* a long value truncates instead of pushing them out. With `minWidth` it instead keeps that width
|
|
40
|
+
* (`sm` 12rem, `md` 17rem) and, in a `Cluster` with `wrap`, drops onto the next line when the room
|
|
41
|
+
* left is narrower, rather than being squashed. Empty, it is a spacer.
|
|
35
42
|
*/
|
|
36
|
-
export function ClusterGrow({ children, ...rest }) {
|
|
37
|
-
return (_jsx("div", { ...rest, className: "min-w-0 flex-1", children: children }));
|
|
43
|
+
export function ClusterGrow({ minWidth, children, ...rest }) {
|
|
44
|
+
return (_jsx("div", { ...rest, className: minWidth ? GROW_MIN_WIDTH[minWidth] : "min-w-0 flex-1", children: children }));
|
|
38
45
|
}
|
package/dist/Dialog.d.ts
CHANGED
|
@@ -11,10 +11,12 @@ export type DialogProps = {
|
|
|
11
11
|
footer?: ReactNode;
|
|
12
12
|
/**
|
|
13
13
|
* The panel's width: `sm` (320px) is a list, `md` (440px) holds a form, `lg` (640px) a wider
|
|
14
|
-
* form or a table.
|
|
14
|
+
* form or a table. `full` fills the whole screen, for media or a player: the body takes the
|
|
15
|
+
* height between the header and the footer and scrolls if it is taller. It has no backdrop to
|
|
16
|
+
* click, so give it a close action in the `footer` (a `<form method="dialog">` button).
|
|
15
17
|
* @default md
|
|
16
18
|
*/
|
|
17
|
-
size?: Size;
|
|
19
|
+
size?: Size | "full";
|
|
18
20
|
/** Called when the dialog closes: Esc, a click on the backdrop, or a `<form method="dialog">` submit. */
|
|
19
21
|
onClose: () => void;
|
|
20
22
|
} & Passthrough;
|
package/dist/Dialog.js
CHANGED
|
@@ -3,8 +3,13 @@ import { useId, useRef } from "react";
|
|
|
3
3
|
import { cx } from "./cx.js";
|
|
4
4
|
import { useModal } from "./modal.js";
|
|
5
5
|
import { HeaderText, SectionHeading } from "./typography.js";
|
|
6
|
-
|
|
7
|
-
const
|
|
6
|
+
/** Shared by every size: the surface, its colours and the fade (and the scrim's) in and out. */
|
|
7
|
+
const BASE = "bg-white p-0 text-slate-900 opacity-0 transition-[opacity,translate,overlay,display] transition-discrete duration-200 open:opacity-100 starting:open:opacity-0 backdrop:bg-slate-900/35 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-200 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none";
|
|
8
|
+
/** A floating panel under the top edge: it also slides down 8px as it fades in. */
|
|
9
|
+
const WINDOW = "mx-auto mt-28 max-w-[calc(100vw-2rem)] overflow-visible rounded-2xl border border-slate-200 shadow-2xl -translate-y-2 open:translate-y-0 starting:open:-translate-y-2";
|
|
10
|
+
const WIDTH = { sm: "w-80", md: "w-110", lg: "w-160" };
|
|
11
|
+
/** The whole viewport, edge to edge: no margin, radius, border or shadow, and a column so the body can fill it. */
|
|
12
|
+
const FULL = "fixed inset-0 m-0 size-full max-h-none max-w-none flex-col overflow-hidden rounded-none border-0 open:flex";
|
|
8
13
|
/**
|
|
9
14
|
* A modal on the browser's own `<dialog>`: it is opened with `showModal()` on mount, so render it
|
|
10
15
|
* only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click
|
|
@@ -14,5 +19,6 @@ export function Dialog({ title, description, children, footer, size = "md", onCl
|
|
|
14
19
|
const dialog = useRef(null);
|
|
15
20
|
const titleId = useId();
|
|
16
21
|
const closed = useModal(dialog, onClose);
|
|
17
|
-
|
|
22
|
+
const full = size === "full";
|
|
23
|
+
return (_jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(BASE, size === "full" ? FULL : `${WINDOW} ${WIDTH[size]}`), children: _jsxs("div", { className: cx("p-5.5", full && "flex min-h-0 flex-1 flex-col"), children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && (_jsx("div", { className: cx("mt-4", full && "min-h-0 flex-1 overflow-auto"), children: children })), footer && (_jsx("div", { className: cx("mt-5 flex justify-end gap-2", full && "shrink-0"), children: footer }))] }) }));
|
|
18
24
|
}
|
package/dist/EmptyState.d.ts
CHANGED
|
@@ -7,11 +7,17 @@ import type { Passthrough } from "./props.js";
|
|
|
7
7
|
* line inside a card, use `Text as="p" color="muted"`. `level` is the title's heading level (default 2): one below the
|
|
8
8
|
* page's own heading, so the empty state inside a `SectionHeading` section is a 3.
|
|
9
9
|
*/
|
|
10
|
-
export declare function EmptyState({ title, level, children, actions, ...rest }: {
|
|
10
|
+
export declare function EmptyState({ title, level, padding, children, actions, ...rest }: {
|
|
11
11
|
/** The heading, centred: what is missing ("No projects yet"). It names the section. */
|
|
12
12
|
title: ReactNode;
|
|
13
13
|
/** The title's heading level, 2 to 6. It does not change how the title looks. */
|
|
14
14
|
level?: 2 | 3 | 4 | 5 | 6 | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* `inset` pads all four sides, for an empty state in a `Card flush`, whose body has no padding
|
|
17
|
+
* of its own. `default` pads above and below only.
|
|
18
|
+
* @default default
|
|
19
|
+
*/
|
|
20
|
+
padding?: "default" | "inset" | undefined;
|
|
15
21
|
children?: ReactNode;
|
|
16
22
|
/** Buttons or links in a centred row at the bottom. */
|
|
17
23
|
actions?: ReactNode;
|
package/dist/EmptyState.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from "react";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
3
4
|
import { SectionHeading } from "./typography.js";
|
|
4
5
|
/**
|
|
5
6
|
* What a list or a page shows when there is nothing in it yet: a centred `title`, the `children`
|
|
@@ -8,7 +9,7 @@ import { SectionHeading } from "./typography.js";
|
|
|
8
9
|
* line inside a card, use `Text as="p" color="muted"`. `level` is the title's heading level (default 2): one below the
|
|
9
10
|
* page's own heading, so the empty state inside a `SectionHeading` section is a 3.
|
|
10
11
|
*/
|
|
11
|
-
export function EmptyState({ title, level = 2, children, actions, ...rest }) {
|
|
12
|
+
export function EmptyState({ title, level = 2, padding = "default", children, actions, ...rest }) {
|
|
12
13
|
const titleId = useId();
|
|
13
|
-
return (_jsxs("section", { ...rest, "aria-labelledby": titleId, className: "mx-auto flex w-full max-w-xl flex-col items-center gap-4
|
|
14
|
+
return (_jsxs("section", { ...rest, "aria-labelledby": titleId, className: cx("mx-auto flex w-full max-w-xl flex-col items-center gap-4 text-center", padding === "inset" ? "p-8" : "py-8"), children: [_jsx(SectionHeading, { as: `h${level}`, id: titleId, children: title }), children != null && (_jsx("div", { className: "flex w-full flex-col items-center gap-5", children: children })), actions != null && _jsx("div", { className: "flex flex-wrap justify-center gap-2", children: actions })] }));
|
|
14
15
|
}
|
package/dist/Field.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare function fieldNote(hint: ReactNode, error: ReactNode): ReactEleme
|
|
|
6
6
|
* A label above a control, with a hint or an error line under it. An error replaces the hint.
|
|
7
7
|
* Inside a `FormGrid`, `span="full"` runs the field across both columns.
|
|
8
8
|
*/
|
|
9
|
-
export declare function Field({ label, htmlFor, hint, error, span, children, ...rest }: {
|
|
9
|
+
export declare function Field({ label, htmlFor, hint, error, span, layout, children, ...rest }: {
|
|
10
10
|
/** The text above the control. */
|
|
11
11
|
label: ReactNode;
|
|
12
12
|
/** The id of the control inside; the label's htmlFor. */
|
|
@@ -17,5 +17,12 @@ export declare function Field({ label, htmlFor, hint, error, span, children, ...
|
|
|
17
17
|
error?: ReactNode;
|
|
18
18
|
/** `full` spans every column of a `FormGrid`; unset takes one. It does nothing outside a `FormGrid`. */
|
|
19
19
|
span?: "full" | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* `row` puts the label beside the control, for a compact setting or a drawer row; the hint or
|
|
22
|
+
* error sits under the control. Every row label takes the same fixed column, so stacked rows line
|
|
23
|
+
* their controls up; a long label wraps inside it. `column` puts the label above it.
|
|
24
|
+
* @default column
|
|
25
|
+
*/
|
|
26
|
+
layout?: "column" | "row" | undefined;
|
|
20
27
|
children: ReactNode;
|
|
21
28
|
} & Passthrough): ReactElement;
|
package/dist/Field.js
CHANGED
|
@@ -14,6 +14,8 @@ export function fieldNote(hint, error) {
|
|
|
14
14
|
* A label above a control, with a hint or an error line under it. An error replaces the hint.
|
|
15
15
|
* Inside a `FormGrid`, `span="full"` runs the field across both columns.
|
|
16
16
|
*/
|
|
17
|
-
export function Field({ label, htmlFor, hint, error, span, children, ...rest }) {
|
|
18
|
-
|
|
17
|
+
export function Field({ label, htmlFor, hint, error, span, layout = "column", children, ...rest }) {
|
|
18
|
+
const row = layout === "row";
|
|
19
|
+
const note = fieldNote(hint, error);
|
|
20
|
+
return (_jsxs("div", { ...rest, className: cx(row ? "grid grid-cols-[8rem_1fr] items-center gap-x-3 gap-y-1.5" : "flex flex-col gap-1.5", span === "full" && "col-span-full"), children: [_jsx("label", { htmlFor: htmlFor, className: cx("text-sm font-medium text-slate-700", row && "min-w-0 wrap-anywhere"), children: label }), children, note && row ? _jsx("div", { className: "col-start-2", children: note }) : note] }));
|
|
19
21
|
}
|
package/dist/Image.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { Passthrough } from "./props.js";
|
|
3
|
+
import type { Size } from "./size.js";
|
|
4
|
+
/** The shapes an image is cropped or letterboxed to. */
|
|
5
|
+
export type ImageAspect = "square" | "4:3" | "3:2" | "16:9" | "3:4" | "9:16";
|
|
6
|
+
/** How wide an image is: `full` fills its parent; the others are 96, 160 or 256px, capped at the parent's width. */
|
|
7
|
+
export type ImageWidth = Size | "full";
|
|
8
|
+
/**
|
|
9
|
+
* `full` is the parent's width; a size is a real width that a narrower parent still caps. It is
|
|
10
|
+
* a width and not a `max-width`, so it holds inside a shrink-to-fit parent, where a placeholder
|
|
11
|
+
* has no intrinsic size of its own to give it one.
|
|
12
|
+
*/
|
|
13
|
+
export declare const IMAGE_WIDTH: Record<ImageWidth, string>;
|
|
14
|
+
export type ImageProps = {
|
|
15
|
+
/**
|
|
16
|
+
* The picture, a local path or a data URL. It is never loaded for you: this is the app's own
|
|
17
|
+
* image. Left out, or if it fails to load, the picture is replaced by a placeholder.
|
|
18
|
+
*/
|
|
19
|
+
src?: string | undefined;
|
|
20
|
+
/** What the picture shows. An empty string marks it as decoration, hidden from a screen reader. */
|
|
21
|
+
alt: string;
|
|
22
|
+
/**
|
|
23
|
+
* The shape it is cropped to: `square`, `4:3`, `3:2`, `16:9`, or the portrait `3:4` and `9:16`.
|
|
24
|
+
* @default 4:3
|
|
25
|
+
*/
|
|
26
|
+
aspect?: ImageAspect | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* `cover` fills the shape and crops what overflows; `contain` shows the whole picture, with the
|
|
29
|
+
* rest of the shape left empty (a QR code, a logo).
|
|
30
|
+
* @default cover
|
|
31
|
+
*/
|
|
32
|
+
fit?: "cover" | "contain" | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* `sm` 96px, `md` 160px and `lg` 256px set the width, capped at the parent's; `full` fills the parent.
|
|
35
|
+
* @default full
|
|
36
|
+
*/
|
|
37
|
+
width?: ImageWidth | undefined;
|
|
38
|
+
} & Passthrough;
|
|
39
|
+
/**
|
|
40
|
+
* A picture in a fixed shape: `aspect` reserves the space before it loads, so the page does not
|
|
41
|
+
* jump, and `fit` crops it to the shape or shows all of it. The corners are rounded and the frame
|
|
42
|
+
* has a hairline. If the picture is missing or fails to load, a same-sized placeholder with a
|
|
43
|
+
* picture mark stands in, still named by `alt`. A user's photo is an `Avatar`; a clickable
|
|
44
|
+
* thumbnail with a label is a `MediaTile`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function Image({ src, alt, aspect, fit, width, ...rest }: ImageProps): ReactElement;
|
package/dist/Image.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
4
|
+
import { Icon } from "./Icon.js";
|
|
5
|
+
const ASPECT = {
|
|
6
|
+
square: "aspect-square",
|
|
7
|
+
"4:3": "aspect-[4/3]",
|
|
8
|
+
"3:2": "aspect-[3/2]",
|
|
9
|
+
"16:9": "aspect-video",
|
|
10
|
+
"3:4": "aspect-[3/4]",
|
|
11
|
+
"9:16": "aspect-[9/16]",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* `full` is the parent's width; a size is a real width that a narrower parent still caps. It is
|
|
15
|
+
* a width and not a `max-width`, so it holds inside a shrink-to-fit parent, where a placeholder
|
|
16
|
+
* has no intrinsic size of its own to give it one.
|
|
17
|
+
*/
|
|
18
|
+
export const IMAGE_WIDTH = {
|
|
19
|
+
sm: "w-24 max-w-full",
|
|
20
|
+
md: "w-40 max-w-full",
|
|
21
|
+
lg: "w-64 max-w-full",
|
|
22
|
+
full: "w-full",
|
|
23
|
+
};
|
|
24
|
+
/** Every image sits on the inset surface with a hairline, so a letterbox or a pale picture has an edge. */
|
|
25
|
+
const FRAME = "block rounded-lg border border-slate-200 bg-slate-100";
|
|
26
|
+
const PICTURE = "M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.22a.75.75 0 0 0-1.06 0l-1.91 1.91.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.09a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z";
|
|
27
|
+
/**
|
|
28
|
+
* A picture in a fixed shape: `aspect` reserves the space before it loads, so the page does not
|
|
29
|
+
* jump, and `fit` crops it to the shape or shows all of it. The corners are rounded and the frame
|
|
30
|
+
* has a hairline. If the picture is missing or fails to load, a same-sized placeholder with a
|
|
31
|
+
* picture mark stands in, still named by `alt`. A user's photo is an `Avatar`; a clickable
|
|
32
|
+
* thumbnail with a label is a `MediaTile`.
|
|
33
|
+
*/
|
|
34
|
+
export function Image({ src, alt, aspect = "4:3", fit = "cover", width = "full", ...rest }) {
|
|
35
|
+
// The `src` that failed to load: a new `src` gets its own try, so a broken one never sticks.
|
|
36
|
+
const [failedSrc, setFailedSrc] = useState();
|
|
37
|
+
const box = cx(FRAME, ASPECT[aspect], IMAGE_WIDTH[width]);
|
|
38
|
+
if (src && src !== failedSrc) {
|
|
39
|
+
return (_jsx("img", { ...rest, src: src, alt: alt, className: cx(box, fit === "cover" ? "object-cover" : "object-contain"), onError: () => setFailedSrc(src) }));
|
|
40
|
+
}
|
|
41
|
+
return (_jsx("span", { ...rest, ...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true }), className: cx(box, "grid place-items-center text-slate-400"), children: _jsx(Icon, { size: "lg", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: PICTURE }) }) }) }));
|
|
42
|
+
}
|
package/dist/KeyValueList.d.ts
CHANGED
|
@@ -14,10 +14,12 @@ export declare function KeyValueList({ align, children, ...rest }: {
|
|
|
14
14
|
align?: KeyValueAlign;
|
|
15
15
|
children: ReactNode;
|
|
16
16
|
} & Unstyled<HTMLAttributes<HTMLDListElement>>): ReactElement;
|
|
17
|
-
/** One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an `align="start"` list). */
|
|
18
|
-
export declare function KeyValue({ label, children, ...rest }: {
|
|
17
|
+
/** One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an `align="start"` list). `total` makes it the sum row: a rule above, strong text. */
|
|
18
|
+
export declare function KeyValue({ label, total, children, ...rest }: {
|
|
19
19
|
/** The term, on the left. */
|
|
20
20
|
label: ReactNode;
|
|
21
|
+
/** The sum the rows above add up to: a firm rule above it and the label and value in strong ink. */
|
|
22
|
+
total?: boolean | undefined;
|
|
21
23
|
children: ReactNode;
|
|
22
24
|
} & Unstyled<HTMLAttributes<HTMLDivElement>>): ReactElement;
|
|
23
25
|
export {};
|
package/dist/KeyValueList.js
CHANGED
|
@@ -12,8 +12,8 @@ export function KeyValueList({ align = "end", children, ...rest }) {
|
|
|
12
12
|
? "grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5"
|
|
13
13
|
: "flex flex-col gap-1.5", "text-sm"), children: _jsx(AlignContext.Provider, { value: align, children: children }) }));
|
|
14
14
|
}
|
|
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). */
|
|
16
|
-
export function KeyValue({ label, children, ...rest }) {
|
|
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). `total` makes it the sum row: a rule above, strong text. */
|
|
16
|
+
export function KeyValue({ label, total = false, children, ...rest }) {
|
|
17
17
|
const start = useContext(AlignContext) === "start";
|
|
18
|
-
return (_jsxs("div", { ...rest, className: start ? "col-span-2 grid grid-cols-subgrid" : "flex justify-between gap-3", children: [_jsx("dt", { className: "text-slate-600", children: label }), _jsx("dd", { className: start
|
|
18
|
+
return (_jsxs("div", { ...rest, className: cx(start ? "col-span-2 grid grid-cols-subgrid" : "flex justify-between gap-3", total && "border-t border-slate-300 pt-1.5 font-semibold"), children: [_jsx("dt", { className: total ? "text-slate-900" : "text-slate-600", children: label }), _jsx("dd", { className: cx(!start && "text-right", "tabular-nums"), children: children })] }));
|
|
19
19
|
}
|
package/dist/ListRow.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ type Shared = {
|
|
|
12
12
|
divided?: boolean | undefined;
|
|
13
13
|
/** Content before the label: an Avatar, a thumbnail, an icon. */
|
|
14
14
|
leading?: ReactNode | undefined;
|
|
15
|
+
/** A second, muted line under the label (a date, a note), like `TableCell`'s `secondary`. */
|
|
16
|
+
secondary?: ReactNode | undefined;
|
|
15
17
|
/** Content at the far end of the row: a value, a count, a Pill. */
|
|
16
18
|
trailing?: ReactNode | undefined;
|
|
17
19
|
/**
|
|
@@ -97,7 +99,7 @@ export type ListRowProps = ListRowButtonProps | ListRowLinkProps | ListRowStatic
|
|
|
97
99
|
* collapsible section: `aria-expanded` and a chevron. Rows sit in a `Card flush`; `divided` draws
|
|
98
100
|
* the line between them, on each row after the first.
|
|
99
101
|
*
|
|
100
|
-
* `leading` is content before the label (`leadingWidth="thumbnail"` gives it a fixed picture width),
|
|
102
|
+
* `secondary` is a second, muted line under the label. `leading` is content before the label (`leadingWidth="thumbnail"` gives it a fixed picture width),
|
|
101
103
|
* `selected` marks the row in view, `focused` the one the keyboard cursor is on and `active` fills
|
|
102
104
|
* the row a list follows (`onPointerEnter`, `onPointerLeave` and `onFocus` reach
|
|
103
105
|
* every form; on a linked row the pointer handlers go on the row itself). The label is a
|
package/dist/ListRow.js
CHANGED
|
@@ -42,11 +42,11 @@ function look(props) {
|
|
|
42
42
|
return cx(rowBase(props), PAD[size], divided && "border-t border-slate-200/70", fillOf(props), focused && FOCUSED);
|
|
43
43
|
}
|
|
44
44
|
function StaticRow(props) {
|
|
45
|
-
const { leading: _l, leadingWidth: _lw, trailing: _t, focused: _f, active = false, selected, size: _s, divided: _d, static: _st, actions, revealOnHover = false, expanded: _e, href: _h, render: _r, start, label, end, ...rest } = props;
|
|
45
|
+
const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active = false, selected, size: _s, divided: _d, static: _st, actions, revealOnHover = false, expanded: _e, href: _h, render: _r, start, label, end, ...rest } = props;
|
|
46
46
|
return (_jsxs("div", { ...rest, ...(selected ? { "aria-current": "true" } : {}), className: look(props), children: [start, label, end, actions != null && (_jsx("span", { className: cx("flex shrink-0 items-center gap-2", revealOnHover && !active && REVEAL), children: actions }))] }));
|
|
47
47
|
}
|
|
48
48
|
function LinkRow(props) {
|
|
49
|
-
const { leading: _l, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded: _e, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, start, label, end, onPointerEnter, onPointerLeave, ...rest } = props;
|
|
49
|
+
const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded: _e, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, start, label, end, onPointerEnter, onPointerLeave, ...rest } = props;
|
|
50
50
|
const rowId = useId();
|
|
51
51
|
return (_jsxs("div", { id: rowId, onPointerEnter: onPointerEnter, onPointerLeave: onPointerLeave, className: look(props), children: [renderLink({
|
|
52
52
|
...(rest["aria-label"] === undefined ? { "aria-labelledby": rowId } : {}),
|
|
@@ -57,7 +57,7 @@ function LinkRow(props) {
|
|
|
57
57
|
}), start, label, end] }));
|
|
58
58
|
}
|
|
59
59
|
function ButtonRow(props) {
|
|
60
|
-
const { leading: _l, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, href: _h, render: _r, start, label, end, ...rest } = props;
|
|
60
|
+
const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, href: _h, render: _r, start, label, end, ...rest } = props;
|
|
61
61
|
return (_jsxs("button", { type: "button", ...rest, ...(selected ? { "aria-current": "true" } : {}), ...(expanded === undefined ? {} : { "aria-expanded": expanded }), className: look(props), children: [expanded !== undefined && _jsx(Chevron, { open: expanded }), start, label, end] }));
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
@@ -68,7 +68,7 @@ function ButtonRow(props) {
|
|
|
68
68
|
* collapsible section: `aria-expanded` and a chevron. Rows sit in a `Card flush`; `divided` draws
|
|
69
69
|
* the line between them, on each row after the first.
|
|
70
70
|
*
|
|
71
|
-
* `leading` is content before the label (`leadingWidth="thumbnail"` gives it a fixed picture width),
|
|
71
|
+
* `secondary` is a second, muted line under the label. `leading` is content before the label (`leadingWidth="thumbnail"` gives it a fixed picture width),
|
|
72
72
|
* `selected` marks the row in view, `focused` the one the keyboard cursor is on and `active` fills
|
|
73
73
|
* the row a list follows (`onPointerEnter`, `onPointerLeave` and `onFocus` reach
|
|
74
74
|
* every form; on a linked row the pointer handlers go on the row itself). The label is a
|
|
@@ -77,10 +77,10 @@ function ButtonRow(props) {
|
|
|
77
77
|
* link): `actions` follow `trailing`, and `revealOnHover` shows them only on hover or focus.
|
|
78
78
|
*/
|
|
79
79
|
export function ListRow(props) {
|
|
80
|
-
const { leading, leadingWidth, trailing, children } = props;
|
|
80
|
+
const { leading, leadingWidth, trailing, secondary, children } = props;
|
|
81
81
|
const pieces = {
|
|
82
82
|
start: leading != null && (_jsx("span", { className: cx("shrink-0", leadingWidth === "thumbnail" && THUMBNAIL), children: leading })),
|
|
83
|
-
label:
|
|
83
|
+
label: (_jsxs("div", { className: "min-w-0 flex-1", children: [children, secondary != null && secondary !== false && (_jsx("div", { className: "text-xs font-normal text-slate-500", children: secondary }))] })),
|
|
84
84
|
end: trailing != null && (_jsx("span", { className: "shrink-0 tabular-nums text-slate-500", children: trailing })),
|
|
85
85
|
};
|
|
86
86
|
if (props.static)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { type ImageAspect, type ImageWidth } from "./Image.js";
|
|
3
|
+
import type { Passthrough } from "./props.js";
|
|
4
|
+
export type MediaTileProps = {
|
|
5
|
+
/** The thumbnail, a local path or a data URL. Left out, or if it fails to load, a placeholder shows. */
|
|
6
|
+
src?: string | undefined;
|
|
7
|
+
/** What the thumbnail shows; it is the tile's name, with `label`. */
|
|
8
|
+
alt: string;
|
|
9
|
+
/** A short line on the bottom edge of the picture, on the media scrim: who posted it, or "Earlier". */
|
|
10
|
+
label?: ReactNode;
|
|
11
|
+
/** Muted text under the picture, e.g. when. */
|
|
12
|
+
caption?: ReactNode;
|
|
13
|
+
/**
|
|
14
|
+
* The shape of the thumbnail, as `Image`'s `aspect`.
|
|
15
|
+
* @default 3:4
|
|
16
|
+
*/
|
|
17
|
+
aspect?: ImageAspect | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* `sm` 96px, `md` 160px or `lg` 256px wide; `full` fills the parent (a tile in a grid).
|
|
20
|
+
* @default md
|
|
21
|
+
*/
|
|
22
|
+
width?: ImageWidth | undefined;
|
|
23
|
+
/** Draws a play mark on the thumbnail: the tile opens a video, not a photo. Say so in `alt` too. */
|
|
24
|
+
video?: boolean | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Makes the whole tile one button; open a `Lightbox` from it. Without it the tile is a plain,
|
|
27
|
+
* non-interactive block.
|
|
28
|
+
*/
|
|
29
|
+
onClick?: (() => void) | undefined;
|
|
30
|
+
} & Passthrough;
|
|
31
|
+
/**
|
|
32
|
+
* A thumbnail tile: a picture in a fixed shape with an optional label on its bottom edge and a
|
|
33
|
+
* caption under it. With `onClick` the whole tile is one button, which is how a grid or a
|
|
34
|
+
* `ScrollStrip` of media opens each item in a `Lightbox`. The label sits on the same near-black
|
|
35
|
+
* media scrim as the Lightbox, in every theme. The thumbnail is cropped to fill its shape.
|
|
36
|
+
*/
|
|
37
|
+
export declare function MediaTile({ src, alt, label, caption, aspect, width, video, onClick, ...rest }: MediaTileProps): ReactElement;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx, focusRing } from "./cx.js";
|
|
3
|
+
import { Icon } from "./Icon.js";
|
|
4
|
+
import { IMAGE_WIDTH, Image } from "./Image.js";
|
|
5
|
+
const PLAY = "M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.34-5.89a1.5 1.5 0 0 0 0-2.54L6.3 2.84Z";
|
|
6
|
+
/**
|
|
7
|
+
* A thumbnail tile: a picture in a fixed shape with an optional label on its bottom edge and a
|
|
8
|
+
* caption under it. With `onClick` the whole tile is one button, which is how a grid or a
|
|
9
|
+
* `ScrollStrip` of media opens each item in a `Lightbox`. The label sits on the same near-black
|
|
10
|
+
* media scrim as the Lightbox, in every theme. The thumbnail is cropped to fill its shape.
|
|
11
|
+
*/
|
|
12
|
+
export function MediaTile({ src, alt, label, caption, aspect = "3:4", width = "md", video = false, onClick, ...rest }) {
|
|
13
|
+
const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative block overflow-hidden rounded-lg", children: [_jsx(Image, { src: src, alt: alt, aspect: aspect }), video && (_jsx("span", { "aria-hidden": "true", className: "absolute inset-0 grid place-items-center", children: _jsx("span", { className: "grid size-10 place-items-center rounded-full bg-black/90 text-white", children: _jsx(Icon, { size: "lg", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: PLAY }) }) }) }) })), label && (_jsx("span", { className: "absolute inset-x-0 bottom-0 truncate bg-black/90 px-2 py-1 text-xs font-medium text-white", children: label }))] }), caption && _jsx("span", { className: "mt-1 block truncate text-xs text-slate-600", children: caption })] }));
|
|
14
|
+
const box = cx("block min-w-0 text-left", IMAGE_WIDTH[width]);
|
|
15
|
+
if (!onClick) {
|
|
16
|
+
return (_jsx("div", { ...rest, className: box, children: body }));
|
|
17
|
+
}
|
|
18
|
+
return (_jsx("button", { ...rest, type: "button", onClick: onClick, className: cx(box, "group rounded-lg", focusRing), children: body }));
|
|
19
|
+
}
|
package/dist/PageLayouts.d.ts
CHANGED
|
@@ -51,6 +51,11 @@ declare const COLUMNS: {
|
|
|
51
51
|
readonly 3: "lg:grid-cols-3";
|
|
52
52
|
readonly 4: "lg:grid-cols-4";
|
|
53
53
|
};
|
|
54
|
+
/** Two unequal columns, one on a phone, the ratio from `md`; `minmax(0,…)` lets a long value shrink instead of widening its column. */
|
|
55
|
+
declare const RATIOS: {
|
|
56
|
+
readonly "2:1": "md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]";
|
|
57
|
+
readonly "1:2": "md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]";
|
|
58
|
+
};
|
|
54
59
|
/** `columns="auto"` fits as many cards per row as the width allows, none narrower than this. */
|
|
55
60
|
declare const AUTO_MIN_WIDTH: {
|
|
56
61
|
readonly sm: "grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]";
|
|
@@ -58,10 +63,11 @@ declare const AUTO_MIN_WIDTH: {
|
|
|
58
63
|
};
|
|
59
64
|
type CardGridProps = ({
|
|
60
65
|
/**
|
|
61
|
-
* Cards per row from `lg` (one on a phone, two from `sm`),
|
|
66
|
+
* Cards per row from `lg` (one on a phone, two from `sm`), `auto` for as many as fit, or a ratio:
|
|
67
|
+
* `2:1` or `1:2` is two columns, the first or the second twice as wide, from `md` (one on a phone).
|
|
62
68
|
* @default 3
|
|
63
69
|
*/
|
|
64
|
-
columns?: keyof typeof COLUMNS | undefined;
|
|
70
|
+
columns?: keyof typeof COLUMNS | keyof typeof RATIOS | undefined;
|
|
65
71
|
/** With `columns="auto"`, the narrowest a card gets: `md` 17rem or `sm` 12rem. */
|
|
66
72
|
minWidth?: undefined;
|
|
67
73
|
} | {
|
|
@@ -84,7 +90,8 @@ type CardGridProps = ({
|
|
|
84
90
|
} & Passthrough;
|
|
85
91
|
/**
|
|
86
92
|
* A responsive grid of tiles: one column on a phone, two from `sm`, `columns` from `lg`.
|
|
87
|
-
* `columns="
|
|
93
|
+
* `columns="2:1"` or `"1:2"` is instead two unequal columns from `md`, one on a phone: a main card beside a
|
|
94
|
+
* narrow one (a record's detail and its side panel). `columns="auto"` instead fits as many per row as the width allows, each at least `minWidth` wide:
|
|
88
95
|
* `md` (17rem, the default) or `sm` (12rem). `align="start"` keeps each card its own height
|
|
89
96
|
* instead of stretching it to the tallest in its row. `as="ul"` makes it a list of cards for a
|
|
90
97
|
* screen reader: the grid is a `<ul>` and each child is wrapped in an `<li>`, still stretched.
|
package/dist/PageLayouts.js
CHANGED
|
@@ -36,22 +36,33 @@ const COLUMNS = {
|
|
|
36
36
|
3: "lg:grid-cols-3",
|
|
37
37
|
4: "lg:grid-cols-4",
|
|
38
38
|
};
|
|
39
|
+
/** Two unequal columns, one on a phone, the ratio from `md`; `minmax(0,…)` lets a long value shrink instead of widening its column. */
|
|
40
|
+
const RATIOS = {
|
|
41
|
+
"2:1": "md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]",
|
|
42
|
+
"1:2": "md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]",
|
|
43
|
+
};
|
|
39
44
|
/** `columns="auto"` fits as many cards per row as the width allows, none narrower than this. */
|
|
40
45
|
const AUTO_MIN_WIDTH = {
|
|
41
46
|
sm: "grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]",
|
|
42
47
|
md: "grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]",
|
|
43
48
|
};
|
|
49
|
+
function gridColumns(columns, minWidth) {
|
|
50
|
+
if (columns === "auto")
|
|
51
|
+
return AUTO_MIN_WIDTH[minWidth];
|
|
52
|
+
if (typeof columns === "string")
|
|
53
|
+
return cx("grid-cols-1", RATIOS[columns]);
|
|
54
|
+
return cx("grid-cols-1 sm:grid-cols-2", COLUMNS[columns]);
|
|
55
|
+
}
|
|
44
56
|
/**
|
|
45
57
|
* A responsive grid of tiles: one column on a phone, two from `sm`, `columns` from `lg`.
|
|
46
|
-
* `columns="
|
|
58
|
+
* `columns="2:1"` or `"1:2"` is instead two unequal columns from `md`, one on a phone: a main card beside a
|
|
59
|
+
* narrow one (a record's detail and its side panel). `columns="auto"` instead fits as many per row as the width allows, each at least `minWidth` wide:
|
|
47
60
|
* `md` (17rem, the default) or `sm` (12rem). `align="start"` keeps each card its own height
|
|
48
61
|
* instead of stretching it to the tallest in its row. `as="ul"` makes it a list of cards for a
|
|
49
62
|
* screen reader: the grid is a `<ul>` and each child is wrapped in an `<li>`, still stretched.
|
|
50
63
|
*/
|
|
51
64
|
export function CardGrid({ columns = 3, minWidth = "md", align = "stretch", as: Tag = "div", children, ...rest }) {
|
|
52
|
-
return (_jsx(Tag, { ...rest, ...(Tag === "ul" ? { role: "list" } : {}), className: cx("grid gap-4", columns === "
|
|
53
|
-
? AUTO_MIN_WIDTH[minWidth]
|
|
54
|
-
: cx("grid-cols-1 sm:grid-cols-2", COLUMNS[columns]), align === "start" && "items-start"), children: Tag === "ul"
|
|
65
|
+
return (_jsx(Tag, { ...rest, ...(Tag === "ul" ? { role: "list" } : {}), className: cx("grid gap-4", gridColumns(columns, minWidth), align === "start" && "items-start"), children: Tag === "ul"
|
|
55
66
|
? Children.toArray(children).map((child, index) => (_jsx("li", { className: "grid", children: child }, isValidElement(child) ? child.key : index)))
|
|
56
67
|
: children }));
|
|
57
68
|
}
|
package/dist/Pill.d.ts
CHANGED
|
@@ -15,8 +15,23 @@ type PillBase = {
|
|
|
15
15
|
round?: boolean;
|
|
16
16
|
/** The tooltip on hover, for a pill whose text is short or cut off: the full status, a date. */
|
|
17
17
|
title?: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* The name half of a two-tone `label: value` pill: `children` is the value. The label keeps the
|
|
20
|
+
* tone's tint and the value sits on a plain surface beside it, in a hairline border. Keep the
|
|
21
|
+
* label to a word or two (`Weight`, `Sleep`).
|
|
22
|
+
*/
|
|
23
|
+
label?: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
type Removable = {
|
|
26
|
+
onRemove?: undefined;
|
|
27
|
+
removeLabel?: undefined;
|
|
28
|
+
} | {
|
|
29
|
+
/** Adds a small x button after the text that calls this: for a chip the reader can drop (an attachment, a filter). It sits beside a link or button pill, never inside it. */
|
|
30
|
+
onRemove: () => void;
|
|
31
|
+
/** The x button's accessible name and tooltip: `Remove invoice.pdf`. Required with `onRemove`. */
|
|
32
|
+
removeLabel: string;
|
|
18
33
|
};
|
|
19
|
-
type PillProps = PillBase & Passthrough & ({
|
|
34
|
+
type PillProps = PillBase & Removable & Passthrough & ({
|
|
20
35
|
href?: undefined;
|
|
21
36
|
render?: undefined;
|
|
22
37
|
external?: undefined;
|
|
@@ -44,10 +59,13 @@ type PillProps = PillBase & Passthrough & ({
|
|
|
44
59
|
})));
|
|
45
60
|
/**
|
|
46
61
|
* A small tinted label; `dot` adds a leading dot in the pill's own text colour, `round` swaps the
|
|
47
|
-
* rounded-md corners for a full pill shape. `
|
|
48
|
-
* the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
62
|
+
* rounded-md corners for a full pill shape. `label` makes it two-tone, `label: value`: the label
|
|
63
|
+
* keeps the tone's tint and the children, the value, sit on the surface beside it. `href` makes it
|
|
64
|
+
* a link (a plain `<a>`, or with `render` the app's own router `<Link>`:
|
|
65
|
+
* `render={(p) => <Link to="/flakes" {...p} />}`; `external` opens a new tab) and `onClick` makes it
|
|
66
|
+
* a button, each with a hover dim and the focus ring. `onRemove` adds an x button after the text
|
|
67
|
+
* (named by `removeLabel`), a separate button beside a link or button pill rather than nested in
|
|
68
|
+
* it. `title` is the native tooltip.
|
|
51
69
|
*/
|
|
52
|
-
export declare function Pill({ children, tone, dot, round, href, render, external, onClick, ...rest }: PillProps): ReactElement;
|
|
70
|
+
export declare function Pill({ children, tone, dot, round, label, href, render, external, onClick, onRemove, removeLabel, ...rest }: PillProps): ReactElement;
|
|
53
71
|
export {};
|
package/dist/Pill.js
CHANGED
|
@@ -10,25 +10,81 @@ const PILL_TONES = {
|
|
|
10
10
|
violet: "bg-violet-50 text-violet-700",
|
|
11
11
|
sky: "bg-sky-50 text-sky-700",
|
|
12
12
|
};
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const INTERACTIVE = cx("transition hover:opacity-80", focusRing);
|
|
14
|
+
/** The x on a removable pill: two strokes in the pill's text colour. */
|
|
15
|
+
const REMOVE_MARK = (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 12 12", className: "size-3", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", children: _jsx("path", { d: "M3 3l6 6M9 3l-6 6" }) }));
|
|
16
|
+
/** The two-tone pill is a hairline-bordered box: the label half carries the tint, the value half sits on the surface. */
|
|
17
|
+
const SPLIT = "inline-flex overflow-hidden border border-slate-200 text-xs font-medium";
|
|
18
|
+
/** The box a one-piece pill draws, or the whole pill when it is a single element. */
|
|
19
|
+
function pillClass({ tone, round, dot, labelled }, interactive) {
|
|
20
|
+
return cx(round ? "rounded-full" : "rounded-md", labelled ? SPLIT : cx("px-1.5 py-0.5 text-xs font-medium", PILL_TONES[tone]), dot && !labelled && "inline-flex items-center gap-1", interactive && INTERACTIVE);
|
|
21
|
+
}
|
|
22
|
+
/** The shell round a pill with an x: it carries the tint and the corners, and the text and the x are its two children. */
|
|
23
|
+
function shellClass({ tone, round, labelled }) {
|
|
24
|
+
return cx(round ? "rounded-full" : "rounded-md", "inline-flex items-center text-xs font-medium", labelled ? "overflow-hidden border border-slate-200" : PILL_TONES[tone]);
|
|
25
|
+
}
|
|
26
|
+
/** The text half of a removable pill, which is a link, a button or a span beside the x. */
|
|
27
|
+
function mainClass({ dot, labelled }, interactive) {
|
|
28
|
+
return cx(labelled ? "inline-flex self-stretch" : "py-0.5 pl-1.5 pr-1", dot && !labelled && "inline-flex items-center gap-1", interactive && INTERACTIVE);
|
|
29
|
+
}
|
|
30
|
+
function removeClass({ labelled }) {
|
|
31
|
+
return cx("inline-flex cursor-pointer items-center", labelled ? "self-stretch bg-white pl-0.5 pr-1.5 text-slate-900" : "py-0.5 pl-0.5 pr-1.5", INTERACTIVE);
|
|
32
|
+
}
|
|
33
|
+
/** The inside of a pill: the dot and the text, or for a two-tone one the tinted label beside the value. */
|
|
34
|
+
function PillBody({ look: { tone, dot, removable }, label, children, }) {
|
|
35
|
+
const mark = dot && _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-1.5 rounded-full bg-current" });
|
|
36
|
+
if (label === undefined) {
|
|
37
|
+
return (_jsxs(_Fragment, { children: [mark, children] }));
|
|
38
|
+
}
|
|
39
|
+
return (_jsxs(_Fragment, { children: [_jsxs("span", { className: cx("px-1.5 py-px", PILL_TONES[tone], dot && "inline-flex items-center gap-1"), children: [mark, label, _jsx("span", { className: "sr-only", children: ":" })] }), _jsx("span", { className: cx("bg-white py-px text-slate-900", removable ? "pl-1.5 pr-0.5" : "px-1.5"), children: children })] }));
|
|
40
|
+
}
|
|
41
|
+
/** The pill itself, or the text half of a removable one: a link, a button or a span. */
|
|
42
|
+
function renderMain({ isLink, interactive, look, rest, href, render, external, onClick, body, }) {
|
|
43
|
+
const className = look.removable ? mainClass(look, interactive) : pillClass(look, interactive);
|
|
44
|
+
// A removable pill's attributes go on its link or button; on a plain one, the shell is the pill.
|
|
45
|
+
const own = look.removable && !interactive ? {} : rest;
|
|
46
|
+
if (isLink)
|
|
47
|
+
return renderLink({ ...own, href, render, external, className, children: body });
|
|
48
|
+
if (onClick) {
|
|
49
|
+
return (_jsx("button", { type: "button", ...own, onClick: onClick, className: cx(className, "cursor-pointer"), children: body }));
|
|
50
|
+
}
|
|
51
|
+
return (_jsx("span", { ...own, className: className, children: body }));
|
|
52
|
+
}
|
|
53
|
+
/** The shell round a removable pill: the text half and the x button beside it. */
|
|
54
|
+
function RemoveShell({ look, remove: { onRemove, removeLabel }, attrs, interactive, children, }) {
|
|
55
|
+
return (_jsxs("span", { ...(interactive ? {} : attrs), className: shellClass(look), children: [children, _jsx("button", { type: "button", "aria-label": removeLabel, title: removeLabel, onClick: onRemove, className: removeClass(look), children: REMOVE_MARK })] }));
|
|
15
56
|
}
|
|
16
57
|
/**
|
|
17
58
|
* A small tinted label; `dot` adds a leading dot in the pill's own text colour, `round` swaps the
|
|
18
|
-
* rounded-md corners for a full pill shape. `
|
|
19
|
-
* the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
59
|
+
* rounded-md corners for a full pill shape. `label` makes it two-tone, `label: value`: the label
|
|
60
|
+
* keeps the tone's tint and the children, the value, sit on the surface beside it. `href` makes it
|
|
61
|
+
* a link (a plain `<a>`, or with `render` the app's own router `<Link>`:
|
|
62
|
+
* `render={(p) => <Link to="/flakes" {...p} />}`; `external` opens a new tab) and `onClick` makes it
|
|
63
|
+
* a button, each with a hover dim and the focus ring. `onRemove` adds an x button after the text
|
|
64
|
+
* (named by `removeLabel`), a separate button beside a link or button pill rather than nested in
|
|
65
|
+
* it. `title` is the native tooltip.
|
|
22
66
|
*/
|
|
23
|
-
export function Pill({ children, tone = "slate", dot = false, round = false, href, render, external = false, onClick, ...rest }) {
|
|
67
|
+
export function Pill({ children, tone = "slate", dot = false, round = false, label, href, render, external = false, onClick, onRemove, removeLabel, ...rest }) {
|
|
24
68
|
const isLink = href !== undefined || render !== undefined;
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
69
|
+
const interactive = isLink || onClick !== undefined;
|
|
70
|
+
const look = {
|
|
71
|
+
tone,
|
|
72
|
+
round,
|
|
73
|
+
dot,
|
|
74
|
+
labelled: label !== undefined,
|
|
75
|
+
removable: onRemove !== undefined,
|
|
76
|
+
};
|
|
77
|
+
const body = (_jsx(PillBody, { look: look, label: label, children: children }));
|
|
78
|
+
const main = renderMain({
|
|
79
|
+
isLink,
|
|
80
|
+
interactive,
|
|
81
|
+
look,
|
|
82
|
+
rest,
|
|
83
|
+
href,
|
|
84
|
+
render,
|
|
85
|
+
external,
|
|
86
|
+
onClick,
|
|
87
|
+
body,
|
|
88
|
+
});
|
|
89
|
+
return onRemove === undefined ? (main) : (_jsx(RemoveShell, { look: look, remove: { onRemove, removeLabel }, attrs: rest, interactive: interactive, children: main }));
|
|
34
90
|
}
|