@semiont/react-ui 0.2.35 → 0.2.37

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 (34) hide show
  1. package/dist/index.d.mts +8 -0
  2. package/dist/index.mjs +252 -166
  3. package/dist/index.mjs.map +1 -1
  4. package/package.json +3 -3
  5. package/src/components/CodeMirrorRenderer.tsx +71 -203
  6. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +142 -0
  7. package/src/components/__tests__/LiveRegion.hooks.test.tsx +79 -0
  8. package/src/components/__tests__/ResizeHandle.test.tsx +165 -0
  9. package/src/components/__tests__/SessionExpiryBanner.test.tsx +123 -0
  10. package/src/components/__tests__/StatusDisplay.test.tsx +160 -0
  11. package/src/components/__tests__/Toolbar.test.tsx +110 -0
  12. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +285 -0
  13. package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +273 -0
  14. package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +90 -0
  15. package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +129 -0
  16. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +180 -0
  17. package/src/components/navigation/__tests__/ObservableLink.test.tsx +90 -0
  18. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +169 -0
  19. package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +371 -0
  20. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +2 -0
  21. package/src/components/resource/AnnotateView.tsx +27 -153
  22. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
  23. package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
  24. package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
  25. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
  26. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
  27. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
  28. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
  29. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
  30. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
  31. package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
  32. package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
  33. package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
  34. package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import React from 'react';
