@tristan2828/ui-foundation 4.32.0 → 4.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/conventions/AGENTS.md +9 -3
  2. package/conventions/docs/add-an-entity.md +45 -4
  3. package/conventions/docs/cell-patterns.md +59 -9
  4. package/conventions/docs/design-language.md +81 -1
  5. package/conventions/docs/timed-screens.md +13 -0
  6. package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
  7. package/dist/components/app/app-shell-bottom-bar.js +49 -0
  8. package/dist/components/app/app-shell-nav.d.ts +41 -0
  9. package/dist/components/app/app-shell-nav.js +40 -8
  10. package/dist/components/app/app-shell.d.ts +15 -4
  11. package/dist/components/app/app-shell.js +21 -7
  12. package/dist/components/app/data-table.d.ts +22 -2
  13. package/dist/components/app/data-table.js +29 -7
  14. package/dist/components/app/line-chart.d.ts +26 -0
  15. package/dist/components/app/line-chart.js +59 -0
  16. package/dist/components/app/list-editor-rows.d.ts +18 -0
  17. package/dist/components/app/list-editor-rows.js +23 -0
  18. package/dist/components/app/list-editor.d.ts +25 -3
  19. package/dist/components/app/list-editor.js +61 -5
  20. package/dist/components/app/markdown.d.ts +17 -2
  21. package/dist/components/app/markdown.js +84 -11
  22. package/dist/components/app/milkdown-editor.d.ts +1 -1
  23. package/dist/components/app/milkdown-editor.js +22 -10
  24. package/dist/components/app/prose-look.d.ts +35 -6
  25. package/dist/components/app/prose-look.js +27 -6
  26. package/dist/components/app/prose-table-scroll.d.ts +33 -0
  27. package/dist/components/app/prose-table-scroll.js +87 -0
  28. package/dist/components/app/rich-text-editor.d.ts +17 -0
  29. package/dist/components/app/rich-text-editor.js +5 -0
  30. package/dist/components/app/rich-text-node-views.d.ts +6 -3
  31. package/dist/components/app/rich-text-node-views.js +108 -5
  32. package/dist/components/app/widget-card.d.ts +11 -1
  33. package/dist/components/app/widget-card.js +19 -3
  34. package/dist/components/ui/collapsible.d.ts +5 -0
  35. package/dist/components/ui/collapsible.js +12 -0
  36. package/dist/hooks/use-muted.d.ts +4 -10
  37. package/dist/hooks/use-muted.js +4 -67
  38. package/dist/hooks/use-remembered-flag.d.ts +18 -0
  39. package/dist/hooks/use-remembered-flag.js +78 -0
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.js +3 -2
  42. package/dist/lib/line-chart-layout.d.ts +23 -0
  43. package/dist/lib/line-chart-layout.js +44 -0
  44. package/dist/lib/markdown-top-heading.d.ts +2 -0
  45. package/dist/lib/markdown-top-heading.js +21 -0
  46. package/dist/testing/index.d.ts +16 -4
  47. package/dist/testing/index.js +5 -3
  48. package/package.json +3 -2
