@hackcove/ui-react 0.10.2 → 0.10.4

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.
Files changed (45) hide show
  1. package/README.md +23 -5
  2. package/dist/AppShell.js +5 -27
  3. package/dist/Avatar.d.ts +1 -1
  4. package/dist/Avatar.js +4 -5
  5. package/dist/Checkbox.d.ts +3 -1
  6. package/dist/Checkbox.js +7 -2
  7. package/dist/CodeBlock.d.ts +2 -2
  8. package/dist/CodeBlock.js +2 -2
  9. package/dist/CodeBody.d.ts +2 -1
  10. package/dist/CodeBody.js +3 -2
  11. package/dist/CodeExample.d.ts +4 -3
  12. package/dist/CodeExample.js +4 -2
  13. package/dist/CopyButton.d.ts +3 -3
  14. package/dist/CopyButton.js +34 -14
  15. package/dist/CopyLine.js +1 -1
  16. package/dist/Dialog.js +1 -1
  17. package/dist/Hero.d.ts +23 -0
  18. package/dist/Hero.js +28 -0
  19. package/dist/Icon.d.ts +2 -1
  20. package/dist/Icon.js +2 -1
  21. package/dist/Image.d.ts +1 -1
  22. package/dist/Image.js +4 -5
  23. package/dist/NavigationProgress.d.ts +20 -0
  24. package/dist/NavigationProgress.js +26 -0
  25. package/dist/PageLayouts.js +5 -3
  26. package/dist/SidebarLayout.d.ts +5 -2
  27. package/dist/SidebarLayout.js +54 -4
  28. package/dist/SiteSearch.d.ts +32 -0
  29. package/dist/SiteSearch.js +150 -0
  30. package/dist/Stack.d.ts +6 -1
  31. package/dist/Stack.js +7 -2
  32. package/dist/copyScript.d.ts +13 -0
  33. package/dist/copyScript.js +13 -0
  34. package/dist/icons.d.ts +2 -2
  35. package/dist/icons.js +30 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.js +4 -0
  38. package/dist/scrollEdges.d.ts +12 -0
  39. package/dist/scrollEdges.js +40 -0
  40. package/dist/stickyColumn.d.ts +18 -0
  41. package/dist/stickyColumn.js +18 -0
  42. package/dist/typography.js +6 -2
  43. package/dist/useImageFailed.d.ts +19 -0
  44. package/dist/useImageFailed.js +21 -0
  45. package/package.json +2 -12
package/dist/Image.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from "react";
3
2
  import { cx } from "./cx.js";
4
3
  import { Icon } from "./Icon.js";
4
+ import { useImageFailed } from "./useImageFailed.js";
5
5
  const ASPECT = {
6
6
  square: "aspect-square",
7
7
  "4:3": "aspect-[4/3]",
@@ -48,11 +48,10 @@ function pictureLook(fit, framed) {
48
48
  * thumbnail with a label is a `MediaTile`.
49
49
  */
50
50
  export function Image({ src, alt, aspect = "4:3", fit = "cover", width = "full", framed = false, ...rest }) {
51
- // The `src` that failed to load: a new `src` gets its own try, so a broken one never sticks.
52
- const [failedSrc, setFailedSrc] = useState();
51
+ const { ref, failed, markFailed } = useImageFailed(src);
53
52
  const box = cx(FRAME, IMAGE_WIDTH[width]);
54
- if (src && src !== failedSrc) {
55
- return (_jsx("img", { ...rest, src: src, alt: alt, className: cx(box, ASPECT[aspect], pictureLook(fit, framed)), onError: () => setFailedSrc(src) }));
53
+ if (src && !failed) {
54
+ return (_jsx("img", { ...rest, ref: ref, src: src, alt: alt, className: cx(box, ASPECT[aspect], pictureLook(fit, framed)), onError: markFailed }));
56
55
  }
57
56
  return (_jsx("span", { ...rest, ...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true }), className: cx(box, aspect === "natural" ? PLACEHOLDER_ASPECT : ASPECT[aspect], INSET, "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 }) }) }) }));
58
57
  }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /**
4
+ * A thin indeterminate bar along the top edge of the viewport, in the accent tone, for a route
5
+ * change that is under way and has no known length. Pass `active` from the router's pending state
6
+ * and render it once per app, beside the page frame. It draws exactly what `active` says: the bar
7
+ * while it is true and nothing while it is false. The stylesheet holds it invisible for the first
8
+ * 150ms, so a fast navigation shows nothing. It is fixed to the viewport and overlays the top of
9
+ * the header, so nothing on the page moves. Under `prefers-reduced-motion: reduce` it is a still
10
+ * bar.
11
+ */
12
+ export declare function NavigationProgress({ active, label, ...rest }: {
13
+ /** True while a navigation is pending, e.g. from the router's state. */
14
+ active: boolean;
15
+ /**
16
+ * The bar's accessible name.
17
+ * @default Loading page
18
+ */
19
+ label?: string;
20
+ } & Passthrough): ReactElement | null;
@@ -0,0 +1,26 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { BAR_FILL } from "./ProgressBar.js";
3
+ // The one place the bar's motion lives: a stylesheet, as Tailwind classes cannot declare
4
+ // `@keyframes`. Nothing here is a timer in script: the 150ms wait is the opacity animation's own
5
+ // delay (backwards fill, so the bar is invisible until it ends), and a navigation that finishes
6
+ // first removes the bar before it ever showed. A segment slides across the bar; under reduced
7
+ // motion it is a still, full-width bar. No colour in it, which the bar's own class carries.
8
+ const css = `@keyframes hc-navigation-progress-in{from{opacity:0}to{opacity:1}}
9
+ @keyframes hc-navigation-progress{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
10
+ [data-navigation-progress]{animation:hc-navigation-progress-in .01s linear 150ms backwards}
11
+ [data-navigation-progress-bar]{width:40%;animation:hc-navigation-progress 1.4s ease-in-out infinite}
12
+ @media (prefers-reduced-motion:reduce){[data-navigation-progress-bar]{width:100%;animation:none}}`;
13
+ /**
14
+ * A thin indeterminate bar along the top edge of the viewport, in the accent tone, for a route
15
+ * change that is under way and has no known length. Pass `active` from the router's pending state
16
+ * and render it once per app, beside the page frame. It draws exactly what `active` says: the bar
17
+ * while it is true and nothing while it is false. The stylesheet holds it invisible for the first
18
+ * 150ms, so a fast navigation shows nothing. It is fixed to the viewport and overlays the top of
19
+ * the header, so nothing on the page moves. Under `prefers-reduced-motion: reduce` it is a still
20
+ * bar.
21
+ */
22
+ export function NavigationProgress({ active, label = "Loading page", ...rest }) {
23
+ if (!active)
24
+ return null;
25
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: css }), _jsx("div", { ...rest, role: "progressbar", "aria-label": label, "data-navigation-progress": "", className: "pointer-events-none fixed inset-x-0 top-0 z-50 h-[3px] overflow-hidden", children: _jsx("div", { "data-navigation-progress-bar": "", className: `h-full ${BAR_FILL.accent}` }) })] }));
26
+ }
@@ -4,8 +4,9 @@ import { Card } from "./Card.js";
4
4
  import { cx } from "./cx.js";
