@tristan2828/ui-foundation 4.32.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 (48) hide show
  1. package/conventions/AGENTS.md +9 -3
  2. package/conventions/docs/add-an-entity.md +45 -4
  3. package/conventions/docs/cell-patterns.md +59 -9
  4. package/conventions/docs/design-language.md +81 -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 +22 -2
  13. package/dist/components/app/data-table.js +29 -7
  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/list-editor-rows.d.ts +18 -0
  17. package/dist/components/app/list-editor-rows.js +23 -0
  18. package/dist/components/app/list-editor.d.ts +25 -3
  19. package/dist/components/app/list-editor.js +61 -5
  20. package/dist/components/app/markdown.d.ts +17 -2
  21. package/dist/components/app/markdown.js +84 -11
  22. package/dist/components/app/milkdown-editor.d.ts +1 -1
  23. package/dist/components/app/milkdown-editor.js +22 -10
  24. package/dist/components/app/prose-look.d.ts +35 -6
  25. package/dist/components/app/prose-look.js +27 -6
  26. package/dist/components/app/prose-table-scroll.d.ts +33 -0
  27. package/dist/components/app/prose-table-scroll.js +87 -0
  28. package/dist/components/app/rich-text-editor.d.ts +17 -0
  29. package/dist/components/app/rich-text-editor.js +5 -0
  30. package/dist/components/app/rich-text-node-views.d.ts +6 -3
  31. package/dist/components/app/rich-text-node-views.js +108 -5
  32. package/dist/components/app/widget-card.d.ts +11 -1
  33. package/dist/components/app/widget-card.js +19 -3
  34. package/dist/components/ui/collapsible.d.ts +5 -0
  35. package/dist/components/ui/collapsible.js +12 -0
  36. package/dist/hooks/use-muted.d.ts +4 -10
  37. package/dist/hooks/use-muted.js +4 -67
  38. package/dist/hooks/use-remembered-flag.d.ts +18 -0
  39. package/dist/hooks/use-remembered-flag.js +78 -0
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.js +3 -2
  42. package/dist/lib/line-chart-layout.d.ts +23 -0
  43. package/dist/lib/line-chart-layout.js +44 -0
  44. package/dist/lib/markdown-top-heading.d.ts +2 -0
  45. package/dist/lib/markdown-top-heading.js +21 -0
  46. package/dist/testing/index.d.ts +16 -4
  47. package/dist/testing/index.js +5 -3
  48. package/package.json +3 -2
@@ -4,7 +4,8 @@ import { useTheme } from 'next-themes';
4
4
  import { useEffect, useId, useState } from 'react';
5
5
  import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
- import { activeNavTo, navGroups, navPageLabel } from './app-shell-nav.js';
7
+ import { BottomBar, BOTTOM_BAR_PADDING } from './app-shell-bottom-bar.js';
8
+ import { activeNavTo, childrenOpen, navGroups, navPageLabel, withChildren, } from './app-shell-nav.js';
8
9
  import { versionHref, versionLabel } from './app-shell-version.js';
9
10
  import { ErrorState } from './error-state.js';
10
11
  import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
@@ -13,7 +14,7 @@ import { useShellDocumentTitle } from '../../hooks/use-document-title.js';
13
14
  import { cn } from 'cn';
14
15
  import { Button } from '../ui/button.js';
15
16
  import { Skeleton } from '../ui/skeleton.js';
16
- import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
17
+ import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
17
18
  import { Toaster } from '../ui/sonner.js';
18
19
  import { TooltipProvider } from '../ui/tooltip.js';
19
20
  // Must match SIDEBAR_COOKIE_NAME in ../ui/sidebar.tsx, which writes the
