@crisp-ui-kit/crisp 0.50.0 → 0.52.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 (128) hide show
  1. package/dist/cjs/components/composewindow/composewindow.js +144 -0
  2. package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
  3. package/dist/cjs/components/composewindow/index.js +8 -0
  4. package/dist/cjs/components/datatable/datatable.js +224 -43
  5. package/dist/cjs/components/datatable/datatable.recipe.js +37 -3
  6. package/dist/cjs/components/datatable/index.js +6 -0
  7. package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
  8. package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
  9. package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
  10. package/dist/cjs/components/menu/menu.js +4 -2
  11. package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
  12. package/dist/cjs/components/select/select.js +2 -1
  13. package/dist/cjs/components/table/table.recipe.js +12 -3
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
  15. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +18 -7
  16. package/dist/cjs/core/caret-rect.js +73 -0
  17. package/dist/cjs/core/cell-date.js +11 -0
  18. package/dist/cjs/core/index.js +8 -1
  19. package/dist/cjs/core/mention.js +32 -0
  20. package/dist/cjs/core/use-mention-trigger.js +124 -0
  21. package/dist/cjs/index.js +1 -0
  22. package/dist/cjs/product/recordpage/recordpage.js +88 -10
  23. package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
  24. package/dist/cjs/product/recordpanel/recordpanel.js +11 -29
  25. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +55 -17
  26. package/dist/cjs/product/recordstable/editable-field.js +113 -0
  27. package/dist/cjs/product/recordstable/index.js +4 -1
  28. package/dist/cjs/product/recordstable/recordstable.js +67 -11
  29. package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
  30. package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
  31. package/dist/cjs/product/richtext/index.js +2 -1
  32. package/dist/cjs/product/richtext/richtext.js +440 -23
  33. package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
  34. package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
  35. package/dist/cjs/product/sidebar/sidebar.js +15 -3
  36. package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
  37. package/dist/composewindow.css +24 -0
  38. package/dist/composewindow.d.ts +7 -0
  39. package/dist/composewindow.layered.css +26 -0
  40. package/dist/datatable.css +33 -14
  41. package/dist/datatable.layered.css +33 -14
  42. package/dist/datepickerfield.css +9 -4
  43. package/dist/datepickerfield.layered.css +9 -4
  44. package/dist/dialog.css +2 -1
  45. package/dist/dialog.layered.css +2 -1
  46. package/dist/esm/components/composewindow/composewindow.js +107 -0
  47. package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
  48. package/dist/esm/components/composewindow/index.js +2 -0
  49. package/dist/esm/components/datatable/datatable.js +226 -45
  50. package/dist/esm/components/datatable/datatable.recipe.js +37 -3
  51. package/dist/esm/components/datatable/index.js +6 -0
  52. package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
  53. package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
  54. package/dist/esm/components/dialog/dialog.recipe.js +9 -1
  55. package/dist/esm/components/menu/menu.js +4 -2
  56. package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
  57. package/dist/esm/components/select/select.js +2 -1
  58. package/dist/esm/components/table/table.recipe.js +12 -3
  59. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
  60. package/dist/esm/components/tabletoolbar/tabletoolbar.js +18 -7
  61. package/dist/esm/core/caret-rect.js +70 -0
  62. package/dist/esm/core/cell-date.js +10 -0
  63. package/dist/esm/core/index.js +4 -1
  64. package/dist/esm/core/mention.js +29 -0
  65. package/dist/esm/core/use-mention-trigger.js +88 -0
  66. package/dist/esm/index.js +1 -0
  67. package/dist/esm/product/recordpage/recordpage.js +89 -11
  68. package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
  69. package/dist/esm/product/recordpanel/index.js +1 -1
  70. package/dist/esm/product/recordpanel/recordpanel.js +13 -31
  71. package/dist/esm/product/recordpanel/recordpanel.recipe.js +56 -18
  72. package/dist/esm/product/recordstable/editable-field.js +76 -0
  73. package/dist/esm/product/recordstable/index.js +1 -0
  74. package/dist/esm/product/recordstable/recordstable.js +69 -13
  75. package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
  76. package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
  77. package/dist/esm/product/richtext/index.js +1 -1
  78. package/dist/esm/product/richtext/richtext.js +439 -22
  79. package/dist/esm/product/richtext/richtext.recipe.js +60 -10
  80. package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
  81. package/dist/esm/product/sidebar/sidebar.js +15 -3
  82. package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
  83. package/dist/multiselect.css +8 -8
  84. package/dist/multiselect.layered.css +8 -8
  85. package/dist/recordpage.css +25 -13
  86. package/dist/recordpage.layered.css +25 -13
  87. package/dist/recordpanel.css +43 -20
  88. package/dist/recordpanel.layered.css +43 -20
  89. package/dist/recordstable.css +9 -2
  90. package/dist/recordstable.layered.css +9 -2
  91. package/dist/richtext.css +31 -5
  92. package/dist/richtext.layered.css +31 -5
  93. package/dist/settingsshell.css +2 -2
  94. package/dist/settingsshell.layered.css +2 -2
  95. package/dist/sidebar.css +7 -2
  96. package/dist/sidebar.layered.css +7 -2
  97. package/dist/styles.css +199 -74
  98. package/dist/styles.layered.css +199 -74
  99. package/dist/table.css +4 -2
  100. package/dist/table.layered.css +4 -2
  101. package/dist/tabletoolbar.css +1 -1
  102. package/dist/tabletoolbar.layered.css +1 -1
  103. package/dist/types/components/composewindow/composewindow.d.ts +80 -0
  104. package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
  105. package/dist/types/components/composewindow/index.d.ts +2 -0
  106. package/dist/types/components/datatable/datatable.d.ts +58 -4
  107. package/dist/types/components/datatable/index.d.ts +1 -1
  108. package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
  109. package/dist/types/components/menu/menu.d.ts +10 -1
  110. package/dist/types/components/select/select.d.ts +6 -1
  111. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +29 -5
  112. package/dist/types/core/caret-rect.d.ts +16 -0
  113. package/dist/types/core/cell-date.d.ts +17 -0
  114. package/dist/types/core/index.d.ts +4 -1
  115. package/dist/types/core/mention.d.ts +27 -0
  116. package/dist/types/core/use-mention-trigger.d.ts +41 -0
  117. package/dist/types/index.d.ts +1 -0
  118. package/dist/types/product/recordpage/recordpage.d.ts +59 -1
  119. package/dist/types/product/recordpanel/index.d.ts +1 -1
  120. package/dist/types/product/recordpanel/recordpanel.d.ts +81 -17
  121. package/dist/types/product/recordstable/editable-field.d.ts +48 -0
  122. package/dist/types/product/recordstable/index.d.ts +1 -0
  123. package/dist/types/product/recordstable/recordstable.d.ts +29 -0
  124. package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
  125. package/dist/types/product/richtext/index.d.ts +1 -1
  126. package/dist/types/product/richtext/richtext.d.ts +168 -7
  127. package/dist/types/product/sidebar/sidebar.d.ts +60 -4
  128. package/package.json +6 -1
