@hackcove/ui-react 0.10.8 → 0.10.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui-react",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
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",