@@ -1,11 +1,51 @@
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 (`![alt](url)`, 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 (`![](url)`) 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 { textTopHeading } from '../../lib/markdown-top-heading.js';
47
+ import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
48
+ import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
9
49
  const REMARK_PLUGINS = [remarkGfm, remarkCallouts];
10
50
  function isCalloutKind(value) {
11
51
  return CALLOUT_KINDS.includes(value);
@@ -17,14 +57,31 @@ function omitNode(props) {
17
57
  delete rest.node;
18
58
  return rest;
19
59
  }
20
- // The text's colour first, so a level's own (`###`'s muted label) wins.
60
+ // A GFM table in the package's own table, in its box that scrolls
61
+ // sideways: a tab stop and a named region only while the table overflows
62
+ // it. Watched again after each render, so a header row with new words
63
+ // renames it.
64
+ function NotesTable(props) {
65
+ const boxRef = useRef(null);
66
+ useEffect(() => {
67
+ const box = boxRef.current;
68
+ if (!box)
69
+ return;
70
+ const table = box.querySelector('table');
71
+ const region = watchTableScroll(box, table, headerTexts(table));
72
+ return () => region.destroy();
73
+ });
74
+ return (_jsx(Table, { ...props, containerRef: boxRef, containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS) }));
75
+ }
76
+ // A heading by its Markdown level: the element the page's outline gives
77
+ // it (headingTag), the look its level has. The text's colour first, so a
78
+ // level's own (`###`'s muted label) wins.
79
+ function heading(level, top, textTop) {
80
+ const Tag = headingTag(level, top, textTop);
81
+ return (props) => (_jsx(Tag, { ...omitNode(props), "data-band": headingBand(level) || undefined, className: cn('text-foreground', headingClass(level)) }));
82
+ }
83
+ // Everything but the headings, which follow `headingLevel` (componentsFor).
21
84
  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
85
  a: (props) => {
29
86
  const { href, children, ...rest } = omitNode(props);
30
87
  if (!href)
@@ -65,7 +122,7 @@ const COMPONENTS = {
65
122
  },
66
123
  // GFM tables use the package's own table, zebra stripes and density
67
124
  // tokens included, so a table in the notes reads like every other table.
68
- table: (props) => _jsx(Table, { ...omitNode(props), containerClassName: "rounded-lg border border-border" }),
125
+ table: (props) => _jsx(NotesTable, { ...omitNode(props) }),
69
126
  thead: (props) => _jsx(TableHeader, { ...omitNode(props), className: "bg-muted" }),
70
127
  tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
71
128
  tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
@@ -74,6 +131,22 @@ const COMPONENTS = {
74
131
  th: (props) => _jsx(TableHead, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
75
132
  td: (props) => _jsx(TableCell, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
76
133
  };
77
- export function Markdown({ children, className, ...rest }) {
78
- 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 }) }));
134
+ // The components for each `headingLevel` and level that takes it (`#`,
135
+ // or the text's highest with `headingFrom="text"`), made once each.
136
+ const BY_LEVEL = new Map();
137
+ function componentsFor(top, textTop) {
138
+ const key = `${top}:${textTop}`;
139
+ let components = BY_LEVEL.get(key);
140
+ if (!components) {
141
+ components = { ...COMPONENTS };
142
+ for (const level of [1, 2, 3, 4, 5, 6])
143
+ components[`h${level}`] = heading(level, top, textTop);
144
+ BY_LEVEL.set(key, components);
145
+ }
146
+ return components;
147
+ }
148
+ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', className, ...rest }) {
149
+ const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
150
+ const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
151
+ 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
152
  }
@@ -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, headingFrom, 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>; they look like <Markdown>'s (prose-look.ts). A task-list item
17
- // renders a real checkbox that toggles it.
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, with Notion's "add a row" and
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.
@@ -65,8 +69,9 @@ import { acceptAttribute } from '../../lib/image-file.js';
65
69
  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';
72
+ import { textTopHeading } from '../../lib/markdown-top-heading.js';
68
73
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
69
- import { headingClass } from './prose-look.js';
74
+ import { DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag } from './prose-look.js';
70
75
  import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
71
76
  import { tableAt, toggleFormat } from './rich-text-formats.js';
72
77
  import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
@@ -138,10 +143,14 @@ function htmlToText(fragment) {
138
143
  });
139
144
  return Fragment.fromArray(nodes);
140
145
  }
141
- function headingView(node) {
146
+ // A heading: the element `headingLevel` gives its level, the look its
147
+ // level has. Marked as a band for PROSE_INSET (`#` and `##`).
148
+ function headingView(node, top, textTop) {
142
149
  const level = Number(node.attrs.level);
143
- const dom = document.createElement(`h${Math.min(level + 2, 6)}`);
150
+ const dom = document.createElement(headingTag(level, top, textTop));
144
151
  dom.className = headingClass(level);
152
+ if (headingBand(level))
153
+ dom.dataset.band = '';
145
154
  return {
146
155
  dom,
147
156
  contentDOM: dom,
@@ -253,7 +262,7 @@ function applyLink(view, target, href) {
253
262
  }
254
263
  view.dispatch(tr.scrollIntoView());
255
264
  }
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, }) {
265
+ 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, headingFrom = 'hash', className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
257
266
  const rootRef = useRef(null);
258
267
  const editorRef = useRef(null);
259
268
  const [linkBox, setLinkBox] = useState(null);
@@ -342,6 +351,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
342
351
  if (!root)
343
352
  return;
344
353
  const original = defaultValue;
354
+ // The level that takes headingLevel, read from the text it loads.
355
+ const textTop = headingFrom === 'text' ? textTopHeading(original) : 1;
345
356
  let baseline = null;
346
357
  let cancelled = false;
347
358
  const emit = () => {
@@ -616,9 +627,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
616
627
  return mod && !event.shiftKey && !event.altKey && event.key === 'Enter' && isInTable(view.state);
617
628
  },
618
629
  nodeViews: {
619
- heading: headingView,
630
+ // The level when it mounts, as with defaultValue.
631
+ heading: (node) => headingView(node, headingLevel, textTop),
620
632
  callout: (node, _view, getPos) => calloutView(node, getPos, registry),
621
- table: (_node, _view, getPos) => tableView(getPos, registry),
633
+ table: (node, view, getPos) => tableView(node, view, getPos, registry),
622
634
  list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
623
635
  },
624
636
  }));