@@ -1,19 +1,139 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Bold, Heading1, Heading2, Italic, Link2, List, ListOrdered, Quote, Strikethrough, Underline, } from "lucide-react";
2
+ import { Baseline, Bold, Heading1, Heading2, Heading3, Highlighter, Image as ImageIcon, Italic, Link2, List, ListOrdered, Pilcrow, Search, Strikethrough, } from "lucide-react";
3
3
  import * as React from "react";
4
+ import { useListboxKeyboard } from "../../components/select/index.js";
5
+ import { matchMentionTrigger } from "../../core/index.js";
4
6
  import { richtextRecipe } from "./richtext.recipe.js";
7
+ export const RICHTEXT_COLOR_TONES = [
8
+ { id: "grey", label: "Grey", text: "rgba(0,0,0,0.63)", highlight: "#F6F7F7" },
9
+ { id: "red", label: "Red", text: "#EB3B3B", highlight: "#FFEBEB" },
10
+ { id: "orange", label: "Orange", text: "#C95908", highlight: "#FEEEE1" },
11
+ { id: "yellow", label: "Yellow", text: "#CF8300", highlight: "#FFF3CC" },
12
+ {
13
+ id: "light-green",
14
+ label: "Light green",
15
+ text: "#748700",
16
+ highlight: "#F4FBCB",
17
+ },
18
+ { id: "green", label: "Green", text: "#02AD6E", highlight: "#E0FCED" },
19
+ { id: "teal", label: "Teal", text: "#0D99BD", highlight: "#DDF7FF" },
20
+ { id: "blue", label: "Blue", text: "#266DF0", highlight: "#E5EEFF" },
21
+ { id: "purple", label: "Purple", text: "#864AFF", highlight: "#F5F0FF" },
22
+ { id: "violet", label: "Violet", text: "#CD33DE", highlight: "#FDEDFF" },
23
+ { id: "magenta", label: "Magenta", text: "#E0387E", highlight: "#FEECF3" },
24
+ { id: "brown", label: "Brown", text: "#8C7D00", highlight: "#FDF7C4" },
25
+ ];
26
+ const BLOCK_MENU_ITEMS = [
27
+ { id: "h1", label: "Heading 1", description: "Large section heading" },
28
+ { id: "h2", label: "Heading 2", description: "Medium section heading" },
29
+ { id: "h3", label: "Heading 3", description: "Small section heading" },
30
+ {
31
+ id: "body",
32
+ label: "Body",
33
+ description: "Just start typing with plain text",
34
+ },
35
+ {
36
+ id: "ul",
37
+ label: "Bulleted list",
38
+ description: "Create a simple bulleted list",
39
+ },
40
+ {
41
+ id: "ol",
42
+ label: "Numbered list",
43
+ description: "Create a list with numbering",
44
+ },
45
+ { id: "image", label: "Image", description: "Upload an image" },
46
+ ];
47
+ const filterBlockItems = (query) => {
48
+ const q = query.trim().toLowerCase();
49
+ if (!q)
50
+ return BLOCK_MENU_ITEMS;
51
+ return BLOCK_MENU_ITEMS.filter((item) => item.label.toLowerCase().includes(q));
52
+ };
53
+ /**
54
+ * Is there an active "/query" block-insert trigger ending at the caret? The
55
+ * same word-boundary rule as a mention trigger: `/` must start a word (text
56
+ * start, or preceded by whitespace) and nothing between it and the caret may
57
+ * contain whitespace. A pure function, kept local rather than imported from
58
+ * a shared primitive — this component doesn't currently depend on one.
59
+ */
60
+ function matchSlashTrigger(textBeforeCaret, trigger = "/") {
61
+ const start = textBeforeCaret.lastIndexOf(trigger);
62
+ if (start === -1)
63
+ return null;
64
+ const before = start > 0 ? textBeforeCaret[start - 1] : undefined;
65
+ if (before !== undefined && !/\s/.test(before))
66
+ return null;
67
+ const query = textBeforeCaret.slice(start + trigger.length);
68
+ if (/\s/.test(query))
69
+ return null;
70
+ return { query, start };
71
+ }
5
72
  const exec = (cmd, value) => document.execCommand(cmd, false, value);
