@hackcove/ui-react 0.8.3 → 0.9.0

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 CHANGED
@@ -172,10 +172,14 @@ import { AppShell, PageHeader, Section, Stack, Tabs, appShellRootClassFor } from
172
172
  </html>;
173
173
  ```
174
174
 
175
- - **`AppShell`** is the page frame: a sticky header that is always one `h-14` row (a nav that does
176
- not fit scrolls sideways, it never wraps, and the edge with more to scroll to fades out until it
177
- is reached), an optional `subnav` second row (same rule), `main`
178
- with fixed `py-8`, and an optional `footer`. The header row is filled by slots, left to right:
175
+ - **`AppShell`** is the page frame: a sticky header that is one `h-14` row on a phone and, from
176
+ `md`, an `h-14` row plus an `h-12` `subnav` row. On a phone `nav` and `subnav` fold behind one
177
+ **Menu** button (a native `<details>`, so it opens with no JavaScript, on the keyboard and for a
178
+ screen reader; Escape closes it and a followed link closes it). The panel hangs below the header
179
+ and never changes its height, nothing wraps and nothing scrolls sideways. `menuLabel` renames the
180
+ button (default `Menu`). From `md` the nav and the subnav are rows again (a row wider than the
181
+ screen scrolls sideways, never wraps, and the edge with more to reach fades out). `main`
182
+ has fixed `py-8`, and an optional `footer`. The header row is filled by slots, left to right:
179
183
  `brand` (logo and name, at the brand type with the logo gap), `nav` (pill `Tabs` of links), `header` (a
180
184
  free node, the escape hatch) and `controls` (end-aligned, `gap-2`, centred): no wrapper and
181
185
  no styling. Nothing in `controls` shrinks, and a full-width control there (a `Select` at its
@@ -72,18 +72,30 @@ export declare function AppShellHideBelow({ breakpoint, children, ...rest }: Pas
72
72
  * the slots do not cover), then `controls`, pushed to the end. An app writes no wrapper or
73
73
  * styling for any of them: an item that only fits a wide screen goes in `AppShellHideBelow`. A bar
74
74
  * that sticks under the header as the page scrolls is a `StickyBar`.
75
+ *
76
+ * On a phone (below `md`) the header is one 56px row at every page: `brand`, `header`, `controls`
77
+ * and a "Menu" button (`menuLabel`), and `nav` and `subnav` move into that menu, so every link is
78
+ * reachable and nothing scrolls sideways. The menu is a native `<details>`, so it works with the
79
+ * keyboard, with a screen reader and on a page that never hydrates. From `md` the header is the
80
+ * row, and the `subnav` row, as they are.
75
81
  */
76
- export declare function AppShell({ brand, nav, controls, header, subnav, footer, bleed, hideChromeInPrint, children, ...rest }: {
82
+ export declare function AppShell({ brand, nav, controls, header, subnav, menuLabel, footer, bleed, hideChromeInPrint, children, ...rest }: {
77
83
  /** The brand: a logo and a name, in the header's brand type, with the gap between them. `AppShellBrand` makes both one link. */
78
84
  brand?: ReactNode;
79
- /** The main navigation, e.g. a `Tabs variant="pill"` of links (it is already a `<nav>`). */
85
+ /** The main navigation, e.g. a `Tabs variant="pill"` of links (it is already a `<nav>`). Below `md` it is in the menu. */
80
86
  nav?: ReactNode;
81
87
  /** 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. */
82
88
  controls?: ReactNode;
83
89
  /** The escape hatch: a free node in the header row, after `brand` and `nav`. */
84
90
  header?: ReactNode;
85
- /** A second fixed-height row under the header, e.g. a `Tabs variant="pill"` of links. */
91
+ /** 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`. */
86
92
  subnav?: ReactNode;
93
+ /**
94
+ * 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.
96
+ * @default Menu
97
+ */
98
+ menuLabel?: string;
87
99
  /** The page footer: small muted text in a `Container`, under a hairline. Hidden in print with `hideChromeInPrint`. */
88
100
  footer?: ReactNode;
89
101
  /** Main without a Container, for a full-bleed layout. */
