@tristan2828/ui-foundation 4.31.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.
Files changed (32) hide show
  1. package/conventions/AGENTS.md +2 -1
  2. package/conventions/docs/add-an-entity.md +13 -2
  3. package/conventions/docs/cell-patterns.md +8 -0
  4. package/conventions/docs/design-language.md +97 -6
  5. package/dist/components/app/data-table.d.ts +21 -1
  6. package/dist/components/app/data-table.js +20 -4
  7. package/dist/components/app/list-editor-rows.d.ts +18 -0
  8. package/dist/components/app/list-editor-rows.js +23 -0
  9. package/dist/components/app/list-editor.d.ts +25 -3
  10. package/dist/components/app/list-editor.js +61 -5
  11. package/dist/components/app/markdown.d.ts +10 -2
  12. package/dist/components/app/markdown.js +80 -11
  13. package/dist/components/app/milkdown-editor.d.ts +1 -1
  14. package/dist/components/app/milkdown-editor.js +19 -10
  15. package/dist/components/app/module-card.d.ts +53 -0
  16. package/dist/components/app/module-card.js +95 -0
  17. package/dist/components/app/prose-look.d.ts +26 -6
  18. package/dist/components/app/prose-look.js +24 -6
  19. package/dist/components/app/prose-table-scroll.d.ts +33 -0
  20. package/dist/components/app/prose-table-scroll.js +87 -0
  21. package/dist/components/app/rich-text-editor.d.ts +10 -0
  22. package/dist/components/app/rich-text-editor.js +5 -0
  23. package/dist/components/app/rich-text-node-views.d.ts +6 -3
  24. package/dist/components/app/rich-text-node-views.js +108 -5
  25. package/dist/components/app/widget-card.js +13 -4
  26. package/dist/components/ui/collapsible.d.ts +5 -0
  27. package/dist/components/ui/collapsible.js +12 -0
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.js +2 -1
  30. package/package.json +1 -1
  31. package/styles/index.css +14 -0
  32. package/styles/theme.css +5 -0
@@ -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) => next.type.name === 'table',
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: () => registry.remove(row),
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
  }
@@ -21,6 +21,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  // muted note ("2 h ago") and an optional trailing element (a badge, a
22
22
  // switch). A trailing control is labelled by the row's text: pass a
23
23
  // function and it gets the id to put in `aria-labelledby`.
24
+ //
25
+ // Under data-density="compact" (#224) the card's padding drops to 12px and
26
+ // a row's to 4px, for a dense hub; text stays at type-body. On a phone a
27
+ // compact row is 44px tall and its link fills that height, so it stays a
28
+ // finger's target.
24
29
  import { useId } from 'react';
25
30
  import { Link } from 'react-router';
26
31
  import { ExternalLinkIcon } from 'lucide-react';
@@ -34,11 +39,14 @@ export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoadin
34
39
  const headingId = useId();
35
40
  const Heading = `h${headingLevel}`;
36
41
  const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
37
- return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
42
+ return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
38
43
  }
39
44
  // One row's height, shared by the skeleton and the list so the card doesn't
40
- // jump: py-2 around one line of type-body (or a small control).
41
- const ROW_CLASS = 'flex min-h-9 items-center gap-3 py-2';
45
+ // jump: py-2 around one line of type-body (or a small control). Compact
46
+ // is 32px (6px around the line); on a phone it's 44px, the link's own
47
+ // padding making up the height (ROW_LINK_CLASS).
48
+ const ROW_CLASS = 'flex min-h-9 items-center gap-3 py-2 density-compact:min-h-8 density-compact:py-1 ' +
49
+ 'max-sm:density-compact:min-h-11 max-sm:density-compact:py-0';
42
50
  // Varied widths, so the skeleton reads as a list of titles, not a grid.
43
51
  const SKELETON_WIDTHS = ['w-3/5', 'w-2/5', 'w-1/2', 'w-2/3', 'w-1/3', 'w-3/5'];
