@tristan2828/ui-foundation 4.32.0 → 4.33.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/docs/add-an-entity.md +13 -2
- package/conventions/docs/cell-patterns.md +8 -0
- package/conventions/docs/design-language.md +17 -1
- package/dist/components/app/data-table.d.ts +21 -1
- package/dist/components/app/data-table.js +20 -4
- 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 +10 -2
- package/dist/components/app/markdown.js +80 -11
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +19 -10
- package/dist/components/app/prose-look.d.ts +26 -6
- package/dist/components/app/prose-look.js +24 -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 +10 -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/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/collapsible.js +12 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -152,7 +152,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
152
152
|
`<ListEditor>` inside a `FieldSet`/`FieldLegend` on the form, each row's
|
|
153
153
|
controls named by position ("Item 2 text"), a per-item `FieldError`
|
|
154
154
|
(a server 422 at `["body", "checklist", 2, "text"]` arrives as
|
|
155
|
-
`fieldErrors["checklist.2.text"]` and binds to that row)
|
|
155
|
+
`fieldErrors["checklist.2.text"]` and binds to that row). A list whose
|
|
156
|
+
items are rich (five or six fields each) and run to ten or more also
|
|
157
|
+
passes `summary={(i) => …}`, one line per item that opens to its
|
|
158
|
+
fields, one at a time, and `invalid={(i) => !!errors.<list>?.[i]}` so
|
|
159
|
+
an item the save refuses opens to show why. A list whose items repeat
|
|
160
|
+
with small changes passes
|
|
161
|
+
``onDuplicate={(i) => insert(i + 1, { ...getValues(`<list>.${i}`) })}``,
|
|
162
|
+
leaving out any saved id; the copy lands after its original and opens.
|
|
163
|
+
Then a summary
|
|
156
164
|
in the table, never the items (an item with a `reference` field
|
|
157
165
|
copies Pairings, below) — and a `computed` field copies
|
|
158
166
|
Progress (`checklistState`): `readOnly` in `openapi.yaml` and absent
|
|
@@ -438,7 +446,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
438
446
|
(`> [!NOTE]`) and links, links opening in a new tab; raw HTML shows
|
|
439
447
|
as the characters typed, never as markup. The app needs no Markdown dependency of its own. Plain
|
|
440
448
|
long text renders as text (`whitespace-pre-line` keeps its line
|
|
441
|
-
breaks).
|
|
449
|
+
breaks). Give `<Markdown>` and `RichTextEditor` a `headingLevel` for
|
|
450
|
+
where they sit: the default (3) in an `EntityView` section,
|
|
451
|
+
`headingLevel={2}` for a `RichTextEditor` on a form straight under
|
|
452
|
+
the page's `<h1>`.
|
|
442
453
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
443
454
|
names**, each a control that saves on its own (cell pattern 17,
|
|
444
455
|
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
@@ -388,6 +388,14 @@ cell: ({ getValue }) => {
|
|
|
388
388
|
- An API `format: decimal` arrives as a **string**. `Number()` is exact
|
|
389
389
|
enough to display (about 15 significant digits). Never add up money in
|
|
390
390
|
floats on the client: totals come from the backend.
|
|
391
|
+
- **A column's total goes in `DataTable`'s `totals`**:
|
|
392
|
+
``totals={data && { label: `Total · ${data.total} items`, cells: { price: usd(data.totals.price) } }}``.
|
|
393
|
+
One formatted value per column id, from the list endpoint beside
|
|
394
|
+
`total`, so it covers every matching row, not only the page. It renders
|
|
395
|
+
as a footer row: the label in a row header in the first visible column,
|
|
396
|
+
each value under its column in that column's alignment and tabular
|
|
397
|
+
figures, above a 2px border. A hidden column's value isn't shown, and
|
|
398
|
+
the row is hidden while loading, on error and when empty.
|
|
391
399
|
- **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
|
|
392
400
|
supports `'center'`, and a right-aligned cell under a left-aligned header
|
|
393
401
|
looks broken. Stay left-aligned until a real column needs
|
|
@@ -269,6 +269,20 @@ In text with a band, the rest (paragraphs, lists, tables, callouts,
|
|
|
269
269
|
words in the band rather than its edge; text with no band stays flush. The level reads at a glance: two bands of
|
|
270
270
|
different strength, then a label.
|
|
271
271
|
|
|
272
|
+
Headings in the text sit under where the text is in the page's outline.
|
|
273
|
+
`headingLevel` on `<Markdown>` and `RichTextEditor` is the HTML level `#`
|
|
274
|
+
renders as: 2 straight under the page's `<h1>` (a form, or a page that is
|
|
275
|
+
the note), 3 under a section's `<h2>` (the default). `##` is one below it
|
|
276
|
+
and `###` two below, never past `<h6>`, so the outline never skips a
|
|
277
|
+
level (axe's heading-order). Only the element moves: each level keeps its
|
|
278
|
+
look, and the editor saves `#` as `#`.
|
|
279
|
+
|
|
280
|
+
A table wider than the text scrolls sideways. While it does, its box is a
|
|
281
|
+
tab stop and a region named "Table, scrolls sideways: " and its first
|
|
282
|
+
columns ("Item, Size, Colour, …"), so two such tables keep distinct
|
|
283
|
+
names; the arrow keys scroll it (in the editor Home and End too), with
|
|
284
|
+
the `ring` focus ring. A table that fits gets no tab stop and no region.
|
|
285
|
+
|
|
272
286
|
The same text has four colour tokens of its own: `--link` (`text-link`), a
|
|
273
287
|
link's colour, blue as well as underlined (5.59:1 on white, 8.90:1 on the
|
|
274
288
|
dark page); `--heading-band` (`bg-heading-band`), the `#` band, one step
|
|
@@ -330,7 +344,9 @@ set, not a `DataTable` prop:
|
|
|
330
344
|
- Under the hood, the table primitive's cells read `--table-cell-px`,
|
|
331
345
|
`--table-cell-py` and `--table-head-height`. `large` also redefines
|
|
332
346
|
Tailwind's own scale (`--spacing`, `--text-*`, `--radius`), the `--type-*`
|
|
333
|
-
roles and `--control-text-sm` (the small Button and Toggle).
|
|
347
|
+
roles and `--control-text-sm` (the small Button and Toggle).
|
|
348
|
+
`DataTable`'s totals row reads the body cells' tokens, so it is a row's
|
|
349
|
+
height at every density. Each
|
|
334
350
|
default is the value it always had, so a screen without `data-density`
|
|
335
351
|
doesn't change. The selectors are in `styles/theme.css`. A composite
|
|
336
352
|
reaches `compact` with the `density-compact:` variant (`styles/index.css`:
|
|
@@ -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,6 +85,15 @@ 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.
|
|
@@ -157,7 +169,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
|
|
|
157
169
|
}, [containerRef, isSuccessView, isOverflowing]);
|
|
158
170
|
return { barRef, isOverflowing, scrollWidth };
|
|
159
171
|
}
|
|
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, }) {
|
|
172
|
+
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
173
|
// Editing in place in the rows (cell pattern 18, "In a table's rows"):
|
|
162
174
|
// a cell that renders an <EditableValue> edits where it's shown, one at
|
|
163
175
|
// a time, with the leave-page prompt while one is open, the same rules
|
|
@@ -216,7 +228,8 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
216
228
|
const containerRef = useRef(null);
|
|
217
229
|
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
|
|
218
230
|
const rows = table.getRowModel().rows;
|
|
219
|
-
const
|
|
231
|
+
const visibleLeafColumns = table.getVisibleLeafColumns();
|
|
232
|
+
const visibleColumnCount = visibleLeafColumns.length;
|
|
220
233
|
// One body row, the same in a plain table and a grouped one.
|
|
221
234
|
const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
|
|
222
235
|
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
|
|
@@ -247,6 +260,9 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
247
260
|
: sortDirection === 'desc'
|
|
248
261
|
? 'descending'
|
|
249
262
|
: '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) }))
|
|
263
|
+
}) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
|
|
264
|
+
const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, column.columnDef.meta?.align === 'center' && 'text-center');
|
|
265
|
+
return index === 0 ? (_jsx(TableHead, { scope: "row", className: cn(TOTALS_LABEL_CLASS, className), children: pinFirstColumn ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: totals.label })) : (totals.label) }, column.id)) : (_jsx(TableCell, { className: className, children: totals.cells?.[column.id] }, column.id));
|
|
266
|
+
}) }) }))] }), 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
267
|
return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
|
|
252
268
|
}
|
|
@@ -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,15 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ComponentProps } from 'react';
|
|
2
|
+
import { 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;
|
|
5
13
|
className?: string;
|
|
6
14
|
} & Omit<ComponentProps<'div'>, 'children'>;
|
|
7
|
-
export declare function Markdown({ children, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
15
|
+
export declare function Markdown({ children, headingLevel, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Long text written as Markdown, rendered to read: headings, lists, links,
|
|
3
|
+
// tables (GitHub-flavoured), code and quotes, in the design language's
|
|
4
|
+
// type roles and tokens. A record's notes on its view (EntityView) are the
|
|
5
|
+
// first use.
|
|
6
|
+
//
|
|
7
|
+
// Safe by default, and kept that way on purpose:
|
|
8
|
+
// - Raw HTML is never rendered. react-markdown escapes it, so `<b>` in the
|
|
9
|
+
// text shows as the characters `<b>`, never as markup, and nothing the
|
|
10
|
+
// author typed silently disappears. Never add rehype-raw.
|
|
11
|
+
// - URLs go through react-markdown's default urlTransform, which drops
|
|
12
|
+
// `javascript:` and other unsafe protocols. A link left with no URL
|
|
13
|
+
// renders as its text.
|
|
14
|
+
// - Links open in a new tab, so following one never leaves the record.
|
|
15
|
+
//
|
|
16
|
+
// Headings start at <h3> by default: the page owns the <h1> (the
|
|
17
|
+
// record's title) and each section its <h2>, so a `# Heading` in the text
|
|
18
|
+
// sits under them instead of competing with the page's own outline. Text
|
|
19
|
+
// that sits elsewhere says so with `headingLevel`, the level `#` renders
|
|
20
|
+
// as (2 straight under the page's <h1>), each level below it one deeper,
|
|
21
|
+
// never past <h6>, so the page's outline never skips a level (axe's
|
|
22
|
+
// heading-order). They look like what they are in the text, wherever they
|
|
23
|
+
// sit: `#` and `##` on a tinted band, `###` a small uppercase label, and
|
|
24
|
+
// the text under them lined up with the words in the band (prose-look.ts).
|
|
25
|
+
//
|
|
26
|
+
// A table wider than the text scrolls sideways, and while it does, its
|
|
27
|
+
// box is a tab stop, named for its columns, that the arrow keys scroll
|
|
28
|
+
// (prose-table-scroll.ts). Its cells wrap first, so that's only a table
|
|
29
|
+
// with many columns, or a long word in one.
|
|
30
|
+
//
|
|
31
|
+
// An image (``, one the rich-text editor uploaded) stays inside
|
|
32
|
+
// the text's width, loads only as it nears the screen, and keeps its alt
|
|
33
|
+
// text; with none (``) it's decorative to a screen reader. Its
|
|
34
|
+
// address goes through the same urlTransform: a web address or a path on
|
|
35
|
+
// the site (`/api/files/7/content`), never `javascript:` or `data:`.
|
|
36
|
+
//
|
|
37
|
+
// A quote that starts `[!NOTE]` (GitHub's alert syntax) is a callout: a
|
|
38
|
+
// tinted box with its kind's icon (src/lib/markdown-callout.ts).
|
|
39
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
2
40
|
import ReactMarkdown, {} from 'react-markdown';
|
|
3
41
|
import remarkGfm from 'remark-gfm';
|
|
4
42
|
import { cn } from 'cn';
|
|
5
43
|
import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
|
|
6
44
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
|
|
7
45
|
import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
|
|
8
|
-
import { CALLOUT_BOX, CALLOUT_LOOK, headingClass, PROSE, PROSE_INSET } from './prose-look.js';
|
|
46
|
+
import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
|
|
47
|
+
import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
|
|
9
48
|
const REMARK_PLUGINS = [remarkGfm, remarkCallouts];
|
|
10
49
|
function isCalloutKind(value) {
|
|
11
50
|
return CALLOUT_KINDS.includes(value);
|
|
@@ -17,14 +56,31 @@ function omitNode(props) {
|
|
|
17
56
|
delete rest.node;
|
|
18
57
|
return rest;
|
|
19
58
|
}
|
|
20
|
-
//
|
|
59
|
+
// A GFM table in the package's own table, in its box that scrolls
|
|
60
|
+
// sideways: a tab stop and a named region only while the table overflows
|
|
61
|
+
// it. Watched again after each render, so a header row with new words
|
|
62
|
+
// renames it.
|
|
63
|
+
function NotesTable(props) {
|
|
64
|
+
const boxRef = useRef(null);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const box = boxRef.current;
|
|
67
|
+
if (!box)
|
|
68
|
+
return;
|
|
69
|
+
const table = box.querySelector('table');
|
|
70
|
+
const region = watchTableScroll(box, table, headerTexts(table));
|
|
71
|
+
return () => region.destroy();
|
|
72
|
+
});
|
|
73
|
+
return (_jsx(Table, { ...props, containerRef: boxRef, containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS) }));
|
|
74
|
+
}
|
|
75
|
+
// A heading by its Markdown level: the element the page's outline gives
|
|
76
|
+
// it (headingTag), the look its level has. The text's colour first, so a
|
|
77
|
+
// level's own (`###`'s muted label) wins.
|
|
78
|
+
function heading(level, top) {
|
|
79
|
+
const Tag = headingTag(level, top);
|
|
80
|
+
return (props) => (_jsx(Tag, { ...omitNode(props), "data-band": headingBand(level) || undefined, className: cn('text-foreground', headingClass(level)) }));
|
|
81
|
+
}
|
|
82
|
+
// Everything but the headings, which follow `headingLevel` (componentsFor).
|
|
21
83
|
const COMPONENTS = {
|
|
22
|
-
h1: (props) => _jsx("h3", { ...omitNode(props), className: cn('text-foreground', headingClass(1)) }),
|
|
23
|
-
h2: (props) => _jsx("h4", { ...omitNode(props), className: cn('text-foreground', headingClass(2)) }),
|
|
24
|
-
h3: (props) => _jsx("h5", { ...omitNode(props), className: cn('text-foreground', headingClass(3)) }),
|
|
25
|
-
h4: (props) => _jsx("h6", { ...omitNode(props), className: cn('text-foreground', headingClass(4)) }),
|
|
26
|
-
h5: (props) => _jsx("h6", { ...omitNode(props), className: cn('text-foreground', headingClass(5)) }),
|
|
27
|
-
h6: (props) => _jsx("h6", { ...omitNode(props), className: cn('text-foreground', headingClass(6)) }),
|
|
28
84
|
a: (props) => {
|
|
29
85
|
const { href, children, ...rest } = omitNode(props);
|
|
30
86
|
if (!href)
|
|
@@ -65,7 +121,7 @@ const COMPONENTS = {
|
|
|
65
121
|
},
|
|
66
122
|
// GFM tables use the package's own table, zebra stripes and density
|
|
67
123
|
// tokens included, so a table in the notes reads like every other table.
|
|
68
|
-
table: (props) => _jsx(
|
|
124
|
+
table: (props) => _jsx(NotesTable, { ...omitNode(props) }),
|
|
69
125
|
thead: (props) => _jsx(TableHeader, { ...omitNode(props), className: "bg-muted" }),
|
|
70
126
|
tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
|
|
71
127
|
tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
|
|
@@ -74,6 +130,19 @@ const COMPONENTS = {
|
|
|
74
130
|
th: (props) => _jsx(TableHead, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
|
|
75
131
|
td: (props) => _jsx(TableCell, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
|
|
76
132
|
};
|
|
77
|
-
|
|
78
|
-
|
|
133
|
+
// The components for each `headingLevel`, made once each.
|
|
134
|
+
const BY_LEVEL = new Map();
|
|
135
|
+
function componentsFor(top) {
|
|
136
|
+
let components = BY_LEVEL.get(top);
|
|
137
|
+
if (!components) {
|
|
138
|
+
components = { ...COMPONENTS };
|
|
139
|
+
for (const level of [1, 2, 3, 4, 5, 6])
|
|
140
|
+
components[`h${level}`] = heading(level, top);
|
|
141
|
+
BY_LEVEL.set(top, components);
|
|
142
|
+
}
|
|
143
|
+
return components;
|
|
144
|
+
}
|
|
145
|
+
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, className, ...rest }) {
|
|
146
|
+
const components = useMemo(() => componentsFor(headingLevel), [headingLevel]);
|
|
147
|
+
return (_jsx("div", { "data-slot": "markdown", ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, components: components, children: children }) }));
|
|
79
148
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { RichTextEditorProps } from './rich-text-editor.js';
|
|
2
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
2
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -13,11 +13,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
13
13
|
// in the text shows as its characters (an uneditable chip, Milkdown's
|
|
14
14
|
// html node), and HTML pasted as such a chip becomes plain text.
|
|
15
15
|
// - Headings render from <h3>, like <Markdown>: the page owns <h1> and
|
|
16
|
-
// <h2
|
|
17
|
-
//
|
|
16
|
+
// <h2>. `headingLevel` moves them to where the field sits in the page's
|
|
17
|
+
// outline, as it does <Markdown>'s; only the element moves, never the
|
|
18
|
+
// level the Markdown is saved with (a node view draws it, from the
|
|
19
|
+
// node's own level). They look like <Markdown>'s (prose-look.ts). A
|
|
20
|
+
// task-list item renders a real checkbox that toggles it.
|
|
18
21
|
// - Callouts (`> [!NOTE]`, src/lib/markdown-callout.ts): a node of their
|
|
19
22
|
// own, drawn as <Markdown> draws them, the icon a menu of kinds.
|
|
20
|
-
// - Tables in a box that scrolls sideways
|
|
23
|
+
// - Tables in a box that scrolls sideways (a tab stop the arrow keys
|
|
24
|
+
// scroll while it does: rich-text-node-views.ts), with Notion's "add a row" and
|
|
21
25
|
// "add a column" bars, Tab in the last cell adding a row and Enter going
|
|
22
26
|
// down a column (rich-text-table.ts); the toolbar's Table menu does the
|
|
23
27
|
// rest.
|
|
@@ -66,7 +70,7 @@ import { linkHref, linkRefusal } from '../../lib/link-href.js';
|
|
|
66
70
|
import { passEscapeThrough } from './escape-passthrough.js';
|
|
67
71
|
import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
|
|
68
72
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
69
|
-
import { headingClass } from './prose-look.js';
|
|
73
|
+
import { DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag } from './prose-look.js';
|
|
70
74
|
import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
|
|
71
75
|
import { tableAt, toggleFormat } from './rich-text-formats.js';
|
|
72
76
|
import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
|
|
@@ -138,10 +142,14 @@ function htmlToText(fragment) {
|
|
|
138
142
|
});
|
|
139
143
|
return Fragment.fromArray(nodes);
|
|
140
144
|
}
|
|
141
|
-
|
|
145
|
+
// A heading: the element `headingLevel` gives its level, the look its
|
|
146
|
+
// level has. Marked as a band for PROSE_INSET (`#` and `##`).
|
|
147
|
+
function headingView(node, top) {
|
|
142
148
|
const level = Number(node.attrs.level);
|
|
143
|
-
const dom = document.createElement(
|
|
149
|
+
const dom = document.createElement(headingTag(level, top));
|
|
144
150
|
dom.className = headingClass(level);
|
|
151
|
+
if (headingBand(level))
|
|
152
|
+
dom.dataset.band = '';
|
|
145
153
|
return {
|
|
146
154
|
dom,
|
|
147
155
|
contentDOM: dom,
|
|
@@ -253,7 +261,7 @@ function applyLink(view, target, href) {
|
|
|
253
261
|
}
|
|
254
262
|
view.dispatch(tr.scrollIntoView());
|
|
255
263
|
}
|
|
256
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
264
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
257
265
|
const rootRef = useRef(null);
|
|
258
266
|
const editorRef = useRef(null);
|
|
259
267
|
const [linkBox, setLinkBox] = useState(null);
|
|
@@ -616,9 +624,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
616
624
|
return mod && !event.shiftKey && !event.altKey && event.key === 'Enter' && isInTable(view.state);
|
|
617
625
|
},
|
|
618
626
|
nodeViews: {
|
|
619
|
-
|
|
627
|
+
// The level when it mounts, as with defaultValue.
|
|
628
|
+
heading: (node) => headingView(node, headingLevel),
|
|
620
629
|
callout: (node, _view, getPos) => calloutView(node, getPos, registry),
|
|
621
|
-
table: (
|
|
630
|
+
table: (node, view, getPos) => tableView(node, view, getPos, registry),
|
|
622
631
|
list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
|
|
623
632
|
},
|
|
624
633
|
}));
|
|
@@ -792,7 +801,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
792
801
|
'[&_.ProseMirror]:flex [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
|
|
793
802
|
// PROSE_INSET, on the editable element: with a heading's band
|
|
794
803
|
// in the text, the rest lines up with the words in it.
|
|
795
|
-
'[&_.ProseMirror:has(
|
|
804
|
+
'[&_.ProseMirror:has(>[data-band])>:not([data-band])]:mx-3', !inline && '[&_.ProseMirror]:min-h-20',
|
|
796
805
|
// ProseMirror's own required style (prosemirror-view's CSS): spaces
|
|
797
806
|
// as typed, so one at the end of a line isn't kept as .
|
|
798
807
|
'[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary',
|
|
@@ -27,13 +27,33 @@ export declare const PROSE: {
|
|
|
27
27
|
readonly rule: "my-2 h-0.5 rounded-full border-0 bg-rule";
|
|
28
28
|
};
|
|
29
29
|
/**
|
|
30
|
-
* On the text's container: when it has a band (
|
|
31
|
-
*
|
|
32
|
-
* padding, so the text lines up with the
|
|
33
|
-
* band stays flush, where the section's
|
|
34
|
-
* list's own indent and a table's or
|
|
30
|
+
* On the text's container: when it has a band (a `#` or `##`, marked
|
|
31
|
+
* `data-band`, whatever element its `headingLevel` makes it), everything
|
|
32
|
+
* else is inset by the band's 12px padding, so the text lines up with the
|
|
33
|
+
* words in the band. Text with no band stays flush, where the section's
|
|
34
|
+
* own text starts. A margin, so a list's own indent and a table's or
|
|
35
|
+
* callout's box keep theirs.
|
|
35
36
|
*/
|
|
36
|
-
export declare const PROSE_INSET = "[&:has(
|
|
37
|
+
export declare const PROSE_INSET = "[&:has(>[data-band])>:not([data-band])]:mx-3";
|
|
38
|
+
/**
|
|
39
|
+
* The HTML level a heading in the text can render as. `headingLevel` on
|
|
40
|
+
* <Markdown> and the editor is the one `#` renders as: where the text
|
|
41
|
+
* sits in the page's outline (2 straight under the page's <h1>, 3 under a
|
|
42
|
+
* section's <h2>, the default).
|
|
43
|
+
*/
|
|
44
|
+
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
45
|
+
/** `#` renders as an <h3>: the page owns the <h1> and each section its <h2>. */
|
|
46
|
+
export declare const DEFAULT_HEADING_LEVEL: HeadingLevel;
|
|
47
|
+
/**
|
|
48
|
+
* The element a heading renders as, by its Markdown level (1 for `#`) and
|
|
49
|
+
* the level `#` renders as: `##` one below it, `###` two below, and so on,
|
|
50
|
+
* never past <h6>. Only the element follows the page; the look (the band,
|
|
51
|
+
* the label) stays the Markdown level's (headingClass), so a `#` looks
|
|
52
|
+
* like a `#` wherever it sits.
|
|
53
|
+
*/
|
|
54
|
+
export declare function headingTag(level: number, top?: HeadingLevel): `h${HeadingLevel}`;
|
|
55
|
+
/** Whether a heading sits on a band (`#` and `##`): the text beside it is inset (PROSE_INSET). */
|
|
56
|
+
export declare function headingBand(level: number): boolean;
|
|
37
57
|
/** A heading's classes by its Markdown level (1 for `#`). */
|
|
38
58
|
export declare function headingClass(level: number): string;
|
|
39
59
|
export type CalloutLook = {
|
|
@@ -33,13 +33,31 @@ export const PROSE = {
|
|
|
33
33
|
rule: 'my-2 h-0.5 rounded-full border-0 bg-rule',
|
|
34
34
|
};
|
|
35
35
|
/**
|
|
36
|
-
* On the text's container: when it has a band (
|
|
37
|
-
*
|
|
38
|
-
* padding, so the text lines up with the
|
|
39
|
-
* band stays flush, where the section's
|
|
40
|
-
* list's own indent and a table's or
|
|
36
|
+
* On the text's container: when it has a band (a `#` or `##`, marked
|
|
37
|
+
* `data-band`, whatever element its `headingLevel` makes it), everything
|
|
38
|
+
* else is inset by the band's 12px padding, so the text lines up with the
|
|
39
|
+
* words in the band. Text with no band stays flush, where the section's
|
|
40
|
+
* own text starts. A margin, so a list's own indent and a table's or
|
|
41
|
+
* callout's box keep theirs.
|
|
41
42
|
*/
|
|
42
|
-
export const PROSE_INSET = '[&:has(
|
|
43
|
+
export const PROSE_INSET = '[&:has(>[data-band])>:not([data-band])]:mx-3';
|
|
44
|
+
/** `#` renders as an <h3>: the page owns the <h1> and each section its <h2>. */
|
|
45
|
+
export const DEFAULT_HEADING_LEVEL = 3;
|
|
46
|
+
/**
|
|
47
|
+
* The element a heading renders as, by its Markdown level (1 for `#`) and
|
|
48
|
+
* the level `#` renders as: `##` one below it, `###` two below, and so on,
|
|
49
|
+
* never past <h6>. Only the element follows the page; the look (the band,
|
|
50
|
+
* the label) stays the Markdown level's (headingClass), so a `#` looks
|
|
51
|
+
* like a `#` wherever it sits.
|
|
52
|
+
*/
|
|
53
|
+
export function headingTag(level, top = DEFAULT_HEADING_LEVEL) {
|
|
54
|
+
const markdown = Math.max(1, Math.floor(level) || 1);
|
|
55
|
+
return `h${Math.min(top + markdown - 1, 6)}`;
|
|
56
|
+
}
|
|
57
|
+
/** Whether a heading sits on a band (`#` and `##`): the text beside it is inset (PROSE_INSET). */
|
|
58
|
+
export function headingBand(level) {
|
|
59
|
+
return level <= 2;
|
|
60
|
+
}
|
|
43
61
|
/** A heading's classes by its Markdown level (1 for `#`). */
|
|
44
62
|
export function headingClass(level) {
|
|
45
63
|
return level === 1 ? PROSE.heading1 : level === 2 ? PROSE.heading2 : level === 3 ? PROSE.heading3 : PROSE.heading4;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The box's focus ring, from the semantic tokens: on the box's own classes. */
|
|
2
|
+
export declare const TABLE_SCROLL_FOCUS = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
|
3
|
+
/**
|
|
4
|
+
* The region's accessible name: "Table, scrolls sideways", then the
|
|
5
|
+
* header row's first few columns ("…: Part, Width, Depth"). Empty
|
|
6
|
+
* headers are skipped; with none, the phrase alone.
|
|
7
|
+
*/
|
|
8
|
+
export declare function tableScrollName(columns: readonly string[]): string;
|
|
9
|
+
/** What the box needs of its element: as much as tests can fake. */
|
|
10
|
+
type ScrollBox = Pick<HTMLElement, 'scrollWidth' | 'clientWidth' | 'setAttribute' | 'removeAttribute' | 'getAttribute'>;
|
|
11
|
+
export type TableScrollRegion = {
|
|
12
|
+
/** The header row's texts, for the name: called again when they change (the editor's edits). */
|
|
13
|
+
setColumns: (columns: readonly string[]) => void;
|
|
14
|
+
/** Checks again whether the table overflows. The observers call it; a test can too. */
|
|
15
|
+
check: () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Takes the tab stop off until `resume`, the rest kept: for a press in
|
|
18
|
+
* the editor's box, whose focus belongs to the text, not the box.
|
|
19
|
+
*/
|
|
20
|
+
suspend: () => void;
|
|
21
|
+
resume: () => void;
|
|
22
|
+
/** Stops watching. The box keeps whatever it last was. */
|
|
23
|
+
destroy: () => void;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Watches a table's scroll box: a tab stop and a named region while the
|
|
27
|
+
* table is wider than the box, nothing while it fits. `table` is what
|
|
28
|
+
* scrolls in it (observed for its size; the box alone when absent).
|
|
29
|
+
*/
|
|
30
|
+
export declare function watchTableScroll(box: ScrollBox, table?: Element | null, columns?: readonly string[]): TableScrollRegion;
|
|
31
|
+
/** A rendered table's header row, as texts: the header cells of its first row. */
|
|
32
|
+
export declare function headerTexts(table: Element | null | undefined): string[];
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A table in written text, <Markdown>'s or the editor's, sits in a box
|
|
2
|
+
// that scrolls sideways when the table is wider than the text. A box that
|
|
3
|
+
// scrolls has to be reachable from the keyboard (axe's
|
|
4
|
+
// scrollable-region-focusable), or someone without a mouse can never see
|
|
5
|
+
// its right-hand columns. So, only while the table overflows, the box is
|
|
6
|
+
// a tab stop and a named region: "Table, scrolls sideways", then its
|
|
7
|
+
// columns, which tell two tables on a page apart (two regions with one
|
|
8
|
+
// name are axe's landmark-unique) and say which table it is. With the
|
|
9
|
+
// arrow keys it then scrolls. A table that fits is left alone: no tab
|
|
10
|
+
// stop and no region, nothing to reach that does nothing.
|
|
11
|
+
//
|
|
12
|
+
// It follows the table: a ResizeObserver on the box (the text's width
|
|
13
|
+
// changing) and on the table (a column added, a cell's text growing)
|
|
14
|
+
// checks again each time either changes size.
|
|
15
|
+
/** The box's focus ring, from the semantic tokens: on the box's own classes. */
|
|
16
|
+
export const TABLE_SCROLL_FOCUS = 'outline-none focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
17
|
+
// The columns named in the region's name; past them, an ellipsis.
|
|
18
|
+
const NAMED_COLUMNS = 3;
|
|
19
|
+
/**
|
|
20
|
+
* The region's accessible name: "Table, scrolls sideways", then the
|
|
21
|
+
* header row's first few columns ("…: Part, Width, Depth"). Empty
|
|
22
|
+
* headers are skipped; with none, the phrase alone.
|
|
23
|
+
*/
|
|
24
|
+
export function tableScrollName(columns) {
|
|
25
|
+
const named = columns.map((column) => column.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
26
|
+
if (named.length === 0)
|
|
27
|
+
return 'Table, scrolls sideways';
|
|
28
|
+
const shown = named.slice(0, NAMED_COLUMNS).join(', ');
|
|
29
|
+
return `Table, scrolls sideways: ${shown}${named.length > NAMED_COLUMNS ? ', …' : ''}`;
|
|
30
|
+
}
|
|
31
|
+
// Set only when it differs, so a check that finds nothing new writes
|
|
32
|
+
// nothing (the editor reads its own DOM's mutations).
|
|
33
|
+
function set(box, name, value) {
|
|
34
|
+
if (box.getAttribute(name) === value)
|
|
35
|
+
return;
|
|
36
|
+
if (value === null)
|
|
37
|
+
box.removeAttribute(name);
|
|
38
|
+
else
|
|
39
|
+
box.setAttribute(name, value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Watches a table's scroll box: a tab stop and a named region while the
|
|
43
|
+
* table is wider than the box, nothing while it fits. `table` is what
|
|
44
|
+
* scrolls in it (observed for its size; the box alone when absent).
|
|
45
|
+
*/
|
|
46
|
+
export function watchTableScroll(box, table, columns = []) {
|
|
47
|
+
let name = tableScrollName(columns);
|
|
48
|
+
let suspended = false;
|
|
49
|
+
const check = () => {
|
|
50
|
+
// scrollWidth counts what's hidden past the right edge; the two are
|
|
51
|
+
// whole pixels, so a table that just fits measures equal.
|
|
52
|
+
const overflows = box.scrollWidth > box.clientWidth;
|
|
53
|
+
set(box, 'tabindex', overflows && !suspended ? '0' : null);
|
|
54
|
+
set(box, 'role', overflows ? 'region' : null);
|
|
55
|
+
set(box, 'aria-label', overflows ? name : null);
|
|
56
|
+
};
|
|
57
|
+
// Server rendering and tests have no ResizeObserver: one check, then
|
|
58
|
+
// whatever the box was.
|
|
59
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(check);
|
|
60
|
+
if (observer) {
|
|
61
|
+
observer.observe(box);
|
|
62
|
+
if (table)
|
|
63
|
+
observer.observe(table);
|
|
64
|
+
}
|
|
65
|
+
check();
|
|
66
|
+
return {
|
|
67
|
+
setColumns: (next) => {
|
|
68
|
+
name = tableScrollName(next);
|
|
69
|
+
check();
|
|
70
|
+
},
|
|
71
|
+
check,
|
|
72
|
+
suspend: () => {
|
|
73
|
+
suspended = true;
|
|
74
|
+
check();
|
|
75
|
+
},
|
|
76
|
+
resume: () => {
|
|
77
|
+
suspended = false;
|
|
78
|
+
check();
|
|
79
|
+
},
|
|
80
|
+
destroy: () => observer?.disconnect(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** A rendered table's header row, as texts: the header cells of its first row. */
|
|
84
|
+
export function headerTexts(table) {
|
|
85
|
+
const row = table?.querySelector('tr');
|
|
86
|
+
return row ? Array.from(row.querySelectorAll('th'), (cell) => cell.textContent ?? '') : [];
|
|
87
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ImageUpload } from '../../lib/image-file.js';
|
|
2
|
+
import type { HeadingLevel } from './prose-look.js';
|
|
2
3
|
export type RichTextEditorProps = {
|
|
3
4
|
/** The editable element's id, for a label's `htmlFor` or a test. */
|
|
4
5
|
id?: string;
|
|
@@ -52,6 +53,15 @@ export type RichTextEditorProps = {
|
|
|
52
53
|
imageAccept?: readonly string[];
|
|
53
54
|
/** The largest image `upload` takes, in bytes: a bigger one is refused before it's sent, with its size and the limit. */
|
|
54
55
|
imageMaxSize?: number;
|
|
56
|
+
/**
|
|
57
|
+
* The HTML level the text's `#` renders as, where the field sits in the
|
|
58
|
+
* page's outline: 2 straight under the page's <h1> (a form), 3 (the
|
|
59
|
+
* default) under a section's <h2>. `##` is one below it, `###` two
|
|
60
|
+
* below, never past <h6>, as in `<Markdown>`. Only the element changes:
|
|
61
|
+
* the look, and the Markdown saved (`#` stays `#`), don't. Read once,
|
|
62
|
+
* when it mounts.
|
|
63
|
+
*/
|
|
64
|
+
headingLevel?: HeadingLevel;
|
|
55
65
|
className?: string;
|
|
56
66
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
57
67
|
'aria-label'?: string;
|
|
@@ -13,6 +13,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
13
13
|
// Raw HTML in the text shows as its characters, as <Markdown> shows it,
|
|
14
14
|
// and nothing pasted or typed can add markup.
|
|
15
15
|
//
|
|
16
|
+
// Headings in the text sit under the page's outline (`headingLevel`, from
|
|
17
|
+
// <h3> by default), as <Markdown>'s do. A table wider than the field
|
|
18
|
+
// scrolls sideways, and while it does, its box is a tab stop the arrow
|
|
19
|
+
// keys scroll.
|
|
20
|
+
//
|
|
16
21
|
// Images in the text (``) show, at most the field's width.
|
|
17
22
|
// With `upload`, an image pasted, dropped or picked with the toolbar's
|
|
18
23
|
// Image button is uploaded through the app's own storage and goes in as
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
-
import type { NodeView } from '@milkdown/kit/prose/view';
|
|
2
|
+
import type { EditorView, NodeView } from '@milkdown/kit/prose/view';
|
|
3
3
|
import { type CalloutKind } from '../../lib/markdown-callout.js';
|
|
4
4
|
/** A slot a node view has, for the editor to render its widget into. */
|
|
5
5
|
export type Widget = {
|
|
@@ -26,6 +26,9 @@ export declare function calloutView(node: ProseNode, getPos: () => number | unde
|
|
|
26
26
|
/**
|
|
27
27
|
* A table: in a box that scrolls sideways when it's wider than the text,
|
|
28
28
|
* with an "add a column" bar along its right and an "add a row" bar under
|
|
29
|
-
* it (widgets), Notion's way.
|
|
29
|
+
* it (widgets), Notion's way. While it scrolls, the box is a tab stop and
|
|
30
|
+
* a region named for its columns (prose-table-scroll.ts), which the arrow
|
|
31
|
+
* keys, Home and End scroll; a click in it still puts the caret in the
|
|
32
|
+
* cell clicked.
|
|
30
33
|
*/
|
|
31
|
-
export declare function tableView(getPos: () => number | undefined, registry: WidgetRegistry): NodeView;
|
|
34
|
+
export declare function tableView(node: ProseNode, view: EditorView, getPos: () => number | undefined, registry: WidgetRegistry): NodeView;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cn } from 'cn';
|
|
2
2
|
import { CALLOUT_KINDS } from '../../lib/markdown-callout.js';
|
|
3
3
|
import { CALLOUT_BOX, CALLOUT_LOOK } from './prose-look.js';
|
|
4
|
+
import { TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
|
|
4
5
|
// Each slot's key for its portal, for as long as the node view lives.
|
|
5
6
|
let made = 0;
|
|
6
7
|
const nextKey = () => `widget-${++made}`;
|
|
@@ -49,17 +50,55 @@ export function calloutView(node, getPos, registry) {
|
|
|
49
50
|
destroy: () => registry.remove(icon),
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
// A table's header row, as texts, from the node (its first row's cells).
|
|
54
|
+
function columnsOf(node) {
|
|
55
|
+
const header = node.firstChild;
|
|
56
|
+
if (!header)
|
|
57
|
+
return [];
|
|
58
|
+
const columns = [];
|
|
59
|
+
header.forEach((cell) => columns.push(cell.textContent));
|
|
60
|
+
return columns;
|
|
61
|
+
}
|
|
62
|
+
// How far an arrow key scrolls the table's box.
|
|
63
|
+
const SCROLL_STEP = 40;
|
|
64
|
+
// The keys a focused table box takes: the arrow keys, Home and End scroll
|
|
65
|
+
// it, as a box that isn't editable scrolls by itself, and any other plain
|
|
66
|
+
// key adds nothing (the text can't be typed into from the table's frame).
|
|
67
|
+
// Tab, Esc and shortcuts are left alone: Tab leaves the box, Esc and the
|
|
68
|
+
// shortcuts go on to the field. True when it took the key.
|
|
69
|
+
function scrollKey(box, event) {
|
|
70
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.key === 'Tab' || event.key === 'Escape')
|
|
71
|
+
return false;
|
|
72
|
+
const max = box.scrollWidth - box.clientWidth;
|
|
73
|
+
const to = {
|
|
74
|
+
ArrowLeft: box.scrollLeft - SCROLL_STEP,
|
|
75
|
+
ArrowRight: box.scrollLeft + SCROLL_STEP,
|
|
76
|
+
Home: 0,
|
|
77
|
+
End: max,
|
|
78
|
+
};
|
|
79
|
+
const target = to[event.key];
|
|
80
|
+
if (target !== undefined)
|
|
81
|
+
box.scrollLeft = Math.min(Math.max(target, 0), max);
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
// How long a press keeps the box out of the tab order: until the click it
|
|
86
|
+
// starts, or this long for one that never clicks (a touch that pans).
|
|
87
|
+
const PRESS_MS = 1000;
|
|
52
88
|
/**
|
|
53
89
|
* A table: in a box that scrolls sideways when it's wider than the text,
|
|
54
90
|
* with an "add a column" bar along its right and an "add a row" bar under
|
|
55
|
-
* it (widgets), Notion's way.
|
|
91
|
+
* it (widgets), Notion's way. While it scrolls, the box is a tab stop and
|
|
92
|
+
* a region named for its columns (prose-table-scroll.ts), which the arrow
|
|
93
|
+
* keys, Home and End scroll; a click in it still puts the caret in the
|
|
94
|
+
* cell clicked.
|
|
56
95
|
*/
|
|
57
|
-
export function tableView(getPos, registry) {
|
|
96
|
+
export function tableView(node, view, getPos, registry) {
|
|
58
97
|
const dom = document.createElement('div');
|
|
59
98
|
dom.dataset.slot = 'rich-text-table';
|
|
60
99
|
dom.className = 'group/table grid grid-cols-[minmax(0,1fr)_auto] gap-1';
|
|
61
100
|
const scroll = document.createElement('div');
|
|
62
|
-
scroll.className = 'overflow-x-auto rounded-lg border border-border';
|
|
101
|
+
scroll.className = cn('overflow-x-auto rounded-lg border border-border', TABLE_SCROLL_FOCUS);
|
|
63
102
|
const table = document.createElement('table');
|
|
64
103
|
const contentDOM = document.createElement('tbody');
|
|
65
104
|
table.append(contentDOM);
|
|
@@ -68,12 +107,76 @@ export function tableView(getPos, registry) {
|
|
|
68
107
|
const row = slot('col-start-1 flex h-5');
|
|
69
108
|
dom.append(scroll, column, row);
|
|
70
109
|
registry.set({ type: 'table', key: nextKey(), slot: row, column, getPos });
|
|
110
|
+
const region = watchTableScroll(scroll, table, columnsOf(node));
|
|
111
|
+
// A press in the box is the text's: a click in a cell puts the caret
|
|
112
|
+
// there. The browser focuses the nearest focusable ancestor of what was
|
|
113
|
+
// pressed, which would be the box while it's a tab stop, so the box
|
|
114
|
+
// leaves the tab order from the press to its click.
|
|
115
|
+
let pressing;
|
|
116
|
+
const released = () => {
|
|
117
|
+
clearTimeout(pressing);
|
|
118
|
+
pressing = undefined;
|
|
119
|
+
document.removeEventListener('click', releasedSoon, true);
|
|
120
|
+
region.resume();
|
|
121
|
+
};
|
|
122
|
+
const releasedSoon = () => setTimeout(released);
|
|
123
|
+
const onPointerDown = (event) => {
|
|
124
|
+
if (event.target === scroll && event.pointerType === 'mouse')
|
|
125
|
+
return; // its own scrollbar
|
|
126
|
+
region.suspend();
|
|
127
|
+
clearTimeout(pressing);
|
|
128
|
+
pressing = setTimeout(released, PRESS_MS);
|
|
129
|
+
document.addEventListener('click', releasedSoon, { capture: true, once: true });
|
|
130
|
+
};
|
|
131
|
+
scroll.addEventListener('pointerdown', onPointerDown);
|
|
132
|
+
// A key pressed while the box has focus is the box's (scrollKey), never
|
|
133
|
+
// ProseMirror's. It comes one of two ways. To the box itself, when the
|
|
134
|
+
// text's selection is outside it. Or, when the browser first moves
|
|
135
|
+
// focus to the editable text around it (as it can for a box inside
|
|
136
|
+
// editable text), to the text: then the box's blur to the text marks
|
|
137
|
+
// the key that follows in the same moment as the box's, and focus goes
|
|
138
|
+
// back to it.
|
|
139
|
+
const onBoxKey = (event) => {
|
|
140
|
+
if (event.target === scroll && scrollKey(scroll, event))
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
};
|
|
143
|
+
let bounced = false;
|
|
144
|
+
const onBlur = (event) => {
|
|
145
|
+
if (event.relatedTarget !== view.dom)
|
|
146
|
+
return;
|
|
147
|
+
bounced = true;
|
|
148
|
+
setTimeout(() => (bounced = false));
|
|
149
|
+
};
|
|
150
|
+
const onKeyDown = (event) => {
|
|
151
|
+
if (!bounced)
|
|
152
|
+
return;
|
|
153
|
+
bounced = false;
|
|
154
|
+
scroll.focus({ preventScroll: true });
|
|
155
|
+
if (scrollKey(scroll, event))
|
|
156
|
+
event.stopImmediatePropagation();
|
|
157
|
+
};
|
|
158
|
+
scroll.addEventListener('keydown', onBoxKey);
|
|
159
|
+
scroll.addEventListener('blur', onBlur);
|
|
160
|
+
view.dom.addEventListener('keydown', onKeyDown, true);
|
|
71
161
|
return {
|
|
72
162
|
dom,
|
|
73
163
|
contentDOM,
|
|
74
|
-
update: (next) =>
|
|
164
|
+
update: (next) => {
|
|
165
|
+
if (next.type.name !== 'table')
|
|
166
|
+
return false;
|
|
167
|
+
region.setColumns(columnsOf(next));
|
|
168
|
+
return true;
|
|
169
|
+
},
|
|
75
170
|
stopEvent: (event) => row.contains(event.target) || column.contains(event.target),
|
|
76
171
|
ignoreMutation: outside(contentDOM),
|
|
77
|
-
destroy: () =>
|
|
172
|
+
destroy: () => {
|
|
173
|
+
released();
|
|
174
|
+
region.destroy();
|
|
175
|
+
scroll.removeEventListener('pointerdown', onPointerDown);
|
|
176
|
+
scroll.removeEventListener('blur', onBlur);
|
|
177
|
+
scroll.removeEventListener('keydown', onBoxKey);
|
|
178
|
+
view.dom.removeEventListener('keydown', onKeyDown, true);
|
|
179
|
+
registry.remove(row);
|
|
180
|
+
},
|
|
78
181
|
};
|
|
79
182
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
|
2
|
+
declare function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props): import("react").JSX.Element;
|
|
3
|
+
declare function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props): import("react").JSX.Element;
|
|
4
|
+
declare function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props): import("react").JSX.Element;
|
|
5
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
|
3
|
+
function Collapsible({ ...props }) {
|
|
4
|
+
return _jsx(CollapsiblePrimitive.Root, { "data-slot": "collapsible", ...props });
|
|
5
|
+
}
|
|
6
|
+
function CollapsibleTrigger({ ...props }) {
|
|
7
|
+
return (_jsx(CollapsiblePrimitive.Trigger, { "data-slot": "collapsible-trigger", ...props }));
|
|
8
|
+
}
|
|
9
|
+
function CollapsibleContent({ ...props }) {
|
|
10
|
+
return (_jsx(CollapsiblePrimitive.Panel, { "data-slot": "collapsible-content", ...props }));
|
|
11
|
+
}
|
|
12
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type AppShellVersion, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
|
|
2
2
|
export type { IconComponent } from './components/app/icon-component.js';
|
|
3
3
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
4
|
-
export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
|
|
4
|
+
export { DataTable, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
5
5
|
export { type RowGroup } from './components/app/table-groups.js';
|
|
6
6
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
7
7
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
@@ -13,6 +13,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
13
13
|
export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
|
|
14
14
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
15
15
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
16
|
+
export type { HeadingLevel } from './components/app/prose-look.js';
|
|
16
17
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
17
18
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
18
19
|
export { MultiPick, type MultiPickOption, type MultiPickProps } from './components/app/multi-pick.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Composites
|
|
6
6
|
export { AppShell } from './components/app/app-shell.js';
|
|
7
7
|
export { CountLink } from './components/app/count-link.js';
|
|
8
|
-
export { DataTable } from './components/app/data-table.js';
|
|
8
|
+
export { DataTable, } from './components/app/data-table.js';
|
|
9
9
|
export {} from './components/app/table-groups.js';
|
|
10
10
|
export { byDay } from './lib/group-by-day.js';
|
|
11
11
|
export { EntityForm } from './components/app/entity-form.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.33.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|