6
73
  /**
7
- * A block rich-text editor — measured from a notes editor. A large title,
8
- * a prose body (headings, lists, quote, marks, links), and a floating toolbar
9
- * that appears over a text selection. Built on contentEditable, dependency-free.
74
+ * A block rich-text editor — measured from Attio's own notes editor. A large
75
+ * title, a prose body (h1/h2/h3, bulleted/numbered lists, an image block,
76
+ * marks), a floating selection toolbar, a searchable block-type menu, and a
77
+ * caret-anchored mention trigger. Built on contentEditable, dependency-free.
10
78
  * Uncontrolled: seed with `defaultTitle`/`defaultHtml`, read via `onChange`.
79
+ * Imperative actions (`insertMention`, `focus`) go through a ref handle
80
+ * rather than a new prop each — the rest of the surface stays uncontrolled.
81
+ *
82
+ * **No underline, no inline code, no blockquote** — all three confirmed
83
+ * absent from Attio's live toolbar (7 controls) and its exhaustive 7-item
84
+ * block-type menu; the generic baseline this component used to ship
85
+ * overshot Attio on all three. `.crisp-rte-body blockquote` still renders
86
+ * correctly for content pasted in from elsewhere — only the control for it
87
+ * is gone.
88
+ *
89
+ * **The block menu's own chrome (356px, radius 12px, `elevation.floating`,
90
+ * a "Search items…" input, 7 rows at 52px with a 16px icon) was measured
91
+ * through the in-editor "Insert" button, not the toolbar's "Body ▾".**
92
+ * Clicking "Body ▾" itself was tried repeatedly, with both synthetic and
93
+ * real CDP-dispatched trusted clicks, single- and multi-step sessions, and
94
+ * consistently closed the ENTIRE note a fraction of a second after opening
95
+ * something (confirmed via a 0/50/150/400ms readback: the toolbar portal was
96
+ * still there at +50ms, everything was gone by +150ms) — a nested-popover
97
+ * dismissable-layer interaction synthetic input in this environment could
98
+ * not reliably drive. The Insert button opens the identical shape ("The
99
+ * same-shaped popover... is also what opens from the 'Body' button" — the
100
+ * source measurement's own finding, and Insert's own DOM confirms the exact
101
+ * same `role="dialog"`/search-input/`role="option"` structure), so the
102
+ * chrome below is that measurement, reused for both entry points. What's
103
+ * NOT independently confirmed is "Body ▾"'s own anchor side — Insert opens
104
+ * `data-side="right"` (it sits at the end of a line, with no room below);
105
+ * the toolbar sits at the TOP of the selection, so this component opens the
106
+ * menu below it, a reasoned default rather than a reading. The `/`-in-body
107
+ * trigger, and the "@" mention trigger, both anchor at the caret and do not
108
+ * steal focus from the body — the toolbar's own "Body ▾" click DOES focus a
109
+ * real search input, since there's no in-document query to read a filter
110
+ * from until one exists.
111
+ *
112
+ * Not built: Attio's own separate "Insert" trigger button (a third way to
113
+ * reach the identical menu, sitting at the end of the body's last line) —
114
+ * only the two entry points #426 asked for.
11
115
  */
