@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.
- package/conventions/AGENTS.md +9 -3
- package/conventions/docs/add-an-entity.md +33 -3
- package/conventions/docs/cell-patterns.md +51 -9
- package/conventions/docs/design-language.md +65 -1
- package/conventions/docs/timed-screens.md +13 -0
- package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
- package/dist/components/app/app-shell-bottom-bar.js +49 -0
- package/dist/components/app/app-shell-nav.d.ts +41 -0
- package/dist/components/app/app-shell-nav.js +40 -8
- package/dist/components/app/app-shell.d.ts +15 -4
- package/dist/components/app/app-shell.js +21 -7
- package/dist/components/app/data-table.d.ts +1 -1
- package/dist/components/app/data-table.js +10 -4
- package/dist/components/app/line-chart.d.ts +26 -0
- package/dist/components/app/line-chart.js +59 -0
- package/dist/components/app/markdown.d.ts +9 -2
- package/dist/components/app/markdown.js +13 -9
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +7 -4
- package/dist/components/app/prose-look.d.ts +13 -4
- package/dist/components/app/prose-look.js +8 -5
- package/dist/components/app/rich-text-editor.d.ts +8 -1
- package/dist/components/app/widget-card.d.ts +11 -1
- package/dist/components/app/widget-card.js +19 -3
- package/dist/hooks/use-muted.d.ts +4 -10
- package/dist/hooks/use-muted.js +4 -67
- package/dist/hooks/use-remembered-flag.d.ts +18 -0
- package/dist/hooks/use-remembered-flag.js +78 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lib/line-chart-layout.d.ts +23 -0
- package/dist/lib/line-chart-layout.js +44 -0
- package/dist/lib/markdown-top-heading.d.ts +2 -0
- package/dist/lib/markdown-top-heading.js +21 -0
- package/dist/testing/index.d.ts +16 -4
- package/dist/testing/index.js +5 -3
- 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
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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>.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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>.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
export type MutedStore =
|
|
3
|
-
|
|
4
|
-
|
|
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 {};
|
package/dist/hooks/use-muted.js
CHANGED
|
@@ -1,73 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
+
}
|