@hackcove/ui-react 0.9.0 → 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/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
+ }
@@ -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 {};
@@ -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 ? "tabular-nums" : "text-right tabular-nums", children: children })] }));
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: _jsx("div", { className: "min-w-0 flex-1", children: children }),
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
+ }
@@ -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`), or `auto` for as many as fit.
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="auto"` instead fits as many per row as the width allows, each at least `minWidth` wide:
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.
@@ -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="auto"` instead fits as many per row as the width allows, each at least `minWidth` wide:
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 === "auto"
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. `href` makes it a link (a plain `<a>`, or with `render`
48
- * the app's own router `<Link>`: `render={(p) => <Link to="/flakes" {...p} />}`; `external` opens a
49
- * new tab) and `onClick` makes it a button, each with a hover dim and the focus ring. `title` is
50
- * the native tooltip.
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
- function pillClass({ tone, round, dot }, interactive) {
14
- return cx(round ? "rounded-full" : "rounded-md", "px-1.5 py-0.5 text-xs font-medium", PILL_TONES[tone], dot && "inline-flex items-center gap-1", interactive && cx("transition hover:opacity-80", focusRing));
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. `href` makes it a link (a plain `<a>`, or with `render`
19
- * the app's own router `<Link>`: `render={(p) => <Link to="/flakes" {...p} />}`; `external` opens a
20
- * new tab) and `onClick` makes it a button, each with a hover dim and the focus ring. `title` is
21
- * the native tooltip.
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 className = pillClass({ tone, round, dot }, isLink || onClick !== undefined);
26
- const content = (_jsxs(_Fragment, { children: [dot && _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-1.5 rounded-full bg-current" }), children] }));
27
- if (isLink) {
28
- return renderLink({ ...rest, href, render, external, className, children: content });
29
- }
30
- if (onClick) {
31
- return (_jsx("button", { type: "button", ...rest, onClick: onClick, className: cx(className, "cursor-pointer"), children: content }));
32
- }
33
- return (_jsx("span", { ...rest, className: className, children: content }));
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
  }
@@ -0,0 +1,21 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { ClusterGap } from "./Cluster.js";
3
+ import type { Passthrough } from "./props.js";
4
+ export type ScrollStripProps = {
5
+ /** Names the strip for a screen reader, e.g. "Recent statuses". */
6
+ label: string;
7
+ /** The items, one after another: `MediaTile`s, `Avatar`s. They keep their own width. */
8
+ children: ReactNode;
9
+ /**
10
+ * The space between items, as `Cluster`'s `gap`.
11
+ * @default sm
12
+ */
13
+ gap?: ClusterGap | undefined;
14
+ } & Passthrough;
15
+ /**
16
+ * A single row of items that scrolls sideways when it runs past the width of its parent, with a
17
+ * thin themed scrollbar and the items snapping into place: a strip of `MediaTile`s or `Avatar`s.
18
+ * Items never shrink or wrap. The strip takes the keyboard focus, so the arrow keys scroll it
19
+ * without a pointer. A grid that wraps is a `CardGrid` or a `Cluster`.
20
+ */
21
+ export declare function ScrollStrip({ label, children, gap, ...rest }: ScrollStripProps): ReactElement;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx, focusRing, themedScrollbar } from "./cx.js";
3
+ import { STACK_GAP } from "./Stack.js";
4
+ /**
5
+ * A single row of items that scrolls sideways when it runs past the width of its parent, with a
6
+ * thin themed scrollbar and the items snapping into place: a strip of `MediaTile`s or `Avatar`s.
7
+ * Items never shrink or wrap. The strip takes the keyboard focus, so the arrow keys scroll it
8
+ * without a pointer. A grid that wraps is a `CardGrid` or a `Cluster`.
9
+ */
10
+ export function ScrollStrip({ label, children, gap = "sm", ...rest }) {
11
+ return (_jsx("div", { ...rest, role: "group", "aria-label": label, tabIndex: 0, className: cx("overflow-x-auto rounded-lg pb-2 [scroll-snap-type:x_proximity]", themedScrollbar, focusRing), children: _jsx("div", { className: cx("flex w-max min-w-full text-slate-900 *:shrink-0 *:snap-start", STACK_GAP[gap]), children: children }) }));
12
+ }
package/dist/Stack.d.ts CHANGED
@@ -2,6 +2,9 @@ import { type ComponentPropsWithoutRef, type ReactElement, type ReactNode } from
2
2
  import type { Passthrough, Unstyled } from "./props.js";
3
3
  export type StackGap = "xs" | "sm" | "md" | "lg" | "xl";
4
4
  export declare const STACK_GAP: Record<StackGap, string>;
5
+ export type StackRowFrom = "sm" | "md" | "lg";
6
+ /** The breakpoint where a `rowFrom` Stack turns from a column into a row. */
7
+ export declare const STACK_ROW_FROM: Record<StackRowFrom, string>;
5
8
  export type StackWidth = "narrow" | "default" | "wide" | "full";
6
9
  /** `default` adds nothing: the Stack is as wide as its parent. */
7
10
  export declare const STACK_WIDTH: Record<StackWidth, string | undefined>;
@@ -14,12 +17,16 @@ export declare const STACK_WIDTH: Record<StackWidth, string | undefined>;
14
17
  * is the parent's whole width and `default` sets nothing. A capped Stack starts at its parent's left
15
18
  * edge (it is never re-centred), so its first line lines up with every other block on the page.
16
19
  *
20
+ * `rowFrom` turns the column into a row from `sm`, `md` or `lg` (a column below it): the blocks sit
21
+ * side by side, `gap` apart, and a `ClusterGrow` around one of them takes the width left. Without it the
22
+ * Stack is always a column; a row that is a row at every width is a `Cluster`.
23
+ *
17
24
  * `as` is a closed set: `div` (default), `section`, `form`, `ul` or `ol`. A `form` takes the form
18
25
  * props (`onSubmit`, `action`, `method`, `noValidate`, ...). A `ul` or `ol` is drawn with no
19
26
  * markers and its children are `<li>`s, as in `BulletList` and `Timeline`: the Stack does not wrap
20
27
  * them, so a child is one list item and `Text as="li"` or a bare `<li>` both work.
21
28
  */
22
- export declare function Stack({ gap, width, as, children, ...rest }: StackProps): ReactElement;
29
+ export declare function Stack({ gap, width, rowFrom, as, children, ...rest }: StackProps): ReactElement;
23
30
  type StackBase = {
24
31
  /**
25
32
  * The space between blocks: `xs` gap-2, `sm` gap-4, `md` gap-6, `lg` gap-8, `xl` gap-12 (gap-16 from `sm`).
@@ -31,6 +38,13 @@ type StackBase = {
31
38
  * @default default
32
39
  */
33
40
  width?: StackWidth | undefined;
41
+ /**
42
+ * Turns the column into a row from this breakpoint (`sm`, `md` or `lg`), a column below it: a main
43
+ * area beside a side column on a wide screen, stacked on a phone. In the row the blocks sit side by side
44
+ * the same `gap` apart and are as tall as the tallest; wrap the one that takes the width left in a
45
+ * `ClusterGrow`. Left out, the Stack is always a column.
46
+ */
47
+ rowFrom?: StackRowFrom | undefined;
34
48
  children?: ReactNode;
35
49
  };
36
50
  type StackProps = StackBase & (({
package/dist/Stack.js CHANGED
@@ -9,6 +9,12 @@ export const STACK_GAP = {
9
9
  lg: "gap-8",
10
10
  xl: "gap-12 sm:gap-16",
11
11
  };
12
+ /** The breakpoint where a `rowFrom` Stack turns from a column into a row. */
13
+ export const STACK_ROW_FROM = {
14
+ sm: "sm:flex-row",
15
+ md: "md:flex-row",
16
+ lg: "lg:flex-row",
17
+ };
12
18
  /** `default` adds nothing: the Stack is as wide as its parent. */
13
19
  export const STACK_WIDTH = {
14
20
  narrow: "max-w-3xl",
@@ -25,13 +31,17 @@ export const STACK_WIDTH = {
25
31
  * is the parent's whole width and `default` sets nothing. A capped Stack starts at its parent's left
26
32
  * edge (it is never re-centred), so its first line lines up with every other block on the page.
27
33
  *
34
+ * `rowFrom` turns the column into a row from `sm`, `md` or `lg` (a column below it): the blocks sit
35
+ * side by side, `gap` apart, and a `ClusterGrow` around one of them takes the width left. Without it the
36
+ * Stack is always a column; a row that is a row at every width is a `Cluster`.
37
+ *
28
38
  * `as` is a closed set: `div` (default), `section`, `form`, `ul` or `ol`. A `form` takes the form
29
39
  * props (`onSubmit`, `action`, `method`, `noValidate`, ...). A `ul` or `ol` is drawn with no
30
40
  * markers and its children are `<li>`s, as in `BulletList` and `Timeline`: the Stack does not wrap
31
41
  * them, so a child is one list item and `Text as="li"` or a bare `<li>` both work.
32
42
  */
33
- export function Stack({ gap = "md", width = "default", as = "div", children, ...rest }) {
34
- return (_jsx(Box, { tag: as, className: cx("flex flex-col", (as === "ul" || as === "ol") && "list-none", STACK_GAP[gap], STACK_WIDTH[width], "*:data-page-header:mb-0"), ...rest, children: children }));
43
+ export function Stack({ gap = "md", width = "default", rowFrom, as = "div", children, ...rest }) {
44
+ return (_jsx(Box, { tag: as, className: cx("flex flex-col", (as === "ul" || as === "ol") && "list-none", rowFrom && STACK_ROW_FROM[rowFrom], STACK_GAP[gap], STACK_WIDTH[width], "*:data-page-header:mb-0"), ...rest, children: children }));
35
45
  }
36
46
  /** The element `as` picked, drawn with props already narrowed by `StackProps`. */
37
47
  function Box({ tag: Tag, ...props }) {
@@ -1,7 +1,7 @@
1
1
  import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
2
  import type { Unstyled } from "./props.js";
3
- /** The colour of a tile's value (and of its accent bar): plain ink, green or red. */
4
- export type StatTileTone = "neutral" | "positive" | "negative";
3
+ /** The colour of a tile's value (and of its accent bar): plain ink, green, amber or red. */
4
+ export type StatTileTone = "neutral" | "positive" | "warn" | "negative";
5
5
  /**
6
6
  * One headline figure: an uppercase caption over a large tabular-nums value, with an optional
7
7
  * hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
package/dist/StatTile.js CHANGED
@@ -5,11 +5,13 @@ import { Overline } from "./typography.js";
5
5
  const VALUE = {
6
6
  neutral: "text-slate-900",
7
7
  positive: "text-emerald-700",
8
+ warn: "text-amber-700",
8
9
  negative: "text-red-700",
9
10
  };
10
11
  const BAR = {
11
12
  neutral: BAR_FILL.accent,
12
13
  positive: BAR_FILL.positive,
14
+ warn: BAR_FILL.warn,
13
15
  negative: BAR_FILL.negative,
14
16
  };
15
17
  /**