@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import {
8
+ RICH_EDITOR_COLOR_PALETTE,
9
+ RichEditor,
10
+ RichEditorColorButton,
11
+ RichEditorColorPalette,
12
+ RichEditorContent,
13
+ RichEditorDivider,
14
+ RichEditorDropdownButton,
15
+ RichEditorIconButton,
16
+ RichEditorMenuItem,
17
+ RichEditorTextDropdown,
18
+ RichEditorToolbar,
19
+ } from '../rich-editor'
20
+
21
+ // jsdom does not implement execCommand / queryCommandState. Provide stub-grade
22
+ // implementations so the primitive can run its toolbar wiring under tests.
23
+ let execMock: jest.Mock
24
+ let queryStateMock: jest.Mock
25
+ let queryValueMock: jest.Mock
26
+
27
+ beforeEach(() => {
28
+ execMock = jest.fn(() => true)
29
+ queryStateMock = jest.fn(() => false)
30
+ queryValueMock = jest.fn(() => '')
31
+ Object.defineProperty(document, 'execCommand', { configurable: true, value: execMock })
32
+ Object.defineProperty(document, 'queryCommandState', { configurable: true, value: queryStateMock })
33
+ Object.defineProperty(document, 'queryCommandValue', { configurable: true, value: queryValueMock })
34
+ })
35
+
36
+ describe('RichEditor — variant presets', () => {
37
+ it('minimal renders Bold / Italic / Underline only (no list, no link)', () => {
38
+ const onChange = jest.fn()
39
+ render(<RichEditor value="" onChange={onChange} variant="minimal" />)
40
+ expect(screen.getByRole('button', { name: 'Bold' })).toBeInTheDocument()
41
+ expect(screen.getByRole('button', { name: 'Italic' })).toBeInTheDocument()
42
+ expect(screen.getByRole('button', { name: 'Underline' })).toBeInTheDocument()
43
+ expect(screen.queryByRole('button', { name: 'Bullet list' })).toBeNull()
44
+ expect(screen.queryByRole('button', { name: 'Link' })).toBeNull()
45
+ })
46
+
47
+ it('basic adds list + link, omits header + strikethrough + color', () => {
48
+ render(<RichEditor value="" onChange={jest.fn()} variant="basic" />)
49
+ expect(screen.getByRole('button', { name: 'Bullet list' })).toBeInTheDocument()
50
+ expect(screen.getByRole('button', { name: 'Link' })).toBeInTheDocument()
51
+ expect(screen.queryByRole('button', { name: 'Header' })).toBeNull()
52
+ expect(screen.queryByRole('button', { name: 'Strikethrough' })).toBeNull()
53
+ expect(screen.queryByRole('button', { name: 'Text color' })).toBeNull()
54
+ })
55
+
56
+ it('standard adds Header dropdown but skips strikethrough/color/font-size/align', () => {
57
+ render(<RichEditor value="" onChange={jest.fn()} variant="standard" />)
58
+ expect(screen.getByRole('button', { name: 'Header' })).toBeInTheDocument()
59
+ expect(screen.queryByRole('button', { name: 'Strikethrough' })).toBeNull()
60
+ expect(screen.queryByRole('button', { name: 'Text color' })).toBeNull()
61
+ expect(screen.queryByRole('button', { name: 'Font size' })).toBeNull()
62
+ expect(screen.queryByRole('button', { name: 'Align' })).toBeNull()
63
+ })
64
+
65
+ it('full renders the extended toolbar (header / font-size / color / B/I/U/S / HR / quote / inline-code / code-block / lists / image / table / checklist / align / link / help)', () => {
66
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
67
+ expect(screen.getByRole('button', { name: 'Header' })).toBeInTheDocument()
68
+ expect(screen.getByRole('button', { name: 'Font size' })).toBeInTheDocument()
69
+ expect(screen.getByRole('button', { name: 'Text color' })).toBeInTheDocument()
70
+ expect(screen.getByRole('button', { name: 'Strikethrough' })).toBeInTheDocument()
71
+ expect(screen.getByRole('button', { name: 'Horizontal rule' })).toBeInTheDocument()
72
+ expect(screen.getByRole('button', { name: 'Quote' })).toBeInTheDocument()
73
+ expect(screen.getByRole('button', { name: 'Inline code' })).toBeInTheDocument()
74
+ expect(screen.getByRole('button', { name: 'Code block' })).toBeInTheDocument()
75
+ expect(screen.getByRole('button', { name: 'Numbered list' })).toBeInTheDocument()
76
+ expect(screen.getByRole('button', { name: 'Image' })).toBeInTheDocument()
77
+ expect(screen.getByRole('button', { name: 'Table' })).toBeInTheDocument()
78
+ expect(screen.getByRole('button', { name: 'Checklist' })).toBeInTheDocument()
79
+ expect(screen.getByRole('button', { name: 'Align' })).toBeInTheDocument()
80
+ expect(screen.getByRole('button', { name: 'Link' })).toBeInTheDocument()
81
+ expect(screen.getByRole('button', { name: 'Help' })).toBeInTheDocument()
82
+ // Mention + Comment are always rendered in `full` (default handlers
83
+ // insert `@` / `[comment: ]` literals). More / Fullscreen stay opt-in.
84
+ expect(screen.getByRole('button', { name: 'Mention' })).toBeInTheDocument()
85
+ expect(screen.getByRole('button', { name: 'Add comment' })).toBeInTheDocument()
86
+ expect(screen.queryByRole('button', { name: 'More' })).toBeNull()
87
+ expect(screen.queryByRole('button', { name: 'Toggle fullscreen' })).toBeNull()
88
+ })
89
+
90
+ it('full opts in More / Fullscreen when handlers + menu are provided', () => {
91
+ render(
92
+ <RichEditor
93
+ value=""
94
+ onChange={jest.fn()}
95
+ variant="full"
96
+ onFullscreen={() => {}}
97
+ moreMenu={<div>menu</div>}
98
+ />,
99
+ )
100
+ expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument()
101
+ expect(screen.getByRole('button', { name: 'Toggle fullscreen' })).toBeInTheDocument()
102
+ })
103
+
104
+ it('clicking Add comment without a custom onComment handler inserts "[comment: ]" via execCommand', () => {
105
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
106
+ fireEvent.click(screen.getByRole('button', { name: 'Add comment' }))
107
+ expect(execMock).toHaveBeenCalledWith('insertText', false, '[comment: ]')
108
+ })
109
+
110
+ it('clicking Mention without a custom onMention handler inserts an "@" via execCommand', () => {
111
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
112
+ fireEvent.click(screen.getByRole('button', { name: 'Mention' }))
113
+ expect(execMock).toHaveBeenCalledWith('insertText', false, '@')
114
+ })
115
+
116
+ it('custom onMention overrides the default "@" insertion', () => {
117
+ const onMention = jest.fn()
118
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" onMention={onMention} />)
119
+ fireEvent.click(screen.getByRole('button', { name: 'Mention' }))
120
+ expect(onMention).toHaveBeenCalledTimes(1)
121
+ expect(execMock).not.toHaveBeenCalledWith('insertText', false, '@')
122
+ })
123
+
124
+ it("clicking Link does NOT call window.prompt (DS Dialog handles it instead)", () => {
125
+ // The DS Dialog uses useT() which requires I18nProvider — we just verify
126
+ // the toolbar button doesn't fall back to the native window.prompt, then
127
+ // catch the Dialog render error from useT (mocked at the source).
128
+ const promptSpy = jest.spyOn(window, 'prompt').mockReturnValue(null)
129
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
130
+ render(<RichEditor value="" onChange={jest.fn()} variant="basic" />)
131
+ try {
132
+ fireEvent.click(screen.getByRole('button', { name: 'Link' }))
133
+ } catch {
134
+ // Dialog mount throws because useT() requires I18nProvider in jsdom;
135
+ // the contract under test is that window.prompt was NOT used.
136
+ }
137
+ expect(promptSpy).not.toHaveBeenCalled()
138
+ promptSpy.mockRestore()
139
+ errorSpy.mockRestore()
140
+ })
141
+
142
+ it('clicking Horizontal rule calls execCommand("insertHorizontalRule")', () => {
143
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
144
+ fireEvent.click(screen.getByRole('button', { name: 'Horizontal rule' }))
145
+ expect(execMock).toHaveBeenCalledWith('insertHorizontalRule', false, undefined)
146
+ })
147
+
148
+ it('clicking Table inserts a 3×3 <table> via insertHTML', () => {
149
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
150
+ fireEvent.click(screen.getByRole('button', { name: 'Table' }))
151
+ expect(execMock).toHaveBeenCalledWith('insertHTML', false, expect.stringContaining('<table'))
152
+ })
153
+
154
+ it('clicking Checklist inserts a <ul data-task-list> with a checkbox', () => {
155
+ render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
156
+ fireEvent.click(screen.getByRole('button', { name: 'Checklist' }))
157
+ expect(execMock).toHaveBeenCalledWith('insertHTML', false, expect.stringContaining('data-task-list'))
158
+ })
159
+ })
160
+
161
+ describe('RichEditor — execCommand wiring', () => {
162
+ it('clicking Bold calls document.execCommand("bold")', () => {
163
+ render(<RichEditor value="" onChange={jest.fn()} variant="minimal" />)
164
+ fireEvent.click(screen.getByRole('button', { name: 'Bold' }))
165
+ expect(execMock).toHaveBeenCalledWith('bold', false, undefined)
166
+ })
167
+
168
+ it('clicking Bullet list calls execCommand("insertUnorderedList")', () => {
169
+ render(<RichEditor value="" onChange={jest.fn()} variant="basic" />)
170
+ fireEvent.click(screen.getByRole('button', { name: 'Bullet list' }))
171
+ expect(execMock).toHaveBeenCalledWith('insertUnorderedList', false, undefined)
172
+ })
173
+
174
+ it('Cmd+B keyboard shortcut on the content area triggers exec("bold")', () => {
175
+ const { container } = render(<RichEditor value="" onChange={jest.fn()} variant="minimal" />)
176
+ const content = container.querySelector('[data-slot="rich-editor-content"]')!
177
+ fireEvent.keyDown(content, { key: 'b', metaKey: true })
178
+ expect(execMock).toHaveBeenCalledWith('bold', false, undefined)
179
+ })
180
+
181
+ it('reflects queryCommandState in data-active on toggle buttons', () => {
182
+ queryStateMock.mockImplementation((cmd: string) => cmd === 'bold')
183
+ const { container } = render(<RichEditor value="" onChange={jest.fn()} variant="minimal" />)
184
+ // Force a selectionchange so the selection state hook latches the mocked value.
185
+ fireEvent(document, new Event('selectionchange'))
186
+ // Render hasn't re-derived yet because the editor's anchorNode is empty.
187
+ // Force a click on Bold which calls refreshSelectionState() at the end.
188
+ fireEvent.click(screen.getByRole('button', { name: 'Bold' }))
189
+ const boldButton = container.querySelector('button[aria-label="Bold"]')!
190
+ expect(boldButton.getAttribute('data-active')).toBe('true')
191
+ })
192
+ })
193
+
194
+ describe('RichEditor — content area + onChange', () => {
195
+ it('renders the placeholder via data-placeholder attribute', () => {
196
+ const { container } = render(
197
+ <RichEditor value="" onChange={jest.fn()} variant="minimal" placeholder="Write a note…" />,
198
+ )
199
+ const content = container.querySelector('[data-slot="rich-editor-content"]')
200
+ expect(content?.getAttribute('data-placeholder')).toBe('Write a note…')
201
+ })
202
+
203
+ it('calls onChange with the sanitized content on blur', () => {
204
+ const onChange = jest.fn()
205
+ const { container } = render(<RichEditor value="" onChange={onChange} variant="minimal" />)
206
+ const content = container.querySelector('[data-slot="rich-editor-content"]') as HTMLDivElement
207
+ content.innerHTML = '<p>hello <script>alert(1)</script></p>'
208
+ fireEvent.blur(content)
209
+ expect(onChange).toHaveBeenCalledTimes(1)
210
+ const value = onChange.mock.calls[0][0]
211
+ // Sanitizer drops <script>, keeps inline text.
212
+ expect(value).not.toContain('<script>')
213
+ expect(value).toContain('hello')
214
+ })
215
+
216
+ it('makes the content non-editable when disabled', () => {
217
+ const { container } = render(
218
+ <RichEditor value="" onChange={jest.fn()} variant="minimal" disabled />,
219
+ )
220
+ const content = container.querySelector('[data-slot="rich-editor-content"]')!
221
+ expect(content.getAttribute('contenteditable')).toBe('false')
222
+ })
223
+
224
+ it('forwards aria-invalid to the root container', () => {
225
+ const { container } = render(
226
+ <RichEditor value="" onChange={jest.fn()} variant="minimal" aria-invalid />,
227
+ )
228
+ const root = container.querySelector('[data-slot="rich-editor"]')!
229
+ expect(root.getAttribute('aria-invalid')).toBe('true')
230
+ })
231
+
232
+ it('renders the character counter when maxLength is set, omits it otherwise', () => {
233
+ const { container, rerender } = render(
234
+ <RichEditor value="<p>hello</p>" onChange={jest.fn()} variant="minimal" maxLength={200} />,
235
+ )
236
+ const counter = container.querySelector('[data-slot="rich-editor-counter"]')
237
+ expect(counter).not.toBeNull()
238
+ expect(counter?.textContent).toBe('5/200')
239
+
240
+ rerender(<RichEditor value="<p>hello</p>" onChange={jest.fn()} variant="minimal" />)
241
+ expect(container.querySelector('[data-slot="rich-editor-counter"]')).toBeNull()
242
+ })
243
+
244
+ it("matches Figma Active dropdown state — Header trigger gets data-state='open' when popover opens", () => {
245
+ render(<RichEditor value="" onChange={jest.fn()} variant="standard" />)
246
+ const trigger = screen.getByRole('button', { name: 'Header' })
247
+ expect(trigger.getAttribute('data-state')).toBe('closed')
248
+ fireEvent.click(trigger)
249
+ expect(trigger.getAttribute('data-state')).toBe('open')
250
+ // The cva data-[state=open] selectors should swap bg + text tokens —
251
+ // we assert the className wiring is present (jsdom can't resolve the
252
+ // computed styles, but Tailwind compiles `data-[state=open]:bg-muted`
253
+ // unconditionally so its presence on the class list is the contract).
254
+ expect(trigger.className).toContain('data-[state=open]:bg-muted')
255
+ expect(trigger.className).toContain('data-[state=open]:text-foreground')
256
+ })
257
+
258
+ it('flips the counter colour when the plaintext exceeds maxLength', () => {
259
+ const { container } = render(
260
+ <RichEditor value="<p>hello world</p>" onChange={jest.fn()} variant="minimal" maxLength={5} />,
261
+ )
262
+ const counter = container.querySelector('[data-slot="rich-editor-counter"]')!
263
+ expect(counter.className).toContain('text-destructive')
264
+ expect(counter.textContent).toBe('11/5')
265
+ })
266
+ })
267
+
268
+ describe('RichEditorColorPalette + RichEditorColorButton', () => {
269
+ it('renders all 12 palette swatches by default (Figma 166331:4100)', () => {
270
+ const onChange = jest.fn()
271
+ const { container } = render(<RichEditorColorPalette onChange={onChange} />)
272
+ const swatches = container.querySelectorAll('[data-color-key]')
273
+ expect(swatches).toHaveLength(12)
274
+ expect(container.querySelector('[data-color-key="black"]')).not.toBeNull()
275
+ expect(container.querySelector('[data-color-key="white"]')).not.toBeNull()
276
+ })
277
+
278
+ it('honours the palette prop subset (Figma reference shows 5 colours)', () => {
279
+ const { container } = render(<RichEditorColorPalette palette={['gray', 'blue', 'orange', 'purple', 'sky']} />)
280
+ const swatches = container.querySelectorAll('[data-color-key]')
281
+ expect(swatches).toHaveLength(5)
282
+ expect(container.querySelector('[data-color-key="red"]')).toBeNull()
283
+ })
284
+
285
+ it('uses the configured Figma palette values verbatim', () => {
286
+ const { container } = render(<RichEditorColorPalette />)
287
+ // After the Profile Dropdown Items layout switch the swatch lives in an
288
+ // inner <span> sibling of the label, so query the swatch span explicitly.
289
+ const blue = container.querySelector('[data-color-key="blue"] span[aria-hidden="true"]') as HTMLElement
290
+ expect(blue.style.backgroundColor).toMatch(/rgb\(99,\s*102,\s*241\)|#6366f1/i)
291
+ expect(RICH_EDITOR_COLOR_PALETTE.blue).toBe('#6366f1')
292
+ })
293
+
294
+ it('emits onChange when a swatch is clicked', () => {
295
+ const onChange = jest.fn()
296
+ render(<RichEditorColorPalette onChange={onChange} />)
297
+ const red = document.querySelector('[data-color-key="red"]') as HTMLElement
298
+ fireEvent.click(red)
299
+ expect(onChange).toHaveBeenCalledWith('red')
300
+ })
301
+
302
+ it('full variant exposes the Text color trigger swatch', () => {
303
+ const { container } = render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
304
+ const colorButton = container.querySelector('button[aria-label="Text color"]')!
305
+ expect(colorButton).not.toBeNull()
306
+ expect(colorButton.querySelector('[data-slot="rich-editor-color-swatch"]')).not.toBeNull()
307
+ })
308
+ })
309
+
310
+ describe('RichEditor — custom variant + compound API', () => {
311
+ it("variant='custom' renders the supplied children (no preset toolbar items)", () => {
312
+ const onActivate = jest.fn()
313
+ render(
314
+ <RichEditor value="" onChange={jest.fn()} variant="custom">
315
+ <RichEditorToolbar>
316
+ <RichEditorIconButton
317
+ icon={<span data-testid="x-icon" />}
318
+ ariaLabel="Custom"
319
+ onActivate={onActivate}
320
+ />
321
+ <RichEditorDivider />
322
+ </RichEditorToolbar>
323
+ <RichEditorContent placeholder="custom" />
324
+ </RichEditor>,
325
+ )
326
+ expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument()
327
+ // No preset Bold button present in custom mode.
328
+ expect(screen.queryByRole('button', { name: 'Bold' })).toBeNull()
329
+ fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
330
+ expect(onActivate).toHaveBeenCalledTimes(1)
331
+ })
332
+
333
+ it('throws when toolbar atoms are rendered outside <RichEditor>', () => {
334
+ // Suppress noisy React error logging for this expected throw.
335
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
336
+ expect(() =>
337
+ render(<RichEditorIconButton icon={<span />} ariaLabel="loose" />),
338
+ ).toThrow(/must be rendered inside <RichEditor>/)
339
+ spy.mockRestore()
340
+ })
341
+
342
+ it('marks the toolbar slot with role="toolbar" and data-slot', () => {
343
+ const { container } = render(<RichEditor value="" onChange={jest.fn()} variant="basic" />)
344
+ const toolbar = container.querySelector('[data-slot="rich-editor-toolbar"]')
345
+ expect(toolbar).not.toBeNull()
346
+ expect(toolbar?.getAttribute('role')).toBe('toolbar')
347
+ })
348
+ })
349
+
350
+ describe('RichEditorDropdownButton + RichEditorTextDropdown', () => {
351
+ it("RichEditorDropdownButton renders inside <RichEditor variant='custom'>", () => {
352
+ render(
353
+ <RichEditor value="" onChange={jest.fn()} variant="custom">
354
+ <RichEditorToolbar>
355
+ <RichEditorDropdownButton
356
+ icon={<span data-testid="more-icon" />}
357
+ ariaLabel="More"
358
+ menu={<div>menu</div>}
359
+ />
360
+ </RichEditorToolbar>
361
+ </RichEditor>,
362
+ )
363
+ expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument()
364
+ })
365
+
366
+ it("RichEditorTextDropdown renders a labelled trigger inside <RichEditor variant='custom'>", () => {
367
+ render(
368
+ <RichEditor value="" onChange={jest.fn()} variant="custom">
369
+ <RichEditorToolbar>
370
+ <RichEditorTextDropdown ariaLabel="Heading" label="H1" menu={<div>menu</div>} />
371
+ </RichEditorToolbar>
372
+ </RichEditor>,
373
+ )
374
+ const trigger = screen.getByRole('button', { name: 'Heading' })
375
+ expect(trigger.textContent).toContain('H1')
376
+ })
377
+
378
+ it('RichEditorMenuItem renders icon + label as a menuitem button', () => {
379
+ const onClick = jest.fn()
380
+ const { container } = render(
381
+ <RichEditorMenuItem icon={<span data-testid="menu-icon" />} onClick={onClick}>Copy</RichEditorMenuItem>,
382
+ )
383
+ const item = container.querySelector('[data-slot="rich-editor-menu-item"]')!
384
+ expect(item.getAttribute('role')).toBe('menuitem')
385
+ expect(screen.getByTestId('menu-icon')).toBeInTheDocument()
386
+ expect(screen.getByText('Copy')).toBeInTheDocument()
387
+ fireEvent.click(item)
388
+ expect(onClick).toHaveBeenCalledTimes(1)
389
+ })
390
+
391
+ it('RichEditorMenuItem destructive flips the label colour to text-destructive', () => {
392
+ const { container } = render(
393
+ <RichEditorMenuItem destructive>Delete</RichEditorMenuItem>,
394
+ )
395
+ const item = container.querySelector('[data-slot="rich-editor-menu-item"]')!
396
+ expect(item.className).toContain('text-destructive')
397
+ })
398
+
399
+ it("RichEditorColorButton renders inside <RichEditor variant='custom'>", () => {
400
+ render(
401
+ <RichEditor value="" onChange={jest.fn()} variant="custom">
402
+ <RichEditorToolbar>
403
+ <RichEditorColorButton ariaLabel="Pick color" colorValue="purple" />
404
+ </RichEditorToolbar>
405
+ </RichEditor>,
406
+ )
407
+ const trigger = screen.getByRole('button', { name: 'Pick color' })
408
+ const swatch = trigger.querySelector('[data-slot="rich-editor-color-swatch"]') as HTMLElement
409
+ expect(swatch?.style.backgroundColor).toMatch(/rgb\(125,\s*82,\s*244\)|#7d52f4/i)
410
+ })
411
+ })
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { render, screen } from '@testing-library/react'
7
+ import { Skeleton } from '../skeleton'
8
+
9
+ describe('Skeleton primitive', () => {
10
+ it('renders a single rect placeholder by default', () => {
11
+ const { container } = render(<Skeleton className="h-8 w-32" />)
12
+ const root = container.querySelector('[data-slot="skeleton"]')
13
+ expect(root).toBeInTheDocument()
14
+ expect(root!.className).toContain('animate-pulse')
15
+ expect(root!.className).toContain('bg-muted')
16
+ expect(root!.className).toContain('rounded-md')
17
+ expect(root!.className).toContain('h-8')
18
+ expect(root!.className).toContain('w-32')
19
+ })
20
+
21
+ it('shape="circle" renders a rounded-full placeholder', () => {
22
+ const { container } = render(<Skeleton shape="circle" className="size-10" />)
23
+ const root = container.querySelector('[data-slot="skeleton"]')
24
+ expect(root!.className).toContain('rounded-full')
25
+ expect(root!.className).toContain('animate-pulse')
26
+ })
27
+
28
+ it('shape="text" with default lines renders one h-4 line', () => {
29
+ const { container } = render(<Skeleton shape="text" />)
30
+ const root = container.querySelector('[data-slot="skeleton"]')
31
+ expect(root).toBeInTheDocument()
32
+ const lines = root!.querySelectorAll('div')
33
+ expect(lines.length).toBe(1)
34
+ expect(lines[0].className).toContain('h-4')
35
+ expect(lines[0].className).toContain('w-full')
36
+ })
37
+
38
+ it('shape="text" with lines={3} renders 3 lines and shortens the last one', () => {
39
+ const { container } = render(<Skeleton shape="text" lines={3} />)
40
+ const root = container.querySelector('[data-slot="skeleton"]')
41
+ const lines = root!.querySelectorAll('div')
42
+ expect(lines.length).toBe(3)
43
+ expect(lines[0].className).toContain('w-full')
44
+ expect(lines[1].className).toContain('w-full')
45
+ expect(lines[2].className).toContain('w-3/4')
46
+ })
47
+
48
+ it('clamps lines to at least 1', () => {
49
+ const { container } = render(<Skeleton shape="text" lines={0} />)
50
+ const root = container.querySelector('[data-slot="skeleton"]')
51
+ const lines = root!.querySelectorAll('div')
52
+ expect(lines.length).toBe(1)
53
+ })
54
+
55
+ it('exposes role="status" + aria-busy for accessibility', () => {
56
+ render(<Skeleton aria-label="Loading user profile" />)
57
+ const root = screen.getByRole('status', { name: 'Loading user profile' })
58
+ expect(root).toHaveAttribute('aria-busy', 'true')
59
+ })
60
+
61
+ it('forwards arbitrary HTML props (data-*, id, etc.)', () => {
62
+ const { container } = render(
63
+ <Skeleton id="loader-1" data-testid="my-loader" className="h-8" />,
64
+ )
65
+ const root = container.querySelector('#loader-1')
66
+ expect(root).not.toBeNull()
67
+ expect(root).toHaveAttribute('data-testid', 'my-loader')
68
+ })
69
+
70
+ it('text variant exposes role="status" on the wrapper, not on each line', () => {
71
+ const { container } = render(<Skeleton shape="text" lines={2} aria-label="Loading copy" />)
72
+ const wrappers = container.querySelectorAll('[role="status"]')
73
+ expect(wrappers.length).toBe(1)
74
+ expect(wrappers[0]).toHaveAttribute('aria-label', 'Loading copy')
75
+ })
76
+ })