@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
@@ -1,14 +1,19 @@
1
+ /**
2
+ * AnnotateToolbar — purely presentational (TOOLBAR-PREFS-AS-PROPS).
3
+ *
4
+ * The bar renders the given values for the given `parts` and reports choices via
5
+ * the on*Change callbacks. No session, no bus events, no storage — those died with
6
+ * the preference channels; owners (viewer instances / hosts) apply the values.
7
+ */
1
8
  import React from 'react';
2
9
  import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
3
10
  import { vi, beforeEach, describe, it, expect } from 'vitest';
11
+ import '@testing-library/jest-dom';
4
12
  import { AnnotateToolbar, type SelectionMotivation, type ClickAction } from '../AnnotateToolbar';
5
13
  import { ANNOTATORS } from '../../../lib/annotation-registry';
6
14
  import { TranslationProvider } from '../../../contexts/TranslationContext';
7
- import { createTestSemiontWrapper } from '../../../test-utils';
8
15
  import type { TranslationManager } from '../../../types/TranslationManager';
9
- import type { EventBus } from '@semiont/core';
10
16
 
11
- // Mock translations
12
17
  const messages: Record<string, Record<string, string>> = {
13
18
  AnnotateToolbar: {
14
19
  modeGroup: 'Mode',
@@ -17,6 +22,7 @@ const messages: Record<string, Record<string, string>> = {
17
22
  clickGroup: 'Click',
18
23
  selectionGroup: 'Motivation',
19
24
  shapeGroup: 'Shape',
25
+ none: 'None',
20
26
  linking: 'Reference',
21
27
  highlighting: 'Highlight',
22
28
  assessing: 'Assess',
@@ -28,63 +34,37 @@ const messages: Record<string, Record<string, string>> = {
28
34
  jsonld: 'JSON-LD',
29
35
  rectangle: 'Rectangle',
30
36
  circle: 'Circle',
31
- polygon: 'Polygon'
32
- }
37
+ polygon: 'Polygon',
38
+ },
33
39
  };
34
40
 
35
41
  const translationManager: TranslationManager = {
36
42
  t: (namespace, key) => messages[namespace]?.[key] ?? `${namespace}.${key}`,
37
43
  };
38
44
 
39
- // Composition-based event tracker
40
- interface TrackedEvent {
41
- event: string;
42
- payload: any;
43
- }
45
+ const renderWithIntl = (component: React.ReactElement) =>
46
+ render(
47
+ <TranslationProvider translationManager={translationManager}>
48
+ {component}
49
+ </TranslationProvider>,
50
+ );
44
51
 
45
- function createEventTracker() {
46
- const events: TrackedEvent[] = [];
47
- return {
48
- events,
49
- clear: () => { events.length = 0; },
50
- _attach(eventBus: EventBus) {
51
- const trackedEvents = [
52
- 'mark:mode-toggled',
53
- 'mark:click-changed',
54
- 'mark:selection-changed',
55
- 'mark:shape-changed',
56
- ] as const;
57
- trackedEvents.forEach((eventName) => {
58
- eventBus.get(eventName).subscribe((payload: any) => {
59
- events.push({ event: eventName, payload });
60
- });
61
- });
62
- },
63
- };
52
+ /** Hover a dropdown group open and click an option in its menu. */
53
+ async function pick(group: string, option: string) {
54
+ const trigger = screen.getByLabelText(group);
55
+ fireEvent.mouseEnter(trigger.parentElement!);
56
+ await waitFor(() => {
57
+ const menu = screen.getByRole('menu');
58
+ fireEvent.click(within(menu).getByText(option));
59
+ });
64
60
  }
65
61
 
66
- const renderWithIntl = (component: React.ReactElement, tracker?: ReturnType<typeof createEventTracker>) => {
67
- const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
68
- if (tracker) tracker._attach(eventBus);
69
- const Wrapper = ({ children }: { children: React.ReactNode }) => (
70
- <SemiontWrapper>
71
- <TranslationProvider translationManager={translationManager}>
72
- {children}
73
- </TranslationProvider>
74
- </SemiontWrapper>
75
- );
76
- const result = render(component, { wrapper: Wrapper });
77
- return { ...result, eventBus };
78
- };
79
-
80
62
  describe('AnnotateToolbar', () => {
81
63
  const defaultProps = {
82
64
  selectedMotivation: null as SelectionMotivation | null,
83
65
  selectedClick: 'detail' as ClickAction,
84
- onSelectionChange: vi.fn(),
85
- onClickChange: vi.fn(),
86
66
  annotateMode: false,
87
- annotators: ANNOTATORS
67
+ annotators: ANNOTATORS,
88
68
  };
89
69
 
90
70
  beforeEach(() => {
@@ -92,382 +72,166 @@ describe('AnnotateToolbar', () => {
92
72
  });
93
73
 
94
74
  describe('Rendering', () => {
95
- it('renders with required props', () => {
75
+ it('renders the click group with the selected action', () => {
96
76
  renderWithIntl(<AnnotateToolbar {...defaultProps} />);
97
- // Check for aria-labels (labels only shown when expanded)
98
77
  expect(screen.getByLabelText('Click')).toBeInTheDocument();
99
78
  expect(screen.getByText('Detail')).toBeInTheDocument();
100
79
  });
101
80
 
102
- it('shows MODE group', () => {
81
+ it('shows the MODE group with the current mode', () => {
103
82
  renderWithIntl(<AnnotateToolbar {...defaultProps} />);
104
- // Check for aria-label (label only shown when expanded)
105
83
  expect(screen.getByLabelText('Mode')).toBeInTheDocument();
106
84
  expect(screen.getByText('Browse')).toBeInTheDocument();
107
85
  });
108
86
 
109
- it('shows selection group by default', () => {
87
+ it('shows the selection group by default (parts default: all)', () => {
110
88
  renderWithIntl(<AnnotateToolbar {...defaultProps} />);
111
- // Check for aria-label (label only shown when expanded)
112
89
  expect(screen.getByLabelText('Motivation')).toBeInTheDocument();
113
90
  });
114
91
 
115
- it('hides selection group when showSelectionGroup is false', () => {
116
- renderWithIntl(
117
- <AnnotateToolbar {...defaultProps} showSelectionGroup={false} />
118
- );
119
- // Check for aria-label absence
120
- expect(screen.queryByLabelText('Motivation')).not.toBeInTheDocument();
121
- });
122
-
123
- it('hides delete button when showDeleteButton is false', () => {
124
- renderWithIntl(
125
- <AnnotateToolbar {...defaultProps} showDeleteButton={false} />
126
- );
127
- // Open click dropdown
92
+ it('hides the delete option when showDeleteButton is false', () => {
93
+ renderWithIntl(<AnnotateToolbar {...defaultProps} showDeleteButton={false} />);
128
94
  const clickGroup = screen.getByLabelText('Click');
129
- fireEvent.mouseEnter(clickGroup);
130
-
131
- // Delete option should not be present
95
+ fireEvent.mouseEnter(clickGroup.parentElement!);
132
96
  expect(screen.queryByText('Delete')).not.toBeInTheDocument();
133
97
  });
134
98
 
135
- it('shows shape group when showShapeGroup is true', () => {
136
- renderWithIntl(
137
- <AnnotateToolbar
138
- {...defaultProps}
139
- showShapeGroup={true}
140
- selectedShape="rectangle"
141
- />
142
- );
143
- // Check for aria-label (label only shown when expanded)
144
- expect(screen.getByLabelText('Shape')).toBeInTheDocument();
145
- expect(screen.getByText('Rectangle')).toBeInTheDocument();
146
- });
147
- });
148
-
149
- describe('MODE Group Interactions', () => {
150
- it('displays current mode correctly', () => {
151
- const { rerender } = renderWithIntl(
152
- <AnnotateToolbar
153
- {...defaultProps}
154
- annotateMode={false}
155
- />
156
- );
99
+ it('displays the given mode across rerenders (value comes from the owner)', () => {
100
+ const { rerender } = renderWithIntl(<AnnotateToolbar {...defaultProps} annotateMode={false} />);
157
101
  expect(screen.getByText('Browse')).toBeInTheDocument();
158
-
159
102
  rerender(
160
- <AnnotateToolbar
161
- {...defaultProps}
162
- annotateMode={true}
163
- />
103
+ <TranslationProvider translationManager={translationManager}>
104
+ <AnnotateToolbar {...defaultProps} annotateMode={true} />
105
+ </TranslationProvider>,
164
106
  );
165
107
  expect(screen.getByText('Annotate')).toBeInTheDocument();
166
108
  });
109
+ });
167
110
 
168
- it('expands on hover', async () => {
169
- renderWithIntl(
170
- <AnnotateToolbar
171
- {...defaultProps}
172
- annotateMode={false}
173
- />
111
+ describe('parts composition', () => {
112
+ it("parts={['shape']} renders exactly the shape group", () => {
113
+ const { container } = renderWithIntl(
114
+ <AnnotateToolbar {...defaultProps} parts={['shape']} selectedShape="rectangle" mediaType="image/png" />,
174
115
  );
175
-
176
- const modeGroup = screen.getByLabelText('Mode');
177
- fireEvent.mouseEnter(modeGroup);
178
-
179
- await waitFor(() => {
180
- // Find the dropdown menu by role
181
- const dropdown = screen.getByRole('menu');
182
- // Both options should be visible in the expanded dropdown menu
183
- expect(within(dropdown).getByText('Browse')).toBeInTheDocument();
184
- expect(within(dropdown).getByText('Annotate')).toBeInTheDocument();
185
- });
116
+ expect(screen.getByLabelText('Shape')).toBeInTheDocument();
117
+ expect(screen.queryByLabelText('Click')).not.toBeInTheDocument();
118
+ expect(screen.queryByLabelText('Mode')).not.toBeInTheDocument();
119
+ expect(screen.queryByLabelText('Motivation')).not.toBeInTheDocument();
120
+ // A lone part renders no separators
121
+ expect(container.querySelector('.semiont-toolbar-separator')).toBeNull();
186
122
  });
187
123
 
188
- it('emits mark:mode-toggled event when Browse is clicked in Annotate mode', async () => {
189
- const tracker = createEventTracker();
190
- renderWithIntl(
191
- <AnnotateToolbar
192
- {...defaultProps}
193
- annotateMode={true}
194
- />,
195
- tracker
124
+ it("parts={['clickAction','mode']} renders those two with one separator", () => {
125
+ const { container } = renderWithIntl(
126
+ <AnnotateToolbar {...defaultProps} parts={['clickAction', 'mode']} />,
196
127
  );
197
-
198
- const modeGroup = screen.getByLabelText('Mode');
199
- fireEvent.mouseEnter(modeGroup);
200
-
201
- await waitFor(() => {
202
- const browseButton = screen.getByText('Browse');
203
- fireEvent.click(browseButton);
204
- });
205
-
206
- await waitFor(() => {
207
- expect(tracker.events.some(e => e.event === 'mark:mode-toggled')).toBe(true);
208
- });
128
+ expect(screen.getByLabelText('Click')).toBeInTheDocument();
129
+ expect(screen.getByLabelText('Mode')).toBeInTheDocument();
130
+ expect(screen.queryByLabelText('Motivation')).not.toBeInTheDocument();
131
+ expect(container.querySelectorAll('.semiont-toolbar-separator')).toHaveLength(1);
209
132
  });
210
133
 
211
- it('emits mark:mode-toggled event when Annotate is clicked in Browse mode', async () => {
212
- const tracker = createEventTracker();
134
+ it('the default renders all four groups (shape media-gated: none for PDF beyond rectangle)', () => {
213
135
  renderWithIntl(
214
- <AnnotateToolbar
215
- {...defaultProps}
216
- annotateMode={false}
217
- />,
218
- tracker
136
+ <AnnotateToolbar {...defaultProps} selectedShape="rectangle" mediaType="image/png" />,
219
137
  );
220
-
221
- const modeGroup = screen.getByLabelText('Mode');
222
- fireEvent.mouseEnter(modeGroup);
223
-
224
- await waitFor(() => {
225
- const annotateButton = screen.getByText('Annotate');
226
- fireEvent.click(annotateButton);
227
- });
228
-
229
- await waitFor(() => {
230
- expect(tracker.events.some(e => e.event === 'mark:mode-toggled')).toBe(true);
231
- });
138
+ expect(screen.getByLabelText('Click')).toBeInTheDocument();
139
+ expect(screen.getByLabelText('Mode')).toBeInTheDocument();
140
+ expect(screen.getByLabelText('Motivation')).toBeInTheDocument();
141
+ expect(screen.getByLabelText('Shape')).toBeInTheDocument();
232
142
  });
143
+ });
233
144
 
234
- it('closes dropdown after selection', async () => {
235
- const tracker = createEventTracker();
236
- const { rerender } = renderWithIntl(
237
- <AnnotateToolbar
238
- {...defaultProps}
239
- annotateMode={false}
240
- />,
241
- tracker
242
- );
243
-
244
- const modeGroup = screen.getByLabelText('Mode');
245
- fireEvent.mouseEnter(modeGroup);
246
-
247
- await waitFor(() => {
248
- expect(screen.getByText('Annotate')).toBeInTheDocument();
249
- });
250
-
251
- const annotateButton = screen.getByText('Annotate');
252
- fireEvent.click(annotateButton);
253
-
254
- // Verify the event was emitted
255
- await waitFor(() => {
256
- expect(tracker.events.some(e => e.event === 'mark:mode-toggled')).toBe(true);
257
- });
145
+ describe('Mode control', () => {
146
+ it('reports the next mode via onModeChange when switching to Annotate', async () => {
147
+ const onModeChange = vi.fn();
148
+ renderWithIntl(<AnnotateToolbar {...defaultProps} annotateMode={false} onModeChange={onModeChange} />);
149
+ await pick('Mode', 'Annotate');
150
+ expect(onModeChange).toHaveBeenCalledWith(true);
151
+ });
258
152
 
259
- // Simulate mode change by rerendering with new mode
260
- rerender(
261
- <AnnotateToolbar
262
- {...defaultProps}
263
- annotateMode={true}
264
- />
265
- );
153
+ it('reports false when switching back to Browse from annotate mode', async () => {
154
+ const onModeChange = vi.fn();
155
+ renderWithIntl(<AnnotateToolbar {...defaultProps} annotateMode={true} onModeChange={onModeChange} />);
156
+ await pick('Mode', 'Browse');
157
+ expect(onModeChange).toHaveBeenCalledWith(false);
158
+ });
266
159
 
267
- // After mode change, the collapsed content should show "Annotate"
268
- // and Browse should not be in the collapsed state
160
+ it('clicking the CURRENT mode reports nothing', async () => {
161
+ const onModeChange = vi.fn();
162
+ renderWithIntl(<AnnotateToolbar {...defaultProps} annotateMode={false} onModeChange={onModeChange} />);
163
+ const trigger = screen.getByLabelText('Mode');
164
+ fireEvent.mouseEnter(trigger.parentElement!);
269
165
  await waitFor(() => {
270
- const modeLabels = screen.getAllByText('Annotate');
271
- // Should have at least the collapsed label
272
- expect(modeLabels.length).toBeGreaterThanOrEqual(1);
166
+ const menu = screen.getByRole('menu');
167
+ fireEvent.click(within(menu).getByText('Browse')); // already in browse
273
168
  });
169
+ expect(onModeChange).not.toHaveBeenCalled();
274
170
  });
275
171
  });
276
172
 
277
- describe('CLICK Group Interactions', () => {
278
- it('emits mark:click-changed event when clicking an action', async () => {
279
- const tracker = createEventTracker();
280
- renderWithIntl(
281
- <AnnotateToolbar {...defaultProps} />,
282
- tracker
283
- );
284
-
285
- const clickGroup = screen.getByLabelText('Click');
286
- fireEvent.mouseEnter(clickGroup);
287
-
288
- await waitFor(() => {
289
- expect(screen.getByText('Follow')).toBeInTheDocument();
290
- });
291
-
292
- tracker.clear();
293
- fireEvent.click(screen.getByText('Follow'));
294
- await waitFor(() => {
295
- expect(tracker.events.some(e =>
296
- e.event === 'mark:click-changed' && e.payload?.action === 'follow'
297
- )).toBe(true);
298
- });
173
+ describe('Click-action control', () => {
174
+ it('reports the chosen action via onClickActionChange', async () => {
175
+ const onClickActionChange = vi.fn();
176
+ renderWithIntl(<AnnotateToolbar {...defaultProps} onClickActionChange={onClickActionChange} />);
177
+ await pick('Click', 'Follow');
178
+ expect(onClickActionChange).toHaveBeenCalledWith('follow');
299
179
  });
300
180
 
301
- it('displays selected action', () => {
302
- renderWithIntl(
303
- <AnnotateToolbar {...defaultProps} selectedClick="follow" />
304
- );
305
- expect(screen.getByText('Follow')).toBeInTheDocument();
181
+ it('displays the selected action from props', () => {
182
+ renderWithIntl(<AnnotateToolbar {...defaultProps} selectedClick="jsonld" />);
183
+ expect(screen.getByText('JSON-LD')).toBeInTheDocument();
306
184
  });
307
185
  });
308
186
 
309
- describe('MOTIVATION Group Interactions', () => {
310
- it('emits mark:selection-changed event when clicking a motivation', async () => {
311
- const tracker = createEventTracker();
312
- renderWithIntl(
313
- <AnnotateToolbar {...defaultProps} />,
314
- tracker
315
- );
316
-
317
- const motivationGroup = screen.getByLabelText('Motivation');
318
- fireEvent.mouseEnter(motivationGroup);
319
-
320
- await waitFor(() => {
321
- expect(screen.getByText('Reference')).toBeInTheDocument();
322
- });
323
-
324
- tracker.clear();
325
- fireEvent.click(screen.getByText('Reference'));
326
- await waitFor(() => {
327
- expect(tracker.events.some(e =>
328
- e.event === 'mark:selection-changed' && e.payload?.motivation === 'linking'
329
- )).toBe(true);
330
- });
187
+ describe('Selection control', () => {
188
+ it('reports the chosen motivation', async () => {
189
+ const onSelectionChange = vi.fn();
190
+ renderWithIntl(<AnnotateToolbar {...defaultProps} onSelectionChange={onSelectionChange} />);
191
+ await pick('Motivation', 'Highlight');
192
+ expect(onSelectionChange).toHaveBeenCalledWith('highlighting');
331
193
  });
332
194
 
333
- it('toggles motivation on/off', async () => {
334
- const tracker = createEventTracker();
335
- const { rerender } = renderWithIntl(
336
- <AnnotateToolbar
337
- {...defaultProps}
338
- selectedMotivation={null}
339
- />,
340
- tracker
341
- );
342
-
343
- const motivationGroup = screen.getByLabelText('Motivation');
344
- fireEvent.mouseEnter(motivationGroup);
345
-
346
- await waitFor(() => {
347
- const dropdown = screen.getByRole('menu');
348
- expect(within(dropdown).getByText('Highlight')).toBeInTheDocument();
349
- });
350
-
351
- const dropdown = screen.getByRole('menu');
352
- tracker.clear();
353
- fireEvent.click(within(dropdown).getByText('Highlight'));
354
- await waitFor(() => {
355
- expect(tracker.events.some(e =>
356
- e.event === 'mark:selection-changed' && e.payload?.motivation === 'highlighting'
357
- )).toBe(true);
358
- });
359
-
360
- // Simulate selection
361
- rerender(
362
- <AnnotateToolbar
363
- {...defaultProps}
364
- selectedMotivation="highlighting"
365
- />
195
+ it('re-picking the current motivation reports null (toggle off)', async () => {
196
+ const onSelectionChange = vi.fn();
197
+ renderWithIntl(
198
+ <AnnotateToolbar {...defaultProps} selectedMotivation="highlighting" onSelectionChange={onSelectionChange} />,
366
199
  );
367
-
368
- // Click again to deselect
369
- fireEvent.mouseEnter(motivationGroup);
370
- await waitFor(() => {
371
- const dropdown = screen.getByRole('menu');
372
- expect(within(dropdown).getByText('Highlight')).toBeInTheDocument();
373
- });
374
- const dropdown2 = screen.getByRole('menu');
375
- tracker.clear();
376
- fireEvent.click(within(dropdown2).getByText('Highlight'));
377
- await waitFor(() => {
378
- expect(tracker.events.some(e =>
379
- e.event === 'mark:selection-changed' && e.payload?.motivation === null
380
- )).toBe(true);
381
- });
200
+ await pick('Motivation', 'Highlight');
201
+ expect(onSelectionChange).toHaveBeenCalledWith(null);
382
202
  });
383
- });
384
203
 
385
- describe('SHAPE Group Interactions', () => {
386
- it('emits mark:shape-changed event when clicking a shape', async () => {
387
- const tracker = createEventTracker();
204
+ it('picking None reports null', async () => {
205
+ const onSelectionChange = vi.fn();
388
206
  renderWithIntl(
389
- <AnnotateToolbar
390
- {...defaultProps}
391
- showShapeGroup={true}
392
- selectedShape="rectangle"
393
- />,
394
- tracker
207
+ <AnnotateToolbar {...defaultProps} selectedMotivation="linking" onSelectionChange={onSelectionChange} />,
395
208
  );
396
-
397
- const shapeGroup = screen.getByLabelText('Shape');
398
- fireEvent.mouseEnter(shapeGroup);
399
-
400
- await waitFor(() => {
401
- expect(screen.getByText('Circle')).toBeInTheDocument();
402
- });
403
-
404
- tracker.clear();
405
- fireEvent.click(screen.getByText('Circle'));
406
- await waitFor(() => {
407
- expect(tracker.events.some(e =>
408
- e.event === 'mark:shape-changed' && e.payload?.shape === 'circle'
409
- )).toBe(true);
410
- });
209
+ await pick('Motivation', 'None');
210
+ expect(onSelectionChange).toHaveBeenCalledWith(null);
411
211
  });
412
212
  });
413
213
 
414
- describe('Keyboard Interactions', () => {
415
- it('closes all dropdowns on Escape key', async () => {
214
+ describe('Shape control', () => {
215
+ it('reports the chosen shape', async () => {
216
+ const onShapeChange = vi.fn();
416
217
  renderWithIntl(
417
- <AnnotateToolbar
418
- {...defaultProps}
419
- annotateMode={false}
420
- />
218
+ <AnnotateToolbar {...defaultProps} parts={['shape']} selectedShape="rectangle"
219
+ mediaType="image/png" onShapeChange={onShapeChange} />,
421
220
  );
422
-
423
- // Open mode dropdown
424
- const modeGroup = screen.getByLabelText('Mode');
425
- fireEvent.mouseEnter(modeGroup);
426
- fireEvent.click(modeGroup); // Pin it
427
-
428
- await waitFor(() => {
429
- // When expanded, dropdown menu should be visible with both options
430
- const dropdown = screen.getByRole('menu');
431
- expect(within(dropdown).getByText('Browse')).toBeInTheDocument();
432
- expect(within(dropdown).getByText('Annotate')).toBeInTheDocument();
433
- });
434
-
435
- // Press Escape
436
- fireEvent.keyDown(document, { key: 'Escape' });
437
-
438
- // Move mouse away to complete closing
439
- fireEvent.mouseLeave(modeGroup);
440
-
441
- await waitFor(() => {
442
- // After closing, dropdown menu should not be present
443
- expect(screen.queryByRole('menu')).not.toBeInTheDocument();
444
- // But collapsed "Browse" label should still be visible
445
- expect(screen.getByText('Browse')).toBeInTheDocument();
446
- });
221
+ await pick('Shape', 'Circle');
222
+ expect(onShapeChange).toHaveBeenCalledWith('circle');
447
223
  });
448
224
  });
449
225
 
450
- describe('Click Outside Behavior', () => {
451
- it('closes pinned dropdown when clicking outside', async () => {
226
+ describe('Dropdown behavior', () => {
227
+ it('closes all dropdowns on Escape', async () => {
452
228
  renderWithIntl(<AnnotateToolbar {...defaultProps} />);
453
-
454
- const clickGroup = screen.getByLabelText('Click');
455
- fireEvent.mouseEnter(clickGroup);
456
- fireEvent.click(clickGroup); // Pin it
457
-
458
- await waitFor(() => {
459
- expect(screen.getByText('Follow')).toBeInTheDocument();
460
- });
461
-
462
- // Click outside - need to click on an element outside the dropdown
463
- fireEvent.mouseDown(document.body);
464
-
465
- // Also move mouse away to ensure hover state is cleared
466
- fireEvent.mouseLeave(clickGroup);
467
-
468
- await waitFor(() => {
469
- expect(screen.queryByText('Follow')).not.toBeInTheDocument();
470
- });
229
+ const trigger = screen.getByLabelText('Mode');
230
+ fireEvent.click(trigger); // pin open
231
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
232
+ fireEvent.keyDown(document, { key: 'Escape' });
233
+ fireEvent.mouseLeave(trigger.parentElement!);
234
+ await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
471
235
  });
472
236
  });
473
237
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { useRef, useState, useEffect, useCallback } from 'react';
4
4
  import type { Annotation } from '@semiont/core';
5
- import { createRectangleSvg, createCircleSvg, createPolygonSvg, scaleSvgToNative, parseSvgSelector, Point } from '@semiont/core';
5
+ import { createRectangleSvg, createCircleSvg, createPolygonSvg, scaleSvgToNative, parseSvgSelector, Point, resourceId as toResourceId } from '@semiont/core';
6
6
  import { AnnotationOverlay } from './AnnotationOverlay';
7
7
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
8
8
  import type { SemiontSession } from '@semiont/sdk';
@@ -35,6 +35,8 @@ function getMotivationColor(motivation: SelectionMotivation | null): { stroke: s
35
35
 
36
36
  interface SvgDrawingCanvasProps {
37
37
  imageUrl: string;
38
+ /** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */
39
+ resourceUri: string;
38
40
  existingAnnotations?: Annotation[];
39
41
  drawingMode: DrawingMode;
40
42
  selectedMotivation?: SelectionMotivation | null;
@@ -52,6 +54,7 @@ interface SvgDrawingCanvasProps {
52
54
  */
53
55
  export function SvgDrawingCanvas({
54
56
  imageUrl,
57
+ resourceUri,
55
58
  existingAnnotations = [],
56
59
  drawingMode,
57
60
  selectedMotivation,
@@ -274,6 +277,7 @@ export function SvgDrawingCanvas({
274
277
  // Emit annotation:requested event with SvgSelector
275
278
  if (session && selectedMotivation) {
276
279
  session.client.mark.request(
280
+ toResourceId(resourceUri),
277
281
  { type: 'SvgSelector', value: nativeSvg },
278
282
  selectedMotivation,
279
283
  );