@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3260.1.dc719cac84

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 (164) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/CrudForm.js +46 -129
  16. package/dist/backend/CrudForm.js.map +3 -3
  17. package/dist/backend/DataTable.js +10 -3
  18. package/dist/backend/DataTable.js.map +2 -2
  19. package/dist/backend/EmptyState.js +1 -47
  20. package/dist/backend/EmptyState.js.map +2 -2
  21. package/dist/backend/FilterOverlay.js +31 -26
  22. package/dist/backend/FilterOverlay.js.map +2 -2
  23. package/dist/backend/FlashMessages.js +23 -25
  24. package/dist/backend/FlashMessages.js.map +2 -2
  25. package/dist/backend/JsonBuilder.js +4 -2
  26. package/dist/backend/JsonBuilder.js.map +2 -2
  27. package/dist/backend/LogList.js +49 -0
  28. package/dist/backend/LogList.js.map +7 -0
  29. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  30. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  31. package/dist/backend/detail/InlineEditors.js +80 -32
  32. package/dist/backend/detail/InlineEditors.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +60 -6
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/DatePicker.js +6 -142
  36. package/dist/backend/inputs/DatePicker.js.map +2 -2
  37. package/dist/backend/inputs/DateTimePicker.js +6 -182
  38. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  39. package/dist/backend/inputs/TagsInput.js +16 -19
  40. package/dist/backend/inputs/TagsInput.js.map +2 -2
  41. package/dist/backend/inputs/TimeInput.js +1 -1
  42. package/dist/backend/inputs/TimeInput.js.map +2 -2
  43. package/dist/backend/inputs/TimePicker.js +83 -72
  44. package/dist/backend/inputs/TimePicker.js.map +2 -2
  45. package/dist/backend/utils/customFieldForms.js +2 -2
  46. package/dist/backend/utils/customFieldForms.js.map +1 -1
  47. package/dist/portal/components/PortalEmptyState.js +13 -9
  48. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  49. package/dist/primitives/accordion.js +137 -0
  50. package/dist/primitives/accordion.js.map +7 -0
  51. package/dist/primitives/alert.js +180 -12
  52. package/dist/primitives/alert.js.map +2 -2
  53. package/dist/primitives/calendar.js +122 -32
  54. package/dist/primitives/calendar.js.map +2 -2
  55. package/dist/primitives/compact-select.js +47 -0
  56. package/dist/primitives/compact-select.js.map +7 -0
  57. package/dist/primitives/counter-input.js +174 -0
  58. package/dist/primitives/counter-input.js.map +7 -0
  59. package/dist/primitives/date-picker-helpers.js +79 -0
  60. package/dist/primitives/date-picker-helpers.js.map +7 -0
  61. package/dist/primitives/date-picker.js +216 -0
  62. package/dist/primitives/date-picker.js.map +7 -0
  63. package/dist/primitives/date-range-picker.js +239 -0
  64. package/dist/primitives/date-range-picker.js.map +7 -0
  65. package/dist/primitives/digit-input.js +157 -0
  66. package/dist/primitives/digit-input.js.map +7 -0
  67. package/dist/primitives/empty-state.js +93 -0
  68. package/dist/primitives/empty-state.js.map +7 -0
  69. package/dist/primitives/inline-input.js +29 -0
  70. package/dist/primitives/inline-input.js.map +7 -0
  71. package/dist/primitives/inline-select.js +41 -0
  72. package/dist/primitives/inline-select.js.map +7 -0
  73. package/dist/primitives/notification-stack.js +116 -0
  74. package/dist/primitives/notification-stack.js.map +7 -0
  75. package/dist/primitives/notification.js +62 -0
  76. package/dist/primitives/notification.js.map +7 -0
  77. package/dist/primitives/rich-editor.js +1230 -0
  78. package/dist/primitives/rich-editor.js.map +7 -0
  79. package/dist/primitives/select.js +1 -0
  80. package/dist/primitives/select.js.map +2 -2
  81. package/dist/primitives/skeleton.js +55 -0
  82. package/dist/primitives/skeleton.js.map +7 -0
  83. package/dist/primitives/tag-input.js +186 -0
  84. package/dist/primitives/tag-input.js.map +7 -0
  85. package/dist/primitives/tag.js +66 -13
  86. package/dist/primitives/tag.js.map +2 -2
  87. package/dist/primitives/time-picker.js +793 -0
  88. package/dist/primitives/time-picker.js.map +7 -0
  89. package/package.json +4 -3
  90. package/src/ai/AiAssistantLauncher.tsx +8 -0
  91. package/src/ai/AiChat.tsx +229 -11
  92. package/src/ai/AiDock.tsx +3 -2
  93. package/src/ai/ModelPicker.tsx +248 -0
  94. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  99. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  100. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  101. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  102. package/src/ai/index.ts +1 -0
  103. package/src/ai/useAiChat.ts +29 -16
  104. package/src/backend/CrudForm.tsx +61 -113
  105. package/src/backend/DataTable.tsx +17 -5
  106. package/src/backend/EmptyState.tsx +13 -63
  107. package/src/backend/FilterOverlay.tsx +38 -22
  108. package/src/backend/FlashMessages.tsx +23 -24
  109. package/src/backend/JsonBuilder.tsx +4 -2
  110. package/src/backend/LogList.tsx +109 -0
  111. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  112. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  113. package/src/backend/__tests__/LogList.test.tsx +163 -0
  114. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  115. package/src/backend/detail/InlineEditors.tsx +78 -25
  116. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  117. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  118. package/src/backend/inputs/DatePicker.tsx +28 -155
  119. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  120. package/src/backend/inputs/TagsInput.tsx +10 -13
  121. package/src/backend/inputs/TimeInput.tsx +2 -1
  122. package/src/backend/inputs/TimePicker.tsx +99 -70
  123. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  124. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  125. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  126. package/src/backend/utils/customFieldForms.ts +2 -2
  127. package/src/portal/components/PortalEmptyState.tsx +27 -22
  128. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  129. package/src/primitives/__tests__/alert.test.tsx +204 -0
  130. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  131. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  132. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  133. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  134. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  135. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  136. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  137. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  138. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  139. package/src/primitives/__tests__/notification.test.tsx +119 -0
  140. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  141. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  142. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  143. package/src/primitives/__tests__/tag.test.tsx +123 -0
  144. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  145. package/src/primitives/accordion.tsx +225 -0
  146. package/src/primitives/alert.tsx +234 -24
  147. package/src/primitives/calendar.tsx +146 -38
  148. package/src/primitives/compact-select.tsx +59 -0
  149. package/src/primitives/counter-input.tsx +211 -0
  150. package/src/primitives/date-picker-helpers.ts +96 -0
  151. package/src/primitives/date-picker.tsx +290 -0
  152. package/src/primitives/date-range-picker.tsx +281 -0
  153. package/src/primitives/digit-input.tsx +208 -0
  154. package/src/primitives/empty-state.tsx +140 -0
  155. package/src/primitives/inline-input.tsx +55 -0
  156. package/src/primitives/inline-select.tsx +74 -0
  157. package/src/primitives/notification-stack.tsx +185 -0
  158. package/src/primitives/notification.tsx +136 -0
  159. package/src/primitives/rich-editor.tsx +1578 -0
  160. package/src/primitives/select.tsx +1 -0
  161. package/src/primitives/skeleton.tsx +70 -0
  162. package/src/primitives/tag-input.tsx +223 -0
  163. package/src/primitives/tag.tsx +69 -10
  164. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,1578 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ AlignCenter,