5
5
  import { PageHeader, PageHeaderContent } from "./PageHeader.js";
6
6
  import { STACK_WIDTH, Stack } from "./Stack.js";
7
+ import { useScrollEdges } from "./scrollEdges.js";
7
8
  import { scrollStopProps, useScrollStop } from "./scrollStop.js";
8
- import { STICKY_ASIDE_LG, STICKY_SCROLL_LG } from "./stickyColumn.js";
9
+ import { STICKY_ASIDE_LG, STICKY_FADE_LG, STICKY_SCROLL_LG } from "./stickyColumn.js";
9
10
  import { StickyBar } from "./StickyBar.js";
10
11
  // Compositions of the layout foundation. None sets a page width or outer padding of its own; the
11
12
  // ones that narrow their content do it inside the Container they are rendered in.
@@ -20,9 +21,10 @@ export function ListLayout({ filters, empty, isEmpty = false, children, breadcru
20
21
  * itself so its end stays reachable.
21
22
  */
22
23
  export function DetailLayout({ aside, asideLabel, stickyAside = false, children, breadcrumbs, title, subtitle, actions, tabs, ...rest }) {
23
- const { attach, stop } = useScrollStop();
24
+ const { element, attach, stop } = useScrollStop();
25
+ useScrollEdges(element, "y");
24
26
  const { props, ring } = scrollStopProps(stickyAside && stop, { label: asideLabel });
25
- return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { breadcrumbs: breadcrumbs, title: title, subtitle: subtitle, actions: actions, tabs: tabs }), _jsxs("div", { ...rest, className: "grid gap-6 lg:grid-cols-3", children: [_jsx("div", { className: "min-w-0 lg:col-span-2", children: _jsx(Stack, { children: children }) }), _jsx("aside", { className: cx("min-w-0", stickyAside && STICKY_ASIDE_LG), "aria-label": asideLabel, children: _jsx("div", { ref: stickyAside ? attach : undefined, ...props, className: stickyAside ? cx(STICKY_SCROLL_LG, ring) : undefined, children: _jsx(Stack, { children: aside }) }) })] })] }));
27
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { breadcrumbs: breadcrumbs, title: title, subtitle: subtitle, actions: actions, tabs: tabs }), _jsxs("div", { ...rest, className: "grid gap-6 lg:grid-cols-3", children: [_jsx("div", { className: "min-w-0 lg:col-span-2", children: _jsx(Stack, { children: children }) }), _jsx("aside", { className: cx("min-w-0", stickyAside && STICKY_ASIDE_LG), "aria-label": asideLabel, children: _jsx("div", { ref: stickyAside ? attach : undefined, ...props, className: stickyAside ? cx(STICKY_SCROLL_LG, STICKY_FADE_LG, ring) : undefined, children: _jsx(Stack, { children: aside }) }) })] })] }));
26
28
  }
27
29
  /** A form page: header (no header `actions`), then a `max-w-3xl` column that starts at the Container's left edge (it is not re-centred), with an `actions` row at the end. Other props go on the `<form>`. */
