@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 +8 -4
- 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/package.json +1 -1
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
package/dist/AppShell.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// `
|
|
53
|
-
//
|
|
54
|
-
|
|
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
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|