@@ -52,10 +53,23 @@ function VersionEntry({ version }) {
52
53
  // One group's entries. A plain Link, not NavLink: NavLink marks every
53
54
  // matching entry current, and only the most specific one should be
54
55
  // (activeNavTo).
56
+ //
57
+ // An entry's children (#239) are a list inside its item, so they're read
58
+ // as its pages, shown while you're in them (childrenOpen) and otherwise
59
+ // in the page but hidden, as a folded section's are. The icon rail has no
60
+ // room for them: there the entry looks current while one of them is, and
61
+ // its tooltip names them.
55
62
  function NavMenu({ items, activeTo }) {
56
- return (_jsx(SidebarMenu, { children: items.map(({ to, label, icon: Icon }) => {
63
+ const { state, isMobile } = useSidebar();
64
+ const rail = state === 'collapsed' && !isMobile;
65
+ return (_jsx(SidebarMenu, { children: items.map((item) => {
66
+ const { to, label, icon: Icon, children } = item;
57
67
  const isActive = to === activeTo;
58
- return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
68
+ const childActive = children?.some((child) => child.to === activeTo) ?? false;
69
+ return (_jsxs(SidebarMenuItem, { children: [_jsxs(SidebarMenuButton, { isActive: isActive || (rail && childActive), tooltip: children?.length ? `${label}: ${children.map((child) => child.label).join(', ')}` : label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }), children && children.length > 0 && (_jsx(SidebarMenuSub, { hidden: !childrenOpen(item, activeTo), children: children.map(({ to: childTo, label: childLabel, icon: ChildIcon }) => {
70
+ const isChildActive = childTo === activeTo;
71
+ return (_jsx(SidebarMenuSubItem, { children: _jsxs(SidebarMenuSubButton, { isActive: isChildActive, render: _jsx(Link, { to: childTo, "aria-current": isChildActive ? 'page' : undefined }), children: [_jsx(ChildIcon, {}), _jsx("span", { children: childLabel })] }) }, childTo));
72
+ }) }))] }, to));
59
73
  }) }));
60
74
  }
61
75
  // Whether a collapsible section is open, remembered per browser by its
@@ -88,7 +102,7 @@ function CollapsibleNavGroup({ group, activeTo }) {
88
102
  const { state, isMobile } = useSidebar();
89
103
  const contentId = useId();
90
104
  const [stored, setStored] = useState(() => storedSectionOpen(group.label));
91
- const holdsCurrent = group.items.some((item) => item.to === activeTo);
105
+ const holdsCurrent = withChildren(group.items).some((item) => item.to === activeTo);
92
106
  // Arriving in a folded section unfolds it, and it stays unfolded after.
93
107
  if (holdsCurrent && stored !== true)
94
108
  setStored(true);
@@ -110,7 +124,7 @@ function CollapsibleNavGroup({ group, activeTo }) {
110
124
  function AppShellSkeleton() {
111
125
  return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
112
126
  }
113
- export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen = true, version }) {
127
+ export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen = true, version, bottomBar, }) {
114
128
  const { user, status, error, retry, logout } = useAuth();
115
129
  const location = useLocation();
116
130
  const reporter = useErrorReporter();
@@ -146,5 +160,5 @@ export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarO
146
160
  // (AuthProvider), so this stays a deliberate logout: a
147
161
  // clean /login, never the previous user's page as `from`.
148
162
  logout().catch(() => { });
149
- }, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "flex-1 p-6", children: _jsx(Outlet, {}) })] })] }), _jsx(Toaster, { position: "top-center" })] }));
163
+ }, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: cn('flex-1 p-6', bottomBar?.length && BOTTOM_BAR_PADDING), children: _jsx(Outlet, {}) })] }), bottomBar && bottomBar.length > 0 && _jsx(BottomBar, { items: bottomBar, nav: nav, activeTo: activeTo })] }), _jsx(Toaster, { position: "top-center" })] }));
150
164
  }
@@ -9,7 +9,7 @@ export type SortingState = {
9
9
  }[];
10
10
  declare module '@tanstack/table-core' {
11
11
  interface ColumnMeta<TFeatures extends TableFeatures, TData extends RowData, TValue extends CellData = CellData> {
12
- align?: 'center';
12
+ align?: 'center' | 'right';
13
13
  }
14
14
  }
15
15
  export type DataTableProps<TData extends Record<string, unknown>> = {
@@ -97,6 +97,26 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
97
97
  * Colour isn't the only signal: say why in the row too (a badge, a word).
98
98
  */
99
99
  highlightRow?: (row: TData) => boolean;
100
+ /**
101
+ * A totals row under the columns it adds up, the way a spreadsheet's
102
+ * is: `label` in the first visible column ("Total · 18 items"), then
103
+ * each of `cells` under the column with that id, in its alignment. The
104
+ * values come from the server, for every row the filters match, never
105
+ * summed from the page on the client. Shown only with rows: never while
106
+ * loading, on error or empty. Leave undefined (or null, before the data
107
+ * has come) for no row.
108
+ */
109
+ totals?: DataTableTotals | null;
100
110
  };
