@tristan2828/ui-foundation 3.20.1 → 3.21.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 (33) hide show
  1. package/conventions/docs/add-an-entity.md +3 -3
  2. package/conventions/docs/cell-patterns.md +13 -4
  3. package/conventions/docs/design-language.md +22 -0
  4. package/dist/components/app/markdown.js +38 -20
  5. package/dist/components/app/milkdown-editor.js +189 -31
  6. package/dist/components/app/prose-look.d.ts +32 -0
  7. package/dist/components/app/prose-look.js +48 -0
  8. package/dist/components/app/rich-text-format-items.d.ts +13 -1
  9. package/dist/components/app/rich-text-format-items.js +43 -10
  10. package/dist/components/app/rich-text-formats.d.ts +15 -2
  11. package/dist/components/app/rich-text-formats.js +99 -10
  12. package/dist/components/app/rich-text-node-views.d.ts +31 -0
  13. package/dist/components/app/rich-text-node-views.js +79 -0
  14. package/dist/components/app/rich-text-shortcuts.d.ts +12 -0
  15. package/dist/components/app/rich-text-shortcuts.js +52 -0
  16. package/dist/components/app/rich-text-slash-menu.js +4 -1
  17. package/dist/components/app/rich-text-slash.js +26 -10
  18. package/dist/components/app/rich-text-table-menu.d.ts +20 -0
  19. package/dist/components/app/rich-text-table-menu.js +53 -0
  20. package/dist/components/app/rich-text-table.d.ts +19 -0
  21. package/dist/components/app/rich-text-table.js +162 -0
  22. package/dist/components/app/rich-text-toolbar.d.ts +11 -2
  23. package/dist/components/app/rich-text-toolbar.js +14 -23
  24. package/dist/components/app/rich-text-widgets.d.ts +19 -0
  25. package/dist/components/app/rich-text-widgets.js +34 -0
  26. package/dist/components/ui/badge.d.ts +1 -1
  27. package/dist/components/ui/dropdown-menu.d.ts +29 -0
  28. package/dist/components/ui/dropdown-menu.js +51 -0
  29. package/dist/lib/markdown-callout.d.ts +38 -0
  30. package/dist/lib/markdown-callout.js +103 -0
  31. package/package.json +1 -1
  32. package/styles/index.css +17 -0
  33. package/styles/theme.css +24 -0
