@tristan2828/ui-foundation 4.33.0 → 4.34.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.
Files changed (37) hide show
  1. package/conventions/AGENTS.md +9 -3
  2. package/conventions/docs/add-an-entity.md +33 -3
  3. package/conventions/docs/cell-patterns.md +51 -9
  4. package/conventions/docs/design-language.md +65 -1
  5. package/conventions/docs/timed-screens.md +13 -0
  6. package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
  7. package/dist/components/app/app-shell-bottom-bar.js +49 -0
  8. package/dist/components/app/app-shell-nav.d.ts +41 -0
  9. package/dist/components/app/app-shell-nav.js +40 -8
  10. package/dist/components/app/app-shell.d.ts +15 -4
  11. package/dist/components/app/app-shell.js +21 -7
  12. package/dist/components/app/data-table.d.ts +1 -1
  13. package/dist/components/app/data-table.js +10 -4
  14. package/dist/components/app/line-chart.d.ts +26 -0
  15. package/dist/components/app/line-chart.js +59 -0
  16. package/dist/components/app/markdown.d.ts +9 -2
  17. package/dist/components/app/markdown.js +13 -9
  18. package/dist/components/app/milkdown-editor.d.ts +1 -1
  19. package/dist/components/app/milkdown-editor.js +7 -4
  20. package/dist/components/app/prose-look.d.ts +13 -4
  21. package/dist/components/app/prose-look.js +8 -5
  22. package/dist/components/app/rich-text-editor.d.ts +8 -1
  23. package/dist/components/app/widget-card.d.ts +11 -1
  24. package/dist/components/app/widget-card.js +19 -3
  25. package/dist/hooks/use-muted.d.ts +4 -10
  26. package/dist/hooks/use-muted.js +4 -67
  27. package/dist/hooks/use-remembered-flag.d.ts +18 -0
  28. package/dist/hooks/use-remembered-flag.js +78 -0
  29. package/dist/index.d.ts +3 -2
  30. package/dist/index.js +2 -1
  31. package/dist/lib/line-chart-layout.d.ts +23 -0
  32. package/dist/lib/line-chart-layout.js +44 -0
  33. package/dist/lib/markdown-top-heading.d.ts +2 -0
  34. package/dist/lib/markdown-top-heading.js +21 -0
  35. package/dist/testing/index.d.ts +16 -4
  36. package/dist/testing/index.js +5 -3
  37. package/package.json +3 -2
@@ -100,6 +100,9 @@ const TOTALS_LABEL_CLASS = 'h-auto py-(--table-cell-py)';
100
100
  const HIDDEN_HEAD_CLASS = 'h-0 p-0';
101
101
  // A group's heading row (`groupBy`): a little air above, so the groups
102
102
  // read apart, and the page's background, never the zebra stripe.
103
+ // A column's `meta.align` as the class its header, cells and totals take.
104
+ const ALIGN_CLASS = { center: 'text-center', right: 'text-right' };
105
+ const alignClass = (align) => align && ALIGN_CLASS[align];
103
106
  const GROUP_HEADING_CLASS = 'h-auto bg-background pt-4 pb-1.5 align-bottom';
104
107
  // `width="content"`: from a table area 80rem wide, as wide as the columns
105
108
  // need (`w-fit` over the table's own `w-full`), at least 36rem, or the
@@ -231,7 +234,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
231
234
  const visibleLeafColumns = table.getVisibleLeafColumns();
232
235
  const visibleColumnCount = visibleLeafColumns.length;
233
236
  // One body row, the same in a plain table and a grouped one.
234
- const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
237
+ const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
235
238
  const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
236
239
  ? { '--data-table-last-width': `${lastWidth}px` }
237
240
  : undefined, children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
@@ -250,10 +253,13 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
250
253
  const sortDirection = header.column.getIsSorted();
251
254
  // Hidden, a header is its name only: a sort button no
252
255
  // one can see would still take focus.