101
111
  export type DataTableWidth = 'full' | 'content';
102
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, }: DataTableProps<TData>): import("react").JSX.Element;
112
+ export type DataTableTotals = {
113
+ /** Goes in the first visible column, as the row's header. */
114
+ label: ReactNode;
115
+ /**
116
+ * One value per column id, already formatted. A column left out, or
117
+ * hidden by `visibleColumns`, gets an empty cell or none. The first
118
+ * visible column holds the label, so a value for it isn't shown.
119
+ */
120
+ cells?: Partial<Record<string, ReactNode>>;
121
+ };
122
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -23,7 +23,7 @@ import { EditInPlaceContext } from './edit-in-place.js';
23
23
  import { EditInPlaceStore } from './edit-in-place-store.js';
24
24
  import { LeaveGuard } from './editable-value.js';
25
25
  import { groupRuns } from './table-groups.js';
26
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
26
+ import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '../ui/table.js';
27
27
  // A column def's id as TanStack resolves it (constructColumn): `id`, then
28
28
  // `accessorKey` with dots as underscores, then a string header. Group
29
29
  // columns are looked through to their leaves, which visibility is keyed by.
@@ -59,6 +59,9 @@ const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-h
59
59
  // so lighting it too read as a second selected thing. Only the first
60
60
  // column marks the hovered row.
61
61
  const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS}`;
62
+ // The totals row's label, pinned with the first column (`totals`): the
63
+ // same opaque cell, with no hover, since the row isn't one to pick.
64
+ const PINNED_TOTALS_LABEL_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS}`;
62
65
  // A pinned first column's content, capped below md at 45% of the table's
63
66
  // scroll area (`cqw`: the container is an inline-size query container)
64
67
  // and wrapping, so a long title can't widen the column past the screen
@@ -82,12 +85,24 @@ const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
82
85
  // Inside, muted text takes the darker grey the wash needs (theme.css).
83
86
  const HIGHLIGHTED_ROW_CLASS = '[--muted-foreground:var(--table-highlight-muted-foreground)] [&>td]:bg-(--table-highlight) ' +
84
87
  '[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--info-text)]';
88
+ // `totals`: a footer row the way a spreadsheet's totals are, set apart
89
+ // from the rows by a border twice a row divider's width and medium type,
90
+ // on the page's background (table.tsx's footer tint is translucent, so a
91
+ // pinned cell over it couldn't match without bleed-through). Its cells
92
+ // read the body's density tokens, so it's a row's height, not the
93
+ // header's; tabular figures line its numbers up with the column's.
94
+ const TOTALS_FOOTER_CLASS = 'border-t-2 border-border bg-background tabular-nums';
95
+ // Its label is a row header (<th scope="row">), padded as a body cell is.
96
+ const TOTALS_LABEL_CLASS = 'h-auto py-(--table-cell-py)';
85
97
  // `header={false}`: the header row stays in the table, so every cell keeps
86
98
  // its column header for a screen reader, but takes no room: each header
87
99
  // is its name in an sr-only span, in a cell with no height or padding.
88
100
  const HIDDEN_HEAD_CLASS = 'h-0 p-0';
89
101
  // A group's heading row (`groupBy`): a little air above, so the groups
90
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];
91
106
  const GROUP_HEADING_CLASS = 'h-auto bg-background pt-4 pb-1.5 align-bottom';
92
107
  // `width="content"`: from a table area 80rem wide, as wide as the columns
93
108
  // need (`w-fit` over the table's own `w-full`), at least 36rem, or the
@@ -157,7 +172,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
157
172
  }, [containerRef, isSuccessView, isOverflowing]);
158
173
  return { barRef, isOverflowing, scrollWidth };
159
174
  }
160
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, }) {
175
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, totals, }) {
161
176
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
162
177
  // a cell that renders an <EditableValue> edits where it's shown, one at
163
178
  // a time, with the leave-page prompt while one is open, the same rules
@@ -216,9 +231,10 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
216
231
  const containerRef = useRef(null);
217
232
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
218
233
  const rows = table.getRowModel().rows;
219
- const visibleColumnCount = table.getVisibleLeafColumns().length;
234
+ const visibleLeafColumns = table.getVisibleLeafColumns();
235
+ const visibleColumnCount = visibleLeafColumns.length;
220
236
  // One body row, the same in a plain table and a grouped one.
221
- 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));
222
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
223
239
  ? { '--data-table-last-width': `${lastWidth}px` }