12
- export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", ...rest }) {
116
+ export const RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, ...rest }, ref) {
13
117
  const titleRef = React.useRef(null);
14
118
  const bodyRef = React.useRef(null);
15
119
  const [bar, setBar] = React.useState(null);
16
120
  const [active, setActive] = React.useState({});
121
+ const [swatchPanel, setSwatchPanel] = React.useState(null);
122
+ const [blockMenu, setBlockMenu] = React.useState(null);
123
+ // The Range covering a live "/query" (trigger included), mirroring the
124
+ // mention primitive below — only set in "slash" mode.
125
+ const slashRangeRef = React.useRef(null);
126
+ const blockSearchRef = React.useRef(null);
127
+ const [mention, setMention] = React.useState(null);
128
+ // The Range covering the live "@query" text (trigger included) — kept
129
+ // outside state since it's DOM identity, not a value to render from.
130
+ const mentionRangeRef = React.useRef(null);
131
+ // Wraps whatever `renderMentionPicker` returns, `display: contents` so it
132
+ // affects no layout — exists only so `updateMention` can tell "focus moved
133
+ // into the consumer's own picker" apart from "focus left the editor
134
+ // entirely", the same distinction `updateBar` already makes for the block
135
+ // menu's search input below.
136
+ const mentionPickerRef = React.useRef(null);
17
137
  const syncEmpty = (el) => {
18
138
  if (el)
19
139
  el.toggleAttribute("data-empty", (el.textContent ?? "") === "");
@@ -38,8 +158,7 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
38
158
  const sel = window.getSelection();
39
159
  let node = sel?.anchorNode;
40
160
  while (node && node !== bodyRef.current) {
41
- if (node.nodeType === 1 &&
42
- /^(H1|H2|H3|BLOCKQUOTE|LI)$/.test(node.tagName))
161
+ if (node.nodeType === 1 && /^(H1|H2|H3|LI)$/.test(node.tagName))
43
162
  return node.tagName.toLowerCase();
44
163
  node = node.parentElement;
45
164
  }
@@ -47,6 +166,15 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
47
166
  };
48
167
  // biome-ignore lint/correctness/useExhaustiveDependencies: blockTag only reads stable refs
49
168
  const updateBar = React.useCallback(() => {
169
+ if (readOnly)
170
+ return;
171
+ // The block menu's own search input lives outside `body`, and opening
172
+ // it (a "toolbar"-mode menu autofocuses it) moves the document
173
+ // selection there deliberately — that is not the user clicking away
174
+ // from their text selection, and must not tear the toolbar (and the
175
+ // menu it owns) down out from under them the instant it opens.
176
+ if (document.activeElement === blockSearchRef.current)
177
+ return;
50
178
  const sel = window.getSelection();
51
179
  const body = bodyRef.current;
52
180
  if (!sel ||
@@ -55,11 +183,13 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
55
183
  !body ||
56
184
  !body.contains(sel.anchorNode)) {
57
185
  setBar(null);
186
+ setSwatchPanel(null);
58
187
  return;
59
188
  }
60
189
  const rect = sel.getRangeAt(0).getBoundingClientRect();
61
190
  if (rect.width === 0 && rect.height === 0) {
62
191
  setBar(null);
192
+ setSwatchPanel(null);
63
193
  return;
64
194
  }
65
195
  setBar({ x: rect.left + rect.width / 2, y: rect.top - 8 });
@@ -67,20 +197,132 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
67
197
  setActive({
68
198
  bold: document.queryCommandState("bold"),
69
199
  italic: document.queryCommandState("italic"),
70
- underline: document.queryCommandState("underline"),
71
200
  strikeThrough: document.queryCommandState("strikeThrough"),
72
201
  h1: tag === "h1",
73
202
  h2: tag === "h2",
203
+ h3: tag === "h3",
74
204
  ul: document.queryCommandState("insertUnorderedList"),
75
205
  ol: document.queryCommandState("insertOrderedList"),
76
- blockquote: tag === "blockquote",
77
206
  });
207
+ }, [readOnly]);
208
+ const closeBlockMenu = React.useCallback(() => {
209
+ slashRangeRef.current = null;
210
+ setBlockMenu(null);
211
+ }, []);
212
+ /** Mirrors the mention-trigger detection below: only handles the caret
213
+ * landing inside a text node (the common case while typing), and only
214
+ * ever auto-closes a "slash"-mode menu — a "toolbar"-mode menu's search
215
+ * input isn't inside the body, so focusing it moves the selection out of
216
+ * `body` entirely, which must not be read as "the trigger went away". */
217
+ const updateSlashTrigger = React.useCallback(() => {
218
+ if (readOnly)
219
+ return;
220
+ const body = bodyRef.current;
221
+ const sel = window.getSelection();
222
+ if (!body ||
223
+ !sel?.isCollapsed ||
224
+ sel.rangeCount === 0 ||
225
+ !body.contains(sel.anchorNode)) {
226
+ if (blockMenu?.mode === "slash")
227
+ closeBlockMenu();
228
+ return;
229
+ }
230
+ const range = sel.getRangeAt(0);
231
+ const node = range.startContainer;
232
+ if (node.nodeType !== Node.TEXT_NODE) {
233
+ if (blockMenu?.mode === "slash")
234
+ closeBlockMenu();
235
+ return;
236
+ }
237
+ const textBefore = (node.textContent ?? "").slice(0, range.startOffset);
238
+ const match = matchSlashTrigger(textBefore);
239
+ if (!match) {
240
+ if (blockMenu?.mode === "slash")
241
+ closeBlockMenu();
242
+ return;
243
+ }
244
+ const triggerRange = document.createRange();
245
+ triggerRange.setStart(node, match.start);
246
+ triggerRange.setEnd(node, range.startOffset);
247
+ slashRangeRef.current = triggerRange;
248
+ const rect = triggerRange.getBoundingClientRect();
249
+ setBlockMenu({
250
+ anchor: { x: rect.left, y: rect.bottom + 4 },
251
+ query: match.query,
252
+ highlighted: 0,
253
+ mode: "slash",
254
+ });
255
+ }, [readOnly, blockMenu, closeBlockMenu]);
256
+ const closeMention = React.useCallback(() => {
257
+ mentionRangeRef.current = null;
258
+ setMention(null);
78
259
  }, []);
260
+ /**
261
+ * Is the caret sitting right after an "@query"? Only handles the caret
262
+ * landing inside a text node — the common case while typing — the same
263
+ * scope `updateBar`'s own selection reading keeps to. A Range spanning
264
+ * element boundaries (the caret at the very start of the body, a fresh
265
+ * empty paragraph) has no "text before it" to search and correctly finds
266
+ * no trigger.
267
+ *
268
+ * Does NOT auto-close while focus sits inside the consumer's own picker
269
+ * (`mentionPickerRef`) — a picker with its own search field would
270
+ * otherwise close itself the instant that field took focus, since focus
271
+ * moving there also moves the document selection out of `body`. This is
272
+ * the same distinction `updateBar` already draws for the block menu's own
273
+ * search input, made while merging the two features together.
274
+ */
275
+ const updateMention = React.useCallback(() => {
276
+ if (readOnly)
277
+ return;
278
+ if (mentionPickerRef.current?.contains(document.activeElement))
279
+ return;
280
+ const body = bodyRef.current;
281
+ const sel = window.getSelection();
282
+ if (!body ||
283
+ !sel?.isCollapsed ||
284
+ sel.rangeCount === 0 ||
285
+ !body.contains(sel.anchorNode)) {
286
+ closeMention();
287
+ return;
288
+ }
289
+ const range = sel.getRangeAt(0);
290
+ const node = range.startContainer;
291
+ if (node.nodeType !== Node.TEXT_NODE) {
292
+ closeMention();
293
+ return;
294
+ }
295
+ const textBefore = (node.textContent ?? "").slice(0, range.startOffset);
296
+ const match = matchMentionTrigger(textBefore, mentionTrigger);
297
+ if (!match) {
298
+ closeMention();
299
+ return;
300
+ }
301
+ const triggerRange = document.createRange();
302
+ triggerRange.setStart(node, match.start);
303
+ triggerRange.setEnd(node, range.startOffset);
304
+ mentionRangeRef.current = triggerRange;
305
+ const rect = triggerRange.getBoundingClientRect();
306
+ setMention({
307
+ query: match.query,
308
+ anchor: { x: rect.left, y: rect.bottom },
309
+ });
310
+ }, [readOnly, mentionTrigger, closeMention]);
79
311
  React.useEffect(() => {
80
- const h = () => updateBar();
312
+ if (readOnly)
313
+ return;
314
+ const h = () => {
315
+ updateBar();
316
+ updateSlashTrigger();
317
+ updateMention();
318
+ };
81
319
  document.addEventListener("selectionchange", h);
82
320
  return () => document.removeEventListener("selectionchange", h);
83
- }, [updateBar]);
321
+ }, [updateBar, updateSlashTrigger, updateMention, readOnly]);
322
+ React.useEffect(() => {
323
+ if (blockMenu?.mode === "toolbar")
324
+ blockSearchRef.current?.focus();
325
+ }, [blockMenu?.mode]);
84
326
  const run = (cmd, value) => {
85
327
  bodyRef.current?.focus();
86
328
  exec(cmd, value);
@@ -88,14 +330,140 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
88
330
  syncEmpty(bodyRef.current);
89
331
  emit();
90
332
  };
