@astralarium/repo-ui 0.1.0 → 0.2.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
@@ -5,3 +5,58 @@ Headless React UI following Base UI patterns.
5
5
  App setup:
6
6
 
7
7
  - `themeScript(cookie)` in `<head>` to apply theme pre-paint.
8
+
9
+ ```tsx
10
+ const [title, setTitle] = useDebounce(draft.title, (title) => save({ title }));
11
+
12
+ // debounced search param (./router)
13
+ const [query, setQuery] = useSearchParam("q");
14
+
15
+ // sticky headers: level n sits at `top: var(--sticky-h<n>)`
16
+ <StickyHeightProvider>{children}</StickyHeightProvider>;
17
+ <h2
18
+ ref={useStickyHeight(1)}
19
+ style={{ position: "sticky", top: "var(--sticky-h1)" }}
20
+ />;
21
+ ```
22
+
23
+ Parts carry `data-slot` and state attributes; the app supplies styling.
24
+ Responsive tables: below its breakpoint, a column collapses into the lead cell;
25
+ the app's CSS hides the inactive copy.
26
+
27
+ ```tsx
28
+ const columns = [
29
+ { key: "name", label: "Name", render: (row: Row) => row.name },
30
+ {
31
+ key: "rank",
32
+ label: "Rank",
33
+ breakpoint: "md",
34
+ render: (row: Row) => row.rank,
35
+ },
36
+ ] as const;
37
+
38
+ <table>
39
+ <ResponsiveTH columns={columns} />
40
+ <tbody>
41
+ {rows.map((row, index) => (
42
+ <ResponsiveTR key={row.id} item={row} index={index} columns={columns} />
43
+ ))}
44
+ </tbody>
45
+ </table>;
46
+ ```
47
+
48
+ ```css
49
+ [data-slot="responsive-cell"][data-breakpoint="md"],
50
+ [data-slot="responsive-header"][data-breakpoint="md"] {
51
+ display: none;
52
+ }
53
+ @media (width >= 48rem) {
54
+ [data-slot="responsive-cell"][data-breakpoint="md"],
55
+ [data-slot="responsive-header"][data-breakpoint="md"] {
56
+ display: table-cell;
57
+ }
58
+ [data-slot="responsive-detail"][data-breakpoint="md"] {
59
+ display: none;
60
+ }
61
+ }
62
+ ```
package/dist/index.d.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  /** Headless React building blocks: theme, tree, page tree, timeline, sorted
2
- * lookups, status, error boundary, storage and small hooks. */
2
+ * lookups, status, error boundary, storage, sticky headers, responsive
3
+ * tables, external scripts and small hooks. */
3
4
  export * from "./error-boundary.tsx";
5
+ export * from "./lazy-google-analytics.tsx";
4
6
  export * from "./page-tree.ts";
7
+ export * from "./responsive-table.tsx";
5
8
  export * from "./sorted.ts";
6
9
  export * from "./status.tsx";
10
+ export * from "./sticky-height.tsx";
7
11
  export * from "./storage.ts";
8
12
  export * from "./theme/index.ts";
9
13
  export * from "./timeline.ts";
10
14
  export * from "./tree/index.ts";
11
15
  export * from "./use-copy-feedback.ts";
16
+ export * from "./use-debounce.ts";
17
+ export * from "./use-external-script.ts";
12
18
  export * from "./use-frame.ts";
13
19
  export * from "./use-hover-focus.ts";
14
20
  export * from "./use-media-query.ts";
15
21
  export * from "./use-pausable-hover.ts";
16
22
  export * from "./use-pulse-style.ts";
23
+ export { useStickyHeight } from "./use-sticky-height.ts";
package/dist/index.js CHANGED
@@ -1,16 +1,23 @@
1
1
  /** Headless React building blocks: theme, tree, page tree, timeline, sorted
2
- * lookups, status, error boundary, storage and small hooks. */
2
+ * lookups, status, error boundary, storage, sticky headers, responsive
3
+ * tables, external scripts and small hooks. */
3
4
  export * from "./error-boundary.js";
5
+ export * from "./lazy-google-analytics.js";
4
6
  export * from "./page-tree.js";
7
+ export * from "./responsive-table.js";
5
8
  export * from "./sorted.js";
6
9
  export * from "./status.js";
10
+ export * from "./sticky-height.js";
7
11
  export * from "./storage.js";
8
12
  export * from "./theme/index.js";
9
13
  export * from "./timeline.js";
10
14
  export * from "./tree/index.js";
11
15
  export * from "./use-copy-feedback.js";
16
+ export * from "./use-debounce.js";
17
+ export * from "./use-external-script.js";
12
18
  export * from "./use-frame.js";
13
19
  export * from "./use-hover-focus.js";
14
20
  export * from "./use-media-query.js";
15
21
  export * from "./use-pausable-hover.js";
16
22
  export * from "./use-pulse-style.js";