package/dist/AppShell.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
+ import { AppShellMenu } from "./AppShellMenu.js";
3
4
  import { ContainerFrame } from "./Container.js";
4
5
  import { schemeClass } from "./colourScheme.js";
5
6
  import { cx, focusRing } from "./cx.js";
@@ -46,12 +47,13 @@ const SHOWN_FROM = {
46
47
  export function AppShellHideBelow({ breakpoint = "sm", children, ...rest }) {
47
48
  return (_jsx("span", { ...rest, className: cx(SHOWN_FROM[breakpoint], "items-center gap-2"), children: children }));
48
49
  }
49
- // One fixed-height row that scrolls sideways and never wraps; its children keep their width.
50
- const ROW = "flex items-center gap-4 overflow-x-auto whitespace-nowrap *:shrink-0";
51
- // While a row has more to scroll to on a side, that edge fades out (a mask, so it follows any theme).
52
- // `ScrollRow` sets `data-more-start` / `data-more-end` (and `data-more` for either); a row that fits
53
- // has none and is untouched.
54
- const FADE = "data-more-start:[--fade-start:2rem] data-more-end:[--fade-end:2rem] data-more:[mask-image:linear-gradient(to_right,transparent,#000_var(--fade-start,0px),#000_calc(100%_-_var(--fade-end,0px)),transparent)]";
50
+ // One fixed-height row that never wraps; its children keep their width. From `md` it scrolls sideways
51
+ // if it must; below `md` nothing in the header scrolls (the nav is in the menu), so it only clips.
52
+ const ROW = "items-center gap-4 overflow-x-clip whitespace-nowrap md:overflow-x-auto *:shrink-0";
53
+ // From `md`, while a row has more to scroll to on a side, that edge fades out (a mask, so it follows
54
+ // any theme). `ScrollRow` sets `data-more-start` / `data-more-end` (and `data-more` for either); a
55
+ // row that fits has none and is untouched. Below `md` there is no mask, which would clip the menu.
56
+ const FADE = "md:data-more-start:[--fade-start:2rem] md:data-more-end:[--fade-end:2rem] md:data-more:[mask-image:linear-gradient(to_right,transparent,#000_var(--fade-start,0px),#000_calc(100%_-_var(--fade-end,0px)),transparent)]";
55
57
  /** A row in a Container, with the fade on whichever side still has content to scroll to. */
56
58
  function ScrollRow({ className, children }) {
57
59
  const row = useRef(null);
@@ -81,9 +83,18 @@ function ScrollRow({ className, children }) {
81
83
  });
82
84
  return (_jsx(ContainerFrame, { elementRef: (el) => void (row.current = el), className: cx(ROW, FADE, className), children: children }));
83
85
  }
84
- /** The header row's slots in order: brand, nav, the free `children`, then controls at the end. */
85
- function HeaderSlots({ brand, nav, controls, children, }) {
86
- 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, children, controls && (_jsx("div", { className: "ml-auto flex items-center gap-2 *:shrink-0 [&>.w-full]:w-auto", children: controls }))] }));
86
+ /**
87
+ * The header row's slots in order: brand, nav (from `md`), the free `children`, then controls and,
88
+ * below `md`, the menu at the end.
89
+ */
90
+ function HeaderSlots({ brand, nav, controls, menu, children, }) {
91
+ 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
92
+ ? "ml-auto flex items-center gap-2 *:shrink-0 [&>.w-full]:w-auto"
93
+ : "ml-auto flex items-center gap-2 *:shrink-0 md:hidden", children: [controls, menu] }))] }));
94
+ }
95
+ /** The sticky header: the one-row slots, and from `md` the `subnav` row under them. */
96
+ function AppShellHeader({ brand, nav, controls, subnav, menuLabel, hideChromeInPrint, children, }) {
97
+ 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 })] }));
87
98
  }
