@hackcove/ui-react 0.10.8 → 0.10.10

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.
@@ -78,21 +78,33 @@ export declare function AppShellHideBelow({ breakpoint, children, ...rest }: Pas
78
78
  * reachable and nothing scrolls sideways. The menu is a native `<details>`, so it works with the
79
79
  * keyboard, with a screen reader and on a page that never hydrates. From `md` the header is the
80
80
  * row, and the `subnav` row, as they are.
81
+ *
82
+ * `controls` stay in the row at every width, so keep them few (a theme toggle, the viewer's avatar);
83
+ * on a phone a crowded `controls` clips, and the Menu button is never the part cut off. Pickers and
84
+ * actions that do not fit a phone row go in `menuControls`: rendered once, they sit in the row after
85
+ * `controls` from `md`, and below `md` at the top of the Menu, above `nav` and `subnav`.
81
86
  */
82
- export declare function AppShell({ brand, nav, controls, header, subnav, menuLabel, footer, bleed, hideChromeInPrint, children, ...rest }: {
87
+ export declare function AppShell({ brand, nav, controls, menuControls, header, subnav, menuLabel, footer, bleed, hideChromeInPrint, children, ...rest }: {
83
88
  /** The brand: a logo and a name, in the header's brand type, with the gap between them. `AppShellBrand` makes both one link. */
84
89
  brand?: ReactNode;
85
90
  /** The main navigation, e.g. a `Tabs variant="pill"` of links (it is already a `<nav>`). Below `md` it is in the menu. */
86
91
  nav?: ReactNode;
87
92
  /** The end of the row: a picker, a theme toggle. Gap-2, vertically centred; no child shrinks, and a full-width control (a `Select` at its default) sizes to its content. */
88
93
  controls?: ReactNode;
94
+ /**
95
+ * Pickers and actions that do not fit a phone row, such as a project `Select` and a Sign out
96
+ * `Button`. From `md` they sit in the row after `controls`; below `md` they are at the top of the
97
+ * Menu, above `nav` and `subnav`. Rendered once, so a field keeps its state and ids stay unique.
98
+ * `controls` stay in the row at every width, so keep those few.
99
+ */
100
+ menuControls?: ReactNode;
89
101
  /** The escape hatch: a free node in the header row, after `brand` and `nav`. */
90
102
  header?: ReactNode;
91
103
  /** A second fixed-height row under the header from `md`, e.g. a `Tabs variant="pill"` of links. Below `md` it is in the menu with `nav`. */
92
104
  subnav?: ReactNode;
93
105
  /**
94
106
  * The phone menu button's name, for an app in another language. Only drawn below `md`, and only
95
- * when `nav` or `subnav` is given.
107
+ * when `nav`, `subnav` or `menuControls` is given.
96
108
  * @default Menu
97
109
  */
98
110
  menuLabel?: string;
package/dist/AppShell.js CHANGED
@@ -62,17 +62,20 @@ function ScrollRow({ className, children }) {
62
62
  return (_jsx(ContainerFrame, { elementRef: setRow, className: cx(ROW, FADE, className), children: children }));
63
63
  }
64
64
  /**
65
- * The header row's slots in order: brand, nav (from `md`), the free `children`, then controls and,
66
- * below `md`, the menu at the end.
65
+ * The header row's slots in order: brand, nav (from `md`), the free `children`, then `controls` and
66
+ * the menu at the end. `controls` have a wrapper of their own that shrinks and clips, and the menu
67
+ * sits beside it, so a crowded `controls` never pushes the Menu off a phone screen. From `md` the
68
+ * menu is the inline row of `menuControls` after `controls`.
67
69
  */
68
- function HeaderSlots({ brand, nav, controls, menu, children, }) {
69
- return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav && _jsx("div", { className: "hidden items-center gap-4 *:shrink-0 md:flex", children: nav }), children, (controls || menu) && (_jsxs("div", { className: controls
70
- ? "ml-auto flex items-center gap-2 *:shrink-0 [&>.w-full]:w-auto"
71
- : "ml-auto flex items-center gap-2 *:shrink-0 md:hidden", children: [controls, menu] }))] }));
70
+ function HeaderSlots({ brand, nav, controls, menu, menuInline, children, }) {
71
+ return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav && _jsx("div", { className: "hidden items-center gap-4 *:shrink-0 md:flex", children: nav }), children, (controls || menu) && (_jsxs("div", { className: cx(
72
+ // Below `md` this wrapper may shrink (the row's `*:shrink-0` is overridden), so a crowded
73
+ // `controls` clips inside its own box; from `md` it keeps its width and the row scrolls.
74
+ "ml-auto flex min-w-0 items-center gap-2 max-md:shrink!", !controls && !menuInline && "md:hidden"), children: [controls && (_jsx("div", { className: "flex min-w-0 items-center gap-2 *:shrink-0 max-md:-mx-1 max-md:overflow-x-clip max-md:px-1 [&>.w-full]:w-auto", children: controls })), menu] }))] }));
72
75
  }