91
- const toggleBlock = (tag) => {
92
- run("formatBlock", blockTag() === tag ? "p" : tag);
93
- };
94
333
  const addLink = () => {
95
334
  const url = window.prompt(linkPromptLabel);
96
335
  if (url)
97
336
  run("createLink", url);
98
337
  };
338
+ const addImage = () => {
339
+ const url = window.prompt(imagePromptLabel);
340
+ if (url)
341
+ run("insertImage", url);
342
+ };
343
+ /** Setting a block type FROM THE MENU is a choice, not a toggle — picking
344
+ * "Heading 1" always makes it a heading 1, unlike the old discrete
345
+ * H1 button, which flipped back to a paragraph if it already was one. */
346
+ const applyBlockItem = (id) => {
347
+ switch (id) {
348
+ case "h1":
349
+ case "h2":
350
+ case "h3":
351
+ run("formatBlock", id);
352
+ break;
353
+ case "body":
354
+ run("formatBlock", "p");
355
+ break;
356
+ case "ul":
357
+ run("insertUnorderedList");
358
+ break;
359
+ case "ol":
360
+ run("insertOrderedList");
361
+ break;
362
+ case "image":
363
+ addImage();
364
+ break;
365
+ }
366
+ };
367
+ const filteredBlockItems = blockMenu ? filterBlockItems(blockMenu.query) : [];
368
+ const pickBlockItem = (item) => {
369
+ if (blockMenu?.mode === "slash" && slashRangeRef.current) {
370
+ const range = slashRangeRef.current;
371
+ const sel = window.getSelection();
372
+ range.deleteContents();
373
+ sel?.removeAllRanges();
374
+ sel?.addRange(range);
375
+ }
376
+ applyBlockItem(item.id);
377
+ closeBlockMenu();
378
+ };
379
+ /** The WAI-ARIA popup-listbox model (↑/↓ wrapping, Home/End, Enter,
380
+ * Escape, Tab-to-dismiss) — shared with Select/MultiSelect rather than
381
+ * hand-rolled a third time (#198's own reasoning). Wired to both the
382
+ * toolbar search input and, for the "/" case where focus never leaves the
383
+ * body, the body's own keydown handler. */
384
+ const { onKeyDown: onBlockMenuKeyDown } = useListboxKeyboard({
385
+ enabledIndexes: filteredBlockItems.map((_, i) => i),
386
+ highlight: blockMenu?.highlighted ?? -1,
387
+ setHighlight: (index) => setBlockMenu((cur) => cur && { ...cur, highlighted: index }),
388
+ commit: () => {
389
+ const item = filteredBlockItems[blockMenu?.highlighted ?? -1];
390
+ if (item)
391
+ pickBlockItem(item);
392
+ },
393
+ close: closeBlockMenu,
394
+ });
395
+ const toggleBlockMenuFromToolbar = () => {
396
+ if (!bar)
397
+ return;
398
+ setBlockMenu((cur) => cur?.mode === "toolbar"
399
+ ? null
400
+ : {
401
+ anchor: { x: bar.x, y: bar.y },
402
+ query: "",
403
+ highlighted: 0,
404
+ mode: "toolbar",
405
+ });
406
+ };
407
+ // biome-ignore lint/correctness/useExhaustiveDependencies: syncEmpty only reads/writes stable refs, same as run()'s own call to it just below
408
+ const insertMention = React.useCallback((item) => {
409
+ const body = bodyRef.current;
410
+ const sel = window.getSelection();
411
+ if (!body || !sel)
412
+ return;
413
+ body.focus();
414
+ const range = mentionRangeRef.current ??
415
+ (sel.rangeCount > 0 ? sel.getRangeAt(0) : null);
416
+ if (!range)
417
+ return;
418
+ const chip = document.createElement("span");
419
+ chip.className = "crisp-rte-mention";
420
+ chip.contentEditable = "false";
421
+ chip.dataset.mentionId = item.id;
422
+ chip.textContent = `${mentionTrigger}${item.label}`;
423
+ range.deleteContents();
424
+ range.insertNode(chip);
425
+ // A non-breaking space right after the chip, so the caret has plain
426
+ // text to land in — inserting a caret directly against a
427
+ // contentEditable=false node's edge is where it can get stuck jumping
428
+ // back INTO the atomic chip instead of past it.
429
+ const space = document.createTextNode(" ");
430
+ chip.after(space);
431
+ const after = document.createRange();
432
+ after.setStartAfter(space);
433
+ after.collapse(true);
434
+ sel.removeAllRanges();
435
+ sel.addRange(after);
436
+ closeMention();
437
+ syncEmpty(bodyRef.current);
438
+ emit();
439
+ }, [mentionTrigger, closeMention, emit]);
440
+ React.useImperativeHandle(ref, () => ({
441
+ insertMention,
442
+ focus: () => bodyRef.current?.focus(),
443
+ }), [insertMention]);
444
+ const applyTextColor = (tone) => {
445
+ run("foreColor", tone.text);
446
+ setSwatchPanel(null);
447
+ };
448
+ const applyHighlight = (tone) => {
449
+ run("hiliteColor", tone.highlight);
450
+ setSwatchPanel(null);
451
+ };
452
+ // "Clear" reads the field's own current computed color rather than a
453
+ // fixed literal — execCommand bakes whatever value it's given into the
454
+ // HTML as-is, and a literal here would silently stop matching either
455
+ // theme the moment the reader's isn't the one this was written in.
456
+ const clearTextColor = () => {
457
+ const current = bodyRef.current
458
+ ? getComputedStyle(bodyRef.current).color
459
+ : undefined;
460
+ run("foreColor", current);
461
+ setSwatchPanel(null);
462
+ };
463
+ const clearHighlight = () => {
464
+ run("hiliteColor", "transparent");
465
+ setSwatchPanel(null);
466
+ };
99
467
  // mousedown on the toolbar must not clear the selection