6
+ AlignJustify,
7
+ AlignLeft,
8
+ AlignRight,
9
+ AtSign,
10
+ Bold,
11
+ ChevronDown,
12
+ Code,
13
+ FileCode,
14
+ HelpCircle,
15
+ Image as ImageIcon,
16
+ Italic,
17
+ Link,
18
+ List,
19
+ ListChecks,
20
+ ListOrdered,
21
+ Maximize2,
22
+ MessageCircle,
23
+ Minus,
24
+ MoreVertical,
25
+ Quote,
26
+ Strikethrough,
27
+ Table as TableIcon,
28
+ Underline,
29
+ } from 'lucide-react'
30
+ import { cva, type VariantProps } from 'class-variance-authority'
31
+
32
+ import { cn } from '@open-mercato/shared/lib/utils'
33
+ import {
34
+ sanitizeHtmlRichText,
35
+ sanitizeRichTextHref,
36
+ sanitizeRichTextPasteContent,
37
+ } from '@open-mercato/ui/backend/utils/richTextSanitizer'
38
+ import { Popover, PopoverContent, PopoverTrigger } from './popover'
39
+ import {
40
+ Tooltip,
41
+ TooltipContent,
42
+ TooltipProvider,
43
+ TooltipTrigger,
44
+ } from './tooltip'
45
+ import {
46
+ Dialog,
47
+ DialogContent,
48
+ DialogFooter,
49
+ DialogHeader,
50
+ DialogTitle,
51
+ } from './dialog'
52
+ import { Input } from './input'
53
+ import { Button } from './button'
54
+
55
+ // Figma `Rich Editor Colors [1.1]` palette (166331:4100) — 12 tokens.
56
+ // Order matches the Figma frame: gray / black / white / blue / orange / red /
57
+ // green / yellow / purple / sky / pink / teal.
58
+ export const RICH_EDITOR_COLOR_PALETTE = {
59
+ gray: '#7b7b7b',
60
+ black: '#171717',
61
+ white: '#ffffff',
62
+ blue: '#6366f1',
63
+ orange: '#f59e0b',
64
+ red: '#ef4444',
65
+ green: '#22c55e',
66
+ yellow: '#f6b51e',
67
+ purple: '#7d52f4',
68
+ sky: '#47c2ff',
69
+ pink: '#fb4ba3',
70
+ teal: '#22d3bb',
71
+ } as const
72
+
73
+ export type RichEditorColorKey = keyof typeof RICH_EDITOR_COLOR_PALETTE
74
+ const COLOR_KEYS: RichEditorColorKey[] = [
75
+ 'gray', 'black', 'white', 'blue', 'orange', 'red', 'green', 'yellow', 'purple', 'sky', 'pink', 'teal',
76
+ ]
77
+
78
+ const COLOR_LABELS_EN: Record<RichEditorColorKey, string> = {
79
+ gray: 'Gray',
80
+ black: 'Black',
81
+ white: 'White',
82
+ blue: 'Blue',
83
+ orange: 'Orange',
84
+ red: 'Red',
85
+ green: 'Green',
86
+ yellow: 'Yellow',
87
+ purple: 'Purple',
88
+ sky: 'Sky',
89
+ pink: 'Pink',
90
+ teal: 'Teal',
91
+ }
92
+
93
+ export type RichEditorVariant = 'full' | 'standard' | 'basic' | 'minimal' | 'custom'
94
+
95
+ export type RichEditorAlign = 'left' | 'center' | 'right' | 'justify'
96
+ export type RichEditorFontSize = '12px' | '14px' | '16px' | '18px' | '20px' | '24px'
97
+
98
+ const ALIGN_ICONS: Record<RichEditorAlign, React.ComponentType<{ className?: string; 'aria-hidden'?: 'true' }>> = {
99
+ left: AlignLeft,
100
+ center: AlignCenter,
101
+ right: AlignRight,
102
+ justify: AlignJustify,
103
+ }
104
+
105
+ const FONT_SIZE_OPTIONS: RichEditorFontSize[] = ['12px', '14px', '16px', '18px', '20px', '24px']
106
+
107
+ type SelectionState = {
108
+ bold: boolean
109
+ italic: boolean
110
+ underline: boolean
111
+ strikethrough: boolean
112
+ unorderedList: boolean
113
+ orderedList: boolean
114
+ blockquote: boolean
115
+ code: boolean
116
+ heading: '' | 'h1' | 'h2' | 'h3'
117
+ align: RichEditorAlign
118
+ }
119
+
120
+ const EMPTY_SELECTION_STATE: SelectionState = {
121
+ bold: false,
122
+ italic: false,
123
+ underline: false,
124
+ strikethrough: false,
125
+ unorderedList: false,
126
+ orderedList: false,
127
+ blockquote: false,
128
+ code: false,
129
+ heading: '',
130
+ align: 'left',
131
+ }
132
+
133
+ type RichEditorContextValue = {
134
+ exec: (command: string, arg?: string) => void
135
+ selection: SelectionState
136
+ disabled: boolean
137
+ /**
138
+ * Open a DS-styled URL prompt (replaces the native `window.prompt`
139
+ * popup). Pre-saves the current selection so the caller's `onConfirm`
140
+ * handler can re-target the same range when running `execCommand`.
141
+ */
142
+ requestUrlPrompt: (request: {
143
+ kind: 'link' | 'image'
144
+ title: string
145
+ placeholder?: string
146
+ onConfirm: (url: string) => void
147
+ }) => void
148
+ }
149
+
150
+ const RichEditorContext = React.createContext<RichEditorContextValue | null>(null)
151
+
152
+ function useRichEditorContext(component: string): RichEditorContextValue {
153
+ const ctx = React.useContext(RichEditorContext)
154
+ if (!ctx) {
155
+ throw new Error(`${component} must be rendered inside <RichEditor>`)
156
+ }
157
+ return ctx
158
+ }
159
+
160
+ export type RichEditorLabels = {
161
+ bold: string
162
+ italic: string
163
+ underline: string
164
+ strikethrough: string
165
+ unorderedList: string
166
+ orderedList: string
167
+ checklist: string
168
+ blockquote: string
169
+ code: string
170
+ inlineCode: string
171
+ codeBlock: string
172
+ horizontalRule: string
173
+ image: string
174
+ imageUrlPrompt: string
175
+ table: string
176
+ heading: string
177
+ heading1: string
178
+ heading2: string
179
+ heading3: string
180
+ paragraph: string
181
+ link: string
182
+ linkUrlPrompt: string
183
+ color: string
184
+ textColor: string
185
+ fontSize: string
186
+ align: string
187
+ alignLeft: string
188
+ alignCenter: string
189
+ alignRight: string
190
+ alignJustify: string
191
+ comment: string
192
+ mention: string
193
+ more: string
194
+ help: string
195
+ fullscreen: string
196
+ placeholder: string
197
+ colors: Record<RichEditorColorKey, string>
198
+ }
199
+
200
+ const DEFAULT_LABELS: RichEditorLabels = {
201
+ bold: 'Bold',
202
+ italic: 'Italic',
203
+ underline: 'Underline',
204
+ strikethrough: 'Strikethrough',
205
+ unorderedList: 'Bullet list',
206
+ orderedList: 'Numbered list',
207
+ checklist: 'Checklist',
208
+ blockquote: 'Quote',
209
+ code: 'Code',
210
+ inlineCode: 'Inline code',
211
+ codeBlock: 'Code block',
212
+ horizontalRule: 'Horizontal rule',
213
+ image: 'Image',
214
+ imageUrlPrompt: 'Enter image URL',
215
+ table: 'Table',
216
+ heading: 'Header',
217
+ heading1: 'Heading 1',
218
+ heading2: 'Heading 2',
219
+ heading3: 'Heading 3',
220
+ paragraph: 'Paragraph',
221
+ link: 'Link',
222
+ linkUrlPrompt: 'Enter URL',
223
+ color: 'Text color',
224
+ textColor: 'Color',
225
+ fontSize: 'Font size',
226
+ align: 'Align',
227
+ alignLeft: 'Align left',
228
+ alignCenter: 'Align center',
229
+ alignRight: 'Align right',
230
+ alignJustify: 'Justify',
231
+ comment: 'Add comment',
232
+ mention: 'Mention',
233
+ more: 'More',
234
+ help: 'Help',
235
+ fullscreen: 'Toggle fullscreen',
236
+ placeholder: 'Placeholder text...',
237
+ colors: COLOR_LABELS_EN,
238
+ }
239
+
240
+ export type RichEditorProps = {
241
+ /** HTML string value (sanitized by `sanitizeHtmlRichText`). */
242
+ value?: string
243
+ /** Called with sanitized HTML on blur. */
244
+ onChange: (html: string) => void
245
+ /**
246
+ * Toolbar preset:
247
+ * - `'full'` — Figma 166331:4006 reference: heading + font size + color + B/I/U/S + lists + align + comment + link + mention + more
248
+ * - `'standard'` — heading + bold/italic/underline + lists + link (CrudForm `editor='html'` default)
249
+ * - `'basic'` — bold/italic/underline + bullet list + link
250
+ * - `'minimal'` — bold/italic/underline
251
+ * - `'custom'` — render `<RichEditorToolbar>{...}</RichEditorToolbar>` + `<RichEditorContent />` children manually
252
+ */
253
+ variant?: RichEditorVariant
254
+ placeholder?: string
255
+ minRows?: number
256
+ disabled?: boolean
257
+ className?: string
258
+ contentClassName?: string
259
+ labels?: Partial<RichEditorLabels>
260
+ /** Optional handler for the comment button (only rendered in `full` variant). */
261
+ onComment?: () => void
262
+ /** Optional handler for the mention `@` button. */
263
+ onMention?: () => void
264
+ /** Optional menu rendered inside the More `⋮` dropdown popover. */
265
+ moreMenu?: React.ReactNode
266
+ /**
267
+ * Optional handler for the fullscreen toggle. When provided, the
268
+ * `full` variant renders the fullscreen icon button as the trailing
269
+ * action — the caller is responsible for switching the editor into a
270
+ * full-window layout (modal / portal / dialog).
271
+ */
272
+ onFullscreen?: () => void
273
+ /**
274
+ * Optional handler for the image insert button. When provided,
275
+ * overrides the default `window.prompt(labels.imageUrlPrompt)` URL
276
+ * picker so consumers can wire a file upload pipeline instead.
277
+ */
278
+ onImageInsert?: () => void
279
+ /**
280
+ * Optional max length for the rich text content. When provided, renders a
281
+ * `<currentLength>/<maxLength>` counter in the bottom-right corner of the
282
+ * content card (Figma 166331:6589 reference). Counts the plaintext length
283
+ * of the sanitized HTML so users see a meaningful character budget.
284
+ */
285
+ maxLength?: number
286
+ children?: React.ReactNode
287
+ id?: string
288
+ name?: string
289
+ 'aria-invalid'?: boolean | 'true' | 'false'
290
+ }
291
+
292
+ function deriveSelectionState(): SelectionState {
293
+ if (typeof document === 'undefined' || !document.queryCommandState) {
294
+ return EMPTY_SELECTION_STATE
295
+ }
296
+ try {
297
+ const block = document.queryCommandValue?.('formatBlock') ?? ''
298
+ const lower = String(block).toLowerCase().replace(/[<>]/g, '')
299
+ const heading: SelectionState['heading'] =
300
+ lower === 'h1' || lower === 'h2' || lower === 'h3' ? lower : ''
301
+ const align: RichEditorAlign = document.queryCommandState('justifyCenter')
302
+ ? 'center'
303
+ : document.queryCommandState('justifyRight')
304
+ ? 'right'
305
+ : document.queryCommandState('justifyFull')
306
+ ? 'justify'
307
+ : 'left'
308
+ return {
309
+ bold: document.queryCommandState('bold'),
310
+ italic: document.queryCommandState('italic'),
311
+ underline: document.queryCommandState('underline'),
312
+ strikethrough: document.queryCommandState('strikeThrough'),
313
+ unorderedList: document.queryCommandState('insertUnorderedList'),
314
+ orderedList: document.queryCommandState('insertOrderedList'),
315
+ blockquote: lower === 'blockquote',
316
+ code: lower === 'pre',
317
+ heading,
318
+ align,
319
+ }
320
+ } catch {
321
+ return EMPTY_SELECTION_STATE
322
+ }
323
+ }
324
+
325
+ export const RichEditor = React.memo(function RichEditor({
326
+ value = '',
327
+ onChange,
328
+ variant = 'standard',
329
+ placeholder,
330
+ minRows = 4,
331
+ disabled = false,
332
+ className,
333
+ contentClassName,
334
+ labels: labelsProp,
335
+ onComment,
336
+ onMention,
337
+ moreMenu,
338
+ onFullscreen,
339
+ onImageInsert,
340
+ maxLength,
341
+ children,
342
+ id,
343
+ name,
344
+ 'aria-invalid': ariaInvalid,
345
+ }: RichEditorProps) {
346
+ const labels = React.useMemo<RichEditorLabels>(
347
+ () => ({
348
+ ...DEFAULT_LABELS,
349
+ ...(labelsProp ?? {}),
350
+ colors: { ...DEFAULT_LABELS.colors, ...(labelsProp?.colors ?? {}) },
351
+ }),
352
+ [labelsProp],
353
+ )
354
+
355
+ const editorRef = React.useRef<HTMLDivElement | null>(null)
356
+ const applyingExternal = React.useRef(false)
357
+ const typingRef = React.useRef(false)
358
+ const savedSelectionRef = React.useRef<Range | null>(null)
359
+ const [selection, setSelection] = React.useState<SelectionState>(EMPTY_SELECTION_STATE)
360
+ const [promptDialog, setPromptDialog] = React.useState<{
361
+ kind: 'link' | 'image'
362
+ title: string
363
+ placeholder?: string
364
+ value: string
365
+ onConfirm: (url: string) => void
366
+ } | null>(null)
367
+
368
+ React.useEffect(() => {
369
+ const el = editorRef.current
370
+ if (!el) return
371
+ const sanitizedValue = sanitizeHtmlRichText(value)
372
+ if (!typingRef.current && el.innerHTML !== sanitizedValue) {
373
+ applyingExternal.current = true
374
+ el.innerHTML = sanitizedValue
375
+ requestAnimationFrame(() => {
376
+ applyingExternal.current = false
377
+ })
378
+ }
379
+ }, [value])
380
+
381
+ const refreshSelectionState = React.useCallback(() => {
382
+ setSelection(deriveSelectionState())
383
+ }, [])
384
+
385
+ const exec = React.useCallback(
386
+ (command: string, arg?: string) => {
387
+ const el = editorRef.current
388
+ if (!el || disabled) return
389
+ el.focus()
390
+ try {
391
+ // Tell the browser to emit modern <span style="…"> output (forecolor,
392
+ // backColor, fontSize, etc.) instead of the deprecated <font> tag —
393
+ // the sanitizer drops <font> outright, so without this colour /
394
+ // font-size changes silently disappear on the next blur.
395
+ document.execCommand('styleWithCSS', false, 'true' as never)
396
+ } catch {
397
+ // ignore — older browsers without styleWithCSS support
398
+ }
399
+ try {
400
+ document.execCommand(command, false, arg)
401
+ } catch {
402
+ // ignore unsupported commands
403
+ }
404
+ refreshSelectionState()
405
+ },
406
+ [disabled, refreshSelectionState],
407
+ )
408
+
409
+ const requestUrlPrompt = React.useCallback<RichEditorContextValue['requestUrlPrompt']>(
410
+ (request) => {
411
+ // Snapshot the current selection so we can restore the same range
412
+ // after the Dialog steals focus.
413
+ if (typeof window !== 'undefined') {
414
+ const sel = window.getSelection()
415
+ if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
416
+ savedSelectionRef.current = sel.getRangeAt(0).cloneRange()
417
+ } else {
418
+ savedSelectionRef.current = null
419
+ }
420
+ }
421
+ setPromptDialog({ ...request, value: '' })
422
+ },
423
+ [],
424
+ )
425
+
426
+ const ctx = React.useMemo<RichEditorContextValue>(
427
+ () => ({ exec, selection, disabled, requestUrlPrompt }),
428
+ [exec, selection, disabled, requestUrlPrompt],
429
+ )
430
+
431
+ React.useEffect(() => {
432
+ if (typeof document === 'undefined') return
433
+ const onSelectionChange = () => {
434
+ if (!editorRef.current) return
435
+ const sel = window.getSelection()
436
+ if (!sel || !sel.anchorNode) return
437
+ if (!editorRef.current.contains(sel.anchorNode)) return
438
+ refreshSelectionState()
439
+ }
440
+ document.addEventListener('selectionchange', onSelectionChange)
441
+ return () => {
442
+ document.removeEventListener('selectionchange', onSelectionChange)
443
+ }
444
+ }, [refreshSelectionState])
445
+
446
+ const onKeyDown = React.useCallback(
447
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
448
+ const isMod = e.metaKey || e.ctrlKey
449
+ if (!isMod) return
450
+ const k = e.key.toLowerCase()
451
+ if (k === 'b') {
452
+ e.preventDefault()
453
+ exec('bold')
454
+ } else if (k === 'i') {
455
+ e.preventDefault()
456
+ exec('italic')
457
+ } else if (k === 'u') {
458
+ e.preventDefault()
459
+ exec('underline')
460
+ }
461
+ },
462
+ [exec],
463
+ )
464
+
465
+ const onPaste = React.useCallback(
466
+ (e: React.ClipboardEvent<HTMLDivElement>) => {
467
+ const html = e.clipboardData.getData('text/html')
468
+ const text = e.clipboardData.getData('text/plain')
469
+ const sanitizedPaste = sanitizeRichTextPasteContent(html, text)
470
+ if (!sanitizedPaste) return
471
+ e.preventDefault()
472
+ exec(sanitizedPaste.command, sanitizedPaste.value)
473
+ },
474
+ [exec],
475
+ )
476
+
477
+ const onBlur = React.useCallback(() => {
478
+ const el = editorRef.current
479
+ if (!el) return
480
+ typingRef.current = false
481
+ const sanitized = sanitizeHtmlRichText(el.innerHTML)
482
+ if (el.innerHTML !== sanitized) {
483
+ applyingExternal.current = true
484
+ el.innerHTML = sanitized
485
+ requestAnimationFrame(() => {
486
+ applyingExternal.current = false
487
+ })
488
+ }
489
+ onChange(sanitized)
490
+ }, [onChange])
491
+
492
+ const presetItems = usePresetToolbarItems({
493
+ variant,
494
+ labels,
495
+ onComment,
496
+ onMention,
497
+ onFullscreen,
498
+ onImageInsert,
499
+ exec,
500
+ selection,
501
+ requestUrlPrompt,
502
+ })
503
+
504
+ const plaintextLength = React.useMemo(() => {
505
+ if (!maxLength) return 0
506
+ if (typeof document === 'undefined') return 0
507
+ const tmp = document.createElement('div')
508
+ tmp.innerHTML = sanitizeHtmlRichText(value)
509
+ return (tmp.textContent ?? '').length
510
+ }, [value, maxLength])
511
+
512
+ return (
513
+ <RichEditorContext.Provider value={ctx}>
514
+ <TooltipProvider delayDuration={400}>
515
+ <div
516
+ className={cn('w-full space-y-2', disabled && 'opacity-60', className)}
517
+ data-slot="rich-editor"
518
+ data-disabled={disabled ? 'true' : 'false'}
519
+ aria-invalid={ariaInvalid}
520
+ >
521
+ {variant === 'custom'
522
+ ? children
523
+ : (
524
+ <>
525
+ <RichEditorAutoToolbar items={presetItems} labels={labels} moreMenu={moreMenu} />
526
+ <div className="relative">
527
+ <RichEditorContent
528
+ ref={editorRef}
529
+ placeholder={placeholder ?? labels.placeholder}
530
+ minRows={minRows}
531
+ disabled={disabled}
532
+ className={contentClassName}
533
+ onKeyDown={onKeyDown}
534
+ onPaste={onPaste}
535
+ onBlur={onBlur}
536
+ onInput={() => {
537
+ if (!applyingExternal.current) typingRef.current = true
538
+ }}
539
+ id={id}
540
+ name={name}
541
+ />
542
+ {maxLength ? (
543
+ <span
544
+ className={cn(
545
+ 'pointer-events-none absolute bottom-2 right-3 select-none text-xs leading-4 text-muted-foreground',
546
+ plaintextLength > maxLength && 'text-destructive',
547
+ )}
548
+ data-slot="rich-editor-counter"
549
+ aria-live="polite"
550
+ >
551
+ {plaintextLength}/{maxLength}
552
+ </span>
553
+ ) : null}
554
+ </div>
555
+ </>
556
+ )}
557
+ </div>
558
+ {promptDialog !== null ? (
559
+ <Dialog
560
+ open
561
+ onOpenChange={(open) => {
562
+ if (!open) setPromptDialog(null)
563
+ }}
564
+ >
565
+ <DialogContent
566
+ className="sm:max-w-md"
567
+ onKeyDown={(e) => {
568
+ if (e.key === 'Enter' && promptDialog) {
569
+ e.preventDefault()
570
+ const url = sanitizeRichTextHref(promptDialog.value)
571
+ const onConfirm = promptDialog.onConfirm
572
+ setPromptDialog(null)
573
+ if (!url) return
574
+ const el = editorRef.current
575
+ const range = savedSelectionRef.current
576
+ if (el) {
577
+ el.focus()
578
+ if (range) {
579
+ const sel = window.getSelection()
580
+ sel?.removeAllRanges()
581
+ sel?.addRange(range)
582
+ }
583
+ }
584
+ onConfirm(url)
585
+ }
586
+ }}
587
+ >
588
+ <DialogHeader>
589
+ <DialogTitle>{promptDialog?.title ?? ''}</DialogTitle>
590
+ </DialogHeader>
591
+ <Input
592
+ autoFocus
593
+ type="url"
594
+ value={promptDialog?.value ?? ''}
595
+ placeholder={promptDialog?.placeholder ?? 'https://…'}
596
+ onChange={(e) => {
597
+ const next = e.target.value
598
+ setPromptDialog((prev) => (prev ? { ...prev, value: next } : prev))
599
+ }}
600
+ />
601
+ <DialogFooter>
602
+ <Button
603
+ type="button"
604
+ variant="outline"
605
+ onClick={() => setPromptDialog(null)}
606
+ >
607
+ Cancel
608
+ </Button>
609
+ <Button
610
+ type="button"
611
+ onClick={() => {
612
+ if (!promptDialog) return
613
+ const url = sanitizeRichTextHref(promptDialog.value)
614
+ const onConfirm = promptDialog.onConfirm
615
+ setPromptDialog(null)
616
+ if (!url) return
617
+ const el = editorRef.current
618
+ const range = savedSelectionRef.current
619
+ if (el) {
620
+ el.focus()
621
+ if (range) {
622
+ const sel = window.getSelection()
623
+ sel?.removeAllRanges()
624
+ sel?.addRange(range)
625
+ }
626
+ }
627
+ onConfirm(url)
628
+ }}
629
+ >
630
+ OK
631
+ </Button>
632
+ </DialogFooter>
633
+ </DialogContent>
634
+ </Dialog>
635
+ ) : null}
636
+ </TooltipProvider>
637
+ </RichEditorContext.Provider>
638
+ )
639
+ }, (prev, next) =>
640
+ prev.value === next.value &&
641
+ prev.disabled === next.disabled &&
642
+ prev.variant === next.variant &&
643
+ prev.maxLength === next.maxLength,
644
+ )
645
+ RichEditor.displayName = 'RichEditor'
646
+
647
+ export type RichEditorToolbarProps = React.HTMLAttributes<HTMLDivElement>
648
+
649
+ export const RichEditorToolbar = React.forwardRef<HTMLDivElement, RichEditorToolbarProps>(
650
+ ({ className, children, ...props }, ref) => (
651
+ <div
652
+ ref={ref}
653
+ role="toolbar"
654
+ aria-label="Rich text formatting"
655
+ className={cn(
656
+ // Figma 166331:4006 reference: standalone card with rounded-8 + border + shadow-xs.
657
+ // 2px outer padding, 2px gap between items.
658
+ 'flex w-fit max-w-full flex-wrap items-center gap-0.5 overflow-x-auto rounded-lg border border-border bg-card p-0.5 shadow-xs',
659
+ className,
660
+ )}
661
+ data-slot="rich-editor-toolbar"
662
+ {...props}
663
+ >
664
+ {children}
665
+ </div>
666
+ ),
667
+ )
668
+ RichEditorToolbar.displayName = 'RichEditorToolbar'
669
+
670
+ const richEditorItemVariants = cva(
671
+ // Figma Rich Editor Items (166251 family): 28×h, rounded-6, bg-white default,
672
+ // bg-weak-50 (= bg-muted token) on hover, on toggle-active (data-active=true,
673
+ // e.g. Bold inside a bold selection), and on dropdown-open
674
+ // (data-state=open from Radix PopoverTrigger). Text / icon colours move from
675
+ // sub-600 (= text-muted-foreground, #5c5c5c) in the default state to
676
+ // strong-950 (= text-foreground, #171717) in the active / open states per
677
+ // Figma 166261:22214 / 166261:22217 reference cells.
678
+ 'group/rich-editor-item inline-flex h-7 shrink-0 items-center justify-center rounded-md bg-transparent text-sm font-medium leading-5 text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40 data-[active=true]:bg-muted data-[active=true]:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground',
679
+ {
680
+ variants: {
681
+ type: {
682
+ // Figma 166251:2698 — pl-[10px] pr-[4px] py-[4px], gap-[2px].
683
+ text: 'min-w-0 gap-0.5 pl-2.5 pr-1 py-1 tracking-[-0.084px] [&_svg]:size-4',
684
+ // Figma 166251:2700 — square icon button p-[4px].
685
+ icon: 'aspect-square p-1 [&_svg]:size-4',
686
+ // Figma 166251:3337 — icon + caret p-[4px] gap-[2px].
687
+ dropdown: 'gap-0.5 p-1 [&_svg]:size-4',
688
+ // Figma 166331:4437 — color swatch + caret pl-[8px] pr-[4px] py-[4px].
689
+ color: 'gap-0.5 pl-2 pr-1 py-1 [&_svg]:size-4',
690
+ },
691
+ },
692
+ defaultVariants: { type: 'icon' },
693
+ },
694
+ )
695
+
696
+ type ToolbarButtonBaseProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'children'> &
697
+ VariantProps<typeof richEditorItemVariants> & {
698
+ active?: boolean
699
+ tooltipLabel?: string
700
+ }
701
+
702
+ const RichEditorButton = React.forwardRef<HTMLButtonElement, ToolbarButtonBaseProps & { children?: React.ReactNode }>(
703
+ ({ className, type, active, tooltipLabel, children, onMouseDown, ...props }, ref) => {
704
+ const button = (
705
+ <button
706
+ ref={ref}
707
+ type="button"
708
+ onMouseDown={(e) => {
709
+ e.preventDefault()
710
+ onMouseDown?.(e)
711
+ }}
712
+ data-active={active ? 'true' : 'false'}
713
+ aria-pressed={active}
714
+ className={cn(richEditorItemVariants({ type }), className)}
715
+ {...props}
716
+ >
717
+ {children}
718
+ </button>
719
+ )
720
+ // Wrap in DS Tooltip when tooltipLabel is provided — matches Figma
721
+ // 166331:4027 (dark rounded popover above the trigger).
722
+ if (!tooltipLabel) return button
723
+ return (
724
+ <Tooltip>
725
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
726
+ <TooltipContent side="top" sideOffset={6}>{tooltipLabel}</TooltipContent>
727
+ </Tooltip>
728
+ )
729
+ },
730
+ )
731
+ RichEditorButton.displayName = 'RichEditorButton'
732
+
733
+ export type RichEditorIconButtonProps = Omit<ToolbarButtonBaseProps, 'type'> & {
734
+ icon: React.ReactNode
735
+ ariaLabel: string
736
+ command?: string
737
+ commandArg?: string
738
+ onActivate?: () => void
739
+ }
740
+
741
+ export const RichEditorIconButton = React.forwardRef<HTMLButtonElement, RichEditorIconButtonProps>(
742
+ ({ icon, ariaLabel, command, commandArg, onActivate, active, onClick, ...props }, ref) => {
743
+ const { exec, disabled } = useRichEditorContext('RichEditorIconButton')
744
+ return (
745
+ <RichEditorButton
746
+ ref={ref}
747
+ type="icon"
748
+ active={active}
749
+ aria-label={ariaLabel}
750
+ disabled={disabled || props.disabled}
751
+ onClick={(e) => {
752
+ if (command) exec(command, commandArg)
753
+ onActivate?.()
754
+ onClick?.(e)
755
+ }}
756
+ {...props}
757
+ >
758
+ <span data-slot="rich-editor-item-icon" aria-hidden="true">
759
+ {icon}
760
+ </span>
761
+ </RichEditorButton>
762
+ )
763
+ },
764
+ )
765
+ RichEditorIconButton.displayName = 'RichEditorIconButton'
766
+
767
+ export type RichEditorTextDropdownProps = Omit<ToolbarButtonBaseProps, 'type' | 'children'> & {
768
+ label: React.ReactNode
769
+ ariaLabel: string
770
+ menu?: React.ReactNode
771
+ onActivate?: () => void
772
+ }
773
+
774
+ export const RichEditorTextDropdown = React.forwardRef<HTMLButtonElement, RichEditorTextDropdownProps>(
775
+ ({ label, ariaLabel, menu, onActivate, ...props }, ref) => {
776
+ const { disabled } = useRichEditorContext('RichEditorTextDropdown')
777
+ const button = (
778
+ <RichEditorButton
779
+ ref={ref}
780
+ type="text"
781
+ aria-label={ariaLabel}
782
+ disabled={disabled || props.disabled}
783
+ onClick={onActivate}
784
+ {...props}
785
+ >
786
+ <span className="truncate">{label}</span>
787
+ <ChevronDown className="opacity-60 transition-transform group-data-[state=open]/rich-editor-item:rotate-180" aria-hidden="true" />
788
+ </RichEditorButton>
789
+ )
790
+ if (!menu) return button
791
+ return (
792
+ <Popover>
793
+ <PopoverTrigger asChild>{button}</PopoverTrigger>
794
+ <PopoverContent align="start" sideOffset={6} className="w-44 p-1">
795
+ {menu}
796
+ </PopoverContent>
797
+ </Popover>
798
+ )
799
+ },
800
+ )
801
+ RichEditorTextDropdown.displayName = 'RichEditorTextDropdown'
802
+
803
+ export type RichEditorDropdownButtonProps = Omit<ToolbarButtonBaseProps, 'type' | 'children'> & {
804
+ icon: React.ReactNode
805
+ ariaLabel: string
806
+ menu: React.ReactNode
807
+ /** When false, the trailing chevron is omitted (e.g. for the `⋮ More` overflow button). */
808
+ showChevron?: boolean
809
+ }
810
+
811
+ export const RichEditorDropdownButton = React.forwardRef<HTMLButtonElement, RichEditorDropdownButtonProps>(
812
+ ({ icon, ariaLabel, menu, showChevron, ...props }, ref) => {
813
+ const { disabled } = useRichEditorContext('RichEditorDropdownButton')
814
+ return (
815
+ <Popover>
816
+ <PopoverTrigger asChild>
817
+ <RichEditorButton
818
+ ref={ref}
819
+ type="dropdown"
820
+ aria-label={ariaLabel}
821
+ disabled={disabled || props.disabled}
822
+ {...props}
823
+ >
824
+ <span data-slot="rich-editor-item-icon" aria-hidden="true">
825
+ {icon}
826
+ </span>
827
+ {showChevron === false ? null : (
828
+ <ChevronDown className="opacity-60 transition-transform group-data-[state=open]/rich-editor-item:rotate-180" aria-hidden="true" />
829
+ )}
830
+ </RichEditorButton>
831
+ </PopoverTrigger>
832
+ <PopoverContent align="start" sideOffset={6} className="w-44 p-1">
833
+ {menu}
834
+ </PopoverContent>
835
+ </Popover>
836
+ )
837
+ },
838
+ )
839
+ RichEditorDropdownButton.displayName = 'RichEditorDropdownButton'
840
+
841
+ export type RichEditorColorButtonProps = Omit<ToolbarButtonBaseProps, 'type' | 'children' | 'value'> & {
842
+ colorValue?: RichEditorColorKey | null
843
+ ariaLabel: string
844
+ command?: string
845
+ onSelect?: (color: RichEditorColorKey | null) => void
846
+ /** Optional translated labels per palette key (defaults to English names). */
847
+ colorLabels?: Partial<Record<RichEditorColorKey, string>>
848
+ /**
849
+ * Restrict the popover palette to a subset of colour keys. Defaults to all
850
+ * 10 colours from the Figma `Rich Editor Colors [1.1]` master frame
851
+ * (166331:4100); pass a 5-key subset for the compact popover variant seen
852
+ * in some Figma mockups.
853
+ */
854
+ palette?: RichEditorColorKey[]
855
+ }
856
+
857
+ export const RichEditorColorButton = React.forwardRef<HTMLButtonElement, RichEditorColorButtonProps>(
858
+ ({ colorValue, ariaLabel, command = 'foreColor', onSelect, colorLabels, palette = COLOR_KEYS, ...props }, ref) => {
859
+ const { exec, disabled } = useRichEditorContext('RichEditorColorButton')
860
+ // Track the most recently selected colour internally so the trigger swatch
861
+ // reflects the latest pick even when the consumer doesn't pass a
862
+ // controlled `colorValue`. The `colorValue` prop still wins in controlled
863
+ // mode (e.g. when the consumer wants the swatch to mirror a saved value).
864
+ const [lastSelected, setLastSelected] = React.useState<RichEditorColorKey | null>(null)
865
+ const effectiveValue = colorValue ?? lastSelected
866
+ const swatchColor = effectiveValue ? RICH_EDITOR_COLOR_PALETTE[effectiveValue] : RICH_EDITOR_COLOR_PALETTE.blue
867
+ return (
868
+ <Popover>
869
+ <PopoverTrigger asChild>
870
+ <RichEditorButton
871
+ ref={ref}
872
+ type="color"
873
+ aria-label={ariaLabel}
874
+ disabled={disabled || props.disabled}
875
+ {...props}
876
+ >
877
+ <span
878
+ className="inline-block size-4 shrink-0 rounded-full border border-black/10"
879
+ style={{ backgroundColor: swatchColor }}
880
+ data-slot="rich-editor-color-swatch"
881
+ aria-hidden="true"
882
+ />
883
+ <ChevronDown className="opacity-60 transition-transform group-data-[state=open]/rich-editor-item:rotate-180" aria-hidden="true" />
884
+ </RichEditorButton>
885
+ </PopoverTrigger>
886
+ <PopoverContent align="start" sideOffset={6} className="w-36 p-1">
887
+ <RichEditorColorPalette
888
+ value={effectiveValue}
889
+ labels={colorLabels}
890
+ palette={palette}
891
+ onChange={(key) => {
892
+ if (key) exec(command, RICH_EDITOR_COLOR_PALETTE[key])
893
+ setLastSelected(key)
894
+ onSelect?.(key)
895
+ }}
896
+ />
897
+ </PopoverContent>
898
+ </Popover>
899
+ )
900
+ },
901
+ )
902
+ RichEditorColorButton.displayName = 'RichEditorColorButton'
903
+
904
+ export type RichEditorColorPaletteProps = {
905
+ value?: RichEditorColorKey | null
906
+ onChange?: (next: RichEditorColorKey | null) => void
907
+ /** Translatable labels per palette key (defaults to English). */
908
+ labels?: Partial<Record<RichEditorColorKey, string>>
909
+ /**
910
+ * Restrict the palette to a subset of colour keys (defaults to all 10). Use
911
+ * the same subset list on `RichEditorColorButton` and standalone palette
912
+ * popovers so the trigger swatch and the popover stay in sync.
913
+ */
914
+ palette?: RichEditorColorKey[]
915
+ className?: string
916
+ }
917
+
918
+ export function RichEditorColorPalette({ value, onChange, labels, palette, className }: RichEditorColorPaletteProps) {
919
+ const resolvedLabels = { ...COLOR_LABELS_EN, ...(labels ?? {}) }
920
+ const keys = palette && palette.length > 0 ? palette : COLOR_KEYS
921
+ return (
922
+ <div
923
+ className={cn('flex flex-col gap-0.5', className)}
924
+ data-slot="rich-editor-color-palette"
925
+ role="listbox"
926
+ aria-label="Color palette"
927
+ >
928
+ {keys.map((key) => {
929
+ const isActive = value === key
930
+ return (
931
+ <button
932
+ key={key}
933
+ type="button"
934
+ role="option"
935
+ aria-selected={isActive}
936
+ aria-label={resolvedLabels[key]}
937
+ onMouseDown={(e) => e.preventDefault()}
938
+ onClick={() => onChange?.(key)}
939
+ className={cn(
940
+ 'flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
941
+ isActive && 'font-medium text-foreground',
942
+ )}
943
+ data-color-key={key}
944
+ >
945
+ <span
946
+ className="size-4 shrink-0 rounded-full border border-black/10"
947
+ style={{ backgroundColor: RICH_EDITOR_COLOR_PALETTE[key] }}
948
+ aria-hidden="true"
949
+ />
950
+ <span className="truncate">{resolvedLabels[key]}</span>
951
+ </button>
952
+ )
953
+ })}
954
+ </div>
955
+ )
956
+ }
957
+
958
+ export type RichEditorMenuItemProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & {
959
+ /** Optional 16×16 leading icon (typically a Lucide glyph). */
960
+ icon?: React.ReactNode
961
+ /** Item label (also forwarded as the accessible name). */
962
+ children: React.ReactNode
963
+ /** Destructive variant — flips the label colour to `text-destructive`. */
964
+ destructive?: boolean
965
+ }
966
+
967
+ /**
968
+ * Menu item helper for `RichEditor` popovers (More `⋮` kebab, custom heading
969
+ * menus, etc.). Renders a Figma `Profile Dropdown Items`-style row: a 16×16
970
+ * icon slot, gap-2, full-width hover background. Use inside the `moreMenu`
971
+ * prop of `<RichEditor>` or the `menu` prop of a compound `RichEditorTextDropdown`
972
+ * / `RichEditorDropdownButton`.
973
+ */
974
+ export const RichEditorMenuItem = React.forwardRef<HTMLButtonElement, RichEditorMenuItemProps>(
975
+ ({ className, icon, children, destructive, onMouseDown, ...props }, ref) => (
976
+ <button
977
+ ref={ref}
978
+ type="button"
979
+ role="menuitem"
980
+ onMouseDown={(e) => {
981
+ e.preventDefault()
982
+ onMouseDown?.(e)
983
+ }}
984
+ className={cn(
985
+ 'flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50',
986
+ destructive && 'text-destructive hover:bg-destructive/10',
987
+ className,
988
+ )}
989
+ data-slot="rich-editor-menu-item"
990
+ {...props}
991
+ >
992
+ {icon ? (
993
+ <span
994
+ className="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
995
+ data-slot="rich-editor-menu-item-icon"
996
+ aria-hidden="true"
997
+ >
998
+ {icon}
999
+ </span>
1000
+ ) : null}
1001
+ <span className="min-w-0 flex-1 truncate">{children}</span>
1002
+ </button>
1003
+ ),
1004
+ )
1005
+ RichEditorMenuItem.displayName = 'RichEditorMenuItem'
1006
+
1007
+ export function RichEditorDivider({ className }: { className?: string }) {
1008
+ return (
1009
+ <span
1010
+ role="separator"
1011
+ aria-orientation="vertical"
1012
+ // Figma 166416:52475 — 4×16 container, 1px line. Toolbar `gap-0.5`
1013
+ // already provides the surrounding 2px gap on each side, so the divider
1014
+ // itself only carries the 1px line + 1.5px horizontal padding (mx-px).
1015
+ className={cn('mx-px inline-block h-4 w-px shrink-0 bg-border', className)}
1016
+ data-slot="rich-editor-divider"
1017
+ />
1018
+ )
1019
+ }
1020
+
1021
+ export type RichEditorContentProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'contentEditable' | 'children'> & {
1022
+ placeholder?: string
1023
+ minRows?: number
1024
+ disabled?: boolean
1025
+ id?: string
1026
+ name?: string
1027
+ }
1028
+
1029
+ export const RichEditorContent = React.forwardRef<HTMLDivElement, RichEditorContentProps>(
1030
+ ({ className, placeholder, minRows = 4, disabled, onBlur, onInput, onKeyDown, onPaste, id, name, ...props }, ref) => {
1031
+ const minHeight = `${Math.max(1, minRows) * 1.5}rem`
1032
+ return (
1033
+ <div
1034
+ ref={ref}
1035
+ contentEditable={!disabled}
1036
+ suppressContentEditableWarning
1037
+ spellCheck
1038
+ role="textbox"
1039
+ aria-multiline="true"
1040
+ data-slot="rich-editor-content"
1041
+ data-placeholder={placeholder ?? ''}
1042
+ data-name={name}
1043
+ id={id}
1044
+ className={cn(
1045
+ // Figma reference: content lives in its own bordered card with rounded-8 + shadow-xs,
1046
+ // visually separated from the toolbar by the small vertical gap from the parent
1047
+ // wrapper's `space-y-2`.
1048
+ 'w-full max-w-none rounded-lg border border-border bg-card px-3 py-3 text-sm leading-6 text-foreground shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
1049
+ // The project does not include `@tailwindcss/typography`, so we have to
1050
+ // re-implement the rich text rendering ourselves via descendant-arbitrary
1051
+ // selectors. Cover every tag the toolbar can produce so the user sees
1052
+ // the formatting instantly after each execCommand.
1053
+ '[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-2xl [&_h1]:font-bold',
1054
+ '[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold',
1055
+ '[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-lg [&_h3]:font-semibold',
1056
+ '[&_p]:my-1',
1057
+ '[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6',
1058
+ '[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6',
1059
+ '[&_li]:my-0.5',
1060
+ // Task list checklist override — when <ul data-task-list> we drop the
1061
+ // disc bullet so the <input type=checkbox> child stands alone.
1062
+ '[&_ul[data-task-list]]:list-none [&_ul[data-task-list]]:pl-0',
1063
+ '[&_ul[data-task-list]_input[type=checkbox]]:mr-2',
1064
+ '[&_blockquote]:my-2 [&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground',
1065
+ '[&_pre]:my-2 [&_pre]:rounded [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono [&_pre]:text-xs',
1066
+ '[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-xs',
1067
+ '[&_a]:text-primary [&_a]:underline',
1068
+ '[&_hr]:my-3 [&_hr]:border-border',
1069
+ '[&_img]:my-2 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded',
1070
+ '[&_table]:my-2 [&_table]:border-collapse',
1071
+ '[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
1072
+ '[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-2 [&_th]:py-1',
1073
+ 'data-[placeholder]:before:pointer-events-none data-[placeholder]:empty:before:content-[attr(data-placeholder)] data-[placeholder]:empty:before:text-muted-foreground',
1074
+ className,
1075
+ )}
1076
+ style={{ minHeight }}
1077
+ onBlur={onBlur}
1078
+ onInput={onInput}
1079
+ onKeyDown={onKeyDown}
1080
+ onPaste={onPaste}
1081
+ {...props}
1082
+ />
1083
+ )
1084
+ },
1085
+ )
1086
+ RichEditorContent.displayName = 'RichEditorContent'
1087
+
1088
+ // ── Preset toolbar ────────────────────────────────────────────────────────
1089
+
1090
+ type ToolbarItem =
1091
+ | { kind: 'divider'; key: string }
1092
+ | {
1093
+ kind: 'iconButton'
1094
+ key: string
1095
+ icon: React.ReactNode
1096
+ ariaLabel: string
1097
+ tooltipLabel?: string
1098
+ active?: boolean
1099
+ command?: string
1100
+ commandArg?: string
1101
+ onActivate?: () => void
1102
+ }
1103
+ | {
1104
+ kind: 'textDropdown'
1105
+ key: string
1106
+ ariaLabel: string
1107
+ label: React.ReactNode
1108
+ menu: React.ReactNode
1109
+ }
1110
+ | {
1111
+ kind: 'dropdownButton'
1112
+ key: string
1113
+ icon: React.ReactNode
1114
+ ariaLabel: string
1115
+ menu: React.ReactNode
1116
+ showChevron?: boolean
1117
+ }
1118
+ | {
1119
+ kind: 'colorButton'
1120
+ key: string
1121
+ ariaLabel: string
1122
+ colorLabels: Record<RichEditorColorKey, string>
1123
+ }
1124
+
1125
+ function renderToolbarItem(item: ToolbarItem): React.ReactNode {
1126
+ switch (item.kind) {
1127
+ case 'divider':
1128
+ return <RichEditorDivider key={item.key} />
1129
+ case 'iconButton':
1130
+ return (
1131
+ <RichEditorIconButton
1132
+ key={item.key}
1133
+ icon={item.icon}
1134
+ ariaLabel={item.ariaLabel}
1135
+ tooltipLabel={item.tooltipLabel}
1136
+ active={item.active}
1137
+ command={item.command}
1138
+ commandArg={item.commandArg}
1139
+ onActivate={item.onActivate}
1140
+ />
1141
+ )
1142
+ case 'textDropdown':
1143
+ return (
1144
+ <RichEditorTextDropdown
1145
+ key={item.key}
1146
+ ariaLabel={item.ariaLabel}
1147
+ label={item.label}
1148
+ menu={item.menu}
1149
+ />
1150
+ )
1151
+ case 'dropdownButton':
1152
+ return (
1153
+ <RichEditorDropdownButton
1154
+ key={item.key}
1155
+ icon={item.icon}
1156
+ ariaLabel={item.ariaLabel}
1157
+ menu={item.menu}
1158
+ showChevron={item.showChevron}
1159
+ />
1160
+ )
1161
+ case 'colorButton':
1162
+ return (
1163
+ <RichEditorColorButton
1164
+ key={item.key}
1165
+ ariaLabel={item.ariaLabel}
1166
+ colorLabels={item.colorLabels}
1167
+ />
1168
+ )
1169
+ }
1170
+ }
1171
+
1172
+ function trimTrailingDividers(items: ToolbarItem[]): ToolbarItem[] {
1173
+ let end = items.length
1174
+ while (end > 0 && items[end - 1].kind === 'divider') end -= 1
1175
+ return items.slice(0, end)
1176
+ }
1177
+
1178
+ function trimLeadingDividers(items: ToolbarItem[]): ToolbarItem[] {
1179
+ let start = 0
1180
+ while (start < items.length && items[start].kind === 'divider') start += 1
1181
+ return items.slice(start)
1182
+ }
1183
+
1184
+ function usePresetToolbarItems({
1185
+ variant,
1186
+ labels,
1187
+ onComment,
1188
+ onMention,
1189
+ onFullscreen,
1190
+ onImageInsert,
1191
+ exec,
1192
+ selection,
1193
+ requestUrlPrompt,
1194
+ }: {
1195
+ variant: RichEditorVariant
1196
+ labels: RichEditorLabels
1197
+ onComment?: () => void
1198
+ onMention?: () => void
1199
+ onFullscreen?: () => void
1200
+ onImageInsert?: () => void
1201
+ exec: RichEditorContextValue['exec']
1202
+ selection: RichEditorContextValue['selection']
1203
+ requestUrlPrompt: RichEditorContextValue['requestUrlPrompt']
1204
+ }): ToolbarItem[] {
1205
+ const onLink = React.useCallback(() => {
1206
+ requestUrlPrompt({
1207
+ kind: 'link',
1208
+ title: labels.linkUrlPrompt,
1209
+ placeholder: 'https://example.com',
1210
+ onConfirm: (url) => exec('createLink', url),
1211
+ })
1212
+ }, [requestUrlPrompt, exec, labels.linkUrlPrompt])
1213
+
1214
+ const onImage = React.useCallback(() => {
1215
+ if (onImageInsert) {
1216
+ onImageInsert()
1217
+ return
1218
+ }
1219
+ requestUrlPrompt({
1220
+ kind: 'image',
1221
+ title: labels.imageUrlPrompt,
1222
+ placeholder: 'https://example.com/image.png',
1223
+ onConfirm: (url) => exec('insertImage', url),
1224
+ })
1225
+ }, [requestUrlPrompt, exec, labels.imageUrlPrompt, onImageInsert])
1226
+
1227
+ const onInsertTable = React.useCallback(() => {
1228
+ const html = '<table style="border-collapse:collapse;width:100%"><tbody>'
1229
+ + '<tr><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td></tr>'
1230
+ + '<tr><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td></tr>'
1231
+ + '<tr><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td><td style="border:1px solid #ebebeb;padding:8px">&nbsp;</td></tr>'
1232
+ + '</tbody></table><p>&nbsp;</p>'
1233
+ exec('insertHTML', html)
1234
+ }, [exec])
1235
+
1236
+ const onInsertChecklist = React.useCallback(() => {
1237
+ const html = '<ul data-task-list="true" style="list-style:none;padding-left:0">'
1238
+ + '<li><label><input type="checkbox" /> </label></li>'
1239
+ + '</ul>'
1240
+ exec('insertHTML', html)
1241
+ }, [exec])
1242
+
1243
+ const onInsertInlineCode = React.useCallback(() => {
1244
+ const selectionText = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : ''
1245
+ if (!selectionText) {
1246
+ exec('insertHTML', '<code>&nbsp;</code>')
1247
+ return
1248
+ }
1249
+ exec('insertHTML', `<code>${selectionText}</code>`)
1250
+ }, [exec])
1251
+
1252
+ const showHeading = variant === 'full' || variant === 'standard'
1253
+ const showFontSize = variant === 'full'
1254
+ const showColor = variant === 'full'
1255
+ const showStrike = variant === 'full'
1256
+ const showHr = variant === 'full'
1257
+ const showQuoteAndCode = variant === 'full'
1258
+ const showImageTable = variant === 'full'
1259
+ const showChecklist = variant === 'full'
1260
+ const showOrdered = variant === 'full' || variant === 'standard' || variant === 'basic'
1261
+ const showAlign = variant === 'full'
1262
+ // Comment + Mention are always available in the `full` variant — when the
1263
+ // consumer doesn't wire a custom popover we fall back to inserting a
1264
+ // sensible plain-text marker at the caret so the buttons never feel dead.
1265
+ // Consumers can still pass `onComment` / `onMention` to fully override and
1266
+ // open their own popover / picker / inline UI.
1267
+ const showComment = variant === 'full'
1268
+ const commentHandler = onComment ?? (() => exec('insertText', '[comment: ]'))
1269
+ const showMention = variant === 'full'
1270
+ const mentionHandler = onMention ?? (() => exec('insertText', '@'))
1271
+ const showLink = variant === 'full' || variant === 'standard' || variant === 'basic'
1272
+ const showLists = variant !== 'minimal'
1273
+ const showHelp = variant === 'full'
1274
+ const showFullscreen = variant === 'full' && (onFullscreen !== undefined)
1275
+
1276
+ const keyboardShortcutsHelp = (
1277
+ <div className="flex w-64 flex-col gap-1 p-1 text-sm" role="menu">
1278
+ <div className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Keyboard</div>
1279
+ <div className="flex items-center justify-between rounded px-2 py-1 hover:bg-muted">
1280
+ <span>{labels.bold}</span>
1281
+ <kbd className="rounded border bg-card px-1.5 py-0.5 text-xs">⌘ B</kbd>
1282
+ </div>
1283
+ <div className="flex items-center justify-between rounded px-2 py-1 hover:bg-muted">
1284
+ <span>{labels.italic}</span>
1285
+ <kbd className="rounded border bg-card px-1.5 py-0.5 text-xs">⌘ I</kbd>
1286
+ </div>
1287
+ <div className="flex items-center justify-between rounded px-2 py-1 hover:bg-muted">
1288
+ <span>{labels.underline}</span>
1289
+ <kbd className="rounded border bg-card px-1.5 py-0.5 text-xs">⌘ U</kbd>
1290
+ </div>
1291
+ </div>
1292
+ )
1293
+
1294
+ const headingMenu = (
1295
+ <div className="flex flex-col gap-0.5" role="menu">
1296
+ <button
1297
+ type="button"
1298
+ role="menuitem"
1299
+ className="cursor-pointer rounded px-2 py-1 text-left text-sm hover:bg-muted"
1300
+ onMouseDown={(e) => e.preventDefault()}
1301
+ onClick={() => exec('formatBlock', '<p>')}
1302
+ >
1303
+ {labels.paragraph}
1304
+ </button>
1305
+ <button
1306
+ type="button"
1307
+ role="menuitem"
1308
+ className="cursor-pointer rounded px-2 py-1 text-left text-lg font-semibold hover:bg-muted"
1309
+ onMouseDown={(e) => e.preventDefault()}
1310
+ onClick={() => exec('formatBlock', '<h1>')}
1311
+ >
1312
+ {labels.heading1}
1313
+ </button>
1314
+ <button
1315
+ type="button"
1316
+ role="menuitem"
1317
+ className="cursor-pointer rounded px-2 py-1 text-left text-base font-semibold hover:bg-muted"
1318
+ onMouseDown={(e) => e.preventDefault()}
1319
+ onClick={() => exec('formatBlock', '<h2>')}
1320
+ >
1321
+ {labels.heading2}
1322
+ </button>
1323
+ <button
1324
+ type="button"
1325
+ role="menuitem"
1326
+ className="cursor-pointer rounded px-2 py-1 text-left text-sm font-semibold hover:bg-muted"
1327
+ onMouseDown={(e) => e.preventDefault()}
1328
+ onClick={() => exec('formatBlock', '<h3>')}
1329
+ >
1330
+ {labels.heading3}
1331
+ </button>
1332
+ </div>
1333
+ )
1334
+
1335
+ const headingLabel: string = (() => {
1336
+ switch (selection.heading) {
1337
+ case 'h1':
1338
+ return labels.heading1
1339
+ case 'h2':
1340
+ return labels.heading2
1341
+ case 'h3':
1342
+ return labels.heading3
1343
+ default:
1344
+ return labels.heading
1345
+ }
1346
+ })()
1347
+
1348
+ const fontSizeMenu = (
1349
+ <div className="flex flex-col gap-0.5" role="menu">
1350
+ {FONT_SIZE_OPTIONS.map((size) => (
1351
+ <button
1352
+ key={size}
1353
+ type="button"
1354
+ role="menuitem"
1355
+ className="cursor-pointer rounded px-2 py-1 text-left text-sm hover:bg-muted"
1356
+ onMouseDown={(e) => e.preventDefault()}
1357
+ onClick={() => {
1358
+ // execCommand('fontSize', …) requires 1–7 — translate the px label
1359
+ // through a CSS-driven span instead.
1360
+ exec('removeFormat')
1361
+ const px = parseInt(size, 10)
1362
+ if (px > 0) {
1363
+ exec('insertHTML', `<span style="font-size:${px}px">${window.getSelection()?.toString() ?? ''}</span>`)
1364
+ }
1365
+ }}
1366
+ >
1367
+ {size}
1368
+ </button>
1369
+ ))}
1370
+ </div>
1371
+ )
1372
+
1373
+ const alignMenu = (
1374
+ <div className="flex flex-col gap-0.5" role="menu">
1375
+ {(['left', 'center', 'right', 'justify'] as RichEditorAlign[]).map((option) => {
1376
+ const Icon = ALIGN_ICONS[option]
1377
+ const command = option === 'justify' ? 'justifyFull' : `justify${option[0].toUpperCase()}${option.slice(1)}`
1378
+ const label = option === 'left'
1379
+ ? labels.alignLeft
1380
+ : option === 'center'
1381
+ ? labels.alignCenter
1382
+ : option === 'right'
1383
+ ? labels.alignRight
1384
+ : labels.alignJustify
1385
+ return (
1386
+ <button
1387
+ key={option}
1388
+ type="button"
1389
+ role="menuitem"
1390
+ className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-muted"
1391
+ onMouseDown={(e) => e.preventDefault()}
1392
+ onClick={() => exec(command)}
1393
+ >
1394
+ <Icon className="size-4" aria-hidden="true" />
1395
+ <span>{label}</span>
1396
+ </button>
1397
+ )
1398
+ })}
1399
+ </div>
1400
+ )
1401
+
1402
+ const AlignActiveIcon = ALIGN_ICONS[selection.align]
1403
+
1404
+ if (variant === 'custom') return []
1405
+
1406
+ const items: ToolbarItem[] = []
1407
+ if (showHeading) {
1408
+ items.push({ kind: 'textDropdown', key: 'heading', ariaLabel: labels.heading, label: headingLabel, menu: headingMenu })
1409
+ items.push({ kind: 'divider', key: 'd-heading' })
1410
+ }
1411
+ if (showFontSize) {
1412
+ items.push({ kind: 'textDropdown', key: 'fontSize', ariaLabel: labels.fontSize, label: '14px', menu: fontSizeMenu })
1413
+ items.push({ kind: 'divider', key: 'd-fontSize' })
1414
+ }
1415
+ if (showColor) {
1416
+ items.push({ kind: 'colorButton', key: 'color', ariaLabel: labels.color, colorLabels: labels.colors })
1417
+ items.push({ kind: 'divider', key: 'd-color' })
1418
+ }
1419
+ items.push({ kind: 'iconButton', key: 'bold', icon: <Bold />, command: 'bold', ariaLabel: labels.bold, tooltipLabel: labels.bold, active: selection.bold })
1420
+ items.push({ kind: 'iconButton', key: 'italic', icon: <Italic />, command: 'italic', ariaLabel: labels.italic, tooltipLabel: labels.italic, active: selection.italic })
1421
+ items.push({ kind: 'iconButton', key: 'underline', icon: <Underline />, command: 'underline', ariaLabel: labels.underline, tooltipLabel: labels.underline, active: selection.underline })
1422
+ if (showStrike) {
1423
+ items.push({ kind: 'iconButton', key: 'strike', icon: <Strikethrough />, command: 'strikeThrough', ariaLabel: labels.strikethrough, tooltipLabel: labels.strikethrough, active: selection.strikethrough })
1424
+ }
1425
+ if (showLists) {
1426
+ items.push({ kind: 'divider', key: 'd-lists' })
1427
+ items.push({ kind: 'iconButton', key: 'ul', icon: <List />, command: 'insertUnorderedList', ariaLabel: labels.unorderedList, tooltipLabel: labels.unorderedList, active: selection.unorderedList })
1428
+ if (showOrdered) {
1429
+ items.push({ kind: 'iconButton', key: 'ol', icon: <ListOrdered />, command: 'insertOrderedList', ariaLabel: labels.orderedList, tooltipLabel: labels.orderedList, active: selection.orderedList })
1430
+ }
1431
+ }
1432
+ if (showHr || showQuoteAndCode) {
1433
+ items.push({ kind: 'divider', key: 'd-structure' })
1434
+ if (showHr) {
1435
+ items.push({ kind: 'iconButton', key: 'hr', icon: <Minus />, ariaLabel: labels.horizontalRule, tooltipLabel: labels.horizontalRule, onActivate: () => exec('insertHorizontalRule') })
1436
+ }
1437
+ if (showQuoteAndCode) {
1438
+ items.push({ kind: 'iconButton', key: 'quote', icon: <Quote />, ariaLabel: labels.blockquote, tooltipLabel: labels.blockquote, active: selection.blockquote, onActivate: () => exec('formatBlock', selection.blockquote ? '<p>' : '<blockquote>') })
1439
+ items.push({ kind: 'iconButton', key: 'inlineCode', icon: <Code />, ariaLabel: labels.inlineCode, tooltipLabel: labels.inlineCode, onActivate: onInsertInlineCode })
1440
+ items.push({ kind: 'iconButton', key: 'codeBlock', icon: <FileCode />, ariaLabel: labels.codeBlock, tooltipLabel: labels.codeBlock, active: selection.code, onActivate: () => exec('formatBlock', selection.code ? '<p>' : '<pre>') })
1441
+ }
1442
+ }
1443
+ if (showImageTable) {
1444
+ items.push({ kind: 'divider', key: 'd-media' })
1445
+ items.push({ kind: 'iconButton', key: 'image', icon: <ImageIcon />, ariaLabel: labels.image, tooltipLabel: labels.image, onActivate: onImage })
1446
+ items.push({ kind: 'iconButton', key: 'table', icon: <TableIcon />, ariaLabel: labels.table, tooltipLabel: labels.table, onActivate: onInsertTable })
1447
+ }
1448
+ if (showChecklist) {
1449
+ items.push({ kind: 'iconButton', key: 'checklist', icon: <ListChecks />, ariaLabel: labels.checklist, tooltipLabel: labels.checklist, onActivate: onInsertChecklist })
1450
+ }
1451
+ if (showAlign) {
1452
+ items.push({ kind: 'divider', key: 'd-align' })
1453
+ items.push({ kind: 'dropdownButton', key: 'align', icon: <AlignActiveIcon />, ariaLabel: labels.align, menu: alignMenu })
1454
+ }
1455
+ if (showLink || showComment || showMention) {
1456
+ items.push({ kind: 'divider', key: 'd-anchors' })
1457
+ }
1458
+ if (showComment) {
1459
+ items.push({ kind: 'iconButton', key: 'comment', icon: <MessageCircle />, ariaLabel: labels.comment, tooltipLabel: labels.comment, onActivate: commentHandler })
1460
+ }
1461
+ if (showLink) {
1462
+ items.push({ kind: 'iconButton', key: 'link', icon: <Link />, ariaLabel: labels.link, tooltipLabel: labels.link, onActivate: onLink })
1463
+ }
1464
+ if (showMention) {
1465
+ items.push({ kind: 'iconButton', key: 'mention', icon: <AtSign />, ariaLabel: labels.mention, tooltipLabel: labels.mention, onActivate: mentionHandler })
1466
+ }
1467
+ if (showHelp || showFullscreen) {
1468
+ items.push({ kind: 'divider', key: 'd-trailing' })
1469
+ if (showHelp) {
1470
+ items.push({ kind: 'dropdownButton', key: 'help', icon: <HelpCircle />, ariaLabel: labels.help, menu: keyboardShortcutsHelp, showChevron: false })
1471
+ }
1472
+ if (showFullscreen && onFullscreen) {
1473
+ items.push({ kind: 'iconButton', key: 'fullscreen', icon: <Maximize2 />, ariaLabel: labels.fullscreen, tooltipLabel: labels.fullscreen, onActivate: onFullscreen })
1474
+ }
1475
+ }
1476
+
1477
+ return trimTrailingDividers(items)
1478
+ }
1479
+
1480
+ function RichEditorAutoToolbar({
1481
+ items,
1482
+ labels,
1483
+ moreMenu,
1484
+ }: {
1485
+ items: ToolbarItem[]
1486
+ labels: RichEditorLabels
1487
+ moreMenu?: React.ReactNode
1488
+ }) {
1489
+ const measureRef = React.useRef<HTMLDivElement>(null)
1490
+ const containerRef = React.useRef<HTMLDivElement>(null)
1491
+ const [firstHidden, setFirstHidden] = React.useState<number | null>(null)
1492
+
1493
+ React.useLayoutEffect(() => {
1494
+ if (typeof window === 'undefined') return
1495
+ if (typeof ResizeObserver === 'undefined') return
1496
+ const container = containerRef.current
1497
+ const measureEl = measureRef.current
1498
+ if (!container || !measureEl) return
1499
+
1500
+ const measure = () => {
1501
+ const containerWidth = container.clientWidth
1502
+ if (containerWidth === 0) return
1503
+
1504
+ const allEls = Array.from(measureEl.children) as HTMLElement[]
1505
+ if (allEls.length === 0) return
1506
+ // Last child is the reserved ⋮ placeholder for budget calculations.
1507
+ const moreEl = allEls[allEls.length - 1]
1508
+ const itemEls = allEls.slice(0, -1)
1509
+ const moreReserved = moreEl.offsetWidth + 4
1510
+ const containerPadding = 4 // p-0.5 left + right
1511
+
1512
+ let totalWidth = 0
1513
+ for (const el of itemEls) totalWidth += el.offsetWidth + 2
1514
+
1515
+ if (totalWidth + containerPadding <= containerWidth + 2) {
1516
+ setFirstHidden(null)
1517
+ return
1518
+ }
1519
+
1520
+ let accumulated = 0
1521
+ for (let i = 0; i < itemEls.length; i++) {
1522
+ accumulated += itemEls[i].offsetWidth + 2
1523
+ if (accumulated > containerWidth - moreReserved - containerPadding) {
1524
+ setFirstHidden(i)
1525
+ return
1526
+ }
1527
+ }
1528
+ setFirstHidden(null)
1529
+ }
1530
+
1531
+ measure()
1532
+ const ro = new ResizeObserver(measure)
1533
+ ro.observe(container)
1534
+ return () => ro.disconnect()
1535
+ }, [items])
1536
+
1537
+ const visibleItems = firstHidden === null ? items : trimTrailingDividers(items.slice(0, firstHidden))
1538
+ const hiddenItems = firstHidden === null ? [] : trimLeadingDividers(items.slice(firstHidden))
1539
+
1540
+ const hasOverflow = hiddenItems.length > 0
1541
+ const showMoreButton = hasOverflow || !!moreMenu
1542
+
1543
+ return (
1544
+ <div className="relative w-full">
1545
+ <div
1546
+ ref={measureRef}
1547
+ aria-hidden="true"
1548
+ className="pointer-events-none invisible absolute left-0 top-0 flex flex-row flex-nowrap items-center gap-0.5"
1549
+ >
1550
+ {items.map(renderToolbarItem)}
1551
+ <RichEditorDropdownButton icon={<MoreVertical />} ariaLabel={labels.more} showChevron={false} menu={<div />} />
1552
+ </div>
1553
+ <RichEditorToolbar ref={containerRef} className="w-full flex-nowrap overflow-hidden">
1554
+ {visibleItems.map(renderToolbarItem)}
1555
+ {showMoreButton ? (
1556
+ <RichEditorDropdownButton
1557
+ icon={<MoreVertical />}
1558
+ ariaLabel={labels.more}
1559
+ showChevron={false}
1560
+ menu={
1561
+ <div className="flex max-w-[320px] flex-row flex-wrap items-center gap-0.5 p-1">
1562
+ {moreMenu ? (
1563
+ <>
1564
+ <div className="w-full">{moreMenu}</div>
1565
+ {hiddenItems.length > 0 ? <div className="my-1 h-px w-full bg-border" /> : null}
1566
+ </>
1567
+ ) : null}
1568
+ {hiddenItems.map(renderToolbarItem)}
1569
+ </div>
1570
+ }
1571
+ />
1572
+ ) : null}
1573
+ </RichEditorToolbar>
1574
+ </div>
1575
+ )
1576
+ }
1577
+
1578
+ export { richEditorItemVariants }