73
76
  /** The sticky header: the one-row slots, and from `md` the `subnav` row under them. */
74
- function AppShellHeader({ brand, nav, controls, subnav, menuLabel, hideChromeInPrint, children, }) {
75
- return (_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "flex h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, menu: (nav || subnav) && (_jsxs(AppShellMenu, { label: menuLabel, children: [nav, subnav] })), children: children }) }), subnav && _jsx(ScrollRow, { className: "hidden h-12 md:flex", children: subnav })] }));
77
+ function AppShellHeader({ brand, nav, controls, menuControls, subnav, menuLabel, hideChromeInPrint, children, }) {
78
+ return (_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "flex h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, menuInline: !!menuControls, menu: (nav || subnav || menuControls) && (_jsxs(AppShellMenu, { label: menuLabel, controls: menuControls, children: [nav, subnav] })), children: children }) }), subnav && _jsx(ScrollRow, { className: "hidden h-12 md:flex", children: subnav })] }));
76
79
  }
77
80
  /**
78
81
  * The page frame: it paints the page canvas and sets the type face, ink and font smoothing
@@ -93,10 +96,15 @@ function AppShellHeader({ brand, nav, controls, subnav, menuLabel, hideChromeInP
93
96
  * reachable and nothing scrolls sideways. The menu is a native `<details>`, so it works with the
94
97
  * keyboard, with a screen reader and on a page that never hydrates. From `md` the header is the
95
98
  * row, and the `subnav` row, as they are.
99
+ *
100
+ * `controls` stay in the row at every width, so keep them few (a theme toggle, the viewer's avatar);
101
+ * on a phone a crowded `controls` clips, and the Menu button is never the part cut off. Pickers and
102
+ * actions that do not fit a phone row go in `menuControls`: rendered once, they sit in the row after
103
+ * `controls` from `md`, and below `md` at the top of the Menu, above `nav` and `subnav`.
96
104
  */
97
- export function AppShell({ brand, nav, controls, header, subnav, menuLabel = "Menu", footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
105
+ export function AppShell({ brand, nav, controls, menuControls, header, subnav, menuLabel = "Menu", footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
98
106
  return (_jsxs("div", { ...rest, className: cx("flex min-h-dvh flex-col bg-gray-50 font-sans text-slate-900 antialiased",
99
107
  // The sticky header's height (its rows and the bottom border), for a bar that sticks under it.
100
108
  // Below `md` it is one row whatever the slots are: the subnav is in the menu.
101
- "[--app-header:calc(3.5rem+1px)]", subnav ? "md:[--app-header:calc(6.5rem+1px)]" : undefined), children: [_jsx(AppShellHeader, { brand: brand, nav: nav, controls: controls, subnav: subnav, menuLabel: menuLabel, hideChromeInPrint: hideChromeInPrint, children: header }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? (children) : (_jsx(ContainerFrame, { className: cx("py-8", hideChromeInPrint && "print:p-0"), children: children })) }), footer && (_jsx("footer", { className: cx("border-t border-slate-200", hideChromeInPrint && "print:hidden"), children: _jsx(ContainerFrame, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
109
+ "[--app-header:calc(3.5rem+1px)]", subnav ? "md:[--app-header:calc(6.5rem+1px)]" : undefined), children: [_jsx(AppShellHeader, { brand: brand, nav: nav, controls: controls, menuControls: menuControls, subnav: subnav, menuLabel: menuLabel, hideChromeInPrint: hideChromeInPrint, children: header }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? (children) : (_jsx(ContainerFrame, { className: cx("py-8", hideChromeInPrint && "print:p-0"), children: children })) }), footer && (_jsx("footer", { className: cx("border-t border-slate-200", hideChromeInPrint && "print:hidden"), children: _jsx(ContainerFrame, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
102
110
  }
@@ -6,8 +6,15 @@ import { type ReactElement, type ReactNode } from "react";
6
6
  * is out of flow (it hangs under the header, `top-full`), so the header keeps its one height open
7
7
  * or closed. With script it also closes on Escape (focus returns to the button) and when a link in
8
8
  * it is followed, for a router that changes the page without a reload.
9
+ *
10
+ * `controls` (AppShell's `menuControls`) sit at the top of the panel, above the links. From `md`
11
+ * the `<details>` drops its own box and shows only them, inline at the row's end, whether it is
12
+ * open or not: the summary and the link copies are hidden (the links render in their own rows from
13
+ * `md`) and the panel is a plain row. They are rendered once, so a field keeps its state.
9
14
  */
10
- export declare function AppShellMenu({ label, children, }: {
15
+ export declare function AppShellMenu({ label, controls, children, }: {
11
16
  label: string;
17
+ /** Rendered once: at the top of the panel below `md`, inline in the row from `md`. */
18
+ controls?: ReactNode;
12
19
  children: ReactNode;
13
20
  }): ReactElement;
@@ -21,8 +21,13 @@ const ICON = {
21
21
  * is out of flow (it hangs under the header, `top-full`), so the header keeps its one height open
22
22
  * or closed. With script it also closes on Escape (focus returns to the button) and when a link in
23
23
  * it is followed, for a router that changes the page without a reload.
24
+ *
25
+ * `controls` (AppShell's `menuControls`) sit at the top of the panel, above the links. From `md`
26
+ * the `<details>` drops its own box and shows only them, inline at the row's end, whether it is
27
+ * open or not: the summary and the link copies are hidden (the links render in their own rows from
28
+ * `md`) and the panel is a plain row. They are rendered once, so a field keeps its state.
24
29
  */
25
- export function AppShellMenu({ label, children, }) {
30
+ export function AppShellMenu({ label, controls, children, }) {
26
31
  const details = useRef(null);
27
32
  const close = (focusButton) => {
28
33
  const el = details.current;
@@ -32,11 +37,17 @@ export function AppShellMenu({ label, children, }) {
32
37
  if (focusButton)
33
38
  el.querySelector("summary")?.focus();
34
39
  };
35
- return (_jsxs("details", { ref: details, className: "group md:hidden", onKeyDown: (event) => {
36
- if (event.key === "Escape")
40
+ return (_jsxs("details", { ref: details, className: cx("group shrink-0", controls
41
+ ? "md:contents md:[&::details-content]:contents md:[&::details-content]:[content-visibility:visible]"
42
+ : "md:hidden"), onKeyDown: (event) => {
43
+ // A dialog opened from a control in the menu takes its own Escape; the menu stays open.
44
+ if (event.key === "Escape" &&
45
+ !(event.target instanceof Element && event.target.closest("dialog")))
37
46
  close(true);
38
- }, children: [_jsxs("summary", { className: cx("flex cursor-pointer list-none items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 select-none hover:bg-slate-50 [&::-webkit-details-marker]:hidden", focusRing), children: [_jsx("svg", { ...ICON, className: cx(ICON.className, "group-open:hidden"), children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14" }) }), _jsx("svg", { ...ICON, className: cx(ICON.className, "hidden group-open:block"), children: _jsx("path", { d: "M5 5l10 10M15 5L5 15" }) }), label] }), _jsx("div", { className: "absolute inset-x-0 top-full max-h-[calc(100dvh-var(--app-header))] overflow-y-auto overscroll-contain border-y border-slate-200 bg-white shadow-lg", onClick: (event) => {
47
+ }, children: [_jsxs("summary", { className: cx("flex cursor-pointer list-none md:hidden items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 select-none hover:bg-slate-50 [&::-webkit-details-marker]:hidden", focusRing), children: [_jsx("svg", { ...ICON, className: cx(ICON.className, "group-open:hidden"), children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14" }) }), _jsx("svg", { ...ICON, className: cx(ICON.className, "hidden group-open:block"), children: _jsx("path", { d: "M5 5l10 10M15 5L5 15" }) }), label] }), _jsx("div", {
48
+ // From `md` the panel draws no box (`contents`), so its dropdown look applies below `md` only.
49
+ className: "absolute inset-x-0 top-full max-h-[calc(100dvh-var(--app-header))] overflow-y-auto overscroll-contain border-y border-slate-200 bg-white shadow-lg md:contents", onClick: (event) => {
39
50
  if (event.target instanceof Element && event.target.closest("a[href]"))
40
51
  close(false);
41
- }, children: _jsx(ContainerFrame, { className: "flex flex-col gap-3 py-3", children: children }) })] }));
52
+ }, children: _jsxs(ContainerFrame, { className: "flex flex-col gap-3 py-3 md:contents", children: [controls && (_jsx("div", { className: "flex flex-wrap items-center gap-2 md:flex-nowrap md:*:shrink-0 md:[&>.w-full]:w-auto", children: controls })), _jsx("div", { className: "flex flex-col gap-3 md:hidden", children: children })] }) })] }));
42
53
  }
package/dist/Card.d.ts CHANGED
@@ -47,6 +47,8 @@ type CardPanelProps = CardBase & {
47
47
  actions?: ReactNode;
48
48
  /** A tinted strip across the bottom of the card, under a hairline, for the card's own actions or a total. It stays at the foot when the card is stretched in a `CardGrid`. Ignored with `dashed`. */
49
49
  footer?: ReactNode;
50
+ /** Take the parent's full height: the title or bar stays put and the body scrolls. The parent sets the height. Ignored with `dashed`. */
51
+ fill?: boolean;
50
52
  /**
51
53
  * The root element. A `div` takes no name from the title. Not on a card that is a link.
52
54
  * @default section
@@ -59,6 +61,7 @@ type CardPanelProps = CardBase & {
59
61
  type CardLinkProps = CardBase & {
60
62
  actions?: undefined;
61
63
  footer?: undefined;
64
+ fill?: undefined;
62
65
  as?: undefined;
63
66
  /** Opens in a new tab: adds `target="_blank"` and `rel="noreferrer"`. */
64
67
  external?: boolean | undefined;
@@ -97,6 +100,9 @@ type CardLinkProps = CardBase & {
97
100
  * total; in a `CardGrid` it stays at the foot of a card that is stretched to the row's height. It
98
101
  * suits a panel, not a card that is a link, and `dashed` has none.
99
102
  *
103
+ * `fill` makes a panel take its parent's full height (the parent sets it): the title or bar stays
104
+ * put and the body scrolls. Not on a link card.
105
+ *
100
106
  * `href` makes the whole card one link: a hover border and the focus ring, with the link stretched
101
107
  * over the card, so it takes no `actions`, `footer` or `as`. A `TextLink`, `Button` or `Checkbox` in
102
108
  * the body sits above the link and is clickable on its own. With `render` it draws the app's own router `<Link>`
package/dist/Card.js CHANGED
@@ -1,8 +1,9 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
3
  import { TONE_SURFACE } from "./Callout.js";
4
4
  import { CardBadge } from "./CardBadge.js";
5
- import { CardBar, CardFooter, CardHeader, CardHeading, CardIcon, CardInset } from "./CardParts.js";
5
+ import { BODY_PAD, cardBody } from "./CardBody.js";
6
+ import { CardFooter } from "./CardParts.js";
6
7
  import { cx, focusRing, liftInteractive } from "./cx.js";
7
8
  import { renderLink } from "./link.js";
8
9
  /** A card with a `footer`: a `dashed` card stands alone and has none. */
@@ -29,12 +30,11 @@ function cardForm(asked, hasHeader) {
29
30
  tone: dashed || bar ? undefined : asked.tone,
30
31
  attention: on(asked.attention, dashed),
31
32
  icon: paddedIcon(asked, dashed || bar),
33
+ fill: on(asked.fill, dashed),
32
34
  size: asked.size,
33
35
  footer: false,
34
36
  };
35
37
  }
36
- /** The padding of a card's body by `size`. */
37
- const BODY_PAD = { sm: "p-3", md: "p-5", lg: "p-6" };
38
38
  function cardBorder(form) {
39
39
  if (form.dashed)
40
40
  return "border-dashed border-slate-300";
@@ -49,10 +49,10 @@ function cardPadding(form) {
49
49
  if (form.dashed) {
50
50
  return cx(form.filled ? "p-10" : "p-8", "text-center text-sm text-slate-500");
51
51
  }
52
- return !form.flush && !form.bar && !form.footer && BODY_PAD[form.size];
52
+ return !form.flush && !form.bar && !form.footer && !form.fill && BODY_PAD[form.size];
53
53
  }
54
54
  function cardClass(form) {
55
- return cx("relative scroll-mt-28 rounded-xl border target:outline-2 target:outline-violet-600", liftInteractive, cardBorder(form), form.filled && "bg-white", cardPadding(form), !form.dashed && !form.tone && "shadow-sm", (form.flush || form.bar || form.footer) && "overflow-hidden", form.footer && "flex flex-col");
55
+ return cx("relative scroll-mt-28 rounded-xl border target:outline-2 target:outline-violet-600", liftInteractive, cardBorder(form), form.filled && "bg-white", cardPadding(form), !form.dashed && !form.tone && "shadow-sm", (form.flush || form.bar || form.footer || form.fill) && "overflow-hidden", form.fill ? "flex h-full min-h-0 flex-col" : form.footer && "flex flex-col");
56
56
  }
57
57
  function isCardLink(props) {
58
58
  return props.href !== undefined || props.render !== undefined;
@@ -81,6 +81,9 @@ function isCardLink(props) {
81
81
  * total; in a `CardGrid` it stays at the foot of a card that is stretched to the row's height. It
82
82
  * suits a panel, not a card that is a link, and `dashed` has none.
83
83
  *
84
+ * `fill` makes a panel take its parent's full height (the parent sets it): the title or bar stays
85
+ * put and the body scrolls. Not on a link card.
86
+ *
84
87
  * `href` makes the whole card one link: a hover border and the focus ring, with the link stretched
85
88
  * over the card, so it takes no `actions`, `footer` or `as`. A `TextLink`, `Button` or `Checkbox` in
86
89
  * the body sits above the link and is clickable on its own. With `render` it draws the app's own router `<Link>`
@@ -95,10 +98,10 @@ export function Card(props) {
95
98
  }
96
99
  /** A card that is not a link: `as` (a `section` by default) named by its heading. */
97
100
  function PanelCard(props) {
98
- const { title, titleStyle, count, status, actions, footer, bar, flush, dashed, tone, badge, inset, attention, icon, size = "md", children, ...panel } = props;
101
+ const { title, titleStyle, count, status, actions, footer, bar, flush, dashed, tone, badge, inset, attention, icon, fill, size = "md", children, ...panel } = props;
99
102
  const { as: Tag = "section", ...rest } = panel;
100
103
  const headingId = useId();
101
- const form = withFooter(cardForm({ flush, bar, dashed, tone, attention, icon, size }, Boolean(title || actions)), footer);
104
+ const form = withFooter(cardForm({ flush, bar, dashed, tone, attention, icon, fill, size }, Boolean(title || actions)), footer);
102
105
  // The title names the section or article; a caller's own name wins, and a bare `div` takes none.
103
106
  const named = title &&
104
107
  Tag !== "div" &&
@@ -143,19 +146,3 @@ function LinkCard(props) {
143
146
  headingId: undefined,
144
147
  }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
145
148
  }
146
- function insetBand(form, inset) {
147
- if (!inset || form.dashed)
148
- return null;
149
- return (_jsx(CardInset, { size: form.size, bleed: !form.flush, children: inset }));
150
- }
151
- function cardBody(form, { title, titleStyle, count, status, actions, inset, children, headingId, }) {
152
- const heading = title && (_jsx(CardHeading, { id: headingId, title: title, titleStyle: titleStyle, count: count, status: status, bar: form.bar, spaced: !form.bar && !actions, size: form.size }));
153
- const grow = form.footer && "flex-1";
154
- const band = insetBand(form, inset);
155
- if (form.bar) {
156
- return (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: actions, size: form.size }), _jsxs("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]) || undefined, children: [band, children] })] }));
157
- }
158
- const content = (_jsxs(_Fragment, { children: [form.icon && _jsx(CardIcon, { name: form.icon, size: form.size }), _jsx(CardHeader, { heading: heading, actions: actions, size: form.size }), band, children] }));
159
- // With a footer the root has no padding, so the body pads itself and takes the room left over.
160
- return form.footer ? (_jsx("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]), children: content })) : (content);
161
- }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from "react";
2
+ import type { IconName } from "./icons.js";
3
+ import type { Size } from "./size.js";
4
+ /** The padding of a card's body by `size`. */
5
+ export declare const BODY_PAD: Record<Size, string>;
6
+ /** The options a card resolved (`cardForm` in `Card.tsx`) that shape its body. */
7
+ type BodyForm = {
8
+ flush: boolean;
9
+ bar: boolean;
10
+ dashed: boolean;
11
+ icon: IconName | undefined;
12
+ fill: boolean;
13
+ footer: boolean;
14
+ size: Size;
15
+ };
16
+ type BodyParts = {
17
+ title: string | undefined;
18
+ titleStyle: "overline" | "name" | undefined;
19
+ count: number | undefined;
20
+ status: ReactNode;
21
+ actions: ReactNode;
22
+ inset: ReactNode;
23
+ children: ReactNode;
24
+ headingId: string | undefined;
25
+ };
26
+ export declare function cardBody(form: BodyForm, parts: BodyParts): ReactNode;
27
+ export {};
@@ -0,0 +1,30 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CardBar, CardHeader, CardHeading, CardIcon, CardInset } from "./CardParts.js";
3
+ import { cx } from "./cx.js";
4
+ /** The padding of a card's body by `size`. */
5
+ export const BODY_PAD = { sm: "p-3", md: "p-5", lg: "p-6" };
6
+ function insetBand(form, inset) {
7
+ if (!inset || form.dashed)
8
+ return null;
9
+ return (_jsx(CardInset, { size: form.size, bleed: !form.flush, children: inset }));
10
+ }
11
+ /** With `fill` the body takes the room the header (and a footer) leave, and only it scrolls. */
12
+ const SCROLL = "min-h-0 flex-1 overflow-y-auto";
13
+ /** The padding the body takes itself, when the root has none: never on a flush card. */
14
+ const pad = (form) => !form.flush && BODY_PAD[form.size];
15
+ /** The icon, header, inset band and children of a card with no bar. */
16
+ function plainBody(form, heading, parts) {
17
+ const content = (_jsxs(_Fragment, { children: [form.icon && _jsx(CardIcon, { name: form.icon, size: form.size }), _jsx(CardHeader, { heading: heading, actions: parts.actions, size: form.size }), insetBand(form, parts.inset), form.fill ? _jsx("div", { className: SCROLL, children: parts.children }) : parts.children] }));
18
+ if (form.fill) {
19
+ return _jsx("div", { className: cx("flex min-h-0 flex-1 flex-col", pad(form)), children: content });
20
+ }
21
+ // With a footer the root has no padding, so the body pads itself and takes the room left over.
22
+ return form.footer ? _jsx("div", { className: cx("flex-1", pad(form)), children: content }) : content;
23
+ }
24
+ export function cardBody(form, parts) {
25
+ const heading = parts.title && (_jsx(CardHeading, { id: parts.headingId, title: parts.title, titleStyle: parts.titleStyle, count: parts.count, status: parts.status, bar: form.bar, spaced: !form.bar && !parts.actions, size: form.size }));
26
+ if (!form.bar)
27
+ return plainBody(form, heading, parts);
28
+ const grow = form.fill ? SCROLL : form.footer && "flex-1";
29
+ return (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: parts.actions, size: form.size }), _jsxs("div", { className: cx(grow, pad(form)) || undefined, children: [insetBand(form, parts.inset), parts.children] })] }));
30
+ }
package/dist/Markdown.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  // A minimal, SAFE markdown renderer (headings / bullets / bold). No
3
- // dangerouslySetInnerHTML — text is rendered as React nodes, so it can't inject HTML.
3
+ // dangerouslySetInnerHTML: text is rendered as React nodes, so it can't inject HTML.
4
4
  function inline(text) {
5
5
  return text.split(/(\*\*[^*]+\*\*)/g).map((p, i) => p.startsWith("**") && p.endsWith("**") ? (_jsx("strong", { className: "font-semibold text-slate-900", children: p.slice(2, -2) }, i)) : (_jsx("span", { children: p }, i)));
6
6
  }
@@ -7,16 +7,16 @@ export type BarTone = "accent" | "positive" | "warn" | "negative";
7
7
  export type BarSize = Size;
8
8
  export declare const BAR_FILL: Record<BarTone, string>;
9
9
  export declare const BAR_TRACK: Record<BarSize, string>;
10
- /** `part` as a percent of `whole`, held to 0–100; 0 when either is not positive. */
10
+ /** `part` as a percent of `whole`, held between 0 and 100; 0 when either is not positive. */
11
11
  export declare function sharePercent(part: number, whole: number): number;
12
12
  /** The share as a whole percent for a screen reader: never 0 or 100 unless it truly is. */
13
13
  export declare function announcedPercent(part: number, whole: number): number;
14
14
  /**
15
- * A thin horizontal bar with a filled share, for progress toward a total. `value` is held to
16
- * 0–`max`. `label` names what it measures, for a screen reader, which also hears the percentage.
15
+ * A thin horizontal bar with a filled share, for progress toward a total. `value` is held
16
+ * between 0 and `max`. `label` names what it measures, for a screen reader, which also hears the percentage.
17
17
  */
18
18
  export declare function ProgressBar({ value, max, label, tone, size, ...rest }: {
19
- /** How far along, held to 0–`max`. */
19
+ /** How far along, held between 0 and `max`. */
20
20
  value: number;
21
21
  /** Defaults to 100. */
22
22
  max?: number;
@@ -11,7 +11,7 @@ export const BAR_TRACK = {
11
11
  md: "h-2",
12
12
  lg: "h-3",
13
13
  };
14
- /** `part` as a percent of `whole`, held to 0–100; 0 when either is not positive. */
14
+ /** `part` as a percent of `whole`, held between 0 and 100; 0 when either is not positive. */
15
15
  export function sharePercent(part, whole) {
16
16
  if (!(whole > 0) || !(part > 0))
17
17
  return 0;
@@ -23,8 +23,8 @@ export function announcedPercent(part, whole) {
23
23
  return share <= 0 || share >= 100 ? share : Math.min(99, Math.max(1, Math.round(share)));
24
24
  }
25
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.
26
+ * A thin horizontal bar with a filled share, for progress toward a total. `value` is held
27
+ * between 0 and `max`. `label` names what it measures, for a screen reader, which also hears the percentage.
28
28
  */
29
29
  export function ProgressBar({ value, max = 100, label, tone = "accent", size = "md", ...rest }) {
30
30
  const share = sharePercent(value, max);
@@ -3,7 +3,7 @@
3
3
  * else: no HTML, no client JS. `CodeText` maps a run to a palette utility, so a theme recolours
4
4
  * highlighted code exactly as it recolours everything else.
5
5
  *
6
- * The grammars are deliberately shallow — enough for examples, not a compiler front end. `text`
6
+ * The grammars are deliberately shallow: enough for examples, not a compiler front end. `text`
7
7
  * returns the code as one plain run, so nothing is coloured where no language is named. The
8
8
  * languages: `text`, `tsx`, `ts`, `js`, `json`, `bash`, `console` (a shell session: `$ ` commands
9
9
  * scanned as bash, everything else output), `css`, `html` (with `<script>` scanned as JS and
package/dist/highlight.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * else: no HTML, no client JS. `CodeText` maps a run to a palette utility, so a theme recolours
4
4
  * highlighted code exactly as it recolours everything else.
5
5
  *
6
- * The grammars are deliberately shallow — enough for examples, not a compiler front end. `text`
6
+ * The grammars are deliberately shallow: enough for examples, not a compiler front end. `text`
7
7
  * returns the code as one plain run, so nothing is coloured where no language is named. The
8
8
  * languages: `text`, `tsx`, `ts`, `js`, `json`, `bash`, `console` (a shell session: `$ ` commands
9
9
  * scanned as bash, everything else output), `css`, `html` (with `<script>` scanned as JS and
@@ -210,7 +210,7 @@ function childrenStep(scan) {
210
210
  takeChar(scan);
211
211
  }
212
212
  /**
213
- * JS, TS and TSX. Three modes — code, inside a tag, and children text — keep an apostrophe in
213
+ * JS, TS and TSX. Three modes (code, inside a tag, and children text) keep an apostrophe in
214
214
  * `<p>Don't</p>` as text rather than the start of a runaway string, and every open element records
215
215
  * the mode to return to, so a `{…}` expression that holds JSX closes back into code.
216
216
  */
@@ -56,7 +56,7 @@ export type InlineTypographyProps<E extends ElementType> = Own & {
56
56
  as?: E;
57
57
  } & Omit<ComponentPropsWithoutRef<E>, keyof Own | "as" | "className" | "style">;
58
58
  /**
59
- * Marketing-scale headline — the one line at the top of a public page. `h1`,
59
+ * Marketing-scale headline: the one line at the top of a public page. `h1`,
60
60
  * text-4xl/tight semibold, primary text, growing to text-6xl on wider screens.
61
61
  * `PageHeading` is the title of an ordinary page, a step below; a brochure hero needs a display
62
62
  * scale, so it gets its own pinned role rather than callers overriding PageHeading's size.
@@ -25,7 +25,7 @@ const ALIGN = {
25
25
  /**
26
26
  * Build a semantic component that pins `variantClass` (size + weight + tracking)
27
27
  * and a default colour. The scale/weight for each role therefore lives in exactly
28
- * one place (the call below), and callers can only vary the colour and the element —
28
+ * one place (the call below), and callers can only vary the colour and the element,
29
29
  * never the pinned size/weight, and never with a class.
30
30
  */
31
31
  function typography(displayName, defaultAs, variantClass, defaultColor) {
@@ -70,7 +70,7 @@ function plainText(children) {
70
70
  }
71
71
  // --- Structural roles (block by default) -----------------------------------
72
72
  /**
73
- * Marketing-scale headline — the one line at the top of a public page. `h1`,
73
+ * Marketing-scale headline: the one line at the top of a public page. `h1`,
74
74
  * text-4xl/tight semibold, primary text, growing to text-6xl on wider screens.
75
75
  * `PageHeading` is the title of an ordinary page, a step below; a brochure hero needs a display
76
76
  * scale, so it gets its own pinned role rather than callers overriding PageHeading's size.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui-react",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a theme from @hackcove/themes.",
5
5
  "keywords": [
6
6
  "components",