@tristan2828/ui-foundation 4.61.0 → 4.62.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.
@@ -0,0 +1,28 @@
1
+ import { type HighlightColor } from './markdown-highlight.js';
2
+ /** A run of a cell's text, with the highlight and bold it's in. */
3
+ export type CellRun = {
4
+ text: string;
5
+ color: HighlightColor | null;
6
+ bold: boolean;
7
+ };
8
+ /** The cell's colour: every run with words in it highlighted, all in one colour. */
9
+ export declare function cellColor(runs: readonly CellRun[]): HighlightColor | null;
10
+ /** Whether a cell's words are all bold (and it has some). */
11
+ export declare function cellBold(runs: readonly CellRun[]): boolean;
12
+ /** Whether a table's first column is row headers: its body rows' first cells with words, all bold. */
13
+ export declare function isHeaderColumn(firstCells: readonly (readonly CellRun[])[]): boolean;
14
+ type HastNode = {
15
+ type: string;
16
+ tagName?: string;
17
+ value?: string;
18
+ properties?: Record<string, unknown>;
19
+ children?: HastNode[];
20
+ };
21
+ /**
22
+ * For <Markdown>: marks each table's coloured cells (`data-color`), and
23
+ * makes a header column's first cells row headers (`<th scope="row">`).
24
+ */
25
+ export declare function rehypeTables(): (tree: HastNode) => void;
26
+ /** Sorts cell texts as a person would: numbers by value ($4.99 before $12), then words, empty last. */
27
+ export declare function compareCells(a: string, b: string): number;
28
+ export {};
@@ -0,0 +1,106 @@
1
+ // What a GFM table holds beyond its cells, read from the Markdown it
2
+ // already has, so nothing new is written into the text (the developer's
3
+ // choice A, 2026-11-04):
4
+ //
5
+ // - A column's alignment: GFM's own (`:---:`, `---:`).
6
+ // - A cell's colour: its whole text in one highlight, `=={green}$4.99==`
7
+ // (src/lib/markdown-highlight.ts), paints the cell in that wash. Text
8
+ // only partly highlighted is just highlighted words. An empty cell has
9
+ // no colour (a highlight needs words).
10
+ // - A header column: when the first cells of the body rows are all bold
11
+ // (those with words; at least one has), the first column is drawn as
12
+ // row headers.
13
+ //
14
+ // The rule is the same for <Markdown> (rehypeTables, on the HTML tree)
15
+ // and the rich-text editor (its table decorations, rich-text-table.ts):
16
+ // both turn a cell into runs of text with their highlight and bold, and
17
+ // ask the functions here.
18
+ import { isHighlightColor } from './markdown-highlight.js';
19
+ /** The cell's colour: every run with words in it highlighted, all in one colour. */
20
+ export function cellColor(runs) {
21
+ const worded = runs.filter((run) => run.text.trim() !== '');
22
+ if (worded.length === 0)
23
+ return null;
24
+ const color = worded[0].color;
25
+ return color && worded.every((run) => run.color === color) ? color : null;
26
+ }
27
+ /** Whether a cell's words are all bold (and it has some). */
28
+ export function cellBold(runs) {
29
+ const worded = runs.filter((run) => run.text.trim() !== '');
30
+ return worded.length > 0 && worded.every((run) => run.bold);
31
+ }
32
+ /** Whether a table's first column is row headers: its body rows' first cells with words, all bold. */
33
+ export function isHeaderColumn(firstCells) {
34
+ const worded = firstCells.filter((runs) => runs.some((run) => run.text.trim() !== ''));
35
+ return worded.length > 0 && worded.every(cellBold);
36
+ }
37
+ // A cell's runs, from its HTML: a <mark data-color> is a highlight
38
+ // (remarkHighlights writes one), <strong> is bold.
39
+ function runsOf(node, color = null, bold = false) {
40
+ if (node.type === 'text')
41
+ return [{ text: node.value ?? '', color, bold }];
42
+ let inner = color;
43
+ if (node.tagName === 'mark') {
44
+ const value = node.properties?.dataColor;
45
+ inner = isHighlightColor(value) ? value : 'amber';
46
+ }
47
+ const strong = bold || node.tagName === 'strong';
48
+ return (node.children ?? []).flatMap((child) => runsOf(child, inner, strong));
49
+ }
50
+ const elements = (node, tagName) => (node?.children ?? []).filter((child) => child.type === 'element' && child.tagName === tagName);
51
+ function eachTable(node, visit) {
52
+ if (node.type === 'element' && node.tagName === 'table')
53
+ visit(node);
54
+ for (const child of node.children ?? [])
55
+ eachTable(child, visit);
56
+ }
57
+ /**
58
+ * For <Markdown>: marks each table's coloured cells (`data-color`), and
59
+ * makes a header column's first cells row headers (`<th scope="row">`).
60
+ */
61
+ export function rehypeTables() {
62
+ return (tree) => {
63
+ eachTable(tree, (table) => {
64
+ const rows = [...elements(table, 'thead'), ...elements(table, 'tbody')].flatMap((section) => elements(section, 'tr'));
65
+ const body = elements(elements(table, 'tbody')[0], 'tr');
66
+ for (const row of rows) {
67
+ for (const cell of row.children ?? []) {
68
+ if (cell.type !== 'element')
69
+ continue;
70
+ const color = cellColor(runsOf(cell));
71
+ if (color)
72
+ cell.properties = { ...cell.properties, dataColor: color };
73
+ }
74
+ }
75
+ const firsts = body.map((row) => (row.children ?? []).find((cell) => cell.type === 'element'));
76
+ if (!isHeaderColumn(firsts.map((cell) => (cell ? runsOf(cell) : []))))
77
+ return;
78
+ table.properties = { ...table.properties, dataHeaderColumn: '' };
79
+ for (const cell of firsts) {
80
+ if (!cell)
81
+ continue;
82
+ cell.tagName = 'th';
83
+ cell.properties = { ...cell.properties, scope: 'row' };
84
+ }
85
+ });
86
+ };
87
+ }
88
+ /** Sorts cell texts as a person would: numbers by value ($4.99 before $12), then words, empty last. */
89
+ export function compareCells(a, b) {
90
+ const x = numberIn(a);
91
+ const y = numberIn(b);
92
+ if (x !== null && y !== null && x !== y)
93
+ return x - y;
94
+ const left = a.trim();
95
+ const right = b.trim();
96
+ if (!left && right)
97
+ return 1;
98
+ if (left && !right)
99
+ return -1;
100
+ return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
101
+ }
102
+ // The first number in a cell's text: "$1,299.50" is 1299.5, "-3 kg" is -3.
103
+ function numberIn(text) {
104
+ const found = text.replace(/,/g, '').match(/-?\d+(?:\.\d+)?/);
105
+ return found ? Number(found[0]) : null;
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.61.0",
3
+ "version": "4.62.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": {
package/styles/index.css CHANGED
@@ -159,6 +159,98 @@
159
159
  }
160
160
  }
