@semiont/react-ui 0.5.9 → 0.5.11

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 (209) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-U4EVBZEV.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +5 -2
  3. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
  4. package/dist/{ar-WA47UUWA.js → ar-ZHKO4XDR.js} +45 -4
  5. package/dist/ar-ZHKO4XDR.js.map +1 -0
  6. package/dist/{bn-5ANDRIU6.js → bn-3BUDKAK5.js} +45 -4
  7. package/dist/bn-3BUDKAK5.js.map +1 -0
  8. package/dist/{chunk-YBGN4ACY.js → chunk-O23TLLXW.js} +1 -6
  9. package/dist/{chunk-WHUGODTB.js → chunk-PFUXQPWJ.js} +127 -127
  10. package/dist/{chunk-WHUGODTB.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  11. package/dist/{chunk-GERSK2DY.js → chunk-R52YAGHK.js} +42 -1
  12. package/dist/chunk-R52YAGHK.js.map +1 -0
  13. package/dist/{cs-3RU7F4JX.js → cs-2EFEUKTW.js} +45 -4
  14. package/dist/cs-2EFEUKTW.js.map +1 -0
  15. package/dist/{da-GSW5P5HG.js → da-SUD3Q44Q.js} +45 -4
  16. package/dist/da-SUD3Q44Q.js.map +1 -0
  17. package/dist/{de-JUAUYF4Z.js → de-6IC5QRX7.js} +45 -4
  18. package/dist/de-6IC5QRX7.js.map +1 -0
  19. package/dist/{el-JNLWCKEC.js → el-Q4NAXFRC.js} +45 -4
  20. package/dist/el-Q4NAXFRC.js.map +1 -0
  21. package/dist/{en-RBN2GUHF.js → en-P2L4X27N.js} +5 -3
  22. package/dist/{es-WPOX225H.js → es-YUKZBD4W.js} +45 -4
  23. package/dist/es-YUKZBD4W.js.map +1 -0
  24. package/dist/{fa-TAEP6N77.js → fa-OJ47LILE.js} +45 -4
  25. package/dist/fa-OJ47LILE.js.map +1 -0
  26. package/dist/{fi-ZVZHANSP.js → fi-QFFL5CUB.js} +45 -4
  27. package/dist/fi-QFFL5CUB.js.map +1 -0
  28. package/dist/{fr-VLZW7M4N.js → fr-BOQZZWP4.js} +45 -4
  29. package/dist/fr-BOQZZWP4.js.map +1 -0
  30. package/dist/{he-QFAFYA77.js → he-LXDY3TFA.js} +45 -4
  31. package/dist/he-LXDY3TFA.js.map +1 -0
  32. package/dist/{hi-AO3DQPCV.js → hi-HOJER77V.js} +45 -4
  33. package/dist/hi-HOJER77V.js.map +1 -0
  34. package/dist/{id-GTXF42WM.js → id-I5WQ6EWD.js} +45 -4
  35. package/dist/id-I5WQ6EWD.js.map +1 -0
  36. package/dist/index.css +150 -112
  37. package/dist/index.css.map +1 -1
  38. package/dist/index.d.ts +403 -45
  39. package/dist/index.js +4660 -18860
  40. package/dist/index.js.map +1 -1
  41. package/dist/integrations/css-modules-helper.js +1 -1
  42. package/dist/integrations/styled-components-theme.js +1 -1
  43. package/dist/{it-AS5GM232.js → it-UYH642IJ.js} +45 -4
  44. package/dist/it-UYH642IJ.js.map +1 -0
  45. package/dist/{ja-GZ4HLUOF.js → ja-JTVWAC4L.js} +45 -4
  46. package/dist/ja-JTVWAC4L.js.map +1 -0
  47. package/dist/{ko-A4XUXFGJ.js → ko-IHXFMOIZ.js} +45 -4
  48. package/dist/ko-IHXFMOIZ.js.map +1 -0
  49. package/dist/{ms-YBAO3S6K.js → ms-JH25VFLO.js} +45 -4
  50. package/dist/ms-JH25VFLO.js.map +1 -0
  51. package/dist/{nl-3TJGIIIU.js → nl-SURXW7IQ.js} +45 -4
  52. package/dist/nl-SURXW7IQ.js.map +1 -0
  53. package/dist/{no-4AXIQPRQ.js → no-HXIKQFDZ.js} +45 -4
  54. package/dist/no-HXIKQFDZ.js.map +1 -0
  55. package/dist/{pl-5GP6GKXO.js → pl-7A4KU6XK.js} +45 -4
  56. package/dist/pl-7A4KU6XK.js.map +1 -0
  57. package/dist/{pt-26Y6JFG5.js → pt-6MGYP7Q5.js} +45 -4
  58. package/dist/pt-6MGYP7Q5.js.map +1 -0
  59. package/dist/{ro-C7UXFRWJ.js → ro-IROCHNIA.js} +45 -4
  60. package/dist/ro-IROCHNIA.js.map +1 -0
  61. package/dist/{sv-44DVD76T.js → sv-WDJD75CV.js} +45 -4
  62. package/dist/sv-WDJD75CV.js.map +1 -0
  63. package/dist/test-utils.js +9 -4
  64. package/dist/test-utils.js.map +1 -1
  65. package/dist/{th-GIQRTBOY.js → th-RIO2E6B3.js} +45 -4
  66. package/dist/th-RIO2E6B3.js.map +1 -0
  67. package/dist/{tr-WMQWO4D6.js → tr-TZ7ERCIN.js} +45 -4
  68. package/dist/tr-TZ7ERCIN.js.map +1 -0
  69. package/dist/{uk-I7ML6RGG.js → uk-ZFWDKLUE.js} +45 -4
  70. package/dist/uk-ZFWDKLUE.js.map +1 -0
  71. package/dist/{vi-FGWECASQ.js → vi-UIOSP2JG.js} +45 -4
  72. package/dist/vi-UIOSP2JG.js.map +1 -0
  73. package/dist/{zh-L5FN73XC.js → zh-6R6YUY46.js} +45 -4
  74. package/dist/zh-6R6YUY46.js.map +1 -0
  75. package/package.json +18 -9
  76. package/src/components/AnnotateReferencesProgressWidget.tsx +35 -16
  77. package/src/components/ResourceTagsInline.tsx +94 -11
  78. package/src/components/StatusDisplay.css +36 -0
  79. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +41 -91
  80. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  81. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  82. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  83. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  84. package/src/components/image-annotation/SvgDrawingCanvas.tsx +5 -1
  85. package/src/components/modals/ConfigureGatherStep.tsx +102 -0
  86. package/src/components/modals/ContextSummary.tsx +15 -6
  87. package/src/components/modals/GatherContextStep.tsx +104 -59
  88. package/src/components/modals/ResourceGenerateModal.tsx +258 -0
  89. package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +82 -0
  90. package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +107 -0
  91. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +197 -0
  92. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
  93. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
  94. package/src/components/resource/AnnotateView.tsx +59 -38
  95. package/src/components/resource/BrowseView.tsx +159 -133
  96. package/src/components/resource/ResourceViewer.tsx +119 -128
  97. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  98. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  99. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  100. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  101. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  102. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  103. package/src/components/resource/__tests__/BrowseView.test.tsx +35 -22
  104. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -0
  105. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  106. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  107. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  108. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  109. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  110. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  111. package/src/components/resource/browse-renderers.tsx +63 -0
  112. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  113. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  114. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  115. package/src/components/resource/panels/ReferencesPanel.tsx +14 -0
  116. package/src/components/resource/panels/ResourceInfoPanel.tsx +22 -0
  117. package/src/components/resource/panels/TaggingPanel.tsx +4 -0
  118. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
  119. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
  120. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
  121. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
  122. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  123. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
  124. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +26 -0
  125. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
  126. package/src/components/toolbar/Toolbar.css +14 -0
  127. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +14 -0
  128. package/src/features/admin-exchange/state/exchange-state-unit.ts +4 -2
  129. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +14 -0
  130. package/src/features/admin-security/state/admin-security-state-unit.ts +4 -2
  131. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +12 -5
  132. package/src/features/admin-users/state/admin-users-state-unit.ts +4 -2
  133. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +11 -0
  134. package/src/features/auth-welcome/state/welcome-state-unit.ts +1 -1
  135. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +14 -0
  136. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +4 -2
  137. package/src/features/resource-compose/components/ResourceComposePage.tsx +13 -7
  138. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +13 -0
  139. package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -2
  140. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +15 -9
  141. package/src/features/resource-discovery/state/discover-state-unit.ts +5 -2
  142. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  143. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +119 -17
  144. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +11 -0
  145. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +30 -0
  146. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +1 -1
  147. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +7 -16
  148. package/src/styles/features/resource-viewer.css +19 -0
  149. package/translations/ar.json +41 -2
  150. package/translations/bn.json +41 -2
  151. package/translations/cs.json +41 -2
  152. package/translations/da.json +41 -2
  153. package/translations/de.json +41 -2
  154. package/translations/el.json +41 -2
  155. package/translations/en.json +39 -0
  156. package/translations/es.json +41 -2
  157. package/translations/fa.json +41 -2
  158. package/translations/fi.json +41 -2
  159. package/translations/fr.json +41 -2
  160. package/translations/he.json +41 -2
  161. package/translations/hi.json +41 -2
  162. package/translations/id.json +41 -2
  163. package/translations/it.json +41 -2
  164. package/translations/ja.json +41 -2
  165. package/translations/ko.json +41 -2
  166. package/translations/ms.json +41 -2
  167. package/translations/nl.json +41 -2
  168. package/translations/no.json +41 -2
  169. package/translations/pl.json +41 -2
  170. package/translations/pt.json +41 -2
  171. package/translations/ro.json +41 -2
  172. package/translations/sv.json +41 -2
  173. package/translations/th.json +41 -2
  174. package/translations/tr.json +41 -2
  175. package/translations/uk.json +41 -2
  176. package/translations/vi.json +41 -2
  177. package/translations/zh.json +41 -2
  178. package/dist/PdfAnnotationCanvas.client-U4EVBZEV.js.map +0 -1
  179. package/dist/ar-WA47UUWA.js.map +0 -1
  180. package/dist/bn-5ANDRIU6.js.map +0 -1
  181. package/dist/chunk-GERSK2DY.js.map +0 -1
  182. package/dist/cs-3RU7F4JX.js.map +0 -1
  183. package/dist/da-GSW5P5HG.js.map +0 -1
  184. package/dist/de-JUAUYF4Z.js.map +0 -1
  185. package/dist/el-JNLWCKEC.js.map +0 -1
  186. package/dist/es-WPOX225H.js.map +0 -1
  187. package/dist/fa-TAEP6N77.js.map +0 -1
  188. package/dist/fi-ZVZHANSP.js.map +0 -1
  189. package/dist/fr-VLZW7M4N.js.map +0 -1
  190. package/dist/he-QFAFYA77.js.map +0 -1
  191. package/dist/hi-AO3DQPCV.js.map +0 -1
  192. package/dist/id-GTXF42WM.js.map +0 -1
  193. package/dist/it-AS5GM232.js.map +0 -1
  194. package/dist/ja-GZ4HLUOF.js.map +0 -1
  195. package/dist/ko-A4XUXFGJ.js.map +0 -1
  196. package/dist/ms-YBAO3S6K.js.map +0 -1
  197. package/dist/nl-3TJGIIIU.js.map +0 -1
  198. package/dist/no-4AXIQPRQ.js.map +0 -1
  199. package/dist/pl-5GP6GKXO.js.map +0 -1
  200. package/dist/pt-26Y6JFG5.js.map +0 -1
  201. package/dist/ro-C7UXFRWJ.js.map +0 -1
  202. package/dist/sv-44DVD76T.js.map +0 -1
  203. package/dist/th-GIQRTBOY.js.map +0 -1
  204. package/dist/tr-WMQWO4D6.js.map +0 -1
  205. package/dist/uk-I7ML6RGG.js.map +0 -1
  206. package/dist/vi-FGWECASQ.js.map +0 -1
  207. package/dist/zh-L5FN73XC.js.map +0 -1
  208. /package/dist/{chunk-YBGN4ACY.js.map → chunk-O23TLLXW.js.map} +0 -0
  209. /package/dist/{en-RBN2GUHF.js.map → en-P2L4X27N.js.map} +0 -0
@@ -209,6 +209,42 @@
209
209
  font-size: var(--semiont-font-size-xs);
210
210
  }