@@ -792,7 +804,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
792
804
  '[&_.ProseMirror]:flex [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
793
805
  // PROSE_INSET, on the editable element: with a heading's band
794
806
  // in the text, the rest lines up with the words in it.
795
- '[&_.ProseMirror:has(>:is(h3,h4))>:not(h3,h4)]:mx-3', !inline && '[&_.ProseMirror]:min-h-20',
807
+ '[&_.ProseMirror:has(>[data-band])>:not([data-band])]:mx-3', !inline && '[&_.ProseMirror]:min-h-20',
796
808
  // ProseMirror's own required style (prosemirror-view's CSS): spaces
797
809
  // as typed, so one at the end of a line isn't kept as &nbsp;.
798
810
  '[&_.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,42 @@ 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 (the `<h3>` or `<h4>` a `#`
31
- * or `##` renders as), everything else is inset by the band's 12px
32
- * padding, so the text lines up with the words in the band. Text with no
33
- * band stays flush, where the section's own text starts. A margin, so a
34
- * list's own indent and a table's or callout's box keep theirs.
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(>:is(h3,h4))>:not(h3,h4)]:mx-3";
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
+ * Which heading in the text takes `headingLevel`: `'hash'` (the default),
49
+ * `#`, whatever the text holds; `'text'`, the text's own highest heading,
50
+ * so notes that start at `##` (an export from Notion, a README) put their
51
+ * first heading straight under the page's, not a level further down.
52
+ */
53
+ export type HeadingFrom = 'hash' | 'text';
54
+ /**
55
+ * The element a heading renders as, by its Markdown level (1 for `#`) and
56
+ * the level `#` renders as: `##` one below it, `###` two below, and so on,
57
+ * never past <h6>. `textTop` is the level that renders at `top` instead
58
+ * of `#` (`headingFrom="text"`: the text's highest), and a heading above
59
+ * it renders at `top` too. Only the element follows the page; the look
60
+ * (the band, the label) stays the Markdown level's (headingClass), so a
61
+ * `#` looks like a `#` wherever it sits.
62
+ */
63
+ export declare function headingTag(level: number, top?: HeadingLevel, textTop?: number): `h${HeadingLevel}`;
64
+ /** Whether a heading sits on a band (`#` and `##`): the text beside it is inset (PROSE_INSET). */
65
+ export declare function headingBand(level: number): boolean;
37
66
  /** A heading's classes by its Markdown level (1 for `#`). */
38
67
  export declare function headingClass(level: number): string;
39
68
  export type CalloutLook = {
@@ -33,13 +33,34 @@ 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 (the `<h3>` or `<h4>` a `#`
37
- * or `##` renders as), everything else is inset by the band's 12px
38
- * padding, so the text lines up with the words in the band. Text with no
39
- * band stays flush, where the section's own text starts. A margin, so a
40
- * list's own indent and a table's or callout's box keep theirs.
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(>:is(h3,h4))>:not(h3,h4)]:mx-3';
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>. `textTop` is the level that renders at `top` instead
50
+ * of `#` (`headingFrom="text"`: the text's highest), and a heading above
51
+ * it renders at `top` too. Only the element follows the page; the look
52
+ * (the band, the label) stays the Markdown level's (headingClass), so a
53
+ * `#` looks like a `#` wherever it sits.
54
+ */
55
+ export function headingTag(level, top = DEFAULT_HEADING_LEVEL, textTop = 1) {
56
+ const markdown = Math.max(1, Math.floor(level) || 1);
57
+ const below = Math.max(0, markdown - Math.max(1, Math.floor(textTop) || 1));
58
+ return `h${Math.min(top + below, 6)}`;
59
+ }
60
+ /** Whether a heading sits on a band (`#` and `##`): the text beside it is inset (PROSE_INSET). */
61
+ export function headingBand(level) {
62
+ return level <= 2;
63
+ }
43
64
  /** A heading's classes by its Markdown level (1 for `#`). */
44
65
  export function headingClass(level) {
45
66
  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 { HeadingFrom, 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,22 @@ 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;
65
+ /**
66
+ * Which heading takes `headingLevel`: `'hash'` (the default), `#`;
67
+ * `'text'`, the text's own highest when it mounts, for notes that start
68
+ * at `##`, as in `<Markdown>`. A heading added above it renders at
69
+ * `headingLevel` too. The Markdown saved is never rewritten.
70
+ */
71
+ headingFrom?: HeadingFrom;
55
72
  className?: string;
56
73
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
57
74
  '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 (`![alt](url)`) 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) => 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
  }