@@ -0,0 +1,162 @@
1
+ import { TextSelection } from '@milkdown/kit/prose/state';
2
+ import { addColumnAfter, addColumnBefore, deleteColumn, deleteRow, deleteTable, isInTable, selectedRect, TableMap, } from '@milkdown/kit/prose/tables';
3
+ const ALIGN = {
4
+ alignLeft: 'left',
5
+ alignCenter: 'center',
6
+ alignRight: 'right',
7
+ };
8
+ /** The caret's column's alignment, as the header cell has it (GFM's default is left). */
9
+ export function columnAlignment(state) {
10
+ if (!isInTable(state))
11
+ return 'left';
12
+ const { map, table, left } = selectedRect(state);
13
+ const value = table.nodeAt(map.map[left])?.attrs.alignment;
14
+ return value ?? 'left';
15
+ }
16
+ /** Whether an action can apply where the caret is. */
17
+ export function tableActionEnabled(state, action) {
18
+ if (!isInTable(state))
19
+ return false;
20
+ const { map, top, bottom, left, right } = selectedRect(state);
21
+ switch (action) {
22
+ case 'rowAbove':
23
+ return top > 0;
24
+ case 'deleteRow':
25
+ // Not the header row, and never every row under it.
26
+ return top > 0 && bottom - top < map.height - 1;
27
+ case 'deleteColumn':
28
+ return right - left < map.width;
29
+ default:
30
+ return true;
31
+ }
32
+ }
33
+ /** Runs an action where the caret is. False when it can't apply there. */
34
+ export function runTableAction(state, dispatch, action) {
35
+ if (!tableActionEnabled(state, action))
36
+ return false;
37
+ const alignment = ALIGN[action];
38
+ if (alignment)
39
+ return alignColumn(state, dispatch, alignment);
40
+ switch (action) {
41
+ case 'rowAbove':
42
+ return addRow(state, dispatch, 'above');
43
+ case 'rowBelow':
44
+ return addRow(state, dispatch, 'below');
45
+ case 'columnLeft':
46
+ return addColumn(state, dispatch, 'left');
47
+ case 'columnRight':
48
+ return addColumn(state, dispatch, 'right');
49
+ case 'deleteRow':
50
+ return deleteRow(state, dispatch);
51
+ case 'deleteColumn':
52
+ return deleteColumn(state, dispatch);
53
+ case 'deleteTable':
54
+ return deleteTable(state, dispatch);
55
+ default:
56
+ return false;
57
+ }
58
+ }
59
+ // A row of empty cells, each aligned as its column is, above or below the
60
+ // caret's rows (or at the end: `'end'`), and the caret into it in the
61
+ // same column (the first, at the end).
62
+ function addRow(state, dispatch, where) {
63
+ const { map, table, tableStart, top, bottom, left } = selectedRect(state);
64
+ const index = where === 'above' ? top : where === 'below' ? bottom : map.height;
65
+ if (index === 0)
66
+ return false;
67
+ const { schema } = state;
68
+ const cells = Array.from({ length: map.width }, (_value, column) => schema.nodes.table_cell.createAndFill({ alignment: table.nodeAt(map.map[column])?.attrs.alignment ?? null }));
69
+ let pos = tableStart;
70
+ for (let row = 0; row < index; row++)
71
+ pos += table.child(row).nodeSize;
72
+ const tr = state.tr.insert(pos, schema.nodes.table_row.create(null, cells));
73
+ const updated = tr.doc.nodeAt(tableStart - 1);
74
+ const column = where === 'end' ? 0 : left;
75
+ tr.setSelection(TextSelection.near(tr.doc.resolve(tableStart + TableMap.get(updated).map[index * map.width + column] + 1)));
76
+ dispatch(tr.scrollIntoView());
77
+ return true;
78
+ }
79
+ // A column of empty cells left or right of the caret's columns, unaligned
80
+ // (prosemirror-tables makes its cells with the schema's default, left,
81
+ // which would write `:---` for a column nobody aligned). The caret stays.
82
+ function addColumn(state, dispatch, side) {
83
+ const { tableStart, left, right } = selectedRect(state);
84
+ let added = null;
85
+ const add = side === 'left' ? addColumnBefore : addColumnAfter;
86
+ if (!add(state, (tr) => (added = tr)))
87
+ return false;
88
+ const tr = added;
89
+ const table = tr.doc.nodeAt(tableStart - 1);
90
+ const map = TableMap.get(table);
91
+ const column = side === 'left' ? left : right;
92
+ for (let row = 0; row < map.height; row++) {
93
+ const pos = map.map[row * map.width + column];
94
+ const cell = table.nodeAt(pos);
95
+ tr.setNodeMarkup(tableStart + pos, undefined, { ...cell.attrs, alignment: null });
96
+ }
97
+ dispatch(tr);
98
+ return true;
99
+ }
100
+ // Every cell in the caret's columns aligned; left is GFM's default, so it
101
+ // clears the alignment rather than writing `:---`.
102
+ function alignColumn(state, dispatch, alignment) {
103
+ const { map, table, tableStart, left, right } = selectedRect(state);
104
+ const tr = state.tr;
105
+ const value = alignment === 'left' ? null : alignment;
106
+ for (let row = 0; row < map.height; row++) {
107
+ for (let column = left; column < right; column++) {
108
+ const pos = map.map[row * map.width + column];
109
+ const cell = table.nodeAt(pos);
110
+ if (cell.attrs.alignment !== value)
111
+ tr.setNodeMarkup(tableStart + pos, undefined, { ...cell.attrs, alignment: value });
112
+ }
113
+ }
114
+ if (tr.docChanged)
115
+ dispatch(tr);
116
+ return true;
117
+ }
118
+ // The caret's cell: its row and column, and the table's size.
119
+ function cellAt(state) {
120
+ const rect = selectedRect(state);
121
+ return { ...rect, row: rect.top, column: rect.left };
122
+ }
123
+ // The caret out of the table: to the start of the block after it, or a
124
+ // new line there when nothing follows.
125
+ function exitTable(state, dispatch) {
126
+ const { tableStart, table } = selectedRect(state);
127
+ const after = tableStart - 1 + table.nodeSize;
128
+ const tr = state.tr;
129
+ const next = tr.doc.resolve(after).nodeAfter;
130
+ if (!next || !next.isTextblock)
131
+ tr.insert(after, state.schema.nodes.paragraph.create());
132
+ tr.setSelection(TextSelection.near(tr.doc.resolve(after + 1), 1));
133
+ dispatch(tr.scrollIntoView());
134
+ return true;
135
+ }
136
+ /**
137
+ * The editor's table keys, before Milkdown's own (which go to the next
138
+ * cell on Tab, and out of the table on any Enter). True when it handled
139
+ * the key.
140
+ */
141
+ export function handleTableKey(state, dispatch, event) {
142
+ if (event.metaKey || event.ctrlKey || event.altKey || !isInTable(state) || !state.selection.empty)
143
+ return false;
144
+ const { map, row, column } = cellAt(state);
145
+ const lastRow = row === map.height - 1;
146
+ if (event.key === 'Tab' && !event.shiftKey && lastRow && column === map.width - 1)
147
+ return addRow(state, dispatch, 'end');
148
+ if (event.key !== 'Enter')
149
+ return false;
150
+ if (event.shiftKey)
151
+ return true;
152
+ if (lastRow)
153
+ return exitTable(state, dispatch);
154
+ const { tableStart } = selectedRect(state);
155
+ const below = map.map[(row + 1) * map.width + column];
156
+ dispatch(state.tr.setSelection(TextSelection.near(state.doc.resolve(tableStart + below + 1))).scrollIntoView());
157
+ return true;
158
+ }
159
+ /** A row added at the table's end, the caret into its first cell (the "Add a row" bar). */
160
+ export function addRowAtEnd(state, dispatch) {
161
+ return isInTable(state) && addRow(state, dispatch, 'end');
162
+ }
@@ -2,6 +2,7 @@ import { type Ref } from 'react';
2
2
  import type { EditorState } from '@milkdown/kit/prose/state';