88
99
  /**
89
100
  * The page frame: it paints the page canvas and sets the type face, ink and font smoothing
@@ -98,9 +109,16 @@ function HeaderSlots({ brand, nav, controls, children, }) {
98
109
  * the slots do not cover), then `controls`, pushed to the end. An app writes no wrapper or
99
110
  * styling for any of them: an item that only fits a wide screen goes in `AppShellHideBelow`. A bar
100
111
  * that sticks under the header as the page scrolls is a `StickyBar`.
112
+ *
113
+ * On a phone (below `md`) the header is one 56px row at every page: `brand`, `header`, `controls`
114
+ * and a "Menu" button (`menuLabel`), and `nav` and `subnav` move into that menu, so every link is
115
+ * reachable and nothing scrolls sideways. The menu is a native `<details>`, so it works with the
116
+ * keyboard, with a screen reader and on a page that never hydrates. From `md` the header is the
117
+ * row, and the `subnav` row, as they are.
101
118
  */
102
- export function AppShell({ brand, nav, controls, header, subnav, footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
119
+ export function AppShell({ brand, nav, controls, header, subnav, menuLabel = "Menu", footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
103
120
  return (_jsxs("div", { ...rest, className: cx("flex min-h-dvh flex-col bg-gray-50 font-sans text-slate-900 antialiased",
104
121
  // The sticky header's height (its rows and the bottom border), for a bar that sticks under it.
105
- subnav ? "[--app-header:calc(6.5rem+1px)]" : "[--app-header:calc(3.5rem+1px)]"), children: [_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, children: header }) }), subnav && _jsx(ScrollRow, { className: "h-12", children: subnav })] }), _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 }) }))] }));
122
+ // Below `md` it is one row whatever the slots are: the subnav is in the menu.
123
+ "[--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 }) }))] }));
106
124
  }
@@ -0,0 +1,13 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ /**
3
+ * The header's phone menu (below `md`): a native `<details>` whose `<summary>` is the button, so it
4
+ * opens and closes by pointer, Enter and Space, and is announced as a disclosure with its expanded
5
+ * state, all with no script. That is why it also works on a page that is never hydrated. The panel
6
+ * is out of flow (it hangs under the header, `top-full`), so the header keeps its one height open
7
+ * or closed. With script it also closes on Escape (focus returns to the button) and when a link in
8
+ * it is followed, for a router that changes the page without a reload.
9
+ */
10
+ export declare function AppShellMenu({ label, children, }: {
11
+ label: string;
12
+ children: ReactNode;
13
+ }): ReactElement;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { ContainerFrame } from "./Container.js";
4
+ import { cx, focusRing } from "./cx.js";
5
+ const ICON = {
6
+ viewBox: "0 0 20 20",
7
+ width: 16,
8
+ height: 16,
9
+ fill: "none",
10
+ stroke: "currentColor",
11
+ strokeWidth: 1.8,
12
+ strokeLinecap: "round",
13
+ strokeLinejoin: "round",
14
+ "aria-hidden": true,
15
+ className: "shrink-0 text-slate-500",
16
+ };
17
+ /**
18
+ * The header's phone menu (below `md`): a native `<details>` whose `<summary>` is the button, so it
19
+ * opens and closes by pointer, Enter and Space, and is announced as a disclosure with its expanded
20
+ * state, all with no script. That is why it also works on a page that is never hydrated. The panel
21
+ * is out of flow (it hangs under the header, `top-full`), so the header keeps its one height open
22
+ * or closed. With script it also closes on Escape (focus returns to the button) and when a link in
23
+ * it is followed, for a router that changes the page without a reload.
24
+ */
25
+ export function AppShellMenu({ label, children, }) {
26
+ const details = useRef(null);
27
+ const close = (focusButton) => {
28
+ const el = details.current;
29
+ if (!el?.open)
30
+ return;
31
+ el.open = false;
32
+ if (focusButton)
33
+ el.querySelector("summary")?.focus();
34
+ };
35
+ return (_jsxs("details", { ref: details, className: "group md:hidden", onKeyDown: (event) => {
36
+ if (event.key === "Escape")
37
+ 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) => {
39
+ if (event.target instanceof Element && event.target.closest("a[href]"))
40
+ close(false);
41
+ }, children: _jsx(ContainerFrame, { className: "flex flex-col gap-3 py-3", children: children }) })] }));
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui-react",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a @hackcove/theme-* package.",
5
5
  "keywords": [
6
6
  "components",