44
52
  function WidgetListSkeleton({ rows }) {
@@ -49,7 +57,8 @@ export function WidgetList({ children, className }) {
49
57
  return (_jsx("ul", { "data-slot": "widget-list", className: cn('flex flex-col divide-y divide-border', className), children: children }));
50
58
  }
51
59
  const ROW_LINK_CLASS = 'min-w-0 truncate rounded-sm font-medium text-foreground underline-offset-4 outline-none ' +
52
- 'hover:underline focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50';
60
+ 'hover:underline focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50 ' +
61
+ 'max-sm:density-compact:py-3';
53
62
  export function WidgetListItem({ children, to, href, note, trailing, className }) {
54
63
  const textId = useId();
55
64
  const text = to !== undefined ? (_jsx(Link, { id: textId, to: to, title: children, className: ROW_LINK_CLASS, children: children })) : href !== undefined ? (_jsxs("a", { id: textId, href: href, target: "_blank", rel: "noreferrer", title: children, className: cn(ROW_LINK_CLASS, 'flex items-center gap-1'), children: [_jsx("span", { className: "truncate", children: children }), _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (_jsx("span", { id: textId, title: children, className: "min-w-0 truncate text-foreground", children: children }));
@@ -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';
@@ -36,6 +37,7 @@ export { ImageField, type ImageFieldProps } from './components/app/image-field.j
36
37
  export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
37
38
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
38
39
  export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
40
+ export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
39
41
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
40
42
  export { ThemeProvider } from './components/theme-provider.js';
41
43
  export { LoginRoute, type LoginRouteProps } from './routes/login.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';
@@ -40,6 +40,7 @@ export { ImageField } from './components/app/image-field.js';
40
40
  export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
41
41
  export { formatPlainDate } from './lib/plain-date.js';
42
42
  export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
43
+ export { ModuleCard, ModuleStrip, } from './components/app/module-card.js';
43
44
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
44
45
  export { ThemeProvider } from './components/theme-provider.js';
45
46
  // Screens outside the app shell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.31.0",
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": {
package/styles/index.css CHANGED
@@ -16,6 +16,12 @@
16
16
 
17
17
  @custom-variant dark (&:is(.dark *));
18
18
 
19
+ /* Compact density beyond tables (design-language.md "Density"): a
20
+ composite's own rows and padding tighten under data-density="compact"
21
+ on any ancestor, the way the table's tokens do. `density-compact:py-1`.
22
+ Text never shrinks under it. */
23
+ @custom-variant density-compact (&:where([data-density="compact"], [data-density="compact"] *));
24
+
19
25
  @theme inline {
20
26
  --font-heading: var(--font-sans);
21
27
  --font-sans: 'Geist Variable', sans-serif;
@@ -113,6 +119,14 @@
113
119
  line-height: var(--type-caption-leading);
114
120
  font-weight: var(--type-caption-weight);
115
121
  }
122
+ /* Counts on a hub's cards: tabular figures, so numbers line up from card
123
+ to card and don't shift width as they change. */
124
+ @utility type-numeric {
125
+ font-size: var(--type-numeric-size);
126
+ line-height: var(--type-numeric-leading);
127
+ font-weight: var(--type-numeric-weight);
128
+ font-variant-numeric: tabular-nums;
129
+ }
116
130
  @utility type-heading-1 {
117
131
  font-size: var(--type-heading-1-size);
118
132
  line-height: var(--type-heading-1-leading);
package/styles/theme.css CHANGED
@@ -138,6 +138,9 @@
138
138
  --type-caption-size: 0.75rem; /* counts, metadata, helper text */
139
139
  --type-caption-leading: 1rem;
140
140
  --type-caption-weight: 400;
141
+ --type-numeric-size: 1.375rem; /* a count on a hub's ModuleCard, in tabular figures */
142
+ --type-numeric-leading: 1.75rem;
143
+ --type-numeric-weight: 500;
141
144
  /*
142
145
  * Headings inside written text (a record's notes: <Markdown> and the
143
146
  * rich-text editor), not the screen's own outline. Three clear steps
@@ -309,6 +312,8 @@
309
312
  --type-label-leading: 1.625rem;
310
313
  --type-caption-size: 0.9375rem;
311
314
  --type-caption-leading: 1.25rem;
315
+ --type-numeric-size: 1.75rem;
316
+ --type-numeric-leading: 2.25rem;
312
317
  --type-heading-1-size: 2.1875rem;
313
318
  --type-heading-1-leading: 2.8125rem;
314
319
  --type-heading-2-size: 1.5625rem;