3
3
  import { type FormatItem } from './rich-text-format-items.js';
4
4
  import { type FormatId } from './rich-text-formats.js';
5
+ import type { TableAction } from './rich-text-table.js';
5
6
  declare const SETS: {
6
7
  all: FormatItem[][];
7
8
  selection: FormatItem[][];
@@ -14,8 +15,12 @@ export type FormatButtonsProps = {
14
15
  readOnly: boolean;
15
16
  /** A button pressed: run the format on the editor and give it focus back. */
16
17
  onFormat: (id: FormatId) => void;
18
+ /** A Table menu item picked (only the fixed toolbar has the menu). */
19
+ onTable?: (action: TableAction) => void;
20
+ /** The editable element, for the Table menu to give focus back to. */
21
+ text?: () => HTMLElement | null;
17
22
  };
18
- export declare function FormatButtons({ formats, state, readOnly, onFormat }: FormatButtonsProps): import("react").JSX.Element;
23
+ export declare function FormatButtons({ formats, state, readOnly, onFormat, onTable, text }: FormatButtonsProps): import("react").JSX.Element;
19
24
  export interface RichTextToolbarProps {
20
25
  /** The editor's state now, to show which formats are on. Null until it's made. */
21
26
  state: EditorState | null;
@@ -24,8 +29,12 @@ export interface RichTextToolbarProps {
24
29
  controls?: string;
25
30
  /** A button pressed: run the format on the editor and give it focus back. */
26
31
  onFormat: (id: FormatId) => void;
32
+ /** A Table menu item picked: run it on the editor and give it focus back. */
33
+ onTable: (action: TableAction) => void;
34
+ /** The editable element, for the Table menu to give focus back to. */
35
+ text: () => HTMLElement | null;
27
36
  /** The toolbar's element, for Alt+F10 to reach it from the text. */
28
37
  ref?: Ref<HTMLDivElement>;
29
38
  }
30
- export declare function RichTextToolbar({ state, readOnly, controls, onFormat, ref }: RichTextToolbarProps): import("react").JSX.Element;
39
+ export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
31
40
  export {};
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The rich-text editor's fixed toolbar: one row of pressed/not-pressed
3
- // buttons above the text, each a format and its keyboard shortcut.
3
+ // buttons above the text, each a format and its keyboard shortcut, and a
4
+ // Table menu (rich-text-table-menu.tsx).
4
5
  //
5
6
  // - One tab stop (ARIA's toolbar pattern): Tab reaches the last button
6
7
  // used, the arrow keys, Home and End move along the row.
@@ -16,35 +17,21 @@ import { Fragment, useRef, useState } from 'react';
16
17
  import { Separator } from '../ui/separator.js';
17
18
  import { Toggle } from '../ui/toggle.js';
18
19
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
19
- import { FORMAT_GROUPS } from './rich-text-format-items.js';
20
+ import { FORMAT_GROUPS, FORMAT_ITEMS } from './rich-text-format-items.js';
20
21
  import { formatOn } from './rich-text-formats.js';
21
- function isApple() {
22
- return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
23
- }
24
- // `aria-keyshortcuts` names real keys: Meta on Apple, Control elsewhere.
25
- function ariaKeys(keys, apple) {
26
- return keys.replace('Mod', apple ? 'Meta' : 'Control');
27
- }
28
- // What a person reads: ⌘⌥X on Apple, Ctrl+Alt+X elsewhere.
29
- function shownKeys(keys, apple) {
30
- if (!apple)
31
- return keys.replace('Mod', 'Ctrl');
32
- const symbols = { Mod: '⌘', Alt: '⌥', Shift: '⇧', Enter: '↵' };
33
- return keys
34
- .split('+')
35
- .map((part) => symbols[part] ?? part)
36
- .join('');
37
- }
22
+ import { ariaKeys, isApple, shownKeys } from './rich-text-shortcuts.js';
23
+ import { RichTextTableMenu } from './rich-text-table-menu.js';
38
24
  // Which buttons a toolbar has: every format (the fixed one), or the ones
39
25
  // that apply to selected words (the floating one).
26
+ const SELECTION = ['bold', 'italic', 'strike', 'code', 'link'];
40
27
  const SETS = {
41
28
  all: FORMAT_GROUPS,
42
- selection: [FORMAT_GROUPS[0], FORMAT_GROUPS[4]],
29
+ selection: [SELECTION.slice(0, 4), SELECTION.slice(4)].map((ids) => ids.map((id) => FORMAT_ITEMS.get(id))),
43
30
  };
44
31
  // A toolbar's row of buttons: one tab stop, the last one used (ARIA's
45
32
  // toolbar pattern); the arrow keys, Home and End move along the row. The
46
33
  // toolbar's element (`role="toolbar"`, its name) is the caller's.
47
- export function FormatButtons({ formats, state, readOnly, onFormat }) {
34
+ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, text = () => null }) {
48
35
  const apple = isApple();
49
36
  const groups = SETS[formats];
50
37
  const items = groups.flat();
@@ -71,6 +58,10 @@ export function FormatButtons({ formats, state, readOnly, onFormat }) {
71
58
  };
72
59
  return (_jsx(TooltipProvider, { delay: 400, children: groups.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
73
60
  const index = items.indexOf(item);
61
+ if (item.id === 'table')
62
+ return (_jsx(RichTextTableMenu, { ref: (element) => {
63
+ buttons.current[index] = element;
64
+ }, state: state, readOnly: readOnly, tabIndex: index === current ? 0 : -1, onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onInsert: () => onFormat('table'), onAction: (action) => onTable?.(action), text: text }, item.id));
74
65
  const Icon = item.icon;
75
66
  const tip = [
76
67
  item.label,
@@ -86,6 +77,6 @@ export function FormatButtons({ formats, state, readOnly, onFormat }) {
86
77
  onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
87
78
  })] }, groupIndex))) }));
