@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.
- package/.turbo/turbo-build.log +1 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
- package/dist/ai/AiAssistantLauncher.js +8 -0
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +226 -58
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiDock.js +3 -2
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ModelPicker.js +186 -0
- package/dist/ai/ModelPicker.js.map +7 -0
- package/dist/ai/index.js +3 -1
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +12 -16
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/ai/AiAssistantLauncher.tsx +8 -0
- package/src/ai/AiChat.tsx +229 -11
- package/src/ai/AiDock.tsx +3 -2
- package/src/ai/ModelPicker.tsx +248 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.test.tsx +180 -0
- package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
- package/src/ai/__tests__/AiDock.test.tsx +12 -1
- package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/useAiChat.ts +29 -16
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- 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
|
+
})
|