23
+ export { useStickyHeight } from "./use-sticky-height.js";
@@ -1,4 +1,8 @@
1
1
  {
2
+ "@astralarium/repo-ui/router/tab-bar.messages#barName": {
3
+ "defaultMessage": "Tabs",
4
+ "description": "Accessible name of a tab bar switching between page sections. Shows: screen readers."
5
+ },
2
6
  "@astralarium/repo-ui/slider/slider.messages#rangeEnd": {
3
7
  "defaultMessage": "{value}, range end",
4
8
  "description": "Spoken value of a range slider's last thumb. Shows: screen readers, sliders with several thumbs. Arguments: value (string) the thumb's formatted value."
@@ -0,0 +1,4 @@
1
+ /** Loads Google Analytics property `gaId` once the browser idles. */
2
+ export declare function LazyGoogleAnalytics({ gaId }: {
3
+ gaId: string;
4
+ }): null;
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { c as _c } from "react/compiler-runtime";
4
+ import { useExternalScript } from "./use-external-script.js";
5
+ /** Queues gtag's start and `gaId`'s config ahead of its script. */
6
+ function initGtag(gaId) {
7
+ const dataLayer = window.dataLayer ??= [];
8
+ function gtag(..._args) {
9
+ // eslint-disable-next-line prefer-rest-params -- gtag.js reads queued `arguments` objects; arrays go unread
10
+ dataLayer.push(arguments);
11
+ }
12
+ gtag("js", new Date());
13
+ gtag("config", gaId);
14
+ }
15
+ /** Loads Google Analytics property `gaId` once the browser idles. */
16
+ export function LazyGoogleAnalytics(t0) {
17
+ const $ = _c(2);
18
+ const {
19
+ gaId
20
+ } = t0;
21
+ let t1;
22
+ if ($[0] !== gaId) {
23
+ t1 = {
24
+ idle: true,
25
+ init: () => initGtag(gaId)
26
+ };
27
+ $[0] = gaId;
28
+ $[1] = t1;
29
+ } else {
30
+ t1 = $[1];
31
+ }
32
+ useExternalScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(gaId)}`, t1);
33
+ return null;
34
+ }
@@ -0,0 +1,54 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ /** Tailwind's breakpoints, smallest first. */
3
+ declare const TAILWIND_BREAKPOINTS: readonly ["sm", "md", "lg", "xl", "2xl"];
4
+ /** A Tailwind breakpoint. */
5
+ export type TailwindBreakpoint = (typeof TAILWIND_BREAKPOINTS)[number];
6
+ /** A column's header: its label and where it collapses. */
7
+ export interface ResponsiveColumnLabel<B extends string = TailwindBreakpoint> {
8
+ key: string;
9
+ /** Header text; also the detail's `<dt>`, visually hidden. */
10
+ label?: ReactNode;
11
+ /** From this breakpoint up, the column shows as a cell; below it, as a
12
+ * detail in the lead cell. Absent, or on the lead column: always a
13
+ * cell. */
14
+ breakpoint?: B;
15
+ /** Class of the column's `<th>` and `<td>`. */
16
+ className?: string;
17
+ /** Class of the column's `<dd>`. */
18
+ detailClassName?: string;
19
+ colSpan?: number;
20
+ }
21
+ /** A column: its header and how it renders a row's item. */
22
+ export interface ResponsiveColumn<T, B extends string = TailwindBreakpoint> extends ResponsiveColumnLabel<B> {
23
+ /** The cell's content for the row at `index`. */
24
+ render: (item: T, index: number) => ReactNode;
25
+ /** The detail's content; `render`'s by default. */
26
+ renderDetail?: (item: T, index: number) => ReactNode;
27
+ }
28
+ /** `ResponsiveTH` props: a `<thead>`'s, and the columns it heads. */
29
+ export interface ResponsiveTHProps<B extends string = TailwindBreakpoint> extends Omit<ComponentProps<"thead">, "children"> {
30
+ columns: readonly ResponsiveColumnLabel<B>[];
31
+ }
32
+ /** The table head: one `<th>` per column. */
33
+ export declare function ResponsiveTH<B extends string = TailwindBreakpoint>({ columns, ...props }: ResponsiveTHProps<B>): import("react/jsx-runtime").JSX.Element;
34
+ /** `ResponsiveTR` props: a `<tr>`'s, the item it renders, and the app's
35
+ * breakpoints, required for breakpoints beyond Tailwind's. */
36
+ export type ResponsiveTRProps<T, B extends string = TailwindBreakpoint> = ResponsiveTRBaseProps<T, B> & ([B] extends [TailwindBreakpoint] ? Partial<ResponsiveTRBreakpoints<B>> : ResponsiveTRBreakpoints<B>);
37
+ /** `ResponsiveTR` props besides `breakpoints`. */
38
+ interface ResponsiveTRBaseProps<T, B extends string = TailwindBreakpoint> extends Omit<ComponentProps<"tr">, "children"> {
39
+ item: T;
40
+ /** The row's index, passed to `render`. */
41
+ index: number;
42
+ columns: readonly ResponsiveColumn<T, B>[];
43
+ /** Renders the lead as the row's header, `<th scope="row">`. */
44
+ rowHeader?: boolean;
45
+ }
46
+ /** `ResponsiveTR`'s breakpoint order. */
47
+ interface ResponsiveTRBreakpoints<B extends string> {
48
+ /** The app's breakpoints, smallest first; Tailwind's when omitted. */
49
+ breakpoints: readonly B[];
50
+ }
51
+ /** A body row: its item's cells, the collapsing columns repeated as
52
+ * details in the lead cell. */
53
+ export declare function ResponsiveTR<T, B extends string = TailwindBreakpoint>({ item, index, columns, breakpoints, rowHeader, ...props }: ResponsiveTRProps<T, B>): import("react/jsx-runtime").JSX.Element;
54
+ export {};
@@ -0,0 +1,180 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ /**
3
+ * Table rows whose columns collapse below a breakpoint into the lead
4
+ * cell's `<dl>`. Parts carry `data-slot`; `data-breakpoint` names where
5
+ * a part's layout changes, and the app's CSS shows or hides each part
6
+ * there:
7
+ * - `responsive-header` / `responsive-cell`: the column's `<th>` / `<td>`,
8
+ * a cell from its breakpoint up. The lead column is always a cell.
9
+ * - `responsive-lead`: the row's first `<td>` (or `<th scope="row">`),
10
+ * holding the details; the lowest breakpoint, where the first column
11
+ * leaves it.
12
+ * - `responsive-details`: the lead's `<dl>`; the highest breakpoint, where
13
+ * the last column leaves it.
14
+ * - `responsive-detail`: a column's `<dt>` + `<dd>` in the `<dl>`, until
15
+ * its breakpoint.
16
+ */
17
+ import { visuallyHidden } from "@base-ui/utils/visuallyHidden";
18
+ /** Tailwind's breakpoints, smallest first. */
19
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
+ const TAILWIND_BREAKPOINTS = ["sm", "md", "lg", "xl", "2xl"];
21
+ /** The table head: one `<th>` per column. */
22
+ export function ResponsiveTH(t0) {
23
+ const $ = _c(10);
24
+ let columns;
25
+ let props;
26
+ if ($[0] !== t0) {
27
+ ({
28
+ columns,
29
+ ...props
30
+ } = t0);
31
+ $[0] = t0;
32
+ $[1] = columns;
33
+ $[2] = props;
34
+ } else {
35
+ columns = $[1];
36
+ props = $[2];
37
+ }
38
+ let t1;
39
+ if ($[3] !== columns) {
40
+ t1 = columns.map(_temp);
41
+ $[3] = columns;
42
+ $[4] = t1;
43
+ } else {
44
+ t1 = $[4];
45
+ }
46
+ let t2;
47
+ if ($[5] !== t1) {
48
+ t2 = /*#__PURE__*/_jsx("tr", {
49
+ children: t1
50
+ });
51
+ $[5] = t1;
52
+ $[6] = t2;
53
+ } else {
54
+ t2 = $[6];
55
+ }
56
+ let t3;
57
+ if ($[7] !== props || $[8] !== t2) {
58
+ t3 = /*#__PURE__*/_jsx("thead", {
59
+ "data-slot": "responsive-head",
60
+ ...props,
61
+ children: t2
62
+ });
63
+ $[7] = props;
64
+ $[8] = t2;
65
+ $[9] = t3;
66
+ } else {
67
+ t3 = $[9];
68
+ }
69
+ return t3;
70
+ }
71
+ /** The lowest and highest of `breakpoints` the columns name. */
72
+ function _temp(column, index) {
73
+ return /*#__PURE__*/_jsx("th", {
74
+ scope: "col",
75
+ colSpan: column.colSpan,
76
+ className: column.className,
77
+ "data-slot": "responsive-header",
78
+ "data-breakpoint": index === 0 ? undefined : column.breakpoint,
79
+ children: column.label
80
+ }, column.key);
81
+ }
82
+ function breakpointRange(columns, breakpoints) {
83
+ const named = breakpoints.filter(breakpoint => columns.some(column => column.breakpoint === breakpoint));
84
+ return {
85
+ min: named[0],
86
+ max: named.at(-1)
87
+ };
88
+ }
89
+ /** A body row: its item's cells, the collapsing columns repeated as
90
+ * details in the lead cell. */
91
+ export function ResponsiveTR(t0) {
92
+ const $ = _c(14);
93
+ let columns;
94
+ let index;
95
+ let item;
96
+ let props;
97
+ let t1;
98
+ let t2;
99
+ if ($[0] !== t0) {
100
+ ({
101
+ item,
102
+ index,
103
+ columns,
104
+ breakpoints: t1,
105
+ rowHeader: t2,
106
+ ...props
107
+ } = t0);
108
+ $[0] = t0;
109
+ $[1] = columns;
110
+ $[2] = index;
111
+ $[3] = item;
112
+ $[4] = props;
113
+ $[5] = t1;
114
+ $[6] = t2;
115
+ } else {
116
+ columns = $[1];
117
+ index = $[2];
118
+ item = $[3];
119
+ props = $[4];
120
+ t1 = $[5];
121
+ t2 = $[6];
122
+ }
123
+ const breakpoints = t1 === undefined ? TAILWIND_BREAKPOINTS : t1;
124
+ const rowHeader = t2 === undefined ? false : t2;
125
+ let t3;
126
+ if ($[7] !== breakpoints || $[8] !== columns || $[9] !== index || $[10] !== item || $[11] !== props || $[12] !== rowHeader) {
127
+ const [lead, ...rest] = columns;
128
+ const collapsing = rest.filter(_temp2);
129
+ const {
130
+ min,
131
+ max
132
+ } = breakpointRange(collapsing, breakpoints);
133
+ const Lead = rowHeader ? "th" : "td";
134
+ t3 = /*#__PURE__*/_jsxs("tr", {
135
+ "data-slot": "responsive-row",
136
+ ...props,
137
+ children: [lead ? /*#__PURE__*/_jsxs(Lead, {
138
+ scope: rowHeader ? "row" : undefined,
139
+ colSpan: lead.colSpan,
140
+ className: lead.className,
141
+ "data-slot": "responsive-lead",
142
+ "data-breakpoint": min,
143
+ children: [lead.render(item, index), collapsing.length > 0 ? /*#__PURE__*/_jsx("dl", {
144
+ "data-slot": "responsive-details",
145
+ "data-breakpoint": max,
146
+ children: collapsing.map(column_0 => /*#__PURE__*/_jsxs("div", {
147
+ "data-slot": "responsive-detail",
148
+ "data-breakpoint": column_0.breakpoint,
149
+ children: [/*#__PURE__*/_jsx("dt", {
150
+ style: visuallyHidden,
151
+ children: column_0.label
152
+ }), /*#__PURE__*/_jsx("dd", {
153
+ className: column_0.detailClassName,
154
+ children: (column_0.renderDetail ?? column_0.render)(item, index)
155
+ })]
156
+ }, column_0.key))
157
+ }) : null]
158
+ }) : null, rest.map(column_1 => /*#__PURE__*/_jsx("td", {
159
+ colSpan: column_1.colSpan,
160
+ className: column_1.className,
161
+ "data-slot": "responsive-cell",
162
+ "data-breakpoint": column_1.breakpoint,
163
+ children: column_1.render(item, index)
164
+ }, column_1.key))]
165
+ });
166
+ $[7] = breakpoints;
167
+ $[8] = columns;
168
+ $[9] = index;
169
+ $[10] = item;
170
+ $[11] = props;
171
+ $[12] = rowHeader;
172
+ $[13] = t3;
173
+ } else {
174
+ t3 = $[13];
175
+ }
176
+ return t3;
177
+ }
178
+ function _temp2(column) {
179
+ return column.breakpoint !== undefined;
180
+ }
@@ -0,0 +1,5 @@
1
+ /** Router-bound UI: link tabs, debounced search params and dev routes. */
2
+ export * from "./dev-route.ts";
3
+ export * from "./tab-bar.tsx";
4
+ export * from "./tabs.ts";
5
+ export * from "./use-search-param.ts";
@@ -0,0 +1,5 @@
1
+ /** Router-bound UI: link tabs, debounced search params and dev routes. */
2
+ export * from "./dev-route.js";
3
+ export * from "./tab-bar.js";
4
+ export * from "./tabs.js";
5
+ export * from "./use-search-param.js";
@@ -0,0 +1,12 @@
1
+ import type { ComponentProps } from "react";
2
+ import type { Tab } from "./tabs.ts";
3
+ /** `TabBar` props: a `<nav>`'s, and its tabs. */
4
+ export interface TabBarProps extends Omit<ComponentProps<"nav">, "children"> {
5
+ tabs: readonly Tab[];
6
+ /** Class of each tab. */
7
+ tabClassName?: string;
8
+ }
9
+ /** A tab bar: router `Link`s current on their exact path, search aside,
10
+ * unless the link sets `activeOptions`; buttons current while `active`.
11
+ * Named "Tabs" unless `aria-label` says otherwise. */
12
+ export declare function TabBar({ tabs, tabClassName, ...props }: TabBarProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ /**
4
+ * A `<nav>` of tabs. Each tab carries `data-slot="tab"`; the current tab
5
+ * carries `data-status="active"` and `aria-current`: `"page"` on a link,
6
+ * `"true"` on a button. Everything visual comes from the app.
7
+ */
8
+ import { c as _c } from "react/compiler-runtime";
9
+ import { useFormat } from "@astralarium/localizable";
10
+ import { Link } from "@tanstack/react-router";
11
+ import messages from "./tab-bar.messages.json" with { type: "json" };
12
+ /** A link tab's default `activeOptions`: current on its exact path,
13
+ * search aside. */
14
+ import { jsx as _jsx } from "react/jsx-runtime";
15
+ const TAB_ACTIVE_OPTIONS = {
16
+ exact: true,
17
+ includeSearch: false
18
+ };
19
+ /** A tab bar: router `Link`s current on their exact path, search aside,
20
+ * unless the link sets `activeOptions`; buttons current while `active`.
21
+ * Named "Tabs" unless `aria-label` says otherwise. */
22
+ export function TabBar(t0) {
23
+ const $ = _c(15);
24
+ let props;
25
+ let tabClassName;
26
+ let tabs;
27
+ if ($[0] !== t0) {
28
+ ({
29
+ tabs,
30
+ tabClassName,
31
+ ...props
32
+ } = t0);
33
+ $[0] = t0;
34
+ $[1] = props;
35
+ $[2] = tabClassName;
36
+ $[3] = tabs;
37
+ } else {
38
+ props = $[1];
39
+ tabClassName = $[2];
40
+ tabs = $[3];
41
+ }
42
+ const format = useFormat();
43
+ let t1;
44
+ if ($[4] !== format) {
45
+ t1 = format(messages.barName);
46
+ $[4] = format;
47
+ $[5] = t1;
48
+ } else {
49
+ t1 = $[5];
50
+ }
51
+ let t2;
52
+ if ($[6] !== tabClassName || $[7] !== tabs) {
53
+ let t3;
54
+ if ($[9] !== tabClassName) {
55
+ t3 = tab => tab.link ? /*#__PURE__*/_jsx(Link, {
56
+ ...tab.link,
57
+ activeOptions: tab.link.activeOptions ?? TAB_ACTIVE_OPTIONS,
58
+ className: tabClassName,
59
+ "data-slot": "tab",
60
+ children: tab.label
61
+ }, tab.name) : /*#__PURE__*/_jsx("button", {
62
+ type: "button",
63
+ onClick: () => tab.onClick?.(tab),
64
+ className: tabClassName,
65
+ "aria-current": tab.active ? "true" : undefined,
66
+ "data-slot": "tab",
67
+ "data-status": tab.active ? "active" : undefined,
68
+ children: tab.label
69
+ }, tab.name);
70
+ $[9] = tabClassName;
71
+ $[10] = t3;
72
+ } else {
73
+ t3 = $[10];
74
+ }
75
+ t2 = tabs.map(t3);
76
+ $[6] = tabClassName;
77
+ $[7] = tabs;
78
+ $[8] = t2;
79
+ } else {
80
+ t2 = $[8];
81
+ }
82
+ let t3;
83
+ if ($[11] !== props || $[12] !== t1 || $[13] !== t2) {
84
+ t3 = /*#__PURE__*/_jsx("nav", {
85
+ "aria-label": t1,
86
+ "data-slot": "tab-bar",
87
+ ...props,
88
+ children: t2
89
+ });
90
+ $[11] = props;
91
+ $[12] = t1;
92
+ $[13] = t2;
93
+ $[14] = t3;
94
+ } else {
95
+ t3 = $[14];
96
+ }
97
+ return t3;
98
+ }
@@ -0,0 +1,7 @@
1
+ // Generated by l10n-types.
2
+ import type { Message } from "@astralarium/localizable";
3
+
4
+ declare const messages: {
5
+ barName: Message<Record<never, never>>;
6
+ };
7
+ export = messages;
@@ -0,0 +1,6 @@
1
+ {
2
+ "barName": {
3
+ "id": "@astralarium/repo-ui/router/tab-bar.messages#barName",
4
+ "defaultMessage": "Tabs"
5
+ }
6
+ }
@@ -0,0 +1,21 @@
1
+ import { type LinkOptions } from "@tanstack/react-router";
2
+ import type { ReactNode } from "react";
3
+ /** A tab: a router link, or a button. */
4
+ export interface Tab {
5
+ /** Identifies the tab; `buildTabs` selects by it. */
6
+ name: string;
7
+ label: ReactNode;
8
+ /** Destination of a link tab; current on its exact path, search
9
+ * aside, unless `activeOptions` says otherwise. */
10
+ link?: LinkOptions;
11
+ /** Press of a button tab. */
12
+ onClick?: (tab: Tab) => unknown;
13
+ /** The selected tab: current among button tabs. */
14
+ active?: boolean;
15
+ }
16
+ /** Binds fixed tabs to a selection: `tabsOf(tab, setTab, onChange)` marks
17
+ * tab `tab` active and selects a pressed tab. */
18
+ export declare function buildTabs(tabs: readonly Omit<Tab, "onClick" | "active">[]): (tab: string, setTab: (name: string) => unknown, onChange?: () => unknown) => Tab[];
19
+ /** Selects a tab outside a `TabBar`, e.g. from a dropdown: navigates to a
20
+ * link tab, presses a button tab. */
21
+ export declare function useSelectTab(): (tab: Tab) => void;
@@ -0,0 +1,37 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ import { useNavigate } from "@tanstack/react-router";
3
+ /** Binds fixed tabs to a selection: `tabsOf(tab, setTab, onChange)` marks
4
+ * tab `tab` active and selects a pressed tab. */
5
+ export function buildTabs(tabs) {
6
+ return function tabsOf(tab, setTab, onChange) {
7
+ return tabs.map(x => ({
8
+ ...x,
9
+ active: x.name === tab,
10
+ onClick: () => {
11
+ setTab(x.name);
12
+ onChange?.();
13
+ }
14
+ }));
15
+ };
16
+ }
17
+ /** Selects a tab outside a `TabBar`, e.g. from a dropdown: navigates to a
18
+ * link tab, presses a button tab. */
19
+ export function useSelectTab() {
20
+ const $ = _c(2);
21
+ const navigate = useNavigate();
22
+ let t0;
23
+ if ($[0] !== navigate) {
24
+ t0 = tab => {
25
+ if (tab.link) {
26
+ navigate(tab.link);
27
+ } else {
28
+ tab.onClick?.(tab);
29
+ }
30
+ };
31
+ $[0] = navigate;
32
+ $[1] = t0;
33
+ } else {
34
+ t0 = $[1];
35
+ }
36
+ return t0;
37
+ }
@@ -0,0 +1,5 @@
1
+ /** String-valued search param `name`, written back after `ms` idle
2
+ * (`useDebounce`), replacing the history entry and keeping the hash;
3
+ * empty text removes it. Other values read as JSON and write back as
4
+ * strings. */
5
+ export declare function useSearchParam(name: string, ms?: number): [string, (text: string) => void];
@@ -0,0 +1,46 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ import { useNavigate, useSearch } from "@tanstack/react-router";
3
+ import { useDebounce } from "../use-debounce.js";
4
+ /** A search param's value as text; empty when absent. */
5
+ function searchText(value) {
6
+ if (value === undefined || value === null) return "";
7
+ return typeof value === "string" ? value : JSON.stringify(value);
8
+ }
9
+ /** String-valued search param `name`, written back after `ms` idle
10
+ * (`useDebounce`), replacing the history entry and keeping the hash;
11
+ * empty text removes it. Other values read as JSON and write back as
12
+ * strings. */
13
+ export function useSearchParam(name, ms) {
14
+ const $ = _c(5);
15
+ let t0;
16
+ if ($[0] !== name) {
17
+ t0 = {
18
+ strict: false,
19
+ select: search => searchText(search[name])
20
+ };
21
+ $[0] = name;
22
+ $[1] = t0;
23
+ } else {
24
+ t0 = $[1];
25
+ }
26
+ const value = useSearch(t0);
27
+ const navigate = useNavigate();
28
+ let t1;
29
+ if ($[2] !== name || $[3] !== navigate) {
30
+ t1 = text => navigate({
31
+ to: ".",
32
+ search: prev => ({
33
+ ...prev,
34
+ [name]: text || undefined
35
+ }),
36
+ hash: true,
37
+ replace: true
38
+ });
39
+ $[2] = name;
40
+ $[3] = navigate;
41
+ $[4] = t1;
42
+ } else {
43
+ t1 = $[4];
44
+ }
45
+ return useDebounce(value, t1, ms);
46
+ }
@@ -0,0 +1,10 @@
1
+ import { useRender } from "@base-ui/react/use-render";
2
+ /** `StickyHeightProvider` props: a div's, or `render`'s element's. */
3
+ export interface StickyHeightProviderProps extends useRender.ComponentProps<"div"> {
4
+ /** Sticky levels measured: 0…`levels - 1`. */
5
+ levels?: number;
6
+ }
7
+ /** Stacks sticky headers: sets `--sticky-h<n>` on its element, the summed
8
+ * heights `useStickyHeight` reports for levels below n. A level-n header
9
+ * sticks at `top: var(--sticky-h<n>)`. */
10
+ export declare function StickyHeightProvider({ levels, render, style, ...props }: StickyHeightProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { c as _c } from "react/compiler-runtime";
4
+ import { useRender } from "@base-ui/react/use-render";
5
+ // eslint-disable-next-line no-restricted-imports -- every `useStickyHeight` ref keys on `setHeight`; its identity holds in uncompiled builds too
6
+ import { useCallback, useState } from "react";
7
+ import { StickyHeightContext } from "./use-sticky-height.js";
8
+ /** `--sticky-h<n>` for n in 1…`levels`: the summed `heights` of levels
9
+ * below n. */
10
+ import { jsx as _jsx } from "react/jsx-runtime";
11
+ function stickyHeightStyle(heights, levels) {
12
+ let sum = 0;
13
+ return Object.fromEntries(Array.from({
14
+ length: levels
15
+ }, (_, level) => {
16
+ sum += heights[level] ?? 0;
17
+ return [`--sticky-h${level + 1}`, `${sum}px`];
18
+ }));
19
+ }
20
+ /** Stacks sticky headers: sets `--sticky-h<n>` on its element, the summed
21
+ * heights `useStickyHeight` reports for levels below n. A level-n header
22
+ * sticks at `top: var(--sticky-h<n>)`. */
23
+ export function StickyHeightProvider(t0) {
24
+ const $ = _c(21);
25
+ let props;
26
+ let render;
27
+ let style;
28
+ let t1;
29
+ if ($[0] !== t0) {
30
+ ({
31
+ levels: t1,
32
+ render,
33
+ style,
34
+ ...props
35
+ } = t0);
36
+ $[0] = t0;
37
+ $[1] = props;
38
+ $[2] = render;
39
+ $[3] = style;
40
+ $[4] = t1;
41
+ } else {
42
+ props = $[1];
43
+ render = $[2];
44
+ style = $[3];
45
+ t1 = $[4];
46
+ }
47
+ const levels = t1 === undefined ? 10 : t1;
48
+ let t2;
49
+ if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
50
+ t2 = [];
51
+ $[5] = t2;
52
+ } else {
53
+ t2 = $[5];
54
+ }
55
+ const [heights, setHeights] = useState(t2);
56
+ let t3;
57
+ if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
58
+ t3 = (level, height) => setHeights(prev => {
59
+ if ((prev[level] ?? 0) === height) {
60
+ return prev;
61
+ }
62
+ const next = Array.from({
63
+ length: Math.max(prev.length, level + 1)
64
+ }, (_, i) => prev[i] ?? 0);
65
+ next[level] = height;
66
+ return next;
67
+ });
68
+ $[6] = t3;
69
+ } else {
70
+ t3 = $[6];
71
+ }
72
+ const setHeight = t3;
73
+ let t4;
74
+ if ($[7] !== heights || $[8] !== levels) {
75
+ t4 = stickyHeightStyle(heights, levels);
76
+ $[7] = heights;
77
+ $[8] = levels;
78
+ $[9] = t4;
79
+ } else {
80
+ t4 = $[9];
81
+ }
82
+ let t5;
83
+ if ($[10] !== style || $[11] !== t4) {
84
+ t5 = {
85
+ ...t4,
86
+ ...style
87
+ };
88
+ $[10] = style;
89
+ $[11] = t4;
90
+ $[12] = t5;
91
+ } else {
92
+ t5 = $[12];
93
+ }
94
+ let t6;
95
+ if ($[13] !== props || $[14] !== t5) {
96
+ t6 = {
97
+ "data-slot": "sticky-heights",
98
+ ...props,
99
+ style: t5
100
+ };
101
+ $[13] = props;
102
+ $[14] = t5;
103
+ $[15] = t6;
104
+ } else {
105
+ t6 = $[15];
106
+ }
107
+ let t7;
108
+ if ($[16] !== render || $[17] !== t6) {
109
+ t7 = {
110
+ defaultTagName: "div",
111
+ render,
112
+ props: t6
113
+ };
114
+ $[16] = render;
115
+ $[17] = t6;
116
+ $[18] = t7;
117
+ } else {
118
+ t7 = $[18];
119
+ }
120
+ const element = useRender(t7);
121
+ let t8;
122
+ if ($[19] !== element) {
123
+ t8 = /*#__PURE__*/_jsx(StickyHeightContext, {
124
+ value: setHeight,
125
+ children: element
126
+ });
127
+ $[19] = element;
128
+ $[20] = t8;
129
+ } else {
130
+ t8 = $[20];
131
+ }
132
+ return t8;
133
+ }
@@ -19,7 +19,7 @@ export declare const ThemeStateContext: import("react").Context<ThemeState | nul
19
19
  /** Effective theme: the setting, or the OS scheme under `system`. */
20
20
  export declare function useTheme(): SystemTheme;
21
21
  /** The visitor's theme setting. */
22
- export declare function useThemeSetting(): "system" | "light" | "dark";
22
+ export declare function useThemeSetting(): "dark" | "light" | "system";
23
23
  /** Applies and persists a new theme setting. */
24
24
  export declare function useSetTheme(): (setting: ThemeSetting) => void;
25
25
  /** Inline `<head>` script: classes `<html>` from the `cookie` preference
@@ -0,0 +1,3 @@
1
+ /** Local copy of `value`; edits write through `setValue` after `ms` idle.
2
+ * A new `value` replaces the copy unless an edit is pending. */
3
+ export declare function useDebounce<T>(value: T, setValue: (value: T) => unknown, ms?: number): [T, (value: T) => void];
@@ -0,0 +1,66 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ import { useEffect, useEffectEvent, useState } from "react";
3
+ /** Local copy of `value`; edits write through `setValue` after `ms` idle.
4
+ * A new `value` replaces the copy unless an edit is pending. */
5
+ export function useDebounce(value, setValue, t0) {
6
+ const $ = _c(12);
7
+ const ms = t0 === undefined ? 400 : t0;
8
+ const [state, setState] = useState(value);
9
+ const [invoked, setInvoked] = useState(value);
10
+ const [seen, setSeen] = useState(value);
11
+ if (value !== seen) {
12
+ setSeen(value);
13
+ setInvoked(value);
14
+ if (state === invoked) {
15
+ setState(value);
16
+ }
17
+ }
18
+ let t1;
19
+ if ($[0] !== invoked || $[1] !== setValue || $[2] !== state) {
20
+ t1 = () => {
21
+ if (state === invoked) {
22
+ return;
23
+ }
24
+ setInvoked(state);
25
+ setValue(state);
26
+ };
27
+ $[0] = invoked;
28
+ $[1] = setValue;
29
+ $[2] = state;
30
+ $[3] = t1;
31
+ } else {
32
+ t1 = $[3];
33
+ }
34
+ const write = useEffectEvent(t1);
35
+ let t2;
36
+ if ($[4] !== ms || $[5] !== write) {
37
+ t2 = () => {
38
+ const timer = setTimeout(write, ms);
39
+ return () => clearTimeout(timer);
40
+ };
41
+ $[4] = ms;
42
+ $[5] = write;
43
+ $[6] = t2;
44
+ } else {
45
+ t2 = $[6];
46
+ }
47
+ let t3;
48
+ if ($[7] !== ms || $[8] !== state) {
49
+ t3 = [ms, state];
50
+ $[7] = ms;
51
+ $[8] = state;
52
+ $[9] = t3;
53
+ } else {
54
+ t3 = $[9];
55
+ }
56
+ useEffect(t2, t3);
57
+ let t4;
58
+ if ($[10] !== state) {
59
+ t4 = [state, setState];
60
+ $[10] = state;
61
+ $[11] = t4;
62
+ } else {
63
+ t4 = $[11];
64
+ }
65
+ return t4;
66
+ }
@@ -0,0 +1,10 @@
1
+ /** When and how `useExternalScript` loads its script. */
2
+ export interface ExternalScriptOptions {
3
+ /** Runs once, right before the script loads: globals it reads. */
4
+ init?: () => void;
5
+ /** Waits for the browser to idle (2 s where `requestIdleCallback` is
6
+ * absent). */
7
+ idle?: boolean;
8
+ }
9
+ /** Loads the script at `src` once per page, after hydration. */
10
+ export declare function useExternalScript(src: string, { init, idle }?: ExternalScriptOptions): void;
@@ -0,0 +1,73 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ import { useEffect, useEffectEvent } from "react";
3
+ /** Script sources this page loads or has loaded. */
4
+ const loaded = new Set();
5
+ /** Loads the script at `src` once per page, after hydration. */
6
+ export function useExternalScript(src, t0) {
7
+ const $ = _c(11);
8
+ let t1;
9
+ if ($[0] !== t0) {
10
+ t1 = t0 === undefined ? {} : t0;
11
+ $[0] = t0;
12
+ $[1] = t1;
13
+ } else {
14
+ t1 = $[1];
15
+ }
16
+ const {
17
+ init,
18
+ idle: t2
19
+ } = t1;
20
+ const idle = t2 === undefined ? false : t2;
21
+ let t3;
22
+ if ($[2] !== init) {
23
+ t3 = () => init?.();
24
+ $[2] = init;
25
+ $[3] = t3;
26
+ } else {
27
+ t3 = $[3];
28
+ }
29
+ const runInit = useEffectEvent(t3);
30
+ let t4;
31
+ if ($[4] !== idle || $[5] !== runInit || $[6] !== src) {
32
+ t4 = () => {
33
+ if (loaded.has(src)) {
34
+ return;
35
+ }
36
+ const load = () => {
37
+ if (loaded.has(src)) {
38
+ return;
39
+ }
40
+ loaded.add(src);
41
+ runInit();
42
+ const script = document.createElement("script");
43
+ script.src = src;
44
+ document.head.appendChild(script);
45
+ };
46
+ if (!idle) {
47
+ return load();
48
+ }
49
+ if ("requestIdleCallback" in window) {
50
+ const handle = window.requestIdleCallback(load);
51
+ return () => window.cancelIdleCallback(handle);
52
+ }
53
+ const timer = setTimeout(load, 2000);
54
+ return () => clearTimeout(timer);
55
+ };
56
+ $[4] = idle;
57
+ $[5] = runInit;
58
+ $[6] = src;
59
+ $[7] = t4;
60
+ } else {
61
+ t4 = $[7];
62
+ }
63
+ let t5;
64
+ if ($[8] !== idle || $[9] !== src) {
65
+ t5 = [src, idle];
66
+ $[8] = idle;
67
+ $[9] = src;
68
+ $[10] = t5;
69
+ } else {
70
+ t5 = $[10];
71
+ }
72
+ useEffect(t4, t5);
73
+ }
@@ -0,0 +1,10 @@
1
+ import { type RefCallback } from "react";
2
+ /** Reports sticky level `level`'s height, in px. */
3
+ export type SetStickyHeight = (level: number, height: number) => void;
4
+ /** The enclosing `StickyHeightProvider`'s height report. */
5
+ export declare const StickyHeightContext: import("react").Context<SetStickyHeight>;
6
+ /** Ref reporting its element's border-box height as sticky level `level`,
7
+ * to the enclosing `StickyHeightProvider`; zero once detached. One
8
+ * header per level: each report replaces the level's height. Stable per
9
+ * `level` and provider: React re-attaches a changed ref callback. */
10
+ export declare function useStickyHeight(level: number): RefCallback<HTMLElement>;
@@ -0,0 +1,37 @@
1
+ import { c as _c } from "react/compiler-runtime";
2
+ // eslint-disable-next-line no-restricted-imports -- React Compiler skips a hook whose only hook call is `use`; the ref needs a stable identity
3
+ import { createContext, use, useCallback } from "react";
4
+ /** The enclosing `StickyHeightProvider`'s height report. */
5
+ export const StickyHeightContext = createContext(() => undefined);
6
+ /** Ref reporting its element's border-box height as sticky level `level`,
7
+ * to the enclosing `StickyHeightProvider`; zero once detached. One
8
+ * header per level: each report replaces the level's height. Stable per
9
+ * `level` and provider: React re-attaches a changed ref callback. */
10
+ export function useStickyHeight(level) {
11
+ const $ = _c(3);
12
+ const setHeight = use(StickyHeightContext);
13
+ let t0;
14
+ if ($[0] !== level || $[1] !== setHeight) {
15
+ t0 = element => {
16
+ if (!element) {
17
+ return;
18
+ }
19
+ const observer = new ResizeObserver(t1 => {
20
+ const [entry] = t1;
21
+ const box = entry?.borderBoxSize?.[0];
22
+ setHeight(level, box ? box.blockSize : element.getBoundingClientRect().height);
23
+ });
24
+ observer.observe(element);
25
+ return () => {
26
+ observer.disconnect();
27
+ setHeight(level, 0);
28
+ };
29
+ };
30
+ $[0] = level;
31
+ $[1] = setHeight;
32
+ $[2] = t0;
33
+ } else {
34
+ t0 = $[2];
35
+ }
36
+ return t0;
37
+ }
package/l10n.json CHANGED
@@ -3,6 +3,8 @@
3
3
  "resources": {
4
4
  "repo-ui": {
5
5
  "include": [
6
+ "*.messages.json",
7
+ "router/*.messages.json",
6
8
  "slider/*.messages.json"
7
9
  ]
8
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astralarium/repo-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-ui#readme",
6
6
  "repository": {
@@ -16,7 +16,7 @@
16
16
  ],
17
17
  "exports": {
18
18
  ".": "./dist/index.js",
19
- "./dev-route": "./dist/dev-route.js",
19
+ "./router": "./dist/router/index.js",
20
20
  "./slider": "./dist/slider/index.js",
21
21
  "./testkit": "./dist/testkit/index.js",
22
22
  "./l10n/*": "./dist/l10n/*"
@@ -43,14 +43,14 @@
43
43
  "typescript": "^6.0.3",
44
44
  "vite": "^8.3.4",
45
45
  "vitest": "^5.0.3",
46
+ "@astralarium/repo-config-vitest": "^0.2.0",
47
+ "@astralarium/repo-config-eslint": "^0.3.0",
48
+ "@astralarium/repo-config-typescript": "^0.1.0",
46
49
  "@astralarium/localizable": "^0.1.0",
47
- "@astralarium/repo-config-vitest": "^0.1.0",
50
+ "@astralarium/repo-l10n": "^0.2.0",
51
+ "@astralarium/repo-l10n-manifest": "^0.1.1",
48
52
  "@astralarium/repo-package-build": "^0.1.0",
49
- "@astralarium/repo-config-typescript": "^0.1.0",
50
- "@astralarium/repo-config-eslint": "^0.1.0",
51
- "@astralarium/repo-l10n-manifest": "^0.1.0",
52
- "@astralarium/repo-package-files": "^0.1.0",
53
- "@astralarium/repo-l10n": "^0.1.0"
53
+ "@astralarium/repo-package-files": "^0.1.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@base-ui/react": "^1.9.0",
@@ -59,8 +59,8 @@
59
59
  "react-dom": "^19.2.0",
60
60
  "vitest": "^5.0.3",
61
61
  "@astralarium/localizable": "^0.1.0",
62
- "@astralarium/repo-config-vitest": "^0.1.0",
63
- "@astralarium/repo-l10n-manifest": "^0.1.0"
62
+ "@astralarium/repo-config-vitest": "^0.2.0",
63
+ "@astralarium/repo-l10n-manifest": "^0.1.1"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "@astralarium/repo-config-vitest": {
File without changes
File without changes