88
79
  }
89
- export function RichTextToolbar({ state, readOnly, controls, onFormat, ref }) {
90
- return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat }) }));
80
+ export function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, text, ref }) {
81
+ return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onTable: onTable, text: text }) }));
91
82
  }
@@ -0,0 +1,19 @@
1
+ import { type CalloutKind } from '../../lib/markdown-callout.js';
2
+ export interface CalloutKindMenuProps {
3
+ kind: CalloutKind;
4
+ readOnly: boolean;
5
+ onPick: (kind: CalloutKind) => void;
6
+ onRemove: () => void;
7
+ /** The editable element, where focus goes when the menu closes. */
8
+ text: () => HTMLElement | null;
9
+ }
10
+ export declare function CalloutKindMenu({ kind, readOnly, onPick, onRemove, text }: CalloutKindMenuProps): import("react").JSX.Element;
11
+ export interface TableBarProps {
12
+ /** Under the table (a row) or along its right (a column). */
13
+ side: 'row' | 'column';
14
+ /** The caret is in this table: shown without the pointer over it. */
15
+ active: boolean;
16
+ readOnly: boolean;
17
+ onAdd: () => void;
18
+ }
19
+ export declare function TableBar({ side, active, readOnly, onAdd }: TableBarProps): import("react").JSX.Element | null;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The React parts of the editor's node views (rich-text-node-views.ts),
3
+ // rendered into their slots through portals:
4
+ //
5
+ // - A callout's icon is a menu of its kinds (and Remove callout). From the
6
+ // keyboard, "/" at the start of a line in the callout lists the kinds
7
+ // too (rich-text-slash.ts), so the icon is a mouse shortcut and out of
8
+ // the tab order.
9
+ // - A table's bars add a column at its right and a row at its end, shown
10
+ // while the pointer is over the table or the caret is in it. Mouse
11
+ // shortcuts as well: Tab in the last cell adds a row, and the toolbar's
12
+ // Table menu does both.
13
+ //
14
+ // Each gives focus back to the text when it's done.
15
+ import { PlusIcon } from 'lucide-react';
16
+ import { cn } from 'cn';
17
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
18
+ import { CALLOUT_KINDS } from '../../lib/markdown-callout.js';
19
+ import { CALLOUT_LOOK } from './prose-look.js';
20
+ export function CalloutKindMenu({ kind, readOnly, onPick, onRemove, text }) {
21
+ const look = CALLOUT_LOOK[kind];
22
+ const Icon = look.icon;
23
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { tabIndex: -1, disabled: readOnly, "aria-label": `${look.label} callout: change its kind`, "data-slot": "rich-text-callout-kind", className: "flex size-5 items-center justify-center rounded-md outline-none hover:bg-foreground/10 focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none", children: _jsx(Icon, { "aria-hidden": "true", className: cn('size-4', look.tint) }) }), _jsxs(DropdownMenuContent, { className: "w-44", finalFocus: () => text() ?? true, children: [_jsx(DropdownMenuRadioGroup, { value: kind, onValueChange: (value) => onPick(value), children: CALLOUT_KINDS.map((one) => {
24
+ const OneIcon = CALLOUT_LOOK[one].icon;
25
+ return (_jsxs(DropdownMenuRadioItem, { value: one, closeOnClick: true, children: [_jsx(OneIcon, { "aria-hidden": "true", className: CALLOUT_LOOK[one].tint }), CALLOUT_LOOK[one].label] }, one));
26
+ }) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onClick: onRemove, children: "Remove callout" })] })] }));
27
+ }
28
+ export function TableBar({ side, active, readOnly, onAdd }) {
29
+ if (readOnly)
30
+ return null;
31
+ return (_jsx("button", { type: "button", tabIndex: -1, "aria-label": side === 'row' ? 'Add a row' : 'Add a column', "data-slot": `rich-text-table-add-${side}`, className: cn('flex flex-1 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-muted hover:text-foreground', 'opacity-0 group-hover/table:opacity-100', active && 'opacity-100'),
32
+ // A press keeps focus in the text: the caret moves into the new cell.
33
+ onMouseDown: (event) => event.preventDefault(), onClick: onAdd, children: _jsx(PlusIcon, { "aria-hidden": "true", className: "size-3.5" }) }));
34
+ }
@@ -1,7 +1,7 @@
1
1
  import { useRender } from "@base-ui/react/use-render";