224
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,
@@ -237,16 +253,22 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
237
253
  const sortDirection = header.column.getIsSorted();
238
254
  // Hidden, a header is its name only: a sort button no
239
255
  // one can see would still take focus.
240
- 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()));
241
260
  return (_jsx(TableHead, { className: cn(!showHeader && HIDDEN_HEAD_CLASS, pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
242
261
  index === headerGroup.headers.length - 1 &&
243
- 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
244
263
  ? undefined
245
264
  : sortDirection === 'asc'
246
265
  ? 'ascending'
247
266
  : sortDirection === 'desc'
248
267
  ? 'descending'
249
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));
250
- }) }, 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) }))] }), 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, {}) })] })] })] }))] }));
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) => {
270
+ const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
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));
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, {}) })] })] })] }))] }));
251
273
  return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
252
274
  }
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The open item once the list's ids have changed: the item that just
3
+ * appeared, when an add or a duplicate asked for it (`opening`), else the
4
+ * one that was open, if it's still there. A list replaced whole (a form's
5
+ * `reset`) gives every item a new id, and nothing asked, so nothing opens.
6
+ */
7
+ export declare function followOpenItem({ before, after, open, opening, }: {
8
+ before: readonly string[];
9
+ after: readonly string[];
10
+ open: string | null;
11
+ opening: boolean;
12
+ }): string | null;
13
+ /** Whether an item shows its fields: it's the open one, or it has an error to show. */
14
+ export declare function isItemOpen({ id, open, invalid }: {
15
+ id: string;
16
+ open: string | null;
17
+ invalid: boolean;
18
+ }): boolean;
@@ -0,0 +1,23 @@
1
+ // Which of ListEditor's items are open, when its items show as summary
2
+ // lines (`summary`, #222). One item is open at a time, held by its stable
3
+ // id (useFieldArray's `field.id`), so it stays open while rows move and
4
+ // closes when it's removed. An item that was just added or duplicated
5
+ // opens, and one with a validation error is open whatever else is.
6
+ /**
7
+ * The open item once the list's ids have changed: the item that just
8
+ * appeared, when an add or a duplicate asked for it (`opening`), else the
9
+ * one that was open, if it's still there. A list replaced whole (a form's
10
+ * `reset`) gives every item a new id, and nothing asked, so nothing opens.
11
+ */
12
+ export function followOpenItem({ before, after, open, opening, }) {
13
+ if (opening) {
14
+ const added = after.find((id) => !before.includes(id));
15
+ if (added !== undefined)
16
+ return added;
17
+ }
18
+ return open !== null && after.includes(open) ? open : null;
19
+ }
20
+ /** Whether an item shows its fields: it's the open one, or it has an error to show. */
21
+ export function isItemOpen({ id, open, invalid }) {
22
+ return invalid || id === open;
23
+ }
@@ -1,25 +1,47 @@
1
- import type { ReactNode } from 'react';
2
- export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, add, disabled, 'aria-label': ariaLabel, }: {
1
+ import { type ReactNode } from 'react';
2
+ export declare function ListEditor({ items, renderItem, summary, invalid, onAdd, onRemove, onMove, onDuplicate, itemName, addLabel, emptyText, max, add, disabled, 'aria-label': ariaLabel, }: {
3
3
  /** One entry per item, with a stable key (useFieldArray's `fields`). */
4
4
  items: readonly {
5
5
  id: string;
6
6
  }[];
7
7
  /** The item's own fields, for the item at this index. */
8
8
  renderItem: (index: number) => ReactNode;
9
+ /**
10
+ * Shows each item as one line, this text (e.g. "Squat · 3 × 8 at 60 kg"),
11
+ * that opens to its `renderItem` fields on a click, Enter or Space. One
12
+ * item is open at a time; one just added or duplicated opens. For a long
13
+ * list of rich items. Without it, every item shows its fields.
14
+ */
15
+ summary?: (index: number) => ReactNode;
16
+ /**
17
+ * With `summary`: the item at this index has an error to show, e.g.
18
+ * (i) => !!errors.steps?.[i]. It opens, beside the open one, until the
19
+ * error clears, so a refused save never hides why. react-hook-form's
20
+ * focus on the first error then makes that item the open one.
21
+ */
22
+ invalid?: (index: number) => boolean;
9
23
  /** The Add button's action. Not needed with `add`. */
10
24
  onAdd?: () => void;
11
25
  onRemove: (index: number) => void;
12
26
  onMove: (from: number, to: number) => void;
27
+ /**
28
+ * Puts a Duplicate button beside Remove. The app inserts the copy right
29
+ * after the original, e.g. (i) => insert(i + 1, { ...getValues(`steps.${i}`) }),
30
+ * leaving out anything a copy mustn't share (a saved id). With `summary`,
31
+ * the copy opens. Disabled once `max` is reached, as Add is.
32
+ */
33
+ onDuplicate?: (index: number) => void;
13
34
  /**
14
35
  * How the row buttons name an item for a screen reader, e.g.
15
36
  * (i) => `item ${i + 1}` gives "Move item 2 up", "Remove item 2".
37
+ * A summary line is named by it too: "item 2: Squat · 3 × 8 at 60 kg".
16
38
  */
17
39
  itemName: (index: number) => string;
18
40
  /** The Add button's text, e.g. "Add item". */
19
41
  addLabel: string;
20
42
  /** Shown instead of the list when it's empty. */
21
43
  emptyText?: string;
22
- /** The most items allowed; Add is disabled once reached. */
44
+ /** The most items allowed; Add and Duplicate are disabled once reached. */
23
45
  max?: number;
24
46
  /** In place of the Add button: what adds an item (editing in place's Add box). */
25
47
  add?: ReactNode;
@@ -1,10 +1,66 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Edits a list of sub-records inside a parent's form: one row per item,
3
+ // each with move up / move down / remove, and an Add button below. The one
4
+ // control for every "sub-records" section of an entity plan
5
+ // (conventions/docs/entity-plan-template.md): Widget's checklist, a task's
6
+ // checklist and links.
7
+ //
8
+ // It owns the row chrome only. Each item's fields are the app's (rendered
9
+ // by `renderItem`), and so is the state: pass react-hook-form's
10
+ // useFieldArray `fields`, `append`, `remove` and `move`, so the items save
11
+ // with the parent's form and their errors bind like any other field.
12
+ //
13
+ // A long list of rich items (#222) can show each item as one line instead
14
+ // (`summary`): the line is a button that opens the item to its fields,
15
+ // one item at a time (list-editor-rows.ts), with the row buttons beside
16
+ // it. And `onDuplicate` puts a Duplicate button beside Remove, for lists
17
+ // whose items repeat with small changes. Without either, every item shows
18
+ // its fields, as before.
19
+ //
20
+ // Editing a list in place on a view (editable-list.tsx) is built on it
21
+ // too, so the rows and their buttons match the form's: there, `add` is the
22
+ // in-place Add box, and `disabled` holds the buttons while a change saves.
23
+ import { useState } from 'react';
24
+ import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon, CopyIcon, PlusIcon, Trash2Icon } from 'lucide-react';
3
25
  import { Button } from '../ui/button.js';
4
- export function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
26
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
27
+ import { followOpenItem, isItemOpen } from './list-editor-rows.js';
28
+ export function ListEditor({ items, renderItem, summary, invalid, onAdd, onRemove, onMove, onDuplicate, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
5
29
  const atMax = max !== undefined && items.length >= max;
30
+ // The open item, by id. When the ids change (an add, a move, a remove),
31
+ // it follows them here, in the render that shows the change, so a new
32
+ // item's fields are already there when react-hook-form focuses them.
33
+ const ids = items.map((item) => item.id);
34
+ const [open, setOpen] = useState(null);
35
+ const [opening, setOpening] = useState(false);
36
+ const [seen, setSeen] = useState(ids);
37
+ if (seen.join('\n') !== ids.join('\n')) {
38
+ setSeen(ids);
39
+ setOpen(followOpenItem({ before: seen, after: ids, open, opening }));
40
+ setOpening(false);
41
+ }
42
+ // An add or a duplicate asks for the item it makes to open. The app's
43
+ // action runs in the same click, so its new id arrives with the ask.
44
+ const addItem = () => {
45
+ setOpening(true);
46
+ onAdd?.();
47
+ };
48
+ const duplicate = (index) => {
49
+ setOpening(true);
50
+ onDuplicate?.(index);
51
+ };
52
+ const rowButtons = (index, name) => (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} up`, disabled: disabled || index === 0, onClick: () => onMove(index, index - 1), children: _jsx(ArrowUpIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} down`, disabled: disabled || index === items.length - 1, onClick: () => onMove(index, index + 1), children: _jsx(ArrowDownIcon, {}) }), onDuplicate && (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Duplicate ${name}`, disabled: disabled || atMax, onClick: () => duplicate(index), children: _jsx(CopyIcon, {}) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, disabled: disabled, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }));
6
53
  return (_jsxs("div", { className: "flex flex-col gap-2", children: [items.length === 0 ? (_jsx("p", { className: "type-caption text-muted-foreground", children: emptyText })) : (_jsx("ul", { "aria-label": ariaLabel, className: "flex flex-col gap-2", children: items.map((item, index) => {
7
54
  const name = itemName(index);
8
- return (_jsxs("li", { className: "flex items-start gap-1", children: [_jsx("div", { className: "flex min-w-0 flex-1 items-start gap-2", children: renderItem(index) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} up`, disabled: disabled || index === 0, onClick: () => onMove(index, index - 1), children: _jsx(ArrowUpIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} down`, disabled: disabled || index === items.length - 1, onClick: () => onMove(index, index + 1), children: _jsx(ArrowDownIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, disabled: disabled, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }, item.id));
9
- }) })), add ?? (_jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: onAdd, disabled: disabled || atMax, children: [_jsx(PlusIcon, {}), addLabel] }) }))] }));
55
+ if (!summary) {
56
+ return (_jsxs("li", { className: "flex items-start gap-1", children: [_jsx("div", { className: "flex min-w-0 flex-1 items-start gap-2", children: renderItem(index) }), rowButtons(index, name)] }, item.id));
57
+ }
58
+ // A summary line: the whole line is the button (named by the
59
+ // item's name, then its summary), the row buttons beside it,
60
+ // and the fields under it while it's open. Clicking the line
61
+ // again closes it; an item with an error stays open. Going into
62
+ // an item's fields makes it the open one, so one opened by its
63
+ // error doesn't close under the caret once the error clears.
64
+ return (_jsx("li", { children: _jsxs(Collapsible, { open: isItemOpen({ id: item.id, open, invalid: invalid?.(index) ?? false }), onOpenChange: (next) => setOpen((current) => (next ? item.id : current === item.id ? null : current)), className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-start gap-1", children: [_jsxs(CollapsibleTrigger, { className: "group/summary flex min-h-7 min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1.5 py-1 text-left type-body text-foreground outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx(ChevronRightIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/summary:rotate-90 motion-reduce:transition-none" }), _jsxs("span", { className: "sr-only", children: [name, ": "] }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: summary(index) })] }), rowButtons(index, name)] }), _jsx(CollapsibleContent, { className: "flex min-w-0 items-start gap-2 pl-7", onFocus: () => setOpen(item.id), children: renderItem(index) })] }) }, item.id));
65
+ }) })), add ?? (_jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, disabled: disabled || atMax, children: [_jsx(PlusIcon, {}), addLabel] }) }))] }));
10
66
  }
@@ -1,7 +1,22 @@
1
- import type { ComponentProps } from 'react';
1
+ import { type ComponentProps } from 'react';
2
+ import { type HeadingFrom, type HeadingLevel } from './prose-look.js';
2
3
  export type MarkdownProps = {
3
4
  /** The Markdown source. Raw HTML in it is shown as text, never rendered. */
4
5
  children: string;
6
+ /**
7
+ * The HTML level the text's `#` renders as, where the text sits in the
8
+ * page's outline: 2 straight under the page's <h1>, 3 (the default)
9
+ * under a section's <h2>. `##` is one below it, `###` two below, never
10
+ * past <h6>. Only the element changes: each level looks the same.
11
+ */
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;
5
20
  className?: string;
6
21
  } & Omit<ComponentProps<'div'>, 'children'>;
7
- export declare function Markdown({ children, className, ...rest }: MarkdownProps): import("react").JSX.Element;
22
+ export declare function Markdown({ children, headingLevel, headingFrom, className, ...rest }: MarkdownProps): import("react").JSX.Element;