28
30
  export function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, ...form }) {
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import type { Passthrough } from "./props.js";
3
3
  /**
4
4
  * A `rail` of links beside the `children` canvas, in the Container the shell already gives every
@@ -10,7 +10,10 @@ import type { Passthrough } from "./props.js";
10
10
  * above the canvas and nothing sticks. A long rail sets `railLabel`:
11
11
  * below `md` it then folds behind a disclosure of that name, closed, so the page comes first on a
12
12
  * phone, and from `md` it is the same rail; `railLabel` also names the rail's `<aside>` landmark. Upgrading from 0.2: the rail no longer has a fill, a
13
- * border or a fixed height, and it no longer sits beside the canvas on a phone.
13
+ * border or a fixed height, and it no longer sits beside the canvas on a phone. Once the page has
14
+ * loaded, a rail that scrolls shows its current link (the one with `aria-current`) in view, and the
15
+ * folded disclosure reads `railLabel` and the current link's name ("All components: Button"); both
16
+ * need the layout to hydrate.
14
17
  */
15
18
  export declare function SidebarLayout({ rail, railLabel, children, ...rest }: {
16
19
  /** The links beside the canvas, usually a `Tabs variant="pill" orientation="vertical"`. */
@@ -1,8 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
2
3
  import { cx } from "./cx.js";
3
4
  import { FoldedColumn } from "./foldedColumn.js";
5
+ import { useScrollEdges } from "./scrollEdges.js";
4
6
  import { scrollStopProps, useScrollStop } from "./scrollStop.js";
5
- import { STICKY_ASIDE_MD, STICKY_SCROLL_MD } from "./stickyColumn.js";
7
+ import { STICKY_ASIDE_MD, STICKY_BAR_MD, STICKY_FADE_MD, STICKY_INK_MD, STICKY_SCROLL_MD, } from "./stickyColumn.js";
8
+ /** The rail's current link: the one `Tabs` marks with `aria-current`. */
9
+ const CURRENT = '[aria-current]:not([aria-current="false"])';
10
+ /** The room the scroller's fade takes at each edge (`STICKY_FADE_MD`'s 1.5rem), in px. */
11
+ const FADE = 24;
12
+ /**
13
+ * Scrolls `scroller` (only it, never the page, which `scrollIntoView` would also move) so `item`
14
+ * sits in the middle of its visible part. Left alone when it is already clear of the faded edges, or
15
+ * when the scroller does not scroll (below `md`, or a rail that fits).
16
+ */
17
+ function scrollToCentre(scroller, item) {
18
+ if (scroller.scrollHeight <= scroller.clientHeight)
19
+ return;
20
+ const box = scroller.getBoundingClientRect();
21
+ const at = item.getBoundingClientRect();
22
+ if (at.top >= box.top + FADE && at.bottom <= box.bottom - FADE)
23
+ return;
24
+ scroller.scrollTop += at.top + at.height / 2 - (box.top + box.height / 2);
25
+ }
6
26
  /**
7
27
  * A `rail` of links beside the `children` canvas, in the Container the shell already gives every
8
28
  * page (a plain `<AppShell>`, no `bleed`): the rail starts at the header logo's x and the canvas
@@ -13,10 +33,40 @@ import { STICKY_ASIDE_MD, STICKY_SCROLL_MD } from "./stickyColumn.js";
13
33
  * above the canvas and nothing sticks. A long rail sets `railLabel`:
14
34
  * below `md` it then folds behind a disclosure of that name, closed, so the page comes first on a
15
35
  * phone, and from `md` it is the same rail; `railLabel` also names the rail's `<aside>` landmark. Upgrading from 0.2: the rail no longer has a fill, a
16
- * border or a fixed height, and it no longer sits beside the canvas on a phone.
36
+ * border or a fixed height, and it no longer sits beside the canvas on a phone. Once the page has
37
+ * loaded, a rail that scrolls shows its current link (the one with `aria-current`) in view, and the
38
+ * folded disclosure reads `railLabel` and the current link's name ("All components: Button"); both
39
+ * need the layout to hydrate.
17
40
  */
18
41
  export function SidebarLayout({ rail, railLabel, children, ...rest }) {
19
- const { attach, stop } = useScrollStop();
42
+ const { element, attach, stop } = useScrollStop();
43
+ useScrollEdges(element, "y");
44
+ const [current, setCurrent] = useState(undefined);
45
+ const scrolledTo = useRef(null);
46
+ // The current link is scrolled into view once, and its name goes on the folded disclosure. It is
47
+ // read when the layout mounts and again whenever the rail's links change (a consumer's router
48
+ // moving `aria-current` to another link).
49
+ useEffect(() => {
50
+ if (!element)
51
+ return undefined;
52
+ const sync = () => {
53
+ const item = element.querySelector(CURRENT);
54
+ setCurrent(item?.textContent?.trim() || undefined);
55
+ if (item && item !== scrolledTo.current)
56
+ scrollToCentre(element, item);
57
+ scrolledTo.current = item;
58
+ };
59
+ queueMicrotask(sync);
60
+ const change = new MutationObserver(sync);
61
+ change.observe(element, {
62
+ childList: true,
63
+ subtree: true,
64
+ characterData: true,
65
+ attributes: true,
66
+ attributeFilter: ["aria-current"],
67
+ });
68
+ return () => change.disconnect();
69
+ }, [element]);
20
70
  const { props, ring } = scrollStopProps(stop, { label: railLabel });
21
- return (_jsxs("div", { ...rest, className: "flex flex-col gap-8 md:flex-row md:gap-10", children: [_jsx("aside", { className: cx("w-full shrink-0 md:w-56", STICKY_ASIDE_MD), "aria-label": railLabel, children: _jsx("div", { ref: attach, ...props, className: cx(STICKY_SCROLL_MD, ring), children: railLabel === undefined ? (rail) : (_jsx(FoldedColumn, { from: "md", label: railLabel, children: rail })) }) }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
71
+ return (_jsxs("div", { ...rest, className: "flex flex-col gap-8 md:flex-row md:gap-10", children: [_jsx("aside", { className: cx("w-full shrink-0 md:w-56", STICKY_ASIDE_MD), "aria-label": railLabel, children: _jsx("div", { ref: attach, ...props, className: cx(STICKY_SCROLL_MD, STICKY_BAR_MD, STICKY_FADE_MD, ring), children: _jsx("div", { className: STICKY_INK_MD, children: railLabel === undefined ? (rail) : (_jsx(FoldedColumn, { from: "md", label: current === undefined ? railLabel : `${railLabel}: ${current}`, children: rail })) }) }) }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
22
72
  }
@@ -0,0 +1,32 @@
1
+ import { type ReactElement } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /** One page the search can find. */
4
+ export type SiteSearchItem = {
5
+ /** The page's name: the row's label and the first thing matched. */
6
+ label: string;
7
+ /** A muted line under the label, matched last. */
8
+ description?: string | undefined;
9
+ /** Where the row goes. */
10
+ href: string;
11
+ /** Other names the page answers to (the exports it documents), matched like the label but not shown. */
12
+ keywords?: string[] | undefined;
13
+ };
14
+ /**
15
+ * The items that match `text`, best first: the label starts with it, then the label contains it,
16
+ * then a keyword contains it, then the description does. Case is ignored, and items of the same
17
+ * rank keep their order in `items`. Empty text matches nothing.
18
+ */
19
+ export declare function rankSiteSearch(items: readonly SiteSearchItem[], text: string): SiteSearchItem[];
20
+ /**
21
+ * The site's search: a header button that opens a dialog with a search field over a list of links
22
+ * to the pages that match. Put it in `AppShell`'s `controls` and pass every page as `items`. Ctrl+K
23
+ * (⌘ K on a Mac) opens it from anywhere on the page, and the button shows that key. Typing filters
24
+ * by name, then by `keywords`, then by description; Enter opens the first result, the arrow keys
25
+ * move through them, and Esc closes it and puts focus back where it was. Render it once per page.
26
+ */
27
+ export declare function SiteSearch({ items, label, ...rest }: {
28
+ /** Every page to search, in the order ties are listed (the rail's order). */
29
+ items: readonly SiteSearchItem[];
30
+ /** The dialog's title and the button's accessible name: "Search components". */
31
+ label: string;
32
+ } & Passthrough): ReactElement;
@@ -0,0 +1,150 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
3
+ import { Button } from "./Button.js";
4
+ import { Dialog } from "./Dialog.js";
5
+ import { Icon } from "./Icon.js";
6
+ import { IconButton } from "./IconButton.js";
7
+ import { Input } from "./Input.js";
8
+ import { Kbd } from "./Kbd.js";
9
+ import { ListRow } from "./ListRow.js";
10
+ import { Text } from "./typography.js";
11
+ /**
12
+ * The items that match `text`, best first: the label starts with it, then the label contains it,
13
+ * then a keyword contains it, then the description does. Case is ignored, and items of the same
14
+ * rank keep their order in `items`. Empty text matches nothing.
15
+ */
16
+ export function rankSiteSearch(items, text) {
17
+ const query = text.trim().toLowerCase();
18
+ if (query === "")
19
+ return [];
20
+ const rankOf = (item) => {
21
+ const label = item.label.toLowerCase();
22
+ if (label.startsWith(query))
23
+ return 0;
24
+ if (label.includes(query))
25
+ return 1;
26
+ if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(query)))
27
+ return 2;
28
+ if (item.description?.toLowerCase().includes(query))
29
+ return 3;
30
+ return -1;
31
+ };
32
+ return items
33
+ .map((item, index) => ({ item, index, rank: rankOf(item) }))
34
+ .filter((entry) => entry.rank >= 0)
35
+ .toSorted((a, b) => a.rank - b.rank || a.index - b.index)
36
+ .map((entry) => entry.item);
37
+ }
38
+ /** What the live region says for a result count. */
39
+ function countText(query, count) {
40
+ if (query.trim() === "")
41
+ return "";
42
+ if (count === 0)
43
+ return "No results";
44
+ return count === 1 ? "1 result" : `${count} results`;
45
+ }
46
+ // The shortcut's key reads ⌘ K on Apple platforms. The server cannot know, so it renders Ctrl K and
47
+ // the client swaps after hydration.
48
+ const noSubscribe = () => () => { };
49
+ const isApple = () => /mac|iphone|ipad|ipod/i.test(typeof navigator === "undefined" ? "" : navigator.userAgent);
50
+ /** Ctrl+K or Cmd+K with no other modifier, the one shortcut that opens the search. */
51
+ function isShortcut(event) {
52
+ return ((event.ctrlKey || event.metaKey) &&
53
+ !event.altKey &&
54
+ !event.shiftKey &&
55
+ event.key.toLowerCase() === "k");
56
+ }
57
+ // A link that stays on the page (a hash, an app's router) still closes the search; one opened in a
58
+ // new tab or window leaves it open.
59
+ function onListClick(event) {
60
+ if (event.ctrlKey || event.metaKey || event.shiftKey)
61
+ return;
62
+ if (event.target instanceof Element && event.target.closest("a[href]")) {
63
+ event.currentTarget.closest("dialog")?.close();
64
+ }
65
+ }
66
+ /** The result links in the list, in order. */
67
+ function linksIn(list) {
68
+ return list ? [...list.querySelectorAll("a[href]")] : [];
69
+ }
70
+ function SearchBody({ items, label }) {
71
+ const [query, setQuery] = useState("");
72
+ const box = useRef(null);
73
+ const list = useRef(null);
74
+ const results = rankSiteSearch(items, query);
75
+ const typed = query.trim() !== "";
76
+ const onFieldKey = (event) => {
77
+ if (event.key === "Escape") {
78
+ // Close here, so no engine spends the first Esc clearing the search field.
79
+ event.preventDefault();
80
+ event.currentTarget.closest("dialog")?.close();
81
+ }
82
+ else if (event.key === "Enter") {
83
+ event.preventDefault();
84
+ linksIn(list.current)[0]?.click();
85
+ }
86
+ else if (event.key === "ArrowDown") {
87
+ const first = linksIn(list.current)[0];
88
+ if (first) {
89
+ event.preventDefault();
90
+ first.focus();
91
+ }
92
+ }
93
+ };
94
+ const onListKey = (event) => {
95
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp")
96
+ return;
97
+ const links = linksIn(list.current);
98
+ const at = links.findIndex((link) => link === document.activeElement);
99
+ if (at < 0)
100
+ return;
101
+ event.preventDefault();
102
+ const next = at + (event.key === "ArrowDown" ? 1 : -1);
103
+ if (next < 0)
104
+ box.current?.querySelector("input")?.focus();
105
+ else
106
+ links[Math.min(next, links.length - 1)]?.focus();
107
+ };
108
+ return (_jsxs("div", { ref: box, className: "flex flex-col gap-3", children: [_jsx(Input, { type: "search", icon: _jsx(Icon, { name: "search" }), "aria-label": label, placeholder: "Search", autoComplete: "off", spellCheck: false, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: onFieldKey }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: countText(query, results.length) }), !typed && (_jsx(Text, { as: "p", tone: "muted", children: "Type a name" })), typed && results.length === 0 && (_jsxs(Text, { as: "p", tone: "muted", children: ["No results for ", query.trim()] })), results.length > 0 && (_jsx("ul", { ref: list, "aria-label": "Results", onKeyDown: onListKey, onClick: onListClick, children: results.map((item, index) => (_jsx("li", { children: _jsx(ListRow, { href: item.href, size: "sm", divided: index > 0, secondary: item.description, children: item.label }) }, item.href))) }))] }));
109
+ }
110
+ /**
111
+ * The site's search: a header button that opens a dialog with a search field over a list of links
112
+ * to the pages that match. Put it in `AppShell`'s `controls` and pass every page as `items`. Ctrl+K
113
+ * (⌘ K on a Mac) opens it from anywhere on the page, and the button shows that key. Typing filters
114
+ * by name, then by `keywords`, then by description; Enter opens the first result, the arrow keys
115
+ * move through them, and Esc closes it and puts focus back where it was. Render it once per page.
116
+ */
117
+ export function SiteSearch({ items, label, ...rest }) {
118
+ const [open, setOpen] = useState(false);
119
+ const opener = useRef(null);
120
+ const apple = useSyncExternalStore(noSubscribe, isApple, () => false);
121
+ const keys = apple ? "⌘ K" : "Ctrl K";
122
+ // Before paint, so a client-rendered trigger that is on screen already answers the shortcut.
123
+ useLayoutEffect(() => {
124
+ const onKey = (event) => {
125
+ if (event.defaultPrevented || event.repeat || !isShortcut(event))
126
+ return;
127
+ event.preventDefault();
128
+ // Only the press that opens it records the opener; `close` clears it.
129
+ if (!opener.current && document.activeElement instanceof HTMLElement) {
130
+ opener.current = document.activeElement;
131
+ }
132
+ setOpen(true);
133
+ };
134
+ window.addEventListener("keydown", onKey);
135
+ return () => window.removeEventListener("keydown", onKey);
136
+ }, []);
137
+ // A pointer click does not focus a button in every engine, so the trigger is remembered by hand.
138
+ const openFrom = (event) => {
139
+ opener.current = event.currentTarget;
140
+ setOpen(true);
141
+ };
142
+ const close = () => {
143
+ setOpen(false);
144
+ const back = opener.current;
145
+ opener.current = null;
146
+ if (back?.isConnected && back !== document.body)
147
+ back.focus();
148
+ };
149
+ return (_jsxs("span", { ...rest, className: "inline-flex", children: [_jsx("span", { className: "hidden sm:inline-flex", children: _jsxs(Button, { variant: "outline", size: "sm", "aria-label": label, "aria-haspopup": "dialog", "aria-keyshortcuts": "Control+K Meta+K", onClick: openFrom, children: [_jsx(Icon, { name: "search", size: "sm" }), "Search", _jsx(Kbd, { children: keys })] }) }), _jsx("span", { className: "inline-flex sm:hidden", children: _jsx(IconButton, { label: label, size: "sm", "aria-haspopup": "dialog", "aria-keyshortcuts": "Control+K Meta+K", onClick: openFrom, children: _jsx(Icon, { name: "search", size: "sm" }) }) }), open && (_jsx(Dialog, { title: label, size: "md", onClose: close, children: _jsx(SearchBody, { items: items, label: label }) }))] }));
150
+ }
package/dist/Stack.d.ts CHANGED
@@ -66,7 +66,12 @@ type StackProps = StackBase & (({
66
66
  * title row, `actions` included, as `SectionHeading`'s `rule` does for a bare heading. A line
67
67
  * between two sections is a `Divider` in the `Stack` that holds them.
68
68
  */
69
- export declare function Section({ title, actions, rule, gap, width, children, ...rest }: {
69
+ export declare function Section({ id, title, actions, rule, gap, width, children, ...rest }: {
70
+ /**
71
+ * Makes the section a link target: `#<id>` scrolls to its heading, which stops below the sticky
72
+ * AppShell header. Needs a `title`: with no title the heading that carries it is not rendered.
73
+ */
74
+ id?: string | undefined;
70
75
  /** A `SectionHeading` at the top, which names the section. */
71
76
  title?: ReactNode;
72
77
  /** Buttons or links at the end of the title row. */
package/dist/Stack.js CHANGED
@@ -47,13 +47,18 @@ export function Stack({ gap = "md", width = "default", rowFrom, as = "div", chil
47
47
  function Box({ tag: Tag, ...props }) {
48
48
  return _jsx(Tag, { ...props });
49
49
  }
50
+ /** The id a `Section`'s heading takes: the caller's, or one of React's. */
51
+ function useHeadingId(id) {
52
+ const generated = useId();
53
+ return id ?? generated;
54
+ }
50
55
  /**
51
56
  * A `<section>` Stack with an optional `SectionHeading` title (which names it) and `actions` beside
52
57
  * it. Its blocks are `gap` apart, `md` by default as in a `Stack`. `rule` draws a hairline under the
53
58
  * title row, `actions` included, as `SectionHeading`'s `rule` does for a bare heading. A line
54
59
  * between two sections is a `Divider` in the `Stack` that holds them.
55
60
  */
56
- export function Section({ title, actions, rule = false, gap = "md", width = "default", children, ...rest }) {
57
- const titleId = useId();
61
+ export function Section({ id, title, actions, rule = false, gap = "md", width = "default", children, ...rest }) {
62
+ const titleId = useHeadingId(id);
58
63
  return (_jsxs(Stack, { as: "section", ...rest, gap: gap, width: width, "aria-labelledby": title ? titleId : rest["aria-labelledby"], children: [(title || actions) && (_jsxs("div", { className: cx("flex items-center justify-between gap-4", rule && "border-b border-slate-200/70 pb-2"), children: [title && _jsx(SectionHeading, { id: titleId, children: title }), actions && _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })] })), children] }));
59
64
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * JavaScript to inline once on a page that is rendered to static HTML and never hydrated, so every
3
+ * `CopyButton` (and the `CopyLine`, `CodeBlock` and `CodeExample` copy buttons) works there too.
4
+ * One click listener on `document` reads what to copy from the button's data attributes, writes the
5
+ * clipboard, then does what `CopyButton` does: sets `data-copied` again after a reflow, so the CSS
6
+ * that shows the copied label for two seconds starts over, and puts a new node holding the copied
7
+ * label in the status region beside the button, so a screen reader hears each copy. No timer: the
8
+ * label goes back when the animation ends, and its end clears `data-copied` (a second listener), so a
9
+ * button hidden and shown again does not replay it. A refused or missing clipboard leaves it alone.
10
+ * Whichever of the script and a hydrated button's own handler hears a click first copies and flags
11
+ * the event (`copyHandled`), and the other skips it: one copy per click.
12
+ */
13
+ export declare const copyScript: string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * JavaScript to inline once on a page that is rendered to static HTML and never hydrated, so every
3
+ * `CopyButton` (and the `CopyLine`, `CodeBlock` and `CodeExample` copy buttons) works there too.
4
+ * One click listener on `document` reads what to copy from the button's data attributes, writes the
5
+ * clipboard, then does what `CopyButton` does: sets `data-copied` again after a reflow, so the CSS
6
+ * that shows the copied label for two seconds starts over, and puts a new node holding the copied
7
+ * label in the status region beside the button, so a screen reader hears each copy. No timer: the
8
+ * label goes back when the animation ends, and its end clears `data-copied` (a second listener), so a
9
+ * button hidden and shown again does not replay it. A refused or missing clipboard leaves it alone.
10
+ * Whichever of the script and a hydrated button's own handler hears a click first copies and flags
11
+ * the event (`copyHandled`), and the other skips it: one copy per click.
12
+ */
13
+ export const copyScript = `document.addEventListener("click",function(e){var b=e.target.closest&&e.target.closest("[data-copy-text]"),c=navigator.clipboard;if(!b||!c||e.copyHandled||b.disabled)return;e.copyHandled=1;c.writeText(b.dataset.copyText).then(function(){var r=b.nextElementSibling;b.removeAttribute("data-copied");void b.offsetWidth;b.setAttribute("data-copied","");if(r&&r.matches("[data-copy-status]"))r.replaceChildren(b.dataset.copyCopied)},function(){})});document.addEventListener("animationend",function(e){var b=e.target.closest&&e.target.closest("[data-copied]");if(b)b.removeAttribute("data-copied")})`;
package/dist/icons.d.ts CHANGED
@@ -6,8 +6,8 @@ import type { ReactElement } from "react";
6
6
  * icon's own shapes carry no `fill`, `stroke` or `stroke-width` (`Icon.test.tsx` holds them to
7
7
  * that). A name is the idea or action the icon stands for, not a product.
8
8
  */
9
- declare const NAMES: readonly ["diff", "baseline", "review", "ci", "self-host", "privacy", "capture", "branch", "theme", "speed", "search", "chevron-right", "chevron-down", "close", "external-link", "settings", "bell", "check", "plus"];
10
- /** The names of the icon set: feature ideas, then UI glyphs. */
9
+ declare const NAMES: readonly ["diff", "baseline", "review", "ci", "self-host", "privacy", "capture", "branch", "theme", "speed", "search", "chevron-right", "chevron-down", "close", "external-link", "settings", "bell", "check", "plus", "grid", "window", "type", "card", "pointer", "field", "table", "compass", "page", "briefcase"];
10
+ /** The names of the icon set: feature ideas, then UI glyphs, then the ideas the docs sections stand for. */
11
11
  export type IconName = (typeof NAMES)[number];
12
12
  /** The icon `name` as an `<svg>`: the set's one grid, stroke, caps and joins, drawn in `currentColor`. */
13
13
  export declare function iconGlyph(name: IconName): ReactElement;
package/dist/icons.js CHANGED
@@ -26,6 +26,16 @@ const NAMES = [
26
26
  "bell",
27
27
  "check",
28
28
  "plus",
29
+ "grid",
30
+ "window",
31
+ "type",
32
+ "card",
33
+ "pointer",
34
+ "field",
35
+ "table",
36
+ "compass",
37
+ "page",
38
+ "briefcase",
29
39
  ];
30
40
  const GLYPHS = {
31
41
  /** Before and after, side by side: a minus on the left, a plus on the right. */
@@ -66,6 +76,26 @@ const GLYPHS = {
66
76
  check: _jsx("path", { d: "m4.5 12.5 5 5L19.5 7" }),
67
77
  /** Add or create: two strokes crossing, upright. */
68
78
  plus: _jsx("path", { d: "M12 5v14M5 12h14" }),
79
+ /** Things laid out in a grid: four tiles. */
80
+ grid: (_jsxs(_Fragment, { children: [_jsx("rect", { x: "3.5", y: "3.5", width: "7", height: "7", rx: "1.5" }), _jsx("rect", { x: "13.5", y: "3.5", width: "7", height: "7", rx: "1.5" }), _jsx("rect", { x: "3.5", y: "13.5", width: "7", height: "7", rx: "1.5" }), _jsx("rect", { x: "13.5", y: "13.5", width: "7", height: "7", rx: "1.5" })] })),
81
+ /** A whole page's frame: a window with a title bar and a panel down the side. */
82
+ window: (_jsxs(_Fragment, { children: [_jsx("rect", { x: "3", y: "4.5", width: "18", height: "15", rx: "2.5" }), _jsx("path", { d: "M3 9h18M9.5 9v10.5M6.5 6.75h.01M9 6.75h.01" })] })),
83
+ /** Text and its styles: a capital T. */
84
+ type: _jsx("path", { d: "M5.5 7V5h13v2M12 5v14M9 19h6" }),
85
+ /** A unit of content: a card with a title line and lines of body. */
86
+ card: (_jsxs(_Fragment, { children: [_jsx("rect", { x: "3", y: "4.5", width: "18", height: "15", rx: "2.5" }), _jsx("path", { d: "M7 9h5M7 12.5h10M7 15.75h7" })] })),
87
+ /** Something to press: a pointer arrow. */
88
+ pointer: _jsx("path", { d: "M7 4.5v13.2l3.3-3.1 2.4 5 2.2-1-2.3-4.9h4.6Z" }),
89
+ /** Something to type in: a text field with its text and caret. */
90
+ field: (_jsxs(_Fragment, { children: [_jsx("rect", { x: "3", y: "7.5", width: "18", height: "9", rx: "2.5" }), _jsx("path", { d: "M7 12h4M13.5 9.5v5" })] })),
91
+ /** Rows and columns of data: a table with a header row and a first column. */
92
+ table: (_jsxs(_Fragment, { children: [_jsx("rect", { x: "3", y: "4.5", width: "18", height: "15", rx: "2.5" }), _jsx("path", { d: "M3 9.5h18M3 14.5h18M9.5 9.5v10" })] })),
93
+ /** Finding your way around: a compass with its needle. */
94
+ compass: (_jsxs(_Fragment, { children: [_jsx("circle", { cx: "12", cy: "12", r: "9" }), _jsx("path", { d: "m15.5 8.5-2 5-5 2 2-5Z" })] })),
95
+ /** A whole page of its own: a sheet with a folded corner and lines of text. */
96
+ page: (_jsxs(_Fragment, { children: [_jsx("path", { d: "M7.5 3H14l5 5v10.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 18.5v-13A2.5 2.5 0 0 1 7.5 3Z" }), _jsx("path", { d: "M14 3v3.5A1.5 1.5 0 0 0 15.5 8H19M9 13h6M9 16.5h6" })] })),
97
+ /** Work for hire: a briefcase with its handle and clasp. */
98
+ briefcase: (_jsxs(_Fragment, { children: [_jsx("rect", { x: "3", y: "7.5", width: "18", height: "12", rx: "2.5" }), _jsx("path", { d: "M8.5 7.5V6a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v1.5M3 13h18M12 12v2.5" })] })),
69
99
  };
70
100
  /** The icon `name` as an `<svg>`: the set's one grid, stroke, caps and joins, drawn in `currentColor`. */
71
101
  export function iconGlyph(name) {
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { Markdown } from "./Markdown.js";
10
10
  export { AppShell, AppShellBrand, type AppShellBreakpoint, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
11
11
  export { Container } from "./Container.js";
12
12
  export { PageHeader, type PageHeaderProps } from "./PageHeader.js";
13
+ export { Hero, type HeroProps } from "./Hero.js";
13
14
  export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, } from "./PageLayouts.js";
14
15
  export { SidebarLayout } from "./SidebarLayout.js";
15
16
  export { SideColumn, type SideColumnWidth } from "./SideColumn.js";
@@ -43,7 +44,9 @@ export { CodeExample } from "./CodeExample.js";
43
44
  export { Disclosure } from "./Disclosure.js";
44
45
  export { Divider } from "./Divider.js";
45
46
  export { PageFade, startPageTransition } from "./PageFade.js";
47
+ export { NavigationProgress } from "./NavigationProgress.js";
46
48
  export { Dialog, type DialogProps } from "./Dialog.js";
49
+ export { SiteSearch, type SiteSearchItem } from "./SiteSearch.js";
47
50
  export { Drawer, type DrawerProps } from "./Drawer.js";
48
51
  export { Lightbox, type LightboxProps } from "./Lightbox.js";
49
52
  export { Icon } from "./Icon.js";
@@ -59,6 +62,7 @@ export { Switch } from "./Switch.js";
59
62
  export { Radio, RadioGroup } from "./RadioGroup.js";
60
63
  export { FileDrop } from "./FileDrop.js";
61
64
  export { CopyButton } from "./CopyButton.js";
65
+ export { copyScript } from "./copyScript.js";
62
66
  export { CopyLine } from "./CopyLine.js";
63
67
  export { Field } from "./Field.js";
64
68
  export { FormGrid, FormRow, FormRowField, type FormRowSpan } from "./FormRow.js";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { Markdown } from "./Markdown.js";
10
10
  export { AppShell, AppShellBrand, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
11
11
  export { Container } from "./Container.js";
12
12
  export { PageHeader } from "./PageHeader.js";
13
+ export { Hero } from "./Hero.js";
13
14
  export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, } from "./PageLayouts.js";
14
15
  export { SidebarLayout } from "./SidebarLayout.js";
15
16
  export { SideColumn } from "./SideColumn.js";
@@ -42,7 +43,9 @@ export { CodeExample } from "./CodeExample.js";
42
43
  export { Disclosure } from "./Disclosure.js";
43
44
  export { Divider } from "./Divider.js";
44
45
  export { PageFade, startPageTransition } from "./PageFade.js";
46
+ export { NavigationProgress } from "./NavigationProgress.js";
45
47
  export { Dialog } from "./Dialog.js";
48
+ export { SiteSearch } from "./SiteSearch.js";
46
49
  export { Drawer } from "./Drawer.js";
47
50
  export { Lightbox } from "./Lightbox.js";
48
51
  export { Icon } from "./Icon.js";
@@ -57,6 +60,7 @@ export { Switch } from "./Switch.js";
57
60
  export { Radio, RadioGroup } from "./RadioGroup.js";
58
61
  export { FileDrop } from "./FileDrop.js";
59
62
  export { CopyButton } from "./CopyButton.js";
63
+ export { copyScript } from "./copyScript.js";
60
64
  export { CopyLine } from "./CopyLine.js";
61
65
  export { Field } from "./Field.js";
62
66
  export { FormGrid, FormRow, FormRowField } from "./FormRow.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Marks a box that scrolls with which of its edges still has content past it, so the layout can fade
3
+ * them: `data-more-start` (scrolled away from the start), `data-more-end` (more to scroll to) and
4
+ * `data-more` for either. `axis` is the way it scrolls: `x` (a row) or `y` (a column). A box that
5
+ * fits carries none. The attributes only carry state; a class turns them into a `mask-image`, so the
6
+ * fade follows any theme. `element` is the box once it is mounted (null before, and then nothing
7
+ * happens).
8
+ *
9
+ * The effect runs after every render on purpose: the box's children may have changed, and each one
10
+ * needs observing so a change in its size is noticed.
11
+ */
12
+ export declare function useScrollEdges(element: HTMLElement | null, axis: "x" | "y"): void;
@@ -0,0 +1,40 @@
1
+ import { useEffect } from "react";
2
+ /**
3
+ * Marks a box that scrolls with which of its edges still has content past it, so the layout can fade
4
+ * them: `data-more-start` (scrolled away from the start), `data-more-end` (more to scroll to) and
5
+ * `data-more` for either. `axis` is the way it scrolls: `x` (a row) or `y` (a column). A box that
6
+ * fits carries none. The attributes only carry state; a class turns them into a `mask-image`, so the
7
+ * fade follows any theme. `element` is the box once it is mounted (null before, and then nothing
8
+ * happens).
9
+ *
10
+ * The effect runs after every render on purpose: the box's children may have changed, and each one
11
+ * needs observing so a change in its size is noticed.
12
+ */
13
+ export function useScrollEdges(element, axis) {
14
+ useEffect(() => {
15
+ if (!element)
16
+ return undefined;
17
+ const el = element;
18
+ const update = () => {
19
+ const [position, scrollSize, clientSize] = axis === "x"
20
+ ? [el.scrollLeft, el.scrollWidth, el.clientWidth]
21
+ : [el.scrollTop, el.scrollHeight, el.clientHeight];
22
+ const start = position > 1;
23
+ const end = scrollSize - clientSize - position > 1;
24
+ el.toggleAttribute("data-more-start", start);
25
+ el.toggleAttribute("data-more-end", end);
26
+ el.toggleAttribute("data-more", start || end);
27
+ };
28
+ update();
29
+ el.addEventListener("scroll", update, { passive: true });
30
+ // jsdom (a consumer's own tests) has no ResizeObserver.
31
+ const resize = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(update);
32
+ resize?.observe(el);
33
+ for (const child of el.children)
34
+ resize?.observe(child);
35
+ return () => {
36
+ el.removeEventListener("scroll", update);
37
+ resize?.disconnect();
38
+ };
39
+ });
40
+ }