253
- const content = header.isPlaceholder ? null : canSort && showHeader ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext()));
256
+ const content = header.isPlaceholder ? null : canSort && showHeader ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center',
257
+ // The arrow on the label's left, so the label's
258
+ // right edge lines up with the numbers under it.
259
+ header.column.columnDef.meta?.align === 'right' && 'ml-auto flex-row-reverse'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext()));
254
260
  return (_jsx(TableHead, { className: cn(!showHeader && HIDDEN_HEAD_CLASS, pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
255
261
  index === headerGroup.headers.length - 1 &&
256
- PINNED_LAST_COLUMN_CLASS, header.column.columnDef.meta?.align === 'center' && 'text-center'), "aria-sort": !canSort
262
+ PINNED_LAST_COLUMN_CLASS, alignClass(header.column.columnDef.meta?.align)), "aria-sort": !canSort
257
263
  ? undefined
258
264
  : sortDirection === 'asc'
259
265
  ? 'ascending'
@@ -261,7 +267,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
261
267
  ? 'descending'
262
268
  : 'none', children: !showHeader ? (_jsx("span", { className: "sr-only", children: content })) : pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
263
269
  }) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
264
- const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, column.columnDef.meta?.align === 'center' && 'text-center');
270
+ const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
265
271
  return index === 0 ? (_jsx(TableHead, { scope: "row", className: cn(TOTALS_LABEL_CLASS, className), children: pinFirstColumn ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: totals.label })) : (totals.label) }, column.id)) : (_jsx(TableCell, { className: className, children: totals.cells?.[column.id] }, column.id));
266
272
  }) }) }))] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
267
273
  return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
@@ -0,0 +1,26 @@
1
+ import { type LineChartMark, type LineChartPoint } from '../../lib/line-chart-layout.js';
2
+ export type { LineChartMark, LineChartPoint } from '../../lib/line-chart-layout.js';
3
+ export type LineChartProps = {
4
+ /** The figure's name: "Price over time". */
5
+ label: string;
6
+ /** The values, oldest first. */
7
+ points: readonly LineChartPoint[];
8
+ /** A dashed line at a value to reach or stay under: `{ y: 99, label: 'Buy at' }`. */
9
+ target?: {
10
+ y: number;
11
+ label: string;
12
+ };
13
+ /** Which point to mark with a dot and its value: the lowest, the highest or the latest. */
14
+ mark?: LineChartMark;
15
+ /** A value as shown: `usd`. Defaults to the number as the browser writes it. */
16
+ formatY?: (y: number) => string;
17
+ /** A point's x as shown: `formatPlainDate`. Defaults to x as given. */
18
+ formatX?: (x: LineChartPoint['x']) => string;
19
+ /** The plot's height in px; the chart fills its container's width. */
20
+ height?: number;
21
+ isLoading?: boolean;
22
+ /** What shows with no points. */
23
+ emptyMessage?: string;
24
+ className?: string;
25
+ };
26
+ export declare function LineChart({ label, points, target, mark, formatY, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // A small value-over-time chart for a record's page (#238): one series (a
3
+ // price checked daily, a weight, a balance) against an optional dashed
4
+ // target, with its lowest, highest or latest point marked. Plain SVG, no
5
+ // chart library: one line, a few dozen points, nothing to zoom.
6
+ //
7
+ // Colours are text tokens, so each clears 3:1 on the card in both themes:
8
+ // the line in `info-text`, the target in `success-text`. Each is its own
9
+ // <svg> drawing in currentColor, so the package's glyph check measures it
10
+ // like any icon. The plot stretches to its box (preserveAspectRatio
11
+ // "none", strokes that don't scale), and the dots and words on it are
12
+ // HTML placed by percentage, so nothing is squashed at any width.
13
+ //
14
+ // The picture is for the eye: a screen reader gets the figure's name,
15
+ // a summary (first, last, lowest, highest, the target) and a table of
16
+ // every point, all visually hidden. The four states: a skeleton at
17
+ // `height` while loading, one quiet line with no points, a dot (no line)
18
+ // for one point, and the chart.
19
+ import { cn } from 'cn';
20
+ import { Skeleton } from '../ui/skeleton.js';
21
+ import { chartLayout } from '../../lib/line-chart-layout.js';
22
+ const defaultY = (y) => y.toLocaleString();
23
+ const defaultX = (x) => String(x);
24
+ const percent = (fraction) => `${fraction * 100}%`;
25
+ // Words on the plot sit beside a point, kept inside the plot at its
26
+ // edges, on a plate of the card's colour so a line under them doesn't
27
+ // cross them.
28
+ const PLATE = 'rounded-sm bg-card px-1';
29
+ function besides(x) {
30
+ return x > 0.75 ? '-translate-x-full -ml-1.5' : 'ml-1.5';
31
+ }
32
+ export function LineChart({ label, points, target, mark, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
33
+ if (isLoading) {
34
+ return (_jsxs("div", { "data-slot": "line-chart", "data-state": "loading", "aria-busy": "true", className: cn('min-w-0', className), children: [_jsxs("span", { className: "sr-only", children: ["Loading ", label] }), _jsx(Skeleton, { style: { height }, className: "w-full" })] }));
35
+ }
36
+ if (points.length === 0) {
37
+ return (_jsx("figure", { "data-slot": "line-chart", "data-state": "empty", "aria-label": label, className: cn('min-w-0', className), children: _jsx("p", { style: { height }, className: "flex items-center justify-center rounded-md border border-dashed border-border type-body text-muted-foreground", children: emptyMessage }) }));
38
+ }
39
+ const layout = chartLayout(points, target?.y, mark);
40
+ const values = points.map((point) => point.y);
41
+ const lowest = values.indexOf(Math.min(...values));
42
+ const highest = values.indexOf(Math.max(...values));
43
+ const line = layout.places.map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
44
+ const marked = layout.markIndex === undefined ? undefined : layout.places[layout.markIndex];
45
+ const single = points.length === 1 ? layout.places[0] : undefined;
46
+ const dot = marked ?? single;
47
+ const describe = (index) => `${formatY(points[index].y)} on ${formatX(points[index].x)}`;
48
+ const summary = [
49
+ points.length === 1 ? `One point: ${describe(0)}` : `From ${describe(0)} to ${describe(points.length - 1)}`,
50
+ points.length > 2 && `lowest ${describe(lowest)}`,
51
+ points.length > 2 && `highest ${describe(highest)}`,
52
+ target && `${target.label} ${formatY(target.y)}`,
53
+ ]
54
+ .filter(Boolean)
55
+ .join('; ');
56
+ return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
57
+ // Under the lowest point, where the line isn't; over any other.
58
+ mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(points[0].x) }), points.length > 1 && _jsx("span", { children: formatX(points[points.length - 1].x) })] }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", children: "Value" })] }) }), _jsx("tbody", { children: points.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsx("td", { children: formatY(point.y) })] }, index))) })] })] }));
59
+ }
@@ -1,5 +1,5 @@
1
1
  import { type ComponentProps } from 'react';
