@semiont/react-ui 0.2.36 → 0.2.38

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 (33) 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 +1 -1
  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/resource/AnnotateView.tsx +27 -153
  21. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
  22. package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
  23. package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
  24. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
  25. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
  26. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
  27. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
  28. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
  29. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
  30. package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
  31. package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
  32. package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
  33. package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -4,29 +4,33 @@ import { useEffect, useRef } from 'react';
4
4
  import { EditorView, Decoration, DecorationSet, lineNumbers } from '@codemirror/view';
5
5
  import { EditorState, RangeSetBuilder, StateField, StateEffect, Compartment } from '@codemirror/state';
6
6
  import { markdown } from '@codemirror/lang-markdown';
7
- import { ANNOTATORS } from '../lib/annotation-registry';
8
7
  import { ReferenceResolutionWidget, showWidgetPreview, hideWidgetPreview } from '../lib/codemirror-widgets';
9
8
  import { scrollAnnotationIntoView } from '../lib/scroll-utils';
10
- import { isHighlight, isReference, isResolvedReference, isComment, isAssessment, isTag, getBodySource } from '@semiont/api-client';
11
- import type { components } from '@semiont/core';
9
+ import { isReference } from '@semiont/api-client';
12
10
  import type { EventBus } from "@semiont/core";
13
11
  import { createHoverHandlers } from '../hooks/useBeckonFlow';
14
-
15
- type Annotation = components['schemas']['Annotation'];
12
+ import {
13
+ convertSegmentPositions,
14
+ computeAnnotationDecorations,
15
+ computeWidgetDecorations,
16
+ } from '../lib/codemirror-logic';
17
+ import type { TextSegment } from '../lib/codemirror-logic';
18
+ import {
19
+ handleAnnotationClick,
20
+ handleWidgetClick as processWidgetClick,
21
+ dispatchWidgetClick,
22
+ handleWidgetMouseEnter as processWidgetMouseEnter,
23
+ handleWidgetMouseLeave as processWidgetMouseLeave,
24
+ } from '../lib/codemirror-handlers';
25
+
26
+ // Re-export TextSegment for consumers
27
+ export type { TextSegment } from '../lib/codemirror-logic';
16
28
 
17
29
  // Type augmentation for custom DOM properties used to store CodeMirror state
18
30
  interface EnrichedHTMLElement extends HTMLElement {
19
- __lastHoveredAnnotation?: string | null;
20
31
  __cmView?: EditorView;
21
32
  }
22
33
 