2
2
  import { type VariantProps } from "class-variance-authority";
3
3
  declare const badgeVariants: (props?: ({
4
- variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "info" | "warning" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
4
+ variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "warning" | "info" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
7
7
  export { Badge, badgeVariants };
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
3
+ declare function DropdownMenu({ ...props }: MenuPrimitive.Root.Props): React.JSX.Element;
4
+ declare function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props): React.JSX.Element;
5
+ declare function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props): React.JSX.Element;
6
+ declare function DropdownMenuContent({ align, alignOffset, side, sideOffset, className, ...props }: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">): React.JSX.Element;
7
+ declare function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props): React.JSX.Element;
8
+ declare function DropdownMenuLabel({ className, inset, ...props }: MenuPrimitive.GroupLabel.Props & {
9
+ inset?: boolean;
10
+ }): React.JSX.Element;
11
+ declare function DropdownMenuItem({ className, inset, variant, ...props }: MenuPrimitive.Item.Props & {
12
+ inset?: boolean;
13
+ variant?: "default" | "destructive";
14
+ }): React.JSX.Element;
15
+ declare function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props): React.JSX.Element;
16
+ declare function DropdownMenuSubTrigger({ className, inset, children, ...props }: MenuPrimitive.SubmenuTrigger.Props & {
17
+ inset?: boolean;
18
+ }): React.JSX.Element;
19
+ declare function DropdownMenuSubContent({ align, alignOffset, side, sideOffset, className, ...props }: React.ComponentProps<typeof DropdownMenuContent>): React.JSX.Element;
20
+ declare function DropdownMenuCheckboxItem({ className, children, checked, inset, ...props }: MenuPrimitive.CheckboxItem.Props & {
21
+ inset?: boolean;
22
+ }): React.JSX.Element;
23
+ declare function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props): React.JSX.Element;
24
+ declare function DropdownMenuRadioItem({ className, children, inset, ...props }: MenuPrimitive.RadioItem.Props & {
25
+ inset?: boolean;
26
+ }): React.JSX.Element;
27
+ declare function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props): React.JSX.Element;
28
+ declare function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">): React.JSX.Element;
29
+ export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
4
+ import { cn } from "cn";
5
+ import { ChevronRightIcon, CheckIcon } from "lucide-react";
6
+ function DropdownMenu({ ...props }) {
7
+ return _jsx(MenuPrimitive.Root, { "data-slot": "dropdown-menu", ...props });
8
+ }
9
+ function DropdownMenuPortal({ ...props }) {
10
+ return _jsx(MenuPrimitive.Portal, { "data-slot": "dropdown-menu-portal", ...props });
11
+ }
12
+ function DropdownMenuTrigger({ ...props }) {
13
+ return _jsx(MenuPrimitive.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
14
+ }
15
+ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom", sideOffset = 4, className, ...props }) {
16
+ return (_jsx(MenuPrimitive.Portal, { children: _jsx(MenuPrimitive.Positioner, { className: "isolate z-50 outline-none", align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, children: _jsx(MenuPrimitive.Popup, { "data-slot": "dropdown-menu-content", className: cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props }) }) }));
17
+ }
18
+ function DropdownMenuGroup({ ...props }) {
19
+ return _jsx(MenuPrimitive.Group, { "data-slot": "dropdown-menu-group", ...props });
20
+ }
21
+ function DropdownMenuLabel({ className, inset, ...props }) {
22
+ return (_jsx(MenuPrimitive.GroupLabel, { "data-slot": "dropdown-menu-label", "data-inset": inset, className: cn("px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", className), ...props }));
23
+ }
24
+ function DropdownMenuItem({ className, inset, variant = "default", ...props }) {
25
+ return (_jsx(MenuPrimitive.Item, { "data-slot": "dropdown-menu-item", "data-inset": inset, "data-variant": variant, className: cn("group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", className), ...props }));
26
+ }
27
+ function DropdownMenuSub({ ...props }) {
28
+ return _jsx(MenuPrimitive.SubmenuRoot, { "data-slot": "dropdown-menu-sub", ...props });
29
+ }
30
+ function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
31
+ return (_jsxs(MenuPrimitive.SubmenuTrigger, { "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset, className: cn("flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
32
+ }
33
+ function DropdownMenuSubContent({ align = "start", alignOffset = -3, side = "right", sideOffset = 0, className, ...props }) {
34
+ return (_jsx(DropdownMenuContent, { "data-slot": "dropdown-menu-sub-content", className: cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, ...props }));
35
+ }
36
+ function DropdownMenuCheckboxItem({ className, children, checked, inset, ...props }) {
37
+ return (_jsxs(MenuPrimitive.CheckboxItem, { "data-slot": "dropdown-menu-checkbox-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), checked: checked, ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-checkbox-item-indicator", children: _jsx(MenuPrimitive.CheckboxItemIndicator, { children: _jsx(CheckIcon, {}) }) }), children] }));
38
+ }
39
+ function DropdownMenuRadioGroup({ ...props }) {
40
+ return (_jsx(MenuPrimitive.RadioGroup, { "data-slot": "dropdown-menu-radio-group", ...props }));
41
+ }
42
+ function DropdownMenuRadioItem({ className, children, inset, ...props }) {
43
+ return (_jsxs(MenuPrimitive.RadioItem, { "data-slot": "dropdown-menu-radio-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-radio-item-indicator", children: _jsx(MenuPrimitive.RadioItemIndicator, { children: _jsx(CheckIcon, {}) }) }), children] }));
44
+ }
45
+ function DropdownMenuSeparator({ className, ...props }) {
46
+ return (_jsx(MenuPrimitive.Separator, { "data-slot": "dropdown-menu-separator", className: cn("-mx-1 my-1 h-px bg-border", className), ...props }));
47
+ }
48
+ function DropdownMenuShortcut({ className, ...props }) {
49
+ return (_jsx("span", { "data-slot": "dropdown-menu-shortcut", className: cn("ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground", className), ...props }));
50
+ }
51
+ export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
@@ -0,0 +1,38 @@
1
+ export declare const CALLOUT_KINDS: readonly ["note", "tip", "important", "warning", "caution"];
2
+ export type CalloutKind = (typeof CALLOUT_KINDS)[number];
3
+ type MdNode = {
4
+ type: string;
5
+ children?: MdNode[];
6
+ value?: string;
7
+ data?: Record<string, unknown>;
8
+ [key: string]: unknown;
9
+ };
10
+ /**
11
+ * For react-markdown: a callout quote becomes a `<div data-callout="note">`
12
+ * (its marker gone), which <Markdown>'s `div` component draws as one.
13
+ */
14
+ export declare function remarkCallouts(): (tree: unknown) => void;
15
+ /**
16
+ * For the rich-text editor: a callout quote becomes a `callout` node
17
+ * (`kind`, `marker`), which its schema reads and `calloutHandler` writes.
18
+ */
19
+ export declare function remarkCalloutNodes(): (tree: unknown) => void;
20
+ /** The marker to write for a callout: as it was written, unless its kind changed. */
21
+ export declare function calloutMarker(kind: CalloutKind, written: string | null | undefined): string;
22
+ type ToMarkdownState = {
23
+ enter: (name: string) => () => void;
24
+ createTracker: (info: unknown) => {
25
+ move: (value: string) => void;
26
+ shift: (value: number) => void;
27
+ current: () => unknown;
28
+ };
29
+ containerFlow: (node: MdNode, info: unknown) => string;
30
+ indentLines: (value: string, map: (line: string, index: number, blank: boolean) => string) => string;
31
+ };
32
+ /**
33
+ * Writes a `callout` node the way remark writes a quote, with the marker
34
+ * as its first line. A first block that isn't a paragraph gets a blank
35
+ * line after the marker, so it can't run into it.
36
+ */
37
+ export declare function calloutHandler(node: MdNode, _parent: unknown, state: ToMarkdownState, info: unknown): string;
38
+ export {};
@@ -0,0 +1,103 @@
1
+ // Callouts (Notion's coloured boxes) in Markdown: GitHub's alert syntax, a
2
+ // quote whose first line is its kind in brackets.
3
+ //
4
+ // > [!NOTE]
5
+ // > Measure twice, cut once.
6
+ //
7
+ // It's plain Markdown, so a note stays readable anywhere (GitHub and
8
+ // Obsidian render it as a callout too, anything else as a quote), and
9
+ // remark parses it as a blockquote whose first text is the marker. These
10
+ // plugins read that marker: `remarkCallouts` for <Markdown> (an element
11
+ // react-markdown hands to its `div` component) and `remarkCalloutNodes`
12
+ // for the rich-text editor (a `callout` node it has a schema for, written
13
+ // back with `calloutHandler`). The merge (markdown-merge.ts) needs
14
+ // neither: to it a callout is the quote it's written as.
15
+ //
16
+ // Five kinds, GitHub's: note, tip, important, warning, caution. The marker
17
+ // is kept as it was written (`[!note]` stays lower case) unless the kind
18
+ // changes, so an untouched callout never reads as edited.
19
+ export const CALLOUT_KINDS = ['note', 'tip', 'important', 'warning', 'caution'];
20
+ // The marker alone on the quote's first line: `[!NOTE]`, any case, spaces
21
+ // after it allowed.
22
+ const MARKER = /^\[!(note|tip|important|warning|caution)\][ \t]*(?:\n|$)/i;
23
+ /**
24
+ * A quote that's a callout: its kind and marker as written, with the
25
+ * marker taken out of its first paragraph (and that paragraph too, when
26
+ * the marker was all of it). Null for any other node, untouched.
27
+ */
28
+ function takeCallout(node) {
29
+ if (node.type !== 'blockquote')
30
+ return null;
31
+ const paragraph = node.children?.[0];
32
+ const text = paragraph?.type === 'paragraph' ? paragraph.children?.[0] : undefined;
33
+ if (!paragraph || text?.type !== 'text' || typeof text.value !== 'string')
34
+ return null;
35
+ const found = MARKER.exec(text.value);
36
+ if (!found)
37
+ return null;
38
+ const rest = text.value.slice(found[0].length);
39
+ if (rest)
40
+ text.value = rest;
41
+ else
42
+ paragraph.children.shift();
43
+ if (paragraph.children.length === 0)
44
+ node.children.shift();
45
+ return { kind: found[1].toLowerCase(), marker: found[0].trim() };
46
+ }
47
+ function visit(node, each) {
48
+ for (const child of node.children ?? [])
49
+ visit(child, each);
50
+ each(node);
51
+ }
52
+ /**
53
+ * For react-markdown: a callout quote becomes a `<div data-callout="note">`
54
+ * (its marker gone), which <Markdown>'s `div` component draws as one.
55
+ */
56
+ export function remarkCallouts() {
57
+ return (tree) => {
58
+ visit(tree, (node) => {
59
+ const callout = takeCallout(node);
60
+ if (callout)
61
+ node.data = { ...node.data, hName: 'div', hProperties: { dataCallout: callout.kind } };
62
+ });
63
+ };
64
+ }
65
+ /**
66
+ * For the rich-text editor: a callout quote becomes a `callout` node
67
+ * (`kind`, `marker`), which its schema reads and `calloutHandler` writes.
68
+ */
69
+ export function remarkCalloutNodes() {
70
+ return (tree) => {
71
+ visit(tree, (node) => {
72
+ const callout = takeCallout(node);
73
+ if (!callout)
74
+ return;
75
+ node.type = 'callout';
76
+ node.kind = callout.kind;
77
+ node.marker = callout.marker;
78
+ });
79
+ };
80
+ }
81
+ /** The marker to write for a callout: as it was written, unless its kind changed. */
82
+ export function calloutMarker(kind, written) {
83
+ return written && written.slice(2, -1).toLowerCase() === kind ? written : `[!${kind.toUpperCase()}]`;
84
+ }
85
+ /**
86
+ * Writes a `callout` node the way remark writes a quote, with the marker
87
+ * as its first line. A first block that isn't a paragraph gets a blank
88
+ * line after the marker, so it can't run into it.
89
+ */
90
+ export function calloutHandler(node, _parent, state, info) {
91
+ const exit = state.enter('blockquote');
92
+ const tracker = state.createTracker(info);
93
+ tracker.move('> ');
94
+ tracker.shift(2);
95
+ const marker = calloutMarker(node.kind, node.marker);
96
+ // Milkdown's serializer starts a container's flow on a new line; the
97
+ // marker's line is that line.
98
+ const body = state.containerFlow(node, tracker.current()).replace(/^\n+/, '');
99
+ const joined = !body ? marker : node.children?.[0]?.type === 'paragraph' ? `${marker}\n${body}` : `${marker}\n\n${body}`;
100
+ const value = state.indentLines(joined, (line, _index, blank) => '>' + (blank ? '' : ' ') + line);
101
+ exit();
102
+ return value;
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.20.1",
3
+ "version": "3.21.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
@@ -47,6 +47,8 @@
47
47
  --color-info: var(--info);
48
48
  --color-info-foreground: var(--info-foreground);
49
49
  --color-info-text: var(--info-text);
50
+ --color-link: var(--link);
51
+ --color-rule: var(--rule);
50
52
  --color-category-1: var(--category-1);
51
53
  --color-category-2: var(--category-2);
52
54
  --color-category-3: var(--category-3);
@@ -105,6 +107,21 @@
105
107
  line-height: var(--type-caption-leading);
106
108
  font-weight: var(--type-caption-weight);
107
109
  }
110
+ @utility type-heading-1 {
111
+ font-size: var(--type-heading-1-size);
112
+ line-height: var(--type-heading-1-leading);
113
+ font-weight: var(--type-heading-1-weight);
114
+ }
115
+ @utility type-heading-2 {
116
+ font-size: var(--type-heading-2-size);
117
+ line-height: var(--type-heading-2-leading);
118
+ font-weight: var(--type-heading-2-weight);
119
+ }
120
+ @utility type-heading-3 {
121
+ font-size: var(--type-heading-3-size);
122
+ line-height: var(--type-heading-3-leading);
123
+ font-weight: var(--type-heading-3-weight);
124
+ }
108
125
 
109
126
  @layer base {
110
127
  * {