100
468
  const keepSel = (e) => e.preventDefault();
101
469
  /**
@@ -105,13 +473,62 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
105
473
  * screen-reader user there is a state here that does not exist.
106
474
  */
107
475
  const tool = (key, label, icon, onClick, toggles = true) => (_jsx("button", { type: "button", className: "crisp-rte-tool", "aria-label": label, "aria-pressed": toggles ? !!active[key] : undefined, "data-active": active[key] ? "" : undefined, onMouseDown: keepSel, onClick: onClick, children: icon }));
476
+ /** Text/Highlight: a labelled tool that opens a swatch picker instead of
477
+ * toggling directly — `aria-expanded` says whether its panel is open, not
478
+ * whether a mark is applied (there's no single "on" state once 12 tones
479
+ * are all valid choices). */
480
+ const colorTool = (key, label, icon) => (_jsxs("button", { type: "button", className: "crisp-rte-tool crisp-rte-tool--labelled", "aria-label": label, "aria-expanded": swatchPanel === key, "aria-haspopup": "true", onMouseDown: keepSel, onClick: () => setSwatchPanel((cur) => (cur === key ? null : key)), children: [icon, _jsx("span", { children: label })] }));
108
481
  const ic = { size: 15, strokeWidth: 2 };
109
- return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-label": titleLabel, "data-placeholder": titlePlaceholder, onInput: () => {
110
- syncEmpty(titleRef.current);
111
- emit();
112
- } }), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-multiline": "true", "aria-label": bodyLabel, "data-placeholder": placeholder, onInput: () => {
113
- syncEmpty(bodyRef.current);
114
- emit();
115
- }, onMouseUp: updateBar, onKeyUp: updateBar }), bar && (_jsxs("div", { className: "crisp-rte-toolbar", style: { left: bar.x, top: bar.y }, children: [tool("bold", "Bold", _jsx(Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", _jsx(Italic, { ...ic }), () => run("italic")), tool("underline", "Underline", _jsx(Underline, { ...ic }), () => run("underline")), tool("strikeThrough", "Strikethrough", _jsx(Strikethrough, { ...ic }), () => run("strikeThrough")), tool("link", "Link", _jsx(Link2, { ...ic }), addLink, false), _jsx("span", { className: "crisp-rte-tool-sep" }), tool("h1", "Heading 1", _jsx(Heading1, { ...ic }), () => toggleBlock("h1")), tool("h2", "Heading 2", _jsx(Heading2, { ...ic }), () => toggleBlock("h2")), tool("ul", "Bullet list", _jsx(List, { ...ic }), () => run("insertUnorderedList")), tool("ol", "Numbered list", _jsx(ListOrdered, { ...ic }), () => run("insertOrderedList")), tool("blockquote", "Quote", _jsx(Quote, { ...ic }), () => toggleBlock("blockquote"))] }))] }));
116
- }
482
+ const menuIc = { size: 16, strokeWidth: 2, "aria-hidden": true };
483
+ const BLOCK_MENU_ICONS = {
484
+ h1: _jsx(Heading1, { ...menuIc }),
485
+ h2: _jsx(Heading2, { ...menuIc }),
486
+ h3: _jsx(Heading3, { ...menuIc }),
487
+ body: _jsx(Pilcrow, { ...menuIc }),
488
+ ul: _jsx(List, { ...menuIc }),
489
+ ol: _jsx(ListOrdered, { ...menuIc }),
490
+ image: _jsx(ImageIcon, { ...menuIc }),
491
+ };
492
+ return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
493
+ ? undefined
494
+ : () => {
495
+ syncEmpty(titleRef.current);
496
+ emit();
497
+ } }), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
498
+ ? undefined
499
+ : () => {
500
+ syncEmpty(bodyRef.current);
501
+ updateSlashTrigger();
502
+ updateMention();
503
+ emit();
504
+ }, onMouseUp: readOnly
505
+ ? undefined
506
+ : () => {
507
+ updateBar();
508
+ updateSlashTrigger();
509
+ updateMention();
510
+ }, onKeyUp: readOnly
511
+ ? undefined
512
+ : () => {
513
+ updateBar();
514
+ updateSlashTrigger();
515
+ updateMention();
516
+ }, onKeyDown: readOnly || blockMenu?.mode !== "slash"
517
+ ? undefined
518
+ : onBlockMenuKeyDown }), bar && (_jsxs("div", { className: "crisp-rte-toolbar", style: { left: bar.x, top: bar.y }, children: [_jsx("button", { type: "button", className: "crisp-rte-tool crisp-rte-tool--labelled", "aria-label": blockMenuLabel, "aria-expanded": blockMenu?.mode === "toolbar", "aria-haspopup": "true", onMouseDown: keepSel, onClick: toggleBlockMenuFromToolbar, children: _jsx("span", { children: blockMenuLabel }) }), _jsx("span", { className: "crisp-rte-tool-sep" }), colorTool("text", textColorLabel, _jsx(Baseline, { ...ic })), colorTool("highlight", highlightColorLabel, _jsx(Highlighter, { ...ic })), _jsx("span", { className: "crisp-rte-tool-sep" }), tool("link", "Link", _jsx(Link2, { ...ic }), addLink, false), _jsx("span", { className: "crisp-rte-tool-sep" }), tool("bold", "Bold", _jsx(Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", _jsx(Italic, { ...ic }), () => run("italic")), tool("strikeThrough", "Strikethrough", _jsx(Strikethrough, { ...ic }), () => run("strikeThrough"))] })), blockMenu && (blockMenu.mode === "slash" || bar) && (_jsxs("div", { className: "crisp-rte-blockmenu", style: { left: blockMenu.anchor.x, top: blockMenu.anchor.y }, children: [blockMenu.mode === "toolbar" && (_jsxs("div", { className: "crisp-rte-blockmenu-search", children: [_jsx(Search, { size: 14, strokeWidth: 2, "aria-hidden": "true" }), _jsx("input", { ref: blockSearchRef, className: "crisp-rte-blockmenu-input", placeholder: blockMenuSearchPlaceholder, "aria-label": blockMenuSearchPlaceholder, value: blockMenu.query, onChange: (e) => setBlockMenu((cur) => cur && { ...cur, query: e.target.value, highlighted: 0 }), onKeyDown: onBlockMenuKeyDown })] })), _jsx("div", { role: "listbox", className: "crisp-rte-blockmenu-list", children: filteredBlockItems.length === 0 ? (_jsx("div", { className: "crisp-rte-blockmenu-empty", children: blockMenuEmptyLabel })) : (filteredBlockItems.map((item, i) => (_jsxs("button", { type: "button", role: "option", "aria-selected": i === blockMenu.highlighted, className: "crisp-rte-blockmenu-item", "data-highlighted": i === blockMenu.highlighted ? "" : undefined, onMouseEnter: () => setBlockMenu((cur) => cur && { ...cur, highlighted: i }), onMouseDown: keepSel, onClick: () => pickBlockItem(item), children: [BLOCK_MENU_ICONS[item.id], _jsxs("span", { className: "crisp-rte-blockmenu-item-text", children: [_jsx("span", { className: "crisp-rte-blockmenu-item-label", children: item.label }), _jsx("span", { className: "crisp-rte-blockmenu-item-desc", children: item.description })] })] }, item.id)))) })] })), bar && swatchPanel && (_jsxs("div", { className: "crisp-rte-swatchpanel", style: { left: bar.x, top: bar.y }, children: [RICHTEXT_COLOR_TONES.map((tone) => (_jsx("button", { type: "button", className: "crisp-rte-swatch", "aria-label": tone.label, style: {
519
+ background: swatchPanel === "highlight" ? tone.highlight : tone.text,
520
+ }, onMouseDown: keepSel, onClick: () => swatchPanel === "highlight"
521
+ ? applyHighlight(tone)
522
+ : applyTextColor(tone) }, tone.id))), _jsx("button", { type: "button", className: "crisp-rte-swatch crisp-rte-swatch--none", "aria-label": clearColorLabel, onMouseDown: keepSel, onClick: swatchPanel === "highlight" ? clearHighlight : clearTextColor })] })), mention &&
523
+ renderMentionPicker &&
524
+ (() => {
525
+ const rendered = renderMentionPicker({
526
+ query: mention.query,
527
+ anchor: mention.anchor,
528
+ select: insertMention,
529
+ close: closeMention,
530
+ });
531
+ return rendered == null ? null : (_jsx("div", { ref: mentionPickerRef, style: { display: "contents" }, children: rendered }));
532
+ })()] }));
533
+ });
117
534
  RichTextEditor.displayName = "RichTextEditor";