2
- import { type HeadingLevel } from './prose-look.js';
2
+ import { type HeadingFrom, type HeadingLevel } from './prose-look.js';
3
3
  export type MarkdownProps = {
4
4
  /** The Markdown source. Raw HTML in it is shown as text, never rendered. */
5
5
  children: string;
@@ -10,6 +10,13 @@ export type MarkdownProps = {
10
10
  * past <h6>. Only the element changes: each level looks the same.
11
11
  */
12
12
  headingLevel?: HeadingLevel;
13
+ /**
14
+ * Which heading takes `headingLevel`: `'hash'` (the default), `#`;
15
+ * `'text'`, the text's own highest, for notes that start at `##` (an
16
+ * import, a paste). The rest keep their distance from it. The text
17
+ * itself is never rewritten.
18
+ */
19
+ headingFrom?: HeadingFrom;
13
20
  className?: string;
14
21
  } & Omit<ComponentProps<'div'>, 'children'>;
15
- export declare function Markdown({ children, headingLevel, className, ...rest }: MarkdownProps): import("react").JSX.Element;
22
+ export declare function Markdown({ children, headingLevel, headingFrom, className, ...rest }: MarkdownProps): import("react").JSX.Element;
@@ -43,6 +43,7 @@ import { cn } from 'cn';
43
43
  import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
44
44
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
45
45
  import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
46
+ import { textTopHeading } from '../../lib/markdown-top-heading.js';
46
47
  import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
47
48
  import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
48
49
  const REMARK_PLUGINS = [remarkGfm, remarkCallouts];
@@ -75,8 +76,8 @@ function NotesTable(props) {
75
76
  // A heading by its Markdown level: the element the page's outline gives
76
77
  // it (headingTag), the look its level has. The text's colour first, so a
77
78
  // level's own (`###`'s muted label) wins.
78
- function heading(level, top) {
79
- const Tag = headingTag(level, top);
79
+ function heading(level, top, textTop) {
80
+ const Tag = headingTag(level, top, textTop);
80
81
  return (props) => (_jsx(Tag, { ...omitNode(props), "data-band": headingBand(level) || undefined, className: cn('text-foreground', headingClass(level)) }));
81
82
  }
82
83
  // Everything but the headings, which follow `headingLevel` (componentsFor).
@@ -130,19 +131,22 @@ const COMPONENTS = {
130
131
  th: (props) => _jsx(TableHead, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
131
132
  td: (props) => _jsx(TableCell, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
132
133
  };
133
- // The components for each `headingLevel`, made once each.
134
+ // The components for each `headingLevel` and level that takes it (`#`,
135
+ // or the text's highest with `headingFrom="text"`), made once each.
134
136
  const BY_LEVEL = new Map();
135
- function componentsFor(top) {
136
- let components = BY_LEVEL.get(top);
137
+ function componentsFor(top, textTop) {
138
+ const key = `${top}:${textTop}`;
139
+ let components = BY_LEVEL.get(key);
137
140
  if (!components) {
138
141
  components = { ...COMPONENTS };
139
142
  for (const level of [1, 2, 3, 4, 5, 6])
140
- components[`h${level}`] = heading(level, top);
141
- BY_LEVEL.set(top, components);
143
+ components[`h${level}`] = heading(level, top, textTop);
144
+ BY_LEVEL.set(key, components);
142
145
  }
143
146
  return components;
144
147
  }
145
- export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, className, ...rest }) {
146
- const components = useMemo(() => componentsFor(headingLevel), [headingLevel]);
148
+ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', className, ...rest }) {
149
+ const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
150
+ const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
147
151
  return (_jsx("div", { "data-slot": "markdown", ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, components: components, children: children }) }));
148
152
  }
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
@@ -69,6 +69,7 @@ import { acceptAttribute } from '../../lib/image-file.js';
69
69
  import { linkHref, linkRefusal } from '../../lib/link-href.js';
70
70
  import { passEscapeThrough } from './escape-passthrough.js';
71
71
  import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
72
+ import { textTopHeading } from '../../lib/markdown-top-heading.js';
72
73
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
73
74
  import { DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag } from './prose-look.js';
74
75
  import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
@@ -144,9 +145,9 @@ function htmlToText(fragment) {
144
145
  }
145
146
  // A heading: the element `headingLevel` gives its level, the look its
146
147
  // level has. Marked as a band for PROSE_INSET (`#` and `##`).
147
- function headingView(node, top) {
148
+ function headingView(node, top, textTop) {
148
149
  const level = Number(node.attrs.level);
149
- const dom = document.createElement(headingTag(level, top));
150
+ const dom = document.createElement(headingTag(level, top, textTop));
150
151
  dom.className = headingClass(level);
151
152
  if (headingBand(level))
152
153
  dom.dataset.band = '';
@@ -261,7 +262,7 @@ function applyLink(view, target, href) {
261
262
  }
262
263
  view.dispatch(tr.scrollIntoView());
263
264
  }
264
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
265
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
265
266
  const rootRef = useRef(null);
266
267
  const editorRef = useRef(null);
267
268
  const [linkBox, setLinkBox] = useState(null);
@@ -350,6 +351,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
350
351
  if (!root)
351
352
  return;
352
353
  const original = defaultValue;
354
+ // The level that takes headingLevel, read from the text it loads.
355
+ const textTop = headingFrom === 'text' ? textTopHeading(original) : 1;
353
356
  let baseline = null;
354
357
  let cancelled = false;
355
358
  const emit = () => {
@@ -625,7 +628,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
625
628
  },
626
629
  nodeViews: {
627
630
  // The level when it mounts, as with defaultValue.
628
- heading: (node) => headingView(node, headingLevel),
631
+ heading: (node) => headingView(node, headingLevel, textTop),
629
632
  callout: (node, _view, getPos) => calloutView(node, getPos, registry),
630
633
  table: (node, view, getPos) => tableView(node, view, getPos, registry),
631
634
  list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
@@ -44,14 +44,23 @@ export declare const PROSE_INSET = "[&:has(>[data-band])>:not([data-band])]:mx-3
44
44
  export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
45
45
  /** `#` renders as an <h3>: the page owns the <h1> and each section its <h2>. */
46
46
  export declare const DEFAULT_HEADING_LEVEL: HeadingLevel;
47
+ /**
48
+ * Which heading in the text takes `headingLevel`: `'hash'` (the default),
49
+ * `#`, whatever the text holds; `'text'`, the text's own highest heading,
50
+ * so notes that start at `##` (an export from Notion, a README) put their
51
+ * first heading straight under the page's, not a level further down.
52
+ */
53
+ export type HeadingFrom = 'hash' | 'text';
47
54
  /**
48
55
  * The element a heading renders as, by its Markdown level (1 for `#`) and
49
56
  * the level `#` renders as: `##` one below it, `###` two below, and so on,
50
- * never past <h6>. Only the element follows the page; the look (the band,
51
- * the label) stays the Markdown level's (headingClass), so a `#` looks
52
- * like a `#` wherever it sits.
57
+ * never past <h6>. `textTop` is the level that renders at `top` instead
58
+ * of `#` (`headingFrom="text"`: the text's highest), and a heading above
59
+ * it renders at `top` too. Only the element follows the page; the look
60
+ * (the band, the label) stays the Markdown level's (headingClass), so a
61
+ * `#` looks like a `#` wherever it sits.
53
62
  */
54
- export declare function headingTag(level: number, top?: HeadingLevel): `h${HeadingLevel}`;
63
+ export declare function headingTag(level: number, top?: HeadingLevel, textTop?: number): `h${HeadingLevel}`;
55
64
  /** Whether a heading sits on a band (`#` and `##`): the text beside it is inset (PROSE_INSET). */
56
65
  export declare function headingBand(level: number): boolean;
57
66
  /** A heading's classes by its Markdown level (1 for `#`). */
@@ -46,13 +46,16 @@ export const DEFAULT_HEADING_LEVEL = 3;
46
46
  /**
47
47
  * The element a heading renders as, by its Markdown level (1 for `#`) and
48
48
  * the level `#` renders as: `##` one below it, `###` two below, and so on,
49
- * never past <h6>. Only the element follows the page; the look (the band,
50
- * the label) stays the Markdown level's (headingClass), so a `#` looks
51
- * like a `#` wherever it sits.
49
+ * never past <h6>. `textTop` is the level that renders at `top` instead
50
+ * of `#` (`headingFrom="text"`: the text's highest), and a heading above
51
+ * it renders at `top` too. Only the element follows the page; the look
52
+ * (the band, the label) stays the Markdown level's (headingClass), so a
53
+ * `#` looks like a `#` wherever it sits.
52
54
  */
53
- export function headingTag(level, top = DEFAULT_HEADING_LEVEL) {
55
+ export function headingTag(level, top = DEFAULT_HEADING_LEVEL, textTop = 1) {
54
56
  const markdown = Math.max(1, Math.floor(level) || 1);
55
- return `h${Math.min(top + markdown - 1, 6)}`;
57
+ const below = Math.max(0, markdown - Math.max(1, Math.floor(textTop) || 1));
58
+ return `h${Math.min(top + below, 6)}`;
56
59
  }
57
60
  /** Whether a heading sits on a band (`#` and `##`): the text beside it is inset (PROSE_INSET). */
58
61
  export function headingBand(level) {
@@ -1,5 +1,5 @@
1
1
  import type { ImageUpload } from '../../lib/image-file.js';
2
- import type { HeadingLevel } from './prose-look.js';
2
+ import type { HeadingFrom, HeadingLevel } from './prose-look.js';
3
3
  export type RichTextEditorProps = {
4
4
  /** The editable element's id, for a label's `htmlFor` or a test. */
5
5
  id?: string;
@@ -62,6 +62,13 @@ export type RichTextEditorProps = {
62
62
  * when it mounts.
63
63
  */
64
64
  headingLevel?: HeadingLevel;
65
+ /**
66
+ * Which heading takes `headingLevel`: `'hash'` (the default), `#`;
67
+ * `'text'`, the text's own highest when it mounts, for notes that start
68
+ * at `##`, as in `<Markdown>`. A heading added above it renders at
69
+ * `headingLevel` too. The Markdown saved is never rewritten.
70
+ */
71
+ headingFrom?: HeadingFrom;
65
72
  className?: string;
66
73
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
67
74
  'aria-label'?: string;
@@ -31,10 +31,20 @@ export interface WidgetCardProps {
31
31
  emptyMessage: string;
32
32
  /** How many rows the loading skeleton shows: the number the widget usually holds. */
33
33
  skeletonRows?: number;
34
+ /** A button at the header's end that folds the card to its header, and opens it again. */
35
+ collapsible?: boolean;
36
+ /** Whether a collapsible card starts folded, until the person folds or opens it. */
37
+ defaultCollapsed?: boolean;
38
+ /**
39
+ * Remembers a collapsible card folded or open on this device, under this
40
+ * key (name it for the app and the widget: `"my-app:hub.waiting"`).
41
+ * Left out, it starts at `defaultCollapsed` on every visit.
42
+ */
43
+ storageKey?: string;
34
44
  children?: ReactNode;
35
45
  className?: string;
36
46
  }
37
- export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, children, className, }: WidgetCardProps): import("react").JSX.Element;
47
+ export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, children, className, }: WidgetCardProps): import("react").JSX.Element;
38
48
  /** The rows of a WidgetCard: a list with a divider between rows. */
39
49
  export declare function WidgetList({ children, className }: {
40
50
  children: ReactNode;
@@ -16,6 +16,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  // outline and its landmarks. Height follows content (`self-start` in a
17
17
  // grid), so a short widget doesn't stretch to its tallest neighbour.
18
18
  //
19
+ // `collapsible` (#233) folds a card to its header, for a hub with more
20
+ // widgets than a phone screen holds: a chevron button at the header's end
21
+ // (named by the title, `aria-expanded`, `aria-controls` the body), the
22
+ // header's link still there while folded. With `storageKey` the choice is
23
+ // remembered on this device (useRememberedFlag: localStorage, in memory
24
+ // where that's blocked). The body is hidden, not unmounted, so whatever
25
+ // the app keeps in it stays put.
26
+ //
19
27
  // WidgetList and WidgetListItem are the common body: rows with dividers,
20
28
  // each a primary link (or plain text) truncated to one line, an optional
21
29
  // muted note ("2 h ago") and an optional trailing element (a badge, a
@@ -28,18 +36,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
28
36
  // finger's target.
29
37
  import { useId } from 'react';
30
38
  import { Link } from 'react-router';
31
- import { ExternalLinkIcon } from 'lucide-react';
39
+ import { ChevronDownIcon, ExternalLinkIcon } from 'lucide-react';
32
40
  import { cn } from 'cn';
33
41
  import { ErrorState } from './error-state.js';
42
+ import { Button } from '../ui/button.js';
34
43
  import { Card, CardContent, CardHeader } from '../ui/card.js';
35
44
  import { Skeleton } from '../ui/skeleton.js';
45
+ import { useRememberedFlag } from '../../hooks/use-remembered-flag.js';
36
46
  const HEADER_LINK_CLASS = 'shrink-0 rounded-sm type-label text-foreground underline underline-offset-4 outline-none ' +
37
47
  'hover:decoration-2 focus-visible:ring-3 focus-visible:ring-ring/50';
38
- export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, children, className, }) {
48
+ export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows = 3, collapsible = false, defaultCollapsed = false, storageKey, children, className, }) {
39
49
  const headingId = useId();
50
+ const contentId = useId();
40
51
  const Heading = `h${headingLevel}`;
41
52
  const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
42
- return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
53
+ const [folded, setFolded] = useRememberedFlag(collapsible && storageKey !== undefined ? `${storageKey}:collapsed` : undefined, defaultCollapsed);
54
+ const collapsed = collapsible && folded;
55
+ return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label })), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
56
+ // The button's own open look (a filled background) would
57
+ // read as pressed: the chevron says it.
58
+ className: "-my-1 -mr-1.5 aria-expanded:not-hover:bg-transparent", children: _jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('transition-transform', collapsed && '-rotate-90') }) }))] }))] }), _jsx(CardContent, { id: contentId, hidden: collapsed, "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
43
59
  }
44
60
  // One row's height, shared by the skeleton and the list so the card doesn't
45
61
  // jump: py-2 around one line of type-body (or a small control). Compact
@@ -1,15 +1,9 @@
1
- type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
2
- export type MutedStore = {
3
- get: (key: string) => boolean;
4
- set: (key: string, muted: boolean) => void;
5
- subscribe: (key: string, listener: () => void) => () => void;
6
- /** Another tab changed `key` (a `storage` event): forget the in-memory value and re-read. */
7
- changedElsewhere: (key: string) => void;
8
- };
9
- export declare function createMutedStore(getStorage: () => StorageLike | undefined): MutedStore;
1
+ import { createFlagStore, type FlagStore } from './use-remembered-flag.js';
2
+ export type MutedStore = FlagStore;
3
+ /** The store under useMuted, for its unit tests (tests/use-muted.test.ts). */
4
+ export declare const createMutedStore: typeof createFlagStore;
10
5
  /**
11
6
  * `[muted, setMuted]`, remembered on this device under `key` (name it for
12
7
  * the app and screen: `"my-app:timer:muted"`). Unmuted until set.
13
8
  */
14
9
  export declare function useMuted(key: string): readonly [boolean, (muted: boolean) => void];
15
- export {};
@@ -1,73 +1,10 @@
1
- import { useCallback, useSyncExternalStore } from 'react';
2
- export function createMutedStore(getStorage) {
3
- // Values this page set, for when storage refuses them.
4
- const memory = new Map();
5
- const listeners = new Map();
6
- const notify = (key) => {
7
- for (const listener of listeners.get(key) ?? [])
8
- listener();
9
- };
10
- return {
11
- get(key) {
12
- const kept = memory.get(key);
13
- if (kept !== undefined)
14
- return kept;
15
- try {
16
- const stored = getStorage()?.getItem(key);
17
- if (stored === 'true')
18
- return true;
19
- if (stored === 'false')
20
- return false;
21
- }
22
- catch {
23
- // Storage blocked: unmuted.
24
- }
25
- return false;
26
- },
27
- set(key, muted) {
28
- memory.set(key, muted);
29
- try {
30
- getStorage()?.setItem(key, String(muted));
31
- }
32
- catch {
33
- // Storage blocked or full: remembered in memory for this page only.
34
- }
35
- notify(key);
36
- },
37
- subscribe(key, listener) {
38
- let set = listeners.get(key);
39
- if (!set)
40
- listeners.set(key, (set = new Set()));
41
- set.add(listener);
42
- return () => set.delete(listener);
43
- },
44
- changedElsewhere(key) {
45
- memory.delete(key);
46
- notify(key);
47
- },
48
- };
49
- }
50
- const browserStore = createMutedStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
51
- let listeningForOtherTabs = false;
52
- function listenForOtherTabs() {
53
- if (listeningForOtherTabs || typeof window === 'undefined')
54
- return;
55
- listeningForOtherTabs = true;
56
- window.addEventListener('storage', (event) => {
57
- if (event.key !== null)
58
- browserStore.changedElsewhere(event.key);
59
- });
60
- }
1
+ import { createFlagStore, useRememberedFlag } from './use-remembered-flag.js';
2
+ /** The store under useMuted, for its unit tests (tests/use-muted.test.ts). */
3
+ export const createMutedStore = createFlagStore;
61
4
  /**
62
5
  * `[muted, setMuted]`, remembered on this device under `key` (name it for
63
6
  * the app and screen: `"my-app:timer:muted"`). Unmuted until set.
64
7
  */
65
8
  export function useMuted(key) {
66
- const subscribe = useCallback((listener) => {
67
- listenForOtherTabs();
68
- return browserStore.subscribe(key, listener);
69
- }, [key]);
70
- const muted = useSyncExternalStore(subscribe, () => browserStore.get(key), () => false);
71
- const setMuted = useCallback((next) => browserStore.set(key, next), [key]);
72
- return [muted, setMuted];
9
+ return useRememberedFlag(key, false);
73
10
  }
@@ -0,0 +1,18 @@
1
+ type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
2
+ export type FlagStore = {
3
+ /** What's held for `key`, or `fallback` when nothing is (or storage is blocked). */
4
+ get: (key: string, fallback?: boolean) => boolean;
5
+ set: (key: string, value: boolean) => void;
6
+ subscribe: (key: string, listener: () => void) => () => void;
7
+ /** Another tab changed `key` (a `storage` event): forget the in-memory value and re-read. */
8
+ changedElsewhere: (key: string) => void;
9
+ };
10
+ export declare function createFlagStore(getStorage: () => StorageLike | undefined): FlagStore;
11
+ /**
12
+ * `[value, setValue]`, remembered on this device under `key` (name it for
13
+ * the app and what it's about: `"my-app:hub.waiting:collapsed"`), and
14
+ * `fallback` until set. Without a key it's plain state, starting at
15
+ * `fallback` and forgotten with the page.
16
+ */
17
+ export declare function useRememberedFlag(key: string | undefined, fallback?: boolean): readonly [boolean, (value: boolean) => void];
18
+ export {};
@@ -0,0 +1,78 @@
1
+ import { useCallback, useState, useSyncExternalStore } from 'react';
2
+ export function createFlagStore(getStorage) {
3
+ // Values this page set, for when storage refuses them.
4
+ const memory = new Map();
5
+ const listeners = new Map();
6
+ const notify = (key) => {
7
+ for (const listener of listeners.get(key) ?? [])
8
+ listener();
9
+ };
10
+ return {
11
+ get(key, fallback = false) {
12
+ const kept = memory.get(key);
13
+ if (kept !== undefined)
14
+ return kept;
15
+ try {
16
+ const stored = getStorage()?.getItem(key);
17
+ if (stored === 'true')
18
+ return true;
19
+ if (stored === 'false')
20
+ return false;
21
+ }
22
+ catch {
23
+ // Storage blocked: the fallback.
24
+ }
25
+ return fallback;
26
+ },
27
+ set(key, value) {
28
+ memory.set(key, value);
29
+ try {
30
+ getStorage()?.setItem(key, String(value));
31
+ }
32
+ catch {
33
+ // Storage blocked or full: remembered in memory for this page only.
34
+ }
35
+ notify(key);
36
+ },
37
+ subscribe(key, listener) {
38
+ let set = listeners.get(key);
39
+ if (!set)
40
+ listeners.set(key, (set = new Set()));
41
+ set.add(listener);
42
+ return () => set.delete(listener);
43
+ },
44
+ changedElsewhere(key) {
45
+ memory.delete(key);
46
+ notify(key);
47
+ },
48
+ };
49
+ }
50
+ const browserStore = createFlagStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
51
+ let listeningForOtherTabs = false;
52
+ function listenForOtherTabs() {
53
+ if (listeningForOtherTabs || typeof window === 'undefined')
54
+ return;
55
+ listeningForOtherTabs = true;
56
+ window.addEventListener('storage', (event) => {
57
+ if (event.key !== null)
58
+ browserStore.changedElsewhere(event.key);
59
+ });
60
+ }
61
+ /**
62
+ * `[value, setValue]`, remembered on this device under `key` (name it for
63
+ * the app and what it's about: `"my-app:hub.waiting:collapsed"`), and
64
+ * `fallback` until set. Without a key it's plain state, starting at
65
+ * `fallback` and forgotten with the page.
66
+ */
67
+ export function useRememberedFlag(key, fallback = false) {
68
+ const [local, setLocal] = useState(fallback);
69
+ const subscribe = useCallback((listener) => {
70
+ if (key === undefined)
71
+ return () => { };
72
+ listenForOtherTabs();
73
+ return browserStore.subscribe(key, listener);
74
+ }, [key]);
75
+ const stored = useSyncExternalStore(subscribe, () => (key === undefined ? fallback : browserStore.get(key, fallback)), () => fallback);
76
+ const set = useCallback((next) => (key === undefined ? setLocal(next) : browserStore.set(key, next)), [key]);
77
+ return [key === undefined ? local : stored, set];
78
+ }