161
161
 
162
+ /* Tables in written text (<Markdown> and the rich-text editor; each puts
163
+ its table in a `.prose-table` box that scrolls sideways). Columns fit
164
+ their content: the table is only as wide as its words, a cell wraps at
165
+ 18rem, and a right-aligned column (numbers) never wraps and uses
166
+ tabular figures. Two looks, set by `tableLook` on either renderer
167
+ (`data-table-look` on an ancestor):
168
+ - compact, the default: a full grid of hairlines, tight cells, the
169
+ header row on the heading band;
170
+ - boxed: a rounded box, roomier cells, the header row on the muted
171
+ fill, zebra rows (as DataTable's).
172
+ Row headers (a header column: src/lib/markdown-table.ts) sit on the
173
+ muted fill, semibold. A coloured cell (its whole text one highlight)
174
+ is painted in that wash, its words' own wash dropped. Unlayered, so a
175
+ mark's utility classes inside don't outrank it. */
176
+ .prose-table {
177
+ width: fit-content;
178
+ /* The space it's in, less its own margins (the text's inset beside a
179
+ heading band): 100% would push it past the text by them. */
180
+ max-width: 100%;
181
+ max-width: -webkit-fill-available;
182
+ max-width: stretch;
183
+ overflow-x: auto;
184
+ /* Holds what's absolutely placed inside (a link's screen-reader text),
185
+ so it scrolls with the table instead of widening the page. */
186
+ position: relative;
187
+ }
188
+ .prose-table > table {
189
+ width: auto;
190
+ border-collapse: collapse;
191
+ }
192
+ .prose-table :is(td, th) {
193
+ min-width: 3rem;
194
+ max-width: 18rem;
195
+ padding: 0.25rem var(--table-cell-px);
196
+ vertical-align: top;
197
+ white-space: normal;
198
+ overflow-wrap: break-word;
199
+ border: 1px solid var(--border);
200
+ font-weight: var(--type-body-weight);
201
+ }
202
+ .prose-table th {
203
+ text-align: start;
204
+ }
205
+ .prose-table :is(td, th):is([style*="right"], [align="right"]) {
206
+ white-space: nowrap;
207
+ font-variant-numeric: tabular-nums;
208
+ }
209
+ .prose-table th:not([scope="row"], [data-row-header]) {
210
+ background: var(--heading-band);
211
+ font-weight: 600;
212
+ }
213
+ .prose-table :is(th[scope="row"], [data-row-header]):not([data-color]) {
214
+ background: var(--muted);
215
+ }
216
+ .prose-table :is(th[scope="row"], [data-row-header]) {
217
+ font-weight: 600;
218
+ }
219
+ [data-table-look="boxed"] .prose-table {
220
+ border: 1px solid var(--border);
221
+ border-radius: var(--radius);
222
+ }
223
+ [data-table-look="boxed"] .prose-table :is(td, th) {
224
+ padding: var(--table-cell-py) 0.75rem;
225
+ border-width: 0 1px 1px 0;
226
+ }
227
+ [data-table-look="boxed"] .prose-table tr > :last-child {
228
+ border-right-width: 0;
229
+ }
230
+ [data-table-look="boxed"] .prose-table tr:last-child > * {
231
+ border-bottom-width: 0;
232
+ }
233
+ [data-table-look="boxed"] .prose-table th:not([scope="row"], [data-row-header]) {
234
+ background: var(--muted);
235
+ font-weight: var(--type-label-weight);
236
+ }
237
+ /* Zebra: <Markdown>'s body rows (after its <thead>), or the editor's,
238
+ whose header row is its only <tbody>'s first. */
239
+ [data-table-look="boxed"] .prose-table :is(thead + tbody > tr:nth-child(even), tbody:only-child > tr:nth-child(odd):not(:first-child)) > :not([data-color], th[scope="row"], [data-row-header]) {
240
+ background: var(--table-stripe);
241
+ }
242
+ .prose-table :is(td, th)[data-color="amber"] { background: var(--highlight-amber); }
243
+ .prose-table :is(td, th)[data-color="yellow"] { background: var(--highlight-yellow); }
244
+ .prose-table :is(td, th)[data-color="green"] { background: var(--highlight-green); }
245
+ .prose-table :is(td, th)[data-color="red"] { background: var(--highlight-red); }
246
+ .prose-table [data-color] mark {
247
+ background: transparent;
248
+ }
249
+ /* Cells picked by dragging across them, in the editor. */
250
+ .prose-table .selectedCell {
251
+ background: var(--table-highlight);
252
+ }
253
+
162
254
  /* Printed notes (EntityView's Print notes, src/components/app/entity-view-print.tsx).
163
255
  While its copy is in <body>, paper shows that copy alone: the app beside
164
256
  it (shell, header, rail, dialogs) is left off, and the copy is in the
@@ -1,20 +0,0 @@
1
- import { type KeyboardEvent, type Ref } from 'react';
2
- import type { EditorState } from '@milkdown/kit/prose/state';
3
- import { type TableAction } from './rich-text-table.js';
4
- export interface RichTextTableMenuProps {
5
- state: EditorState | null;
6
- readOnly: boolean;
7
- /** The toolbar's roving tab stop: 0 for the one Tab reaches. */
8
- tabIndex: number;
9
- onFocus: () => void;
10
- /** The toolbar's arrow keys. */
11
- onKeyDown: (event: KeyboardEvent) => void;
12
- /** Insert table picked. */
13
- onInsert: () => void;
14
- /** A row, column or alignment item picked. */
15
- onAction: (action: TableAction) => void;
16
- /** The editable element, where focus goes once an item is picked. */
17
- text: () => HTMLElement | null;
18
- ref?: Ref<HTMLButtonElement>;
19
- }
20
- export declare function RichTextTableMenu({ state, readOnly, tabIndex, onFocus, onKeyDown, onInsert, onAction, text, ref, }: RichTextTableMenuProps): import("react").JSX.Element;
@@ -1,53 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // The fixed toolbar's Table menu: a table inserted where the caret is, or,
3
- // in a table, its rows, columns and alignment (rich-text-table.ts). A menu
4
- // button in the toolbar's row (one of its tab stops' places, moved to with
5
- // the arrow keys like the rest); the menu itself is Base UI's (ARIA's menu
6
- // pattern). Picking an item gives focus back to the text, the caret where
7
- // it was (the editor keeps its selection while the menu has focus); Esc
8
- // gives it back to the button, as a menu button's does.
9
- import { useRef } from 'react';
10
- import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, BetweenHorizontalEndIcon, BetweenHorizontalStartIcon, BetweenVerticalEndIcon, BetweenVerticalStartIcon, ChevronDownIcon, TableIcon, Trash2Icon, } from 'lucide-react';
11
- import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
12
- import { toggleVariants } from '../ui/toggle.js';
13
- import { tableAt } from './rich-text-formats.js';
14
- import { columnAlignment, tableActionEnabled } from './rich-text-table.js';
15
- const ROWS = [
16
- { action: 'rowAbove', label: 'Insert row above', icon: BetweenHorizontalStartIcon },
17
- { action: 'rowBelow', label: 'Insert row below', icon: BetweenHorizontalEndIcon },
18
- { action: 'columnLeft', label: 'Insert column left', icon: BetweenVerticalStartIcon },
19
- { action: 'columnRight', label: 'Insert column right', icon: BetweenVerticalEndIcon },
20
- ];
21
- const ALIGNMENTS = [
22
- { value: 'left', action: 'alignLeft', label: 'Left', icon: AlignLeftIcon },
23
- { value: 'center', action: 'alignCenter', label: 'Center', icon: AlignCenterIcon },
24
- { value: 'right', action: 'alignRight', label: 'Right', icon: AlignRightIcon },
25
- ];
26
- const DELETES = [
27
- { action: 'deleteRow', label: 'Delete row', icon: Trash2Icon },
28
- { action: 'deleteColumn', label: 'Delete column', icon: Trash2Icon },
29
- { action: 'deleteTable', label: 'Delete table', icon: Trash2Icon },
30
- ];
31
- export function RichTextTableMenu({ state, readOnly, tabIndex, onFocus, onKeyDown, onInsert, onAction, text, ref, }) {
32
- const inTable = state ? tableAt(state) !== null : false;
33
- const enabled = (action) => (state ? tableActionEnabled(state, action) : false);
34
- // Whether the menu is closing because an item was picked.
35
- const picked = useRef(false);
36
- const run = (action) => {
37
- picked.current = true;
38
- action();
39
- };
40
- const finalFocus = () => {
41
- const toText = picked.current;
42
- picked.current = false;
43
- return toText ? (text() ?? true) : true;
44
- };
45
- const item = ({ action, label, icon: Icon }) => (_jsxs(DropdownMenuItem, { disabled: !enabled(action), onClick: () => run(() => onAction(action)), children: [_jsx(Icon, { "aria-hidden": "true" }), label] }, action));
46
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { ref: ref, "aria-label": "Table", disabled: readOnly, tabIndex: tabIndex, "data-active": inTable || undefined,
47
- // Shaped like the toolbar's toggles: on while the caret is in a table.
48
- className: toggleVariants({ size: 'sm', className: 'gap-0.5 px-1.5 data-active:bg-muted data-active:text-foreground' }), onFocus: onFocus, onKeyDown: onKeyDown, children: [_jsx(TableIcon, { "aria-hidden": "true" }), _jsx(ChevronDownIcon, { "aria-hidden": "true", className: "size-3" })] }), _jsxs(DropdownMenuContent, { className: "w-56", finalFocus: finalFocus, children: [_jsxs(DropdownMenuItem, { disabled: inTable, onClick: () => run(onInsert), children: [_jsx(TableIcon, { "aria-hidden": "true" }), "Insert table"] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: ROWS.map(item) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Align column" }), _jsx(DropdownMenuRadioGroup, { value: state && inTable ? columnAlignment(state) : null, onValueChange: (value) => {
49
- const alignment = ALIGNMENTS.find((one) => one.value === value);
50
- if (alignment)
51
- run(() => onAction(alignment.action));
52
- }, children: ALIGNMENTS.map(({ value, label, icon: Icon }) => (_jsxs(DropdownMenuRadioItem, { value: value, disabled: !inTable, closeOnClick: true, children: [_jsx(Icon, { "aria-hidden": "true" }), label] }, value))) })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: DELETES.map(item) })] })] }));
53
- }