211
211
 
212
+ /* Editing affordances (RESOURCE-TAGS-INLINE-EDITING) */
213
+ .semiont-resource-tag__remove {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ padding: 0;
217
+ border: none;
218
+ background: none;
219
+ color: inherit;
220
+ font-size: var(--semiont-font-size-xs);
221
+ cursor: pointer;
222
+ opacity: 0.7;
223
+ }
224
+
225
+ .semiont-resource-tag__remove:hover:not(:disabled) {
226
+ opacity: 1;
227
+ }
228
+
229
+ .semiont-resource-tag__remove:disabled {
230
+ cursor: default;
231
+ opacity: 0.4;
232
+ }
233
+
234
+ .semiont-resource-tag--add {
235
+ cursor: pointer;
236
+ background: none;
237
+ }
238
+
239
+ .semiont-resource-tag--add:disabled {
240
+ cursor: default;
241
+ opacity: 0.4;
242
+ }
243
+
244
+ .semiont-resource-tags-inline--editing .semiont-form__entity-type-buttons {
245
+ margin-top: var(--semiont-spacing-xs);
246
+ }
247
+
212
248
  /* Dark mode for ResourceTagsInline - Fixed to respect data-theme attribute */
213
249
  [data-theme="dark"] .semiont-resource-tags-inline {
214
250
  border-top-color: var(--semiont-color-neutral-700);
@@ -7,76 +7,56 @@ import type { components } from '@semiont/core';
7
7
 
8
8
  type JobProgress = components['schemas']['JobProgress'];
9
9
 
10
+ // The widget no longer reads translations internally — the caller supplies the
11
+ // copy + cancel job type. These are the annotation-flow values for these tests.
12
+ const tr = {
13
+ title: 'Annotating',
14
+ cancel: 'Cancel',
15
+ inProgress: 'In progress',
16
+ complete: 'Complete',
17
+ failed: 'Failed',
18
+ found: (count: number) => `Found ${count}`,
19
+ current: (entityType: string) => `Current ${entityType}`,
20
+ };
21
+
22
+ function renderWidget(progress: JobProgress | null, opts?: { returnEventBus?: boolean }) {
23
+ return renderWithProviders(
24
+ <AnnotateReferencesProgressWidget progress={progress} cancelJobType="annotation" translations={tr} />,
25
+ opts,
26
+ );
27
+ }
28
+
10
29
  describe('AnnotateReferencesProgressWidget', () => {
11
30
  it('returns null when progress is null', () => {
12
- const { container } = renderWithProviders(
13
- <AnnotateReferencesProgressWidget progress={null} />
14
- );
15
-
31
+ const { container } = renderWidget(null);
16
32
  expect(container.firstChild).toBeNull();
17
33
  });
18
34
 
19
35
  it('renders progress with stage message', () => {
20
- const progress: JobProgress = {
21
- stage: 'in-progress',
22
- percentage: 50,
23
- message: 'Processing entities...',
24
- };
25
-
26
- renderWithProviders(
27
- <AnnotateReferencesProgressWidget progress={progress} />
28
- );
29
-
36
+ const progress: JobProgress = { stage: 'in-progress', percentage: 50, message: 'Processing entities...' };
37
+ renderWidget(progress);
30
38
  expect(screen.getByText('Processing entities...')).toBeInTheDocument();
31
39
  });
32
40
 
33
41
  it('shows cancel button when not complete', () => {
34
- const progress: JobProgress = {
35
- stage: 'in-progress',
36
- percentage: 30,
37
- message: 'Working...',
38
- };
39
-
40
- renderWithProviders(
41
- <AnnotateReferencesProgressWidget progress={progress} />
42
- );
43
-
44
- const cancelButton = screen.getByTitle('ReferencesPanel.cancelAnnotation');
45
- expect(cancelButton).toBeInTheDocument();
42
+ const progress: JobProgress = { stage: 'in-progress', percentage: 30, message: 'Working...' };
43
+ renderWidget(progress);
44
+ expect(screen.getByTitle('Cancel')).toBeInTheDocument();
46
45
  });
47
46
 
48
47
  it('hides cancel button when complete', () => {
49
- const progress: JobProgress = {
50
- stage: 'complete',
51
- percentage: 100,
52
- message: '',
53
- };
54
-
55
- renderWithProviders(
56
- <AnnotateReferencesProgressWidget progress={progress} />
57
- );
58
-
59
- expect(screen.queryByTitle('ReferencesPanel.cancelAnnotation')).not.toBeInTheDocument();
48
+ const progress: JobProgress = { stage: 'complete', percentage: 100, message: '' };
49
+ renderWidget(progress);
50
+ expect(screen.queryByTitle('Cancel')).not.toBeInTheDocument();
60
51
  });
61
52
 
62
53
  it('emits job:cancel-requested on cancel click', () => {
63
54
  const handler = vi.fn();
64
- const progress: JobProgress = {
65
- stage: 'in-progress',
66
- percentage: 40,
67
- message: 'Working...',
68
- };
69
-
70
- const { eventBus } = renderWithProviders(
71
- <AnnotateReferencesProgressWidget progress={progress} />,
72
- { returnEventBus: true }
73
- );
55
+ const progress: JobProgress = { stage: 'in-progress', percentage: 40, message: 'Working...' };
56
+ const { eventBus } = renderWidget(progress, { returnEventBus: true });
74
57
 
75
58
  const subscription = eventBus!.get('job:cancel-requested').subscribe(handler);
76
-
77
- const cancelButton = screen.getByTitle('ReferencesPanel.cancelAnnotation');
78
- fireEvent.click(cancelButton);
79
-
59
+ fireEvent.click(screen.getByTitle('Cancel'));
80
60
  expect(handler).toHaveBeenCalledWith({ jobType: 'annotation' });
81
61
 
82
62
  subscription.unsubscribe();
@@ -92,60 +72,30 @@ describe('AnnotateReferencesProgressWidget', () => {
92
72
  { entityType: 'Organization', foundCount: 3 },
93
73
  ],
94
74
  };
95
-
96
- renderWithProviders(
97
- <AnnotateReferencesProgressWidget progress={progress} />
98
- );
99
-
75
+ renderWidget(progress);
100
76
  expect(screen.getByText('Person:')).toBeInTheDocument();
101
77
  expect(screen.getByText('Organization:')).toBeInTheDocument();
102
- // Translation mock returns "ReferencesPanel.found" for each entity type
103
- const foundLabels = screen.getAllByText('ReferencesPanel.found');
104
- expect(foundLabels).toHaveLength(2);
78
+ expect(screen.getByText('Found 5')).toBeInTheDocument();
79
+ expect(screen.getByText('Found 3')).toBeInTheDocument();
105
80
  });
106
81
 
107
82
  it('shows complete icon for complete stage', () => {
108
- const progress: JobProgress = {
109
- stage: 'complete',
110
- percentage: 100,
111
- message: '',
112
- };
113
-
114
- const { container } = renderWithProviders(
115
- <AnnotateReferencesProgressWidget progress={progress} />
116
- );
117
-
83
+ const progress: JobProgress = { stage: 'complete', percentage: 100, message: '' };
84
+ const { container } = renderWidget(progress);
118
85
  expect(container.querySelector('[data-status="complete"]')).toBeInTheDocument();
119
- expect(screen.getByText('ReferencesPanel.complete')).toBeInTheDocument();
86
+ expect(screen.getByText('Complete')).toBeInTheDocument();
120
87
  });
121
88
 
122
89
  it('shows error message for error stage', () => {
123
- const progress: JobProgress = {
124
- stage: 'error',
125
- percentage: 0,
126
- message: 'Something went wrong',
127
- };
128
-
129
- const { container } = renderWithProviders(
130
- <AnnotateReferencesProgressWidget progress={progress} />
131
- );
132
-
90
+ const progress: JobProgress = { stage: 'error', percentage: 0, message: 'Something went wrong' };
91
+ const { container } = renderWidget(progress);
133
92
  expect(container.querySelector('[data-status="error"]')).toBeInTheDocument();
134
93
  expect(screen.getByText('Something went wrong')).toBeInTheDocument();
135
94
  });
136
95
 
137
96
  it('shows current entity type processing details', () => {
138
- const progress: JobProgress = {
139
- stage: 'in-progress',
140
- percentage: 50,
141
- message: '',
142
- currentEntityType: 'Location',
143
- };
144
-
145
- renderWithProviders(
146
- <AnnotateReferencesProgressWidget progress={progress} />
147
- );
148
-
97
+ const progress: JobProgress = { stage: 'in-progress', percentage: 50, message: '', currentEntityType: 'Location' };
98
+ renderWidget(progress);
149
99
  expect(screen.getByText(/Processing: Location/)).toBeInTheDocument();
150
100
  });
151
101
  });
@@ -1,9 +1,9 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { render, screen } from '@testing-library/react';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import { ResourceTagsInline } from '../ResourceTagsInline';
4
4
 
5
5
  describe('ResourceTagsInline', () => {
6
- const mockOnUpdate = vi.fn();
6
+ const mockOnUpdate = vi.fn(async () => {});
7
7
  const defaultProps = {
8
8
  resourceId: 'doc-123',
9
9
  tags: ['tag1', 'tag2', 'tag3'],
@@ -11,6 +11,11 @@ describe('ResourceTagsInline', () => {
11
11
  onUpdate: mockOnUpdate,
12
12
  };
13
13
 
14
+ beforeEach(() => {
15
+ mockOnUpdate.mockClear();
16
+ mockOnUpdate.mockImplementation(async () => {});
17
+ });
18
+
14
19
  describe('Rendering', () => {
15
20
  it('should render tags', () => {
16
21
  render(<ResourceTagsInline {...defaultProps} />);
@@ -177,4 +182,97 @@ describe('ResourceTagsInline', () => {
177
182
  expect(container.firstChild).toBeNull();
178
183
  });
179
184
  });
185
+
186
+ // ── RESOURCE-TAGS-INLINE-EDITING: the declared contract, implemented ──
187
+
188
+ describe('Editing mode', () => {
189
+ it('browse mode renders no editing affordances (pins today’s rendering)', () => {
190
+ const { container } = render(
191
+ <ResourceTagsInline {...defaultProps} isEditing={false} vocabulary={['Person']} />,
192
+ );
193
+ expect(container.querySelector('button')).toBeNull();
194
+ });
195
+
196
+ it('renders the editor for EMPTY tags while editing (the primary tagging case)', () => {
197
+ const { container } = render(
198
+ <ResourceTagsInline {...defaultProps} tags={[]} isEditing={true} vocabulary={['Person']} />,
199
+ );
200
+ // No null short-circuit in edit mode: the strip renders with an add affordance.
201
+ expect(container.firstChild).not.toBeNull();
202
+ expect(screen.getByLabelText('Add tag')).toBeInTheDocument();
203
+ });
204
+
205
+ it('removing a tag commits ONCE with the full new set', async () => {
206
+ render(<ResourceTagsInline {...defaultProps} isEditing={true} />);
207
+ fireEvent.click(screen.getByLabelText('Remove tag2'));
208
+ await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledTimes(1));
209
+ expect(mockOnUpdate).toHaveBeenCalledWith(['tag1', 'tag3']);
210
+ });
211
+
212
+ it('disabled renders the editor inert', () => {
213
+ render(
214
+ <ResourceTagsInline {...defaultProps} isEditing={true} disabled={true} vocabulary={['Person']} />,
215
+ );
216
+ fireEvent.click(screen.getByLabelText('Remove tag1'));
217
+ expect(mockOnUpdate).not.toHaveBeenCalled();
218
+ expect(screen.getByLabelText('Remove tag1')).toBeDisabled();
219
+ expect(screen.getByLabelText('Add tag')).toBeDisabled();
220
+ });
221
+
222
+ it('an in-flight onUpdate blocks a second commit', async () => {
223
+ let resolveUpdate!: () => void;
224
+ mockOnUpdate.mockImplementation(() => new Promise<void>((r) => { resolveUpdate = r; }));
225
+ render(<ResourceTagsInline {...defaultProps} isEditing={true} />);
226
+
227
+ fireEvent.click(screen.getByLabelText('Remove tag1'));
228
+ fireEvent.click(screen.getByLabelText('Remove tag2')); // while pending — must not fire
229
+ expect(mockOnUpdate).toHaveBeenCalledTimes(1);
230
+
231
+ resolveUpdate();
232
+ await waitFor(() => expect(screen.getByLabelText('Remove tag2')).not.toBeDisabled());
233
+ fireEvent.click(screen.getByLabelText('Remove tag2')); // settled — fires again
234
+ expect(mockOnUpdate).toHaveBeenCalledTimes(2);
235
+ });
236
+ });
237
+
238
+ describe('Vocabulary (controlled — adds are a picker, never free text)', () => {
239
+ it('the add affordance offers ONLY vocabulary entries minus already-applied tags', () => {
240
+ render(
241
+ <ResourceTagsInline {...defaultProps} tags={['Person']} isEditing={true}
242
+ vocabulary={['Person', 'Place', 'Topic']} />,
243
+ );
244
+ fireEvent.click(screen.getByLabelText('Add tag'));
245
+ expect(screen.getByRole('button', { name: 'Place' })).toBeInTheDocument();
246
+ expect(screen.getByRole('button', { name: 'Topic' })).toBeInTheDocument();
247
+ // Already applied → not a candidate (the chip renders as text, not a candidate button)
248
+ expect(screen.queryByRole('button', { name: 'Person' })).not.toBeInTheDocument();
249
+ });
250
+
251
+ it('picking a candidate commits the full set including the added tag', async () => {
252
+ render(
253
+ <ResourceTagsInline {...defaultProps} tags={['Person']} isEditing={true}
254
+ vocabulary={['Person', 'Place']} />,
255
+ );
256
+ fireEvent.click(screen.getByLabelText('Add tag'));
257
+ fireEvent.click(screen.getByRole('button', { name: 'Place' }));
258
+ await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledTimes(1));
259
+ expect(mockOnUpdate).toHaveBeenCalledWith(['Person', 'Place']);
260
+ });
261
+
262
+ it('without a vocabulary the editor is REMOVAL-ONLY (no add affordance)', () => {
263
+ render(<ResourceTagsInline {...defaultProps} isEditing={true} />);
264
+ expect(screen.queryByLabelText('Add tag')).not.toBeInTheDocument();
265
+ expect(screen.getByLabelText('Remove tag1')).toBeInTheDocument();
266
+ });
267
+
268
+ it('removal is never vocabulary-gated — a stale tag not in the vocabulary is removable', async () => {
269
+ render(
270
+ <ResourceTagsInline {...defaultProps} tags={['LegacyType', 'Person']} isEditing={true}
271
+ vocabulary={['Person']} />,
272
+ );
273
+ fireEvent.click(screen.getByLabelText('Remove LegacyType'));
274
+ await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledTimes(1));
275
+ expect(mockOnUpdate).toHaveBeenCalledWith(['Person']);
276
+ });
277
+ });
180
278
  });
@@ -2,8 +2,6 @@
2
2
 
3
3
  import React, { useState, useRef, useEffect } from 'react';
4
4
  import { useTranslations } from '../../contexts/TranslationContext';
5
- import { useSemiont } from '../../session/SemiontProvider';
6
- import { useObservable } from '../../hooks/useObservable';
7
5
  import { getSupportedShapes } from '../../lib/media-shapes';
8
6
  import type { Annotator } from '../../lib/annotation-registry';
9
7
  import './annotations.css';
@@ -14,12 +12,13 @@ export type SelectionMotivation = 'linking' | 'highlighting' | 'assessing' | 'co
14
12
  export type ClickAction = 'detail' | 'follow' | 'jsonld' | 'deleting';
15
13
  export type ShapeType = 'rectangle' | 'circle' | 'polygon';
16
14
 
15
+ /** The bar's composable controls, in fixed render order. */
16
+ export type ToolbarPart = 'clickAction' | 'mode' | 'selection' | 'shape';
17
+
17
18
  interface AnnotateToolbarProps {
18
19
  selectedMotivation: SelectionMotivation | null;
19
20
  selectedClick: ClickAction;
20
- showSelectionGroup?: boolean;
21
21
  showDeleteButton?: boolean;
22
- showShapeGroup?: boolean;
23
22
  selectedShape?: ShapeType;
24
23
  mediaType?: string | null; // MIME type to determine supported shapes
25
24
 
@@ -28,6 +27,26 @@ interface AnnotateToolbarProps {
28
27
 
29
28
  // Annotators for emoji lookup
30
29
  annotators: Record<string, Annotator>;
30
+
31
+ /**
32
+ * Which controls to render (composition over CSS hiding). Default: all four.
33
+ * Order is fixed (click, mode, selection, shape); shape stays media-gated.
34
+ */
35
+ parts?: ToolbarPart[];
36
+
37
+ /**
38
+ * Purely presentational: each control reports the chosen value via its
39
+ * callback; the owner (viewer instance or host) applies it. The bar holds no
40
+ * pref state, emits no bus events, and touches no storage.
41
+ */
42
+ onModeChange?: (mode: boolean) => void;
43
+ onClickActionChange?: (action: ClickAction) => void;
44
+ onSelectionChange?: (motivation: SelectionMotivation | null) => void;
45
+ onShapeChange?: (shape: ShapeType) => void;
46
+
47
+ // Compact display form (display-only): icon-only, tight, chromeless — for inline
48
+ // embeds. The bar's functionality is identical; only its form changes.
49
+ compact?: boolean;
31
50
  }
32
51
 
33
52
  interface DropdownGroupProps {
@@ -101,26 +120,26 @@ function DropdownGroup({
101
120
  }
102
121
 
103
122
  /**
104
- * Toolbar for annotation controls with mode, selection, click, and shape options
105
- *
106
- * @emits mark:selection-changed - Selection motivation changed. Payload: { motivation: SelectionMotivation | null }
107
- * @emits mark:click-changed - Click action mode changed. Payload: { action: ClickAction }
108
- * @emits mark:shape-changed - Drawing shape changed. Payload: { shape: ShapeType }
109
- * @emits mark:mode-toggled - View mode toggled between browse and annotate. Payload: undefined
123
+ * Toolbar for annotation controls with mode, selection, click, and shape options.
124
+ * Purely presentational (TOOLBAR-PREFS-AS-PROPS): renders the given values for the
125
+ * given `parts` and reports choices via the on*Change callbacks no bus, no storage.
110
126
  */
111
127
  export function AnnotateToolbar({
112
128
  selectedMotivation,
113
129
  selectedClick,
114
- showSelectionGroup = true,
115
130
  showDeleteButton = true,
116
- showShapeGroup = false,
117
131
  selectedShape = 'rectangle',
118
132
  mediaType,
119
133
  annotateMode = false,
120
- annotators
134
+ annotators,
135
+ parts = ['clickAction', 'mode', 'selection', 'shape'],
136
+ onModeChange,
137
+ onClickActionChange,
138
+ onSelectionChange,
139
+ onShapeChange,
140
+ compact = false,
121
141
  }: AnnotateToolbarProps) {
122
142
  const t = useTranslations('AnnotateToolbar');
123
- const session = useObservable(useSemiont().activeSession$);
124
143
 
125
144
  // Helper to get emoji from annotators by motivation (with fallback for safety)
126
145
  const getMotivationEmoji = (motivation: SelectionMotivation): string => {
@@ -188,32 +207,28 @@ export function AnnotateToolbar({
188
207
 
189
208
  const handleSelectionClick = (motivation: SelectionMotivation | null) => {
190
209
  // If null is clicked, always deselect. Otherwise toggle.
191
- if (motivation === null) {
192
- session?.client.mark.changeSelection(null);
193
- } else {
194
- session?.client.mark.changeSelection(selectedMotivation === motivation ? null : motivation);
195
- }
210
+ onSelectionChange?.(motivation === null ? null : (selectedMotivation === motivation ? null : motivation));
196
211
  // Close dropdown after selection
197
212
  setSelectionPinned(false);
198
213
  setSelectionHovered(false);
199
214
  };
200
215
 
201
216
  const handleClickClick = (action: ClickAction) => {
202
- session?.client.mark.changeClick(action);
217
+ onClickActionChange?.(action);
203
218
  // Close dropdown after selection
204
219
  setClickPinned(false);
205
220
  setClickHovered(false);
206
221
  };
207
222
 
208
223
  const handleShapeClick = (shape: ShapeType) => {
209
- session?.client.mark.changeShape(shape);
224
+ onShapeChange?.(shape);
210
225
  // Close dropdown after selection
211
226
  setShapePinned(false);
212
227
  setShapeHovered(false);
213
228
  };
214
229
 
215
230
  const handleModeToggle = () => {
216
- session?.client.mark.toggleMode();
231
+ onModeChange?.(!annotateMode);
217
232
  setModePinned(false);
218
233
  setModeHovered(false);
219
234
  };
@@ -288,9 +303,16 @@ export function AnnotateToolbar({
288
303
  const supportedShapes = getSupportedShapes(mediaType);
289
304
  const shapeTypes = allShapeTypes.filter(st => supportedShapes.includes(st.shape));
290
305
 
306
+ // Part gates (fixed order: click, mode, selection, shape); shape stays media-gated.
307
+ const showClick = parts.includes('clickAction');
308
+ const showMode = parts.includes('mode');
309
+ const showSelection = parts.includes('selection');
310
+ const showShape = parts.includes('shape') && shapeTypes.length > 0;
311
+
291
312
  return (
292
- <div className="semiont-annotate-toolbar">
313
+ <div className={`semiont-annotate-toolbar${compact ? ' semiont-annotate-toolbar--compact' : ''}`}>
293
314
  {/* Click Group */}
315
+ {showClick && (
294
316
  <DropdownGroup
295
317
  label={t('clickGroup')}
296
318
  isExpanded={clickExpanded}
@@ -320,11 +342,13 @@ export function AnnotateToolbar({
320
342
  </>
321
343
  }
322
344
  />
345
+ )}
323
346
 
324
347
  {/* Separator */}
325
- <div className="semiont-toolbar-separator" />
348
+ {showMode && showClick && <div className="semiont-toolbar-separator" />}
326
349
 
327
350
  {/* Mode Group */}
351
+ {showMode && (
328
352
  <DropdownGroup
329
353
  label={t('modeGroup')}
330
354
  isExpanded={modeExpanded}
@@ -346,12 +370,13 @@ export function AnnotateToolbar({
346
370
  </>
347
371
  }
348
372
  />
373
+ )}
349
374
 
350
375
  {/* Separator */}
351
- {showSelectionGroup && <div className="semiont-toolbar-separator" />}
376
+ {showSelection && (showClick || showMode) && <div className="semiont-toolbar-separator" />}
352
377
 
353
378
  {/* Selection Group */}
354
- {showSelectionGroup && (
379
+ {showSelection && (
355
380
  <DropdownGroup
356
381
  label={t('selectionGroup')}
357
382
  isExpanded={selectionExpanded}
@@ -394,10 +419,10 @@ export function AnnotateToolbar({
394
419
  )}
395
420
 
396
421
  {/* Separator */}
397
- {showShapeGroup && <div className="semiont-toolbar-separator" />}
422
+ {showShape && (showClick || showMode || showSelection) && <div className="semiont-toolbar-separator" />}
398
423
 
399
424
  {/* Shape Group */}
400
- {showShapeGroup && shapeTypes.length > 0 && (
425
+ {showShape && (
401
426
  <DropdownGroup
402
427
  label={t('shapeGroup')}
403
428
  isExpanded={shapeExpanded}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 3 — Annotate Bar display forms.
3
+ *
4
+ * Behavior is mostly fixed (the bar IS the annotation capability); the host
5
+ * gets freedom over its display form. `compact` is a display-only variant:
6
+ * icon-only, tight, chromeless — the functional groups stay present and wired.
7
+ * BrowseView's `inline` embed shows the compact bar automatically. Theming
8
+ * (semiont-* classes + CSS vars) and labels (i18n) already exist — no new API
9
+ * for those.
10
+ *
11
+ * Started RED (no `compact` prop) and GREEN once Phase 3 lands.
12
+ */
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import { render, screen } from '@testing-library/react';
15
+ import '@testing-library/jest-dom';
16
+ import { readFileSync } from 'node:fs';
17
+ import { resolve } from 'node:path';
18
+ import type { SemiontSession } from '@semiont/sdk';
19
+ import { AnnotateToolbar } from '../AnnotateToolbar';
20
+ import { ANNOTATORS } from '../../../lib/annotation-registry';
21
+ import { BrowseView } from '../../resource/BrowseView';
22
+
23
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
24
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
25
+
26
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
27
+
28
+ function fakeSession(): SemiontSession {
29
+ return {
30
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
31
+ subscribe: () => () => {},
32
+ } as unknown as SemiontSession;
33
+ }
34
+
35
+ const toolbarProps = {
36
+ selectedMotivation: null,
37
+ selectedClick: 'detail' as const,
38
+ annotateMode: false,
39
+ annotators: ANNOTATORS,
40
+ session: null,
41
+ };
42
+
43
+ describe('Annotate Bar display forms (Phase 3)', () => {
44
+ it('`compact` adds the display modifier; default does not', () => {
45
+ const { container: normal } = render(<AnnotateToolbar {...toolbarProps} />);
46
+ expect(normal.querySelector('.semiont-annotate-toolbar')).not.toHaveClass('semiont-annotate-toolbar--compact');
47
+
48
+ const { container } = render(<AnnotateToolbar {...toolbarProps} compact />);
49
+ expect(container.querySelector('.semiont-annotate-toolbar')).toHaveClass('semiont-annotate-toolbar--compact');
50
+ });
51
+
52
+ it('compact is display-only: the functional groups are still present', () => {
53
+ render(<AnnotateToolbar {...toolbarProps} compact />);
54
+ // Groups render with their aria labels (default-English translations, no provider).
55
+ expect(screen.getByLabelText(/mode/i)).toBeInTheDocument();
56
+ expect(screen.getByLabelText(/click/i)).toBeInTheDocument();
57
+ });
58
+
59
+ it('BrowseView inline embeds show the compact bar automatically', () => {
60
+ const props = {
61
+ content: 'x', mimeType: 'text/plain', resourceUri: 'res-1',
62
+ annotations: emptyAnnotations, annotateMode: false, session: fakeSession(),
63
+ };
64
+ const { container: pane } = render(<BrowseView {...props} />);
65
+ expect(pane.querySelector('.semiont-annotate-toolbar')).not.toHaveClass('semiont-annotate-toolbar--compact');
66
+
67
+ const { container } = render(<BrowseView {...props} inline />);
68
+ expect(container.querySelector('.semiont-annotate-toolbar')).toHaveClass('semiont-annotate-toolbar--compact');
69
+ });
70
+
71
+ it('stylesheet carries the compact form (static gate — icon-only, chromeless)', () => {
72
+ const css = readFileSync(resolve(process.cwd(), 'src/components/toolbar/Toolbar.css'), 'utf8');
73
+ expect(css).toMatch(/\.semiont-annotate-toolbar--compact\s*\{/);
74
+ expect(css).toMatch(/\.semiont-annotate-toolbar--compact\s+\.semiont-dropdown-label\s*\{[^}]*display:\s*none/);
75
+ });
76
+ });