@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.
- package/conventions/AGENTS.md +9 -3
- package/conventions/docs/add-an-entity.md +45 -4
- package/conventions/docs/cell-patterns.md +59 -9
- package/conventions/docs/design-language.md +81 -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 +22 -2
- package/dist/components/app/data-table.js +29 -7
- package/dist/components/app/line-chart.d.ts +26 -0
- package/dist/components/app/line-chart.js +59 -0
- package/dist/components/app/list-editor-rows.d.ts +18 -0
- package/dist/components/app/list-editor-rows.js +23 -0
- package/dist/components/app/list-editor.d.ts +25 -3
- package/dist/components/app/list-editor.js +61 -5
- package/dist/components/app/markdown.d.ts +17 -2
- package/dist/components/app/markdown.js +84 -11
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +22 -10
- package/dist/components/app/prose-look.d.ts +35 -6
- package/dist/components/app/prose-look.js +27 -6
- package/dist/components/app/prose-table-scroll.d.ts +33 -0
- package/dist/components/app/prose-table-scroll.js +87 -0
- package/dist/components/app/rich-text-editor.d.ts +17 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-node-views.d.ts +6 -3
- package/dist/components/app/rich-text-node-views.js +108 -5
- package/dist/components/app/widget-card.d.ts +11 -1
- package/dist/components/app/widget-card.js +19 -3
- package/dist/components/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/collapsible.js +12 -0
- 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 +4 -2
- package/dist/index.js +3 -2
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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'
|
|
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
|
|
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) }))
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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;
|