23
- export interface TextSegment {
24
- exact: string;
25
- annotation?: Annotation;
26
- start: number;
27
- end: number;
28
- }
29
-
30
34
  interface Props {
31
35
  content: string;
32
36
  segments?: TextSegment[]; // Optional - only needed for annotation rendering
@@ -63,118 +67,24 @@ interface WidgetUpdate {
63
67
 
64
68
  const updateWidgetsEffect = StateEffect.define<WidgetUpdate>();
65
69
 
66
- /**
67
- * Convert positions from CRLF character space to LF character space.
68
- * CodeMirror normalizes all line endings to LF internally, but annotation positions
69
- * are calculated in the original content's character space (which may have CRLF).
70
- *
71
- * @param segments - Segments with positions in CRLF space
72
- * @param content - Original content (may have CRLF line endings)
73
- * @returns Segments with positions adjusted for LF space
74
- */
75
- function convertSegmentPositions(segments: TextSegment[], content: string): TextSegment[] {
76
- // If content has no CRLF, no conversion needed
77
- if (!content.includes('\r\n')) {
78
- return segments;
79
- }
80
-
81
- // Build a map of CRLF positions for efficient lookup
82
- const crlfPositions: number[] = [];
83
- for (let i = 0; i < content.length - 1; i++) {
84
- if (content[i] === '\r' && content[i + 1] === '\n') {
85
- crlfPositions.push(i);
86
- }
87
- }
88
-
89
- // Binary search: count CRLFs before a position in O(log n)
90
- const convertPosition = (pos: number): number => {
91
- let lo = 0;
92
- let hi = crlfPositions.length;
93
- while (lo < hi) {
94
- const mid = (lo + hi) >>> 1;
95
- if (crlfPositions[mid]! < pos) lo = mid + 1;
96
- else hi = mid;
97
- }
98
- return pos - lo;
99
- };
100
-
101
- return segments.map(seg => ({
102
- ...seg,
103
- start: convertPosition(seg.start),
104
- end: convertPosition(seg.end)
105
- }));
106
- }
107
-
108
- /**
109
- * Get tooltip text for annotation based on type/motivation
110
- */
111
- function getAnnotationTooltip(annotation: Annotation): string {
112
- const isCommentAnn = isComment(annotation);
113
- const isHighlightAnn = isHighlight(annotation);
114
- const isAssessmentAnn = isAssessment(annotation);
115
- const isTagAnn = isTag(annotation);
116
- const isReferenceAnn = isReference(annotation);
117
- const isResolvedRef = isResolvedReference(annotation);
118
-
119
- if (isCommentAnn) {
120
- return 'Comment';
121
- } else if (isHighlightAnn) {
122
- return 'Highlight';
123
- } else if (isAssessmentAnn) {
124
- return 'Assessment';
125
- } else if (isTagAnn) {
126
- return 'Tag';
127
- } else if (isResolvedRef) {
128
- return 'Resolved Reference';
129
- } else if (isReferenceAnn) {
130
- return 'Unresolved Reference';
131
- }
132
- return 'Annotation';
133
- }
134
-
135
- // Build decorations from segments
70
+ // Build CodeMirror decorations from pure metadata
136
71
  function buildAnnotationDecorations(
137
72
  segments: TextSegment[],
138
73
  newAnnotationIds?: Set<string>
139
74
  ): DecorationSet {
140
75
  const builder = new RangeSetBuilder<Decoration>();
76
+ const entries = computeAnnotationDecorations(segments, newAnnotationIds);
141
77
 
142
- const annotatedSegments = segments
143
- .filter(s => s.annotation)
144
- .sort((a, b) => a.start - b.start);
145
-
146
- for (const segment of annotatedSegments) {
147
- if (!segment.annotation) continue;
148
-
149
- const isNew = newAnnotationIds?.has(segment.annotation.id) || false;
150
- const baseClassName = Object.values(ANNOTATORS).find(a => a.matchesAnnotation(segment.annotation!))?.className || 'annotation-highlight';
151
- const className = isNew ? `${baseClassName} annotation-sparkle` : baseClassName;
152
-
153
- // Use W3C helpers to determine annotation type
154
- const isHighlightAnn = isHighlight(segment.annotation);
155
- const isReferenceAnn = isReference(segment.annotation);
156
- const isCommentAnn = isComment(segment.annotation);
157
- const isAssessmentAnn = isAssessment(segment.annotation);
158
- const isTagAnn = isTag(segment.annotation);
159
-
160
- // Determine annotation type for data attribute - use motivation directly
161
- let annotationType = 'highlight'; // default
162
- if (isCommentAnn) annotationType = 'comment';
163
- else if (isReferenceAnn) annotationType = 'reference';
164
- else if (isAssessmentAnn) annotationType = 'assessment';
165
- else if (isTagAnn) annotationType = 'tag';
166
- else if (isHighlightAnn) annotationType = 'highlight';
167
-
78
+ for (const { start, end, meta } of entries) {
168
79
  const decoration = Decoration.mark({
169
- class: className,
80
+ class: meta.className,
170
81
  attributes: {
171
- 'data-annotation-id': segment.annotation.id,
172
- 'data-annotation-type': annotationType,
173
- title: getAnnotationTooltip(segment.annotation)
174
- }
82
+ 'data-annotation-id': meta.annotationId,
83
+ 'data-annotation-type': meta.annotationType,
84
+ title: meta.tooltip,
85
+ },
175
86
  });
176
-
177
- builder.add(segment.start, segment.end, decoration);
87
+ builder.add(start, end, decoration);
178
88
  }
179
89
 
180
90
  return builder.finish();
@@ -201,7 +111,7 @@ function createAnnotationDecorationsField() {
201
111
  });
202
112
  }
203
113
 
204
- // Build widget decorations
114
+ // Build widget decorations using pure metadata
205
115
  function buildWidgetDecorations(
206
116
  _content: string,
207
117
  segments: TextSegment[],
@@ -209,39 +119,26 @@ function buildWidgetDecorations(
209
119
  getTargetDocumentName?: (documentId: string) => string | undefined
210
120
  ): DecorationSet {
211
121
  const builder = new RangeSetBuilder<Decoration>();
122
+ const widgetMetas = computeWidgetDecorations(segments, generatingReferenceId, getTargetDocumentName);
212
123
 
213
- // Process all annotations (references and highlights) in sorted order
214
- // This ensures decorations are added in the correct order for CodeMirror
215
- const allAnnotatedSegments = segments
216
- .filter(s => s.annotation)
217
- .sort((a, b) => a.end - b.end); // Sort by end position
218
-
219
- for (const segment of allAnnotatedSegments) {
220
- if (!segment.annotation) continue;
221
-
222
- const annotation = segment.annotation;
223
-
224
- // For references: add resolution widget (🔗, ✨ pulsing, or ❓)
225
- if (isReference(annotation)) {
226
- const bodySource = getBodySource(annotation.body);
227
- const targetName = bodySource
228
- ? getTargetDocumentName?.(bodySource)
229
- : undefined;
230
- const isGenerating = generatingReferenceId
231
- ? annotation.id === generatingReferenceId
232
- : false;
233
- const widget = new ReferenceResolutionWidget(
234
- annotation,
235
- targetName,
236
- isGenerating
237
- );
238
- builder.add(
239
- segment.end,
240
- segment.end,
241
- Decoration.widget({ widget, side: 1 })
242
- );
124
+ // We still need the full annotation objects for ReferenceResolutionWidget
125
+ const annotationsByEnd = new Map<number, TextSegment>();
126
+ for (const s of segments) {
127
+ if (s.annotation && isReference(s.annotation)) {
128
+ annotationsByEnd.set(s.end, s);
243
129
  }
130
+ }
131
+
132
+ for (const meta of widgetMetas) {
133
+ const segment = annotationsByEnd.get(meta.position);
134
+ if (!segment?.annotation) continue;
244
135
 
136
+ const widget = new ReferenceResolutionWidget(
137
+ segment.annotation,
138
+ meta.targetName,
139
+ meta.isGenerating
140
+ );
141
+ builder.add(meta.position, meta.position, Decoration.widget({ widget, side: 1 }));
245
142
  }
246
143
 
247
144
  return builder.finish();
@@ -337,28 +234,18 @@ export function CodeMirrorRenderer({
337
234
  onChange(newContent);
338
235
  }
339
236
  }),
340
- // Handle clicks on annotations
237
+ // Handle clicks on annotations — delegates to extracted handler
341
238
  EditorView.domEventHandlers({
342
239
  click: (event, _view) => {
343
240
  const target = event.target as HTMLElement;
344
- const annotationElement = target.closest('[data-annotation-id]');
345
- const annotationId = annotationElement?.getAttribute('data-annotation-id');
346
-
347
- if (annotationId && eventBusRef.current) {
348
- const segment = segmentsByIdRef.current.get(annotationId);
349
- if (segment?.annotation) {
350
- event.preventDefault();
351
- eventBusRef.current.get('browse:click').next({
352
- annotationId,
353
- motivation: segment.annotation.motivation
354
- });
355
- return true; // Stop propagation
356
- }
241
+ if (eventBusRef.current && handleAnnotationClick(target, segmentsByIdRef.current, eventBusRef.current)) {
242
+ event.preventDefault();
243
+ return true;
357
244
  }
358
245
  return false;
359
246
  }
360
247
  }),
361
- // Style the editor - use CSS string to inject !important rules
248
+ // Style the editor
362
249
  EditorView.baseTheme({
363
250
  '&.cm-editor': {
364
251
  height: '100%',
@@ -368,7 +255,7 @@ export function CodeMirrorRenderer({
368
255
  outline: 'none'
369
256
  },
370
257
  '.cm-scroller': {
371
- overflow: 'visible !important', // Let parent container handle scrolling
258
+ overflow: 'visible !important',
372
259
  height: 'auto !important'
373
260
  },
374
261
  '.cm-content, .cm-gutters': {
@@ -433,67 +320,48 @@ export function CodeMirrorRenderer({
433
320
  if (annotationElement) handleMouseLeave();
434
321
  };
435
322
 
436
- // Delegated widget event handlers (replaces per-widget listeners)
437
- const handleWidgetClick = (e: MouseEvent) => {
323
+ // Delegated widget event handlers delegates to extracted handlers
324
+ const onWidgetClick = (e: MouseEvent) => {
438
325
  const target = e.target as HTMLElement;
439
- const widget = target.closest('.reference-preview-widget') as HTMLElement | null;
440
- if (!widget || widget.dataset.widgetGenerating === 'true') return;
326
+ const result = processWidgetClick(target);
327
+ if (!result.handled) return;
441
328
 
442
329
  e.preventDefault();
443
330
  e.stopPropagation();
444
331
 
445
- const annotationId = widget.dataset.widgetAnnotationId;
446
- const bodySource = widget.dataset.widgetBodySource;
447
- const isResolved = widget.dataset.widgetResolved === 'true';
448
-
449
- if (!annotationId || !eventBusRef.current) return;
450
-
451
- if (isResolved && bodySource) {
452
- eventBusRef.current.get('browse:reference-navigate').next({ documentId: bodySource });
453
- } else {
454
- const motivation = (widget.dataset.widgetMotivation || 'linking') as Annotation['motivation'];
455
- eventBusRef.current.get('browse:click').next({ annotationId, motivation });
332
+ if (eventBusRef.current) {
333
+ dispatchWidgetClick(result, eventBusRef.current);
456
334
  }
457
335
  };
458
336
 
459
- const handleWidgetMouseEnter = (e: MouseEvent) => {
337
+ const onWidgetMouseEnter = (e: MouseEvent) => {
460
338
  const target = e.target as HTMLElement;
461
- const widget = target.closest('.reference-preview-widget') as HTMLElement | null;
462
- if (!widget || widget.dataset.widgetGenerating === 'true') return;
463
-
464
- const indicator = widget.querySelector('.reference-indicator') as HTMLElement | null;
465
- if (indicator) indicator.style.opacity = '1';
466
-
467
- if (widget.dataset.widgetResolved === 'true' && widget.dataset.widgetTargetName) {
468
- showWidgetPreview(widget, widget.dataset.widgetTargetName);
339
+ const result = processWidgetMouseEnter(target);
340
+ if (result.showPreview && result.targetName && result.widget) {
341
+ showWidgetPreview(result.widget, result.targetName);
469
342
  }
470
343
  };
471
344
 
472
- const handleWidgetMouseLeave = (e: MouseEvent) => {
345
+ const onWidgetMouseLeave = (e: MouseEvent) => {
473
346
  const target = e.target as HTMLElement;
474
- const widget = target.closest('.reference-preview-widget') as HTMLElement | null;
475
- if (!widget) return;
476
-
477
- const indicator = widget.querySelector('.reference-indicator') as HTMLElement | null;
478
- if (indicator) indicator.style.opacity = '0.6';
479
-
480
- if (widget.dataset.widgetResolved === 'true') {
481
- hideWidgetPreview(widget);
347
+ const result = processWidgetMouseLeave(target);
348
+ if (result.hidePreview && result.widget) {
349
+ hideWidgetPreview(result.widget);
482
350
  }
483
351
  };
484
352
 
485
353
  container.addEventListener('mouseover', handleMouseOver);
486
354
  container.addEventListener('mouseout', handleMouseOut);
487
- container.addEventListener('click', handleWidgetClick);
488
- container.addEventListener('mouseenter', handleWidgetMouseEnter, true);
489
- container.addEventListener('mouseleave', handleWidgetMouseLeave, true);
355
+ container.addEventListener('click', onWidgetClick);
356
+ container.addEventListener('mouseenter', onWidgetMouseEnter, true);
357
+ container.addEventListener('mouseleave', onWidgetMouseLeave, true);
490
358
 
491
359
  return () => {
492
360
  container.removeEventListener('mouseover', handleMouseOver);
493
361
  container.removeEventListener('mouseout', handleMouseOut);
494
- container.removeEventListener('click', handleWidgetClick);
495
- container.removeEventListener('mouseenter', handleWidgetMouseEnter, true);
496
- container.removeEventListener('mouseleave', handleWidgetMouseLeave, true);
362
+ container.removeEventListener('click', onWidgetClick);
363
+ container.removeEventListener('mouseenter', onWidgetMouseEnter, true);
364
+ container.removeEventListener('mouseleave', onWidgetMouseLeave, true);
497
365
  cleanupHover();
498
366
  view.destroy();
499
367
  viewRef.current = null;
@@ -617,4 +485,4 @@ export function CodeMirrorRenderer({
617
485
  : "semiont-codemirror";
618
486
 
619
487
  return <div ref={containerRef} className={containerClasses} data-markdown-container />;
620
- }
488
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, 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, resetEventBusForTesting } from '../../test-utils';
6
+ import { AnnotateReferencesProgressWidget } from '../AnnotateReferencesProgressWidget';
7
+ import type { MarkProgress } from '@semiont/core';
8
+
9
+ describe('AnnotateReferencesProgressWidget', () => {
10
+ beforeEach(() => {
11
+ resetEventBusForTesting();
12
+ });
13
+
14
+ it('returns null when progress is null', () => {
15
+ const { container } = renderWithProviders(
16
+ <AnnotateReferencesProgressWidget progress={null} />
17
+ );
18
+
19
+ expect(container.firstChild).toBeNull();
20
+ });
21
+
22
+ it('renders progress with status message', () => {
23
+ const progress: MarkProgress = {
24
+ status: 'in-progress',
25
+ message: 'Processing entities...',
26
+ };
27
+
28
+ renderWithProviders(
29
+ <AnnotateReferencesProgressWidget progress={progress} />
30
+ );
31
+
32
+ expect(screen.getByText('Processing entities...')).toBeInTheDocument();
33
+ });
34
+
35
+ it('shows cancel button when not complete', () => {
36
+ const progress: MarkProgress = {
37
+ status: 'in-progress',
38
+ message: 'Working...',
39
+ };
40
+
41
+ renderWithProviders(
42
+ <AnnotateReferencesProgressWidget progress={progress} />
43
+ );
44
+
45
+ const cancelButton = screen.getByTitle('ReferencesPanel.cancelAnnotation');
46
+ expect(cancelButton).toBeInTheDocument();
47
+ });
48
+
49
+ it('hides cancel button when complete', () => {
50
+ const progress: MarkProgress = {
51
+ status: 'complete',
52
+ };
53
+
54
+ renderWithProviders(
55
+ <AnnotateReferencesProgressWidget progress={progress} />
56
+ );
57
+
58
+ expect(screen.queryByTitle('ReferencesPanel.cancelAnnotation')).not.toBeInTheDocument();
59
+ });
60
+
61
+ it('emits job:cancel-requested on cancel click', () => {
62
+ const handler = vi.fn();
63
+ const progress: MarkProgress = {
64
+ status: 'in-progress',
65
+ message: 'Working...',
66
+ };
67
+
68
+ const { eventBus } = renderWithProviders(
69
+ <AnnotateReferencesProgressWidget progress={progress} />,
70
+ { returnEventBus: true }
71
+ );
72
+
73
+ const subscription = eventBus!.get('job:cancel-requested').subscribe(handler);
74
+
75
+ const cancelButton = screen.getByTitle('ReferencesPanel.cancelAnnotation');
76
+ fireEvent.click(cancelButton);
77
+
78
+ expect(handler).toHaveBeenCalledWith({ jobType: 'annotation' });
79
+
80
+ subscription.unsubscribe();
81
+ });
82
+
83
+ it('renders completed entity types', () => {
84
+ const progress: MarkProgress = {
85
+ status: 'in-progress',
86
+ completedEntityTypes: [
87
+ { entityType: 'Person', foundCount: 5 },
88
+ { entityType: 'Organization', foundCount: 3 },
89
+ ],
90
+ };
91
+
92
+ renderWithProviders(
93
+ <AnnotateReferencesProgressWidget progress={progress} />
94
+ );
95
+
96
+ expect(screen.getByText('Person:')).toBeInTheDocument();
97
+ expect(screen.getByText('Organization:')).toBeInTheDocument();
98
+ // Translation mock returns "ReferencesPanel.found" for each entity type
99
+ const foundLabels = screen.getAllByText('ReferencesPanel.found');
100
+ expect(foundLabels).toHaveLength(2);
101
+ });
102
+
103
+ it('shows complete icon for complete status', () => {
104
+ const progress: MarkProgress = {
105
+ status: 'complete',
106
+ };
107
+
108
+ const { container } = renderWithProviders(
109
+ <AnnotateReferencesProgressWidget progress={progress} />
110
+ );
111
+
112
+ expect(container.querySelector('[data-status="complete"]')).toBeInTheDocument();
113
+ expect(screen.getByText('ReferencesPanel.complete')).toBeInTheDocument();
114
+ });
115
+
116
+ it('shows error message for error status', () => {
117
+ const progress: MarkProgress = {
118
+ status: 'error',
119
+ message: 'Something went wrong',
120
+ };
121
+
122
+ const { container } = renderWithProviders(
123
+ <AnnotateReferencesProgressWidget progress={progress} />
124
+ );
125
+
126
+ expect(container.querySelector('[data-status="error"]')).toBeInTheDocument();
127
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
128
+ });
129
+
130
+ it('shows current entity type processing details', () => {
131
+ const progress: MarkProgress = {
132
+ status: 'in-progress',
133
+ currentEntityType: 'Location',
134
+ };
135
+
136
+ renderWithProviders(
137
+ <AnnotateReferencesProgressWidget progress={progress} />
138
+ );
139
+
140
+ expect(screen.getByText(/Processing: Location/)).toBeInTheDocument();
141
+ });
142
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import React from 'react';
3
+ import { renderHook, act } from '@testing-library/react';
4
+ import { render, screen } from '@testing-library/react';
5
+ import '@testing-library/jest-dom';
6
+ import {
7
+ LiveRegionProvider,
8
+ useFormAnnouncements,
9
+ useLanguageChangeAnnouncements,
10
+ } from '../LiveRegion';
11
+
12
+ function Wrapper({ children }: { children: React.ReactNode }) {
13
+ return <LiveRegionProvider>{children}</LiveRegionProvider>;
14
+ }
15
+
16
+ describe('useFormAnnouncements', () => {
17
+ it('announceFormSubmitting sets polite message', () => {
18
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
19
+
20
+ act(() => result.current.announceFormSubmitting());
21
+
22
+ const { container } = render(
23
+ <LiveRegionProvider>
24
+ <div />
25
+ </LiveRegionProvider>
26
+ );
27
+ // The announcement happens via context; verify the hook returns the functions
28
+ expect(typeof result.current.announceFormSubmitting).toBe('function');
29
+ expect(typeof result.current.announceFormSuccess).toBe('function');
30
+ expect(typeof result.current.announceFormError).toBe('function');
31
+ expect(typeof result.current.announceFormValidationError).toBe('function');
32
+ container.remove();
33
+ });
34
+
35
+ it('announceFormSuccess uses custom message', () => {
36
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
37
+ act(() => result.current.announceFormSuccess('Created!'));
38
+ // No throw = success
39
+ });
40
+
41
+ it('announceFormSuccess uses default message', () => {
42
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
43
+ act(() => result.current.announceFormSuccess());
44
+ });
45
+
46
+ it('announceFormError uses custom message', () => {
47
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
48
+ act(() => result.current.announceFormError('Network error'));
49
+ });
50
+
51
+ it('announceFormError uses default message', () => {
52
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
53
+ act(() => result.current.announceFormError());
54
+ });
55
+
56
+ it('announceFormValidationError with 1 field', () => {
57
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
58
+ act(() => result.current.announceFormValidationError(1));
59
+ });
60
+
61
+ it('announceFormValidationError with multiple fields', () => {
62
+ const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
63
+ act(() => result.current.announceFormValidationError(3));
64
+ });
65
+ });
66
+
67
+ describe('useLanguageChangeAnnouncements', () => {
68
+ it('announceLanguageChanging is callable', () => {
69
+ const { result } = renderHook(() => useLanguageChangeAnnouncements(), { wrapper: Wrapper });
70
+ expect(typeof result.current.announceLanguageChanging).toBe('function');
71
+ act(() => result.current.announceLanguageChanging('French'));
72
+ });
73
+
74
+ it('announceLanguageChanged is callable', () => {
75
+ const { result } = renderHook(() => useLanguageChangeAnnouncements(), { wrapper: Wrapper });
76
+ expect(typeof result.current.announceLanguageChanged).toBe('function');
77
+ act(() => result.current.announceLanguageChanged('German'));
78
+ });
79
+ });