@@ -1,9 +1,30 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
- import { contract as c } from "../../tokens/index.js";
3
- // A block rich-text editor — measured from a Notes editor: a large 32/36
4
- // title, a 14/20 prose body (headings, lists, quote, code, marks, links), and a
5
- // floating selection toolbar. Built on contentEditable so it stays dependency-
6
- // free. Placeholders show via [data-empty]::before.
2
+ import { contract as c, elevation as e } from "../../tokens/index.js";
3
+ // A block rich-text editor — measured from Attio's own notes editor: a large
4
+ // 32/36 title, a 14/20 prose body (h1/h2/h3, lists, an image block, marks),
5
+ // and a floating selection toolbar. Built on contentEditable so it stays
6
+ // dependency-free. Placeholders show via [data-empty]::before.
7
+ //
8
+ // The toolbar (#426) is Attio's OWN measured chrome, not the generic dark
9
+ // pill this file used to ship: white, `border-radius: 10px` (a genuinely
10
+ // measured value with no match in the radius scale — kept raw rather than
11
+ // rounded to the nearest token, same reasoning as `elevation.field`'s two
12
+ // raw layers), and `elevation.floating`'s exact shadow — which the live
13
+ // toolbar's own 4-layer box-shadow matched value-for-value when re-checked.
14
+ // Icon color reproduces the measured `rgba(0,0,0,0.63)` via the same
15
+ // color-mix-over-fgPrimary idiom the placeholder rule above already uses,
16
+ // rather than reaching for `fgMuted` (a different, unrelated gray).
17
+ //
18
+ // `.crisp-rte-blockmenu*` is the block-type menu ("Body ▾" and "/"), also
19
+ // measured live: width 356px (raw — close to but not the same as the
20
+ // popover family's 360px max-width, so kept as its own measured number,
21
+ // not rounded to match), `elevation.floating` again (the identical shadow
22
+ // the toolbar itself carries — both are the same floating tier), radius
23
+ // `xl` (12px, an exact token match this time), rows at the measured 52px
24
+ // with 6px 8px padding and a 16px icon. See richtext.tsx's own doc comment
25
+ // for how this was measured (through the "Insert" button, not "Body ▾" —
26
+ // "Body ▾" itself closed the whole note before a submenu could render, in
27
+ // every attempt).
7
28
  const config = defineRecipe({
8
29
  name: "richtext",
9
30
  base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${c.font.sans}; color: ${c.fgPrimary};
@@ -25,13 +46,42 @@ const config = defineRecipe({
25
46
  & .crisp-rte-body code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: ${c.text.md}; padding: 1px ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
26
47
  & .crisp-rte-body a { color: ${c.bgBrandSolid}; text-decoration: underline; text-underline-offset: 2px; }
27
48
  & .crisp-rte-body strong { font-weight: ${c.weight.semibold}; }
49
+ & .crisp-rte-body img { display: block; max-width: 100%; border-radius: ${c.radius.md}; }
50
+ /* Not measured against Attio (#428) — nothing in a read-only pass over
51
+ app.attio.com had a live @mention rendered to read computed styles
52
+ from, and typing one to create one is off-limits there. Reused
53
+ verbatim from the one measured "brand" tag/chip this repo already
54
+ has (RecordsTable/MultiSelect's [data-tone="brand"]) rather than
55
+ inventing a new color or shape. */
56
+ & .crisp-rte-mention { display: inline; border-radius: ${c.radius.chip}; padding: 0 ${c.space["1"]}; background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); font-weight: ${c.weight.medium}; cursor: default; }
28
57
 
29
- & .crisp-rte-toolbar { position: fixed; z-index: 70; display: inline-flex; align-items: center; gap: 2px; padding: ${c.space["0.5"]}; border-radius: ${c.radius.md}; background: #20242c; box-shadow: 0 8px 28px -6px rgba(0,0,0,0.36), 0 2px 6px -2px rgba(0,0,0,0.24); transform: translate(-50%, -100%); opacity: 1; transition: opacity 0.1s ease; }
58
+ & .crisp-rte-toolbar { position: fixed; z-index: 70; display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; background: ${c.bgSurface}; box-shadow: ${e.floating}; transform: translate(-50%, -100%); opacity: 1; transition: opacity 0.1s ease; }
30
59
  & .crisp-rte-toolbar { @starting-style { opacity: 0; } }
31
- & .crisp-rte-tool { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; padding: 0; margin: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: rgba(255,255,255,0.86); font-family: inherit; cursor: pointer; transition: background-color .1s; }
32
- & .crisp-rte-tool:hover { background: rgba(255,255,255,0.12); color: #fff; }
33
- & .crisp-rte-tool[data-active] { color: ${c.bgBrandSolid}; }
34
- & .crisp-rte-tool-sep { width: 1px; height: 16px; margin: 0 2px; background: rgba(255,255,255,0.16); }`,
60
+ & .crisp-rte-tool { display: inline-flex; align-items: center; justify-content: center; gap: ${c.space["1"]}; min-width: 30px; height: 28px; padding: 0 7px; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); font-family: inherit; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; cursor: pointer; transition: background-color .1s; }
61
+ & .crisp-rte-tool--labelled { width: auto; }
62
+ & .crisp-rte-tool:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
63
+ & .crisp-rte-tool[data-active], & .crisp-rte-tool[aria-expanded="true"] { background: color-mix(in srgb, ${c.bgBrandSolid} 12%, transparent); color: ${c.bgBrandSolid}; }
64
+ & .crisp-rte-tool-sep { width: 1px; height: 16px; margin: 0 2px; background: ${c.borderSubtle}; }
65
+
66
+ & .crisp-rte-swatchpanel { position: fixed; z-index: 71; display: grid; grid-template-columns: repeat(4, 1fr); gap: ${c.space["1"]}; width: 168px; padding: ${c.space["1.5"]}; border-radius: ${c.radius.lg}; background: ${c.bgSurface}; box-shadow: ${e.floating}; transform: translate(-50%, ${c.space["1.5"]}); opacity: 1; transition: opacity 0.1s ease; }
67
+ & .crisp-rte-swatchpanel { @starting-style { opacity: 0; } }
68
+ & .crisp-rte-swatch { width: 28px; height: 28px; padding: 0; margin: 0; border: none; border-radius: 50%; background: none; font-family: inherit; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 10%, transparent); cursor: pointer; }
69
+ & .crisp-rte-swatch--none { background: ${c.bgSurface}; position: relative; }
70
+ & .crisp-rte-swatch--none::after { content: ""; position: absolute; inset: 6px; border-top: 1.5px solid color-mix(in srgb, ${c.fgPrimary} 40%, transparent); transform: rotate(-45deg); }
71
+
72
+ & .crisp-rte-blockmenu { position: fixed; z-index: 71; box-sizing: border-box; width: 356px; padding: 0; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: ${e.floating}; opacity: 1; transition: opacity 0.1s ease; }
73
+ & .crisp-rte-blockmenu { @starting-style { opacity: 0; } }
74
+ & .crisp-rte-blockmenu-search { display: flex; align-items: center; gap: ${c.space["1.5"]}; padding: ${c.space["2.5"]} ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
75
+ & .crisp-rte-blockmenu-input { flex: 1; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; height: 20px; color: ${c.fgPrimary}; }
76
+ & .crisp-rte-blockmenu-input::placeholder { color: color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
77
+ & .crisp-rte-blockmenu-list { display: flex; flex-direction: column; padding: ${c.space["1.5"]}; gap: ${c.space.px}; }
78
+ & .crisp-rte-blockmenu-item { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: 52px; padding: ${c.space["1.5"]} ${c.space["2"]}; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; font-family: inherit; text-align: start; cursor: pointer; }
79
+ & .crisp-rte-blockmenu-item svg { flex-shrink: 0; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
80
+ & .crisp-rte-blockmenu-item[data-highlighted] { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
81
+ & .crisp-rte-blockmenu-item-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
82
+ & .crisp-rte-blockmenu-item-label { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
83
+ & .crisp-rte-blockmenu-item-desc { font-size: ${c.text.sm}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
84
+ & .crisp-rte-blockmenu-empty { padding: ${c.space["4"]} ${c.space["3"]}; text-align: center; font-size: ${c.text.md}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }`,
35
85
  variants: {},
36
86
  defaultVariants: {},
37
87
  compoundVariants: [],