3
+ import { screen, fireEvent, waitFor } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import '@testing-library/jest-dom';
6
+ import type { components } from '@semiont/core';
7
+
8
+ type Annotation = components['schemas']['Annotation'];
9
+
10
+ // Mock CodeMirror modules
11
+ vi.mock('@codemirror/view', () => {
12
+ class MockEditorView {
13
+ destroy = vi.fn();
14
+ constructor(_config: any) {}
15
+ static editable = { of: vi.fn() };
16
+ static theme = vi.fn(() => ({}));
17
+ }
18
+
19
+ return {
20
+ EditorView: MockEditorView,
21
+ lineNumbers: vi.fn(),
22
+ };
23
+ });
24
+
25
+ vi.mock('@codemirror/state', () => ({
26
+ EditorState: {
27
+ create: vi.fn(() => ({})),
28
+ readOnly: { of: vi.fn() },
29
+ },
30
+ }));
31
+
32
+ vi.mock('@codemirror/lang-json', () => ({
33
+ json: vi.fn(),
34
+ }));
35
+
36
+ vi.mock('@codemirror/theme-one-dark', () => ({
37
+ oneDark: {},
38
+ }));
39
+
40
+ vi.mock('@codemirror/language', () => ({
41
+ syntaxHighlighting: vi.fn(),
42
+ HighlightStyle: {
43
+ define: vi.fn(() => ({})),
44
+ },
45
+ }));
46
+
47
+ vi.mock('../../../lib/codemirror-json-theme', () => ({
48
+ jsonLightTheme: {},
49
+ jsonLightHighlightStyle: {},
50
+ }));
51
+
52
+ vi.mock('@/hooks/useLineNumbers');
53
+
54
+ import { useLineNumbers } from '@/hooks/useLineNumbers';
55
+ import { renderWithProviders } from '../../../test-utils';
56
+ import { JsonLdView } from '../JsonLdView';
57
+
58
+ const createMockAnnotation = (overrides?: Partial<Annotation>): Annotation => ({
59
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
60
+ id: 'anno-1',
61
+ type: 'Annotation',
62
+ motivation: 'highlighting',
63
+ creator: { name: 'user@example.com' },
64
+ created: '2024-01-01T10:00:00Z',
65
+ target: {
66
+ source: 'resource-1',
67
+ selector: {
68
+ type: 'TextPositionSelector',
69
+ start: 0,
70
+ end: 10,
71
+ },
72
+ },
73
+ body: [],
74
+ ...overrides,
75
+ });
76
+
77
+ describe('JsonLdView', () => {
78
+ let mockClipboard: { writeText: ReturnType<typeof vi.fn> };
79
+
80
+ beforeEach(() => {
81
+ vi.clearAllMocks();
82
+
83
+ mockClipboard = {
84
+ writeText: vi.fn().mockResolvedValue(undefined),
85
+ };
86
+ Object.defineProperty(navigator, 'clipboard', {
87
+ value: mockClipboard,
88
+ writable: true,
89
+ configurable: true,
90
+ });
91
+
92
+ Object.defineProperty(document.documentElement, 'classList', {
93
+ value: {
94
+ contains: vi.fn().mockReturnValue(false),
95
+ },
96
+ writable: true,
97
+ configurable: true,
98
+ });
99
+
100
+ vi.mocked(useLineNumbers).mockReturnValue({
101
+ showLineNumbers: false,
102
+ toggleLineNumbers: vi.fn(),
103
+ });
104
+ });
105
+
106
+ afterEach(() => {
107
+ vi.restoreAllMocks();
108
+ });
109
+
110
+ describe('Rendering', () => {
111
+ it('should render the JSON-LD title', () => {
112
+ const annotation = createMockAnnotation();
113
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
114
+
115
+ expect(screen.getByText('JSON-LD')).toBeInTheDocument();
116
+ });
117
+
118
+ it('should render the back button', () => {
119
+ const annotation = createMockAnnotation();
120
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
121
+
122
+ const backButton = screen.getByTitle('Go back (Escape)');
123
+ expect(backButton).toBeInTheDocument();
124
+ });
125
+
126
+ it('should render the copy button', () => {
127
+ const annotation = createMockAnnotation();
128
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
129
+
130
+ expect(screen.getByText(/Copy/)).toBeInTheDocument();
131
+ });
132
+
133
+ it('should render editor container', () => {
134
+ const annotation = createMockAnnotation();
135
+ const { container } = renderWithProviders(
136
+ <JsonLdView annotation={annotation} onBack={vi.fn()} />
137
+ );
138
+
139
+ const editorDiv = container.querySelector('.semiont-jsonld-view__editor');
140
+ expect(editorDiv).toBeInTheDocument();
141
+ });
142
+ });
143
+
144
+ describe('Back button', () => {
145
+ it('should call onBack when back button is clicked', async () => {
146
+ const onBack = vi.fn();
147
+ const annotation = createMockAnnotation();
148
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={onBack} />);
149
+
150
+ const backButton = screen.getByTitle('Go back (Escape)');
151
+ await userEvent.click(backButton);
152
+
153
+ expect(onBack).toHaveBeenCalledOnce();
154
+ });
155
+
156
+ it('should call onBack when Escape key is pressed', () => {
157
+ const onBack = vi.fn();
158
+ const annotation = createMockAnnotation();
159
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={onBack} />);
160
+
161
+ fireEvent.keyDown(window, { key: 'Escape' });
162
+
163
+ expect(onBack).toHaveBeenCalledOnce();
164
+ });
165
+
166
+ it('should not call onBack for non-Escape keys', () => {
167
+ const onBack = vi.fn();
168
+ const annotation = createMockAnnotation();
169
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={onBack} />);
170
+
171
+ fireEvent.keyDown(window, { key: 'Enter' });
172
+
173
+ expect(onBack).not.toHaveBeenCalled();
174
+ });
175
+ });
176
+
177
+ describe('Copy to clipboard', () => {
178
+ it('should copy annotation JSON to clipboard when copy button is clicked', async () => {
179
+ const annotation = createMockAnnotation();
180
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
181
+
182
+ const copyButton = screen.getByText(/Copy/);
183
+ await userEvent.click(copyButton);
184
+
185
+ expect(mockClipboard.writeText).toHaveBeenCalledOnce();
186
+ const copiedText = mockClipboard.writeText.mock.calls[0][0];
187
+ expect(copiedText).toBe(JSON.stringify(annotation, null, 2));
188
+ });
189
+
190
+ it('should copy formatted JSON with indentation', async () => {
191
+ const annotation = createMockAnnotation();
192
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
193
+
194
+ const copyButton = screen.getByText(/Copy/);
195
+ await userEvent.click(copyButton);
196
+
197
+ const copiedText = mockClipboard.writeText.mock.calls[0][0];
198
+ expect(copiedText).toContain('\n');
199
+ expect(copiedText).toContain(' ');
200
+ expect(() => JSON.parse(copiedText)).not.toThrow();
201
+ });
202
+
203
+ it('should handle clipboard API errors gracefully', async () => {
204
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
205
+ mockClipboard.writeText.mockRejectedValue(new Error('Clipboard error'));
206
+
207
+ const annotation = createMockAnnotation();
208
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
209
+
210
+ const copyButton = screen.getByText(/Copy/);
211
+ await userEvent.click(copyButton);
212
+
213
+ await waitFor(() => {
214
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
215
+ 'Failed to copy JSON-LD:',
216
+ expect.any(Error)
217
+ );
218
+ });
219
+
220
+ consoleErrorSpy.mockRestore();
221
+ });
222
+ });
223
+
224
+ describe('Styling', () => {
225
+ it('should have proper view structure', () => {
226
+ const annotation = createMockAnnotation();
227
+ const { container } = renderWithProviders(
228
+ <JsonLdView annotation={annotation} onBack={vi.fn()} />
229
+ );
230
+
231
+ const view = container.firstChild as HTMLElement;
232
+ expect(view).toHaveClass('semiont-jsonld-view');
233
+ });
234
+
235
+ it('should have proper header class', () => {
236
+ const annotation = createMockAnnotation();
237
+ const { container } = renderWithProviders(
238
+ <JsonLdView annotation={annotation} onBack={vi.fn()} />
239
+ );
240
+
241
+ const header = container.querySelector('.semiont-jsonld-view__header');
242
+ expect(header).toBeInTheDocument();
243
+ });
244
+
245
+ it('should have proper back button class', () => {
246
+ const annotation = createMockAnnotation();
247
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
248
+
249
+ const backButton = screen.getByTitle('Go back (Escape)');
250
+ expect(backButton).toHaveClass('semiont-jsonld-view__back-button');
251
+ });
252
+
253
+ it('should have proper copy button class', () => {
254
+ const annotation = createMockAnnotation();
255
+ renderWithProviders(<JsonLdView annotation={annotation} onBack={vi.fn()} />);
256
+
257
+ const copyButton = screen.getByTitle('Copy to clipboard');
258
+ expect(copyButton).toHaveClass('semiont-jsonld-view__copy-button');
259
+ });
260
+ });
261
+
262
+ describe('Cleanup', () => {
263
+ it('should remove keydown listener on unmount', () => {
264
+ const onBack = vi.fn();
265
+ const annotation = createMockAnnotation();
266
+ const { unmount } = renderWithProviders(
267
+ <JsonLdView annotation={annotation} onBack={onBack} />
268
+ );
269
+
270
+ unmount();
271
+
272
+ fireEvent.keyDown(window, { key: 'Escape' });
273
+ expect(onBack).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it('should unmount without errors', () => {
277
+ const annotation = createMockAnnotation();
278
+ const { unmount } = renderWithProviders(
279
+ <JsonLdView annotation={annotation} onBack={vi.fn()} />
280
+ );
281
+
282
+ expect(() => unmount()).not.toThrow();
283
+ });
284
+ });
285
+ });
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import React from 'react';
3
+ import { screen, fireEvent } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import { renderWithProviders } from '../../../test-utils';
6
+ import {
7
+ SelectedTextDisplay,
8
+ EntityTypeBadges,
9
+ PopupHeader,
10
+ PopupContainer,
11
+ } from '../SharedPopupElements';
12
+
13
+ // Mock HeadlessUI to avoid jsdom OOM issues
14
+ vi.mock('@headlessui/react', () => ({
15
+ Dialog: ({ children, onClose, ...props }: any) => (
16
+ <div role="dialog" {...props}>
17
+ {typeof children === 'function' ? children({ open: true }) : children}
18
+ </div>
19
+ ),
20
+ DialogPanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
21
+ Transition: ({ show, children }: any) => (show ? <>{children}</> : null),
22
+ TransitionChild: ({ children }: any) => <>{children}</>,
23
+ }));
24
+
25
+ describe('SelectedTextDisplay', () => {
26
+ it('should render the selected text with quotes', () => {
27
+ renderWithProviders(<SelectedTextDisplay exact="hello world" />);
28
+
29
+ expect(screen.getByText(/"hello world"/)).toBeInTheDocument();
30
+ });
31
+
32
+ it('should render the label', () => {
33
+ renderWithProviders(<SelectedTextDisplay exact="test" />);
34
+
35
+ expect(screen.getByText('Selected text:')).toBeInTheDocument();
36
+ });
37
+
38
+ it('should have proper container class', () => {
39
+ const { container } = renderWithProviders(<SelectedTextDisplay exact="test" />);
40
+
41
+ expect(container.querySelector('.semiont-selected-text-display')).toBeInTheDocument();
42
+ });
43
+
44
+ it('should have proper label class', () => {
45
+ const { container } = renderWithProviders(<SelectedTextDisplay exact="test" />);
46
+
47
+ expect(container.querySelector('.semiont-selected-text-display__label')).toBeInTheDocument();
48
+ });
49
+
50
+ it('should have proper content class', () => {
51
+ const { container } = renderWithProviders(<SelectedTextDisplay exact="test" />);
52
+
53
+ expect(container.querySelector('.semiont-selected-text-display__content')).toBeInTheDocument();
54
+ });
55
+
56
+ it('should handle empty string', () => {
57
+ renderWithProviders(<SelectedTextDisplay exact="" />);
58
+
59
+ expect(screen.getByText('Selected text:')).toBeInTheDocument();
60
+ });
61
+
62
+ it('should handle special characters in text', () => {
63
+ renderWithProviders(<SelectedTextDisplay exact="<script>alert('xss')</script>" />);
64
+
65
+ expect(screen.getByText('Selected text:')).toBeInTheDocument();
66
+ });
67
+ });
68
+
69
+ describe('EntityTypeBadges', () => {
70
+ it('should render a single entity type badge', () => {
71
+ renderWithProviders(<EntityTypeBadges entityTypes="Person" />);
72
+
73
+ expect(screen.getByText('Person')).toBeInTheDocument();
74
+ });
75
+
76
+ it('should render multiple comma-separated entity types as badges', () => {
77
+ renderWithProviders(<EntityTypeBadges entityTypes="Person,Organization,Place" />);
78
+
79
+ expect(screen.getByText('Person')).toBeInTheDocument();
80
+ expect(screen.getByText('Organization')).toBeInTheDocument();
81
+ expect(screen.getByText('Place')).toBeInTheDocument();
82
+ });
83
+
84
+ it('should trim whitespace from entity types', () => {
85
+ renderWithProviders(<EntityTypeBadges entityTypes="Person , Organization , Place" />);
86
+
87
+ expect(screen.getByText('Person')).toBeInTheDocument();
88
+ expect(screen.getByText('Organization')).toBeInTheDocument();
89
+ expect(screen.getByText('Place')).toBeInTheDocument();
90
+ });
91
+
92
+ it('should return null for empty string', () => {
93
+ const { container } = renderWithProviders(<EntityTypeBadges entityTypes="" />);
94
+
95
+ expect(container.querySelector('.semiont-entity-type-badges')).not.toBeInTheDocument();
96
+ });
97
+
98
+ it('should have proper container class', () => {
99
+ const { container } = renderWithProviders(<EntityTypeBadges entityTypes="Person" />);
100
+
101
+ expect(container.querySelector('.semiont-entity-type-badges')).toBeInTheDocument();
102
+ });
103
+
104
+ it('should have proper badge class on each badge', () => {
105
+ const { container } = renderWithProviders(
106
+ <EntityTypeBadges entityTypes="Person,Organization" />
107
+ );
108
+
109
+ const badges = container.querySelectorAll('.semiont-entity-type-badges__badge');
110
+ expect(badges).toHaveLength(2);
111
+ });
112
+ });
113
+
114
+ describe('PopupHeader', () => {
115
+ it('should render the title', () => {
116
+ renderWithProviders(
117
+ <PopupHeader title="Annotation Details" onClose={vi.fn()} />
118
+ );
119
+
120
+ expect(screen.getByText('Annotation Details')).toBeInTheDocument();
121
+ });
122
+
123
+ it('should render selected text when provided', () => {
124
+ renderWithProviders(
125
+ <PopupHeader title="Details" selectedText="highlighted text" onClose={vi.fn()} />
126
+ );
127
+
128
+ expect(screen.getByText(/highlighted text/)).toBeInTheDocument();
129
+ });
130
+
131
+ it('should not render selected text subtitle when not provided', () => {
132
+ const { container } = renderWithProviders(
133
+ <PopupHeader title="Details" onClose={vi.fn()} />
134
+ );
135
+
136
+ expect(container.querySelector('.semiont-popup-header__subtitle')).not.toBeInTheDocument();
137
+ });
138
+
139
+ it('should render close button', () => {
140
+ renderWithProviders(
141
+ <PopupHeader title="Details" onClose={vi.fn()} />
142
+ );
143
+
144
+ const closeButton = screen.getByRole('button');
145
+ expect(closeButton).toBeInTheDocument();
146
+ });
147
+
148
+ it('should call onClose when close button is clicked', () => {
149
+ const onClose = vi.fn();
150
+ renderWithProviders(<PopupHeader title="Details" onClose={onClose} />);
151
+
152
+ const closeButton = screen.getByRole('button');
153
+ fireEvent.click(closeButton);
154
+
155
+ expect(onClose).toHaveBeenCalledOnce();
156
+ });
157
+
158
+ it('should have proper header class', () => {
159
+ const { container } = renderWithProviders(
160
+ <PopupHeader title="Details" onClose={vi.fn()} />
161
+ );
162
+
163
+ expect(container.querySelector('.semiont-popup-header')).toBeInTheDocument();
164
+ });
165
+
166
+ it('should have proper title class', () => {
167
+ const { container } = renderWithProviders(
168
+ <PopupHeader title="Details" onClose={vi.fn()} />
169
+ );
170
+
171
+ expect(container.querySelector('.semiont-popup-header__title')).toBeInTheDocument();
172
+ });
173
+
174
+ it('should have proper close button class', () => {
175
+ const { container } = renderWithProviders(
176
+ <PopupHeader title="Details" onClose={vi.fn()} />
177
+ );
178
+
179
+ expect(container.querySelector('.semiont-popup-header__close-button')).toBeInTheDocument();
180
+ });
181
+ });
182
+
183
+ describe('PopupContainer', () => {
184
+ const defaultProps = {
185
+ position: { x: 100, y: 200 },
186
+ onClose: vi.fn(),
187
+ isOpen: true,
188
+ };
189
+
190
+ it('should render children when open', () => {
191
+ renderWithProviders(
192
+ <PopupContainer {...defaultProps}>
193
+ <div>Popup content</div>
194
+ </PopupContainer>
195
+ );
196
+
197
+ expect(screen.getByText('Popup content')).toBeInTheDocument();
198
+ });
199
+
200
+ it('should not render children when closed', () => {
201
+ renderWithProviders(
202
+ <PopupContainer {...defaultProps} isOpen={false}>
203
+ <div>Popup content</div>
204
+ </PopupContainer>
205
+ );
206
+
207
+ expect(screen.queryByText('Popup content')).not.toBeInTheDocument();
208
+ });
209
+
210
+ it('should render with dialog role', () => {
211
+ renderWithProviders(
212
+ <PopupContainer {...defaultProps}>
213
+ <div>Content</div>
214
+ </PopupContainer>
215
+ );
216
+
217
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
218
+ });
219
+
220
+ it('should render backdrop element', () => {
221
+ const { container } = renderWithProviders(
222
+ <PopupContainer {...defaultProps}>
223
+ <div>Content</div>
224
+ </PopupContainer>
225
+ );
226
+
227
+ expect(container.querySelector('.semiont-popup-backdrop')).toBeInTheDocument();
228
+ });
229
+
230
+ it('should set data-wide to false by default', () => {
231
+ const { container } = renderWithProviders(
232
+ <PopupContainer {...defaultProps}>
233
+ <div>Content</div>
234
+ </PopupContainer>
235
+ );
236
+
237
+ const panel = container.querySelector('.semiont-popup-panel');
238
+ expect(panel).toHaveAttribute('data-wide', 'false');
239
+ });
240
+
241
+ it('should set data-wide to true when wide prop is true', () => {
242
+ const { container } = renderWithProviders(
243
+ <PopupContainer {...defaultProps} wide>
244
+ <div>Content</div>
245
+ </PopupContainer>
246
+ );
247
+
248
+ const panel = container.querySelector('.semiont-popup-panel');
249
+ expect(panel).toHaveAttribute('data-wide', 'true');
250
+ });
251
+
252
+ it('should have data-annotation-ui attribute', () => {
253
+ const { container } = renderWithProviders(
254
+ <PopupContainer {...defaultProps}>
255
+ <div>Content</div>
256
+ </PopupContainer>
257
+ );
258
+
259
+ const panel = container.querySelector('[data-annotation-ui]');
260
+ expect(panel).toBeInTheDocument();
261
+ });
262
+
263
+ it('should position popup with fixed positioning', () => {
264
+ const { container } = renderWithProviders(
265
+ <PopupContainer {...defaultProps}>
266
+ <div>Content</div>
267
+ </PopupContainer>
268
+ );
269
+
270
+ const panel = container.querySelector('.semiont-popup-panel') as HTMLElement;
271
+ expect(panel?.style.position).toBe('fixed');
272
+ });
273
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import React from 'react';
3
+ import { screen, fireEvent } from '@testing-library/react';
4
+ import { renderWithProviders } from '../../../test-utils';
5
+ import '@testing-library/jest-dom';
6
+ import { KeyboardShortcutsHelpModal } from '../KeyboardShortcutsHelpModal';
7
+
8
+ // Mock HeadlessUI to avoid jsdom OOM issues
9
+ vi.mock('@headlessui/react', () => ({
10
+ Dialog: ({ children, onClose, ...props }: any) => <div role="dialog" {...props}>{typeof children === 'function' ? children({ open: true }) : children}</div>,
11
+ DialogPanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
12
+ DialogTitle: ({ children, ...props }: any) => <h2 {...props}>{children}</h2>,
13
+ Transition: ({ show, children }: any) => show ? <>{children}</> : null,
14
+ TransitionChild: ({ children }: any) => <>{children}</>,
15
+ }));
16
+
17
+ describe('KeyboardShortcutsHelpModal', () => {
18
+ const defaultProps = {
19
+ isOpen: true,
20
+ onClose: vi.fn(),
21
+ };
22
+
23
+ it('renders modal title when open', () => {
24
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
25
+ expect(screen.getByText('KeyboardShortcuts.title')).toBeInTheDocument();
26
+ });
27
+
28
+ it('does not render content when closed', () => {
29
+ renderWithProviders(
30
+ <KeyboardShortcutsHelpModal {...defaultProps} isOpen={false} />
31
+ );
32
+ expect(screen.queryByText('KeyboardShortcuts.title')).not.toBeInTheDocument();
33
+ });
34
+
35
+ it('renders all shortcut groups', () => {
36
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
37
+ expect(screen.getByText('KeyboardShortcuts.navigationTitle')).toBeInTheDocument();
38
+ expect(screen.getByText('KeyboardShortcuts.sidebarTitle')).toBeInTheDocument();
39
+ expect(screen.getByText('KeyboardShortcuts.annotationsTitle')).toBeInTheDocument();
40
+ expect(screen.getByText('KeyboardShortcuts.listsTitle')).toBeInTheDocument();
41
+ expect(screen.getByText('KeyboardShortcuts.searchModalTitle')).toBeInTheDocument();
42
+ expect(screen.getByText('KeyboardShortcuts.modalTitle')).toBeInTheDocument();
43
+ expect(screen.getByText('KeyboardShortcuts.accessibilityTitle')).toBeInTheDocument();
44
+ });
45
+
46
+ it('renders keyboard shortcut descriptions', () => {
47
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
48
+ expect(screen.getByText('KeyboardShortcuts.navOpenSearch')).toBeInTheDocument();
49
+ expect(screen.getByText('KeyboardShortcuts.annotHighlight')).toBeInTheDocument();
50
+ });
51
+
52
+ it('renders kbd elements for shortcuts', () => {
53
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
54
+ const kbdElements = document.querySelectorAll('kbd.semiont-shortcuts__key');
55
+ expect(kbdElements.length).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('renders close button with aria-label', () => {
59
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
60
+ expect(screen.getByLabelText('KeyboardShortcuts.closeDialog')).toBeInTheDocument();
61
+ });
62
+
63
+ it('calls onClose when close button is clicked', () => {
64
+ const onClose = vi.fn();
65
+ renderWithProviders(
66
+ <KeyboardShortcutsHelpModal isOpen={true} onClose={onClose} />
67
+ );
68
+ fireEvent.click(screen.getByLabelText('KeyboardShortcuts.closeDialog'));
69
+ expect(onClose).toHaveBeenCalled();
70
+ });
71
+
72
+ it('renders footer close button', () => {
73
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
74
+ expect(screen.getByText('KeyboardShortcuts.close')).toBeInTheDocument();
75
+ });
76
+
77
+ it('calls onClose when footer close button is clicked', () => {
78
+ const onClose = vi.fn();
79
+ renderWithProviders(
80
+ <KeyboardShortcutsHelpModal isOpen={true} onClose={onClose} />
81
+ );
82
+ fireEvent.click(screen.getByText('KeyboardShortcuts.close'));
83
+ expect(onClose).toHaveBeenCalled();
84
+ });
85
+
86
+ it('renders platform note for non-Mac', () => {
87
+ renderWithProviders(<KeyboardShortcutsHelpModal {...defaultProps} />);
88
+ expect(screen.getByText('KeyboardShortcuts.windowsNote')).toBeInTheDocument();
89
+ });
90
+ });