@semiont/react-ui 0.5.10 → 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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +4 -1
  3. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
  4. package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
  5. package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  6. package/dist/index.css +150 -112
  7. package/dist/index.css.map +1 -1
  8. package/dist/index.d.ts +287 -39
  9. package/dist/index.js +2525 -2284
  10. package/dist/index.js.map +1 -1
  11. package/dist/test-utils.js +7 -2
  12. package/dist/test-utils.js.map +1 -1
  13. package/package.json +6 -6
  14. package/src/components/ResourceTagsInline.tsx +94 -11
  15. package/src/components/StatusDisplay.css +36 -0
  16. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  17. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  18. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  19. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  20. package/src/components/image-annotation/SvgDrawingCanvas.tsx +5 -1
  21. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
  22. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
  23. package/src/components/resource/AnnotateView.tsx +59 -38
  24. package/src/components/resource/BrowseView.tsx +159 -133
  25. package/src/components/resource/ResourceViewer.tsx +119 -128
  26. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  27. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  28. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  29. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  30. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  31. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  32. package/src/components/resource/__tests__/BrowseView.test.tsx +35 -22
  33. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -0
  34. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  35. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  36. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  37. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  38. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  39. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  40. package/src/components/resource/browse-renderers.tsx +63 -0
  41. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  42. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  43. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  44. package/src/components/resource/panels/ReferencesPanel.tsx +4 -0
  45. package/src/components/resource/panels/TaggingPanel.tsx +4 -0
  46. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
  47. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
  48. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
  49. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
  50. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  51. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
  52. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
  53. package/src/components/toolbar/Toolbar.css +14 -0
  54. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  55. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +40 -17
  56. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  57. package/src/styles/features/resource-viewer.css +19 -0
  58. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "engines": {
5
5
  "node": ">=24.0.0"
6
6
  },
@@ -98,9 +98,9 @@
98
98
  "@types/react-dom": "^19",
99
99
  "@vitest/coverage-v8": "^4.1.8",
100
100
  "autoprefixer": "^10.5.2",
101
- "fast-check": "^4.8.0",
102
101
  "axe-core": "^4.12.1",
103
102
  "cssnano": "^8.0.2",
103
+ "fast-check": "^4.8.0",
104
104
  "jest-axe": "^10.0.0",
105
105
  "jsdom": "^29.1.1",
106
106
  "postcss-import": "^16.1.1",
@@ -122,10 +122,10 @@
122
122
  "directory": "packages/react-ui"
123
123
  },
124
124
  "dependencies": {
125
- "@semiont/core": "0.5.10",
126
- "@semiont/http-transport": "0.5.10",
127
- "@semiont/sdk": "0.5.10",
128
- "pdfjs-dist": "^6.0.227",
125
+ "@semiont/core": "0.5.11",
126
+ "@semiont/http-transport": "0.5.11",
127
+ "@semiont/sdk": "0.5.11",
128
+ "pdfjs-dist": "^6.1.200",
129
129
  "react-error-boundary": "^6.1.2",
130
130
  "react-markdown": "^10.1.0",
131
131
  "remark-gfm": "^4.0.1",
@@ -1,34 +1,117 @@
1
1
  'use client';
2
2
 
3
+ import { useState } from 'react';
4
+
3
5
  interface ResourceTagsInlineProps {
4
6
  resourceId: string;
5
7
  tags: string[];
6
8
  isEditing: boolean;
9
+ /**
10
+ * Commit: called ONCE per edit with the FULL new tag set (the host diffs —
11
+ * its SDK call takes `(current, updated)`). Awaited; the editor renders
12
+ * inert while the returned promise is pending.
13
+ */
7
14
  onUpdate: (tags: string[]) => Promise<void>;
8
15
  disabled?: boolean;
16
+ /**
17
+ * The KB's registered entity types — the CONTROLLED VOCABULARY, host-supplied
18
+ * (the component is session-less; hosts fetch via `browse.entityTypes()`).
19
+ * Adds are a picker constrained to it: no free-text minting. Without it the
20
+ * editor is REMOVAL-ONLY (it cannot offer valid candidates). Removal is never
21
+ * vocabulary-gated — stale/unregistered tags must stay removable.
22
+ */
23
+ vocabulary?: string[];
9
24
  }
10
25
 
11
26
  export function ResourceTagsInline({
12
- tags
27
+ tags,
28
+ isEditing,
29
+ onUpdate,
30
+ disabled = false,
31
+ vocabulary,
13
32
  }: ResourceTagsInlineProps) {
14
- // If no tags, don't show anything
15
- if (tags.length === 0) {
16
- return null;
33
+ const [busy, setBusy] = useState(false);
34
+ const [picking, setPicking] = useState(false);
35
+
36
+ // Browse mode: exactly the historical read-only rendering (null when empty).
37
+ if (!isEditing) {
38
+ if (tags.length === 0) {
39
+ return null;
40
+ }
41
+ return (
42
+ <div className="semiont-resource-tags-inline">
43
+ <div className="semiont-resource-tags-list">
44
+ {tags.map((tag) => (
45
+ <span key={tag} className="semiont-resource-tag">
46
+ {tag}
47
+ </span>
48
+ ))}
49
+ </div>
50
+ </div>
51
+ );
17
52
  }
18
53
 
54
+ // Edit mode: the empty strip still renders — tagging an UNTAGGED resource is
55
+ // the primary case; the null short-circuit is browse-only.
56
+ const inert = disabled || busy;
57
+ const candidates = (vocabulary ?? []).filter((v) => !tags.includes(v));
58
+
59
+ const commit = async (next: string[]) => {
60
+ if (inert) return; // in-flight or host-disabled: block further commits
61
+ setBusy(true);
62
+ try {
63
+ await onUpdate(next);
64
+ } finally {
65
+ setBusy(false);
66
+ setPicking(false);
67
+ }
68
+ };
69
+
19
70
  return (
20
- <div className="semiont-resource-tags-inline">
71
+ <div className="semiont-resource-tags-inline semiont-resource-tags-inline--editing">
21
72
  <div className="semiont-resource-tags-list">
22
- {/* Display existing tags */}
23
73
  {tags.map((tag) => (
24
- <span
25
- key={tag}
26
- className="semiont-resource-tag"
27
- >
74
+ <span key={tag} className="semiont-resource-tag semiont-resource-tag--editable">
28
75
  {tag}
76
+ <button
77
+ type="button"
78
+ className="semiont-resource-tag__remove"
79
+ aria-label={`Remove ${tag}`}
80
+ disabled={inert}
81
+ onClick={() => void commit(tags.filter((t) => t !== tag))}
82
+ >
83
+
84
+ </button>
29
85
  </span>
30
86
  ))}
87
+ {vocabulary && (
88
+ <button
89
+ type="button"
90
+ className="semiont-resource-tag semiont-resource-tag--add"
91
+ aria-label="Add tag"
92
+ aria-expanded={picking}
93
+ disabled={inert || candidates.length === 0}
94
+ onClick={() => setPicking((v) => !v)}
95
+ >
96
+ +
97
+ </button>
98
+ )}
31
99
  </div>
100
+ {picking && candidates.length > 0 && (
101
+ <div className="semiont-form__entity-type-buttons">
102
+ {candidates.map((entityType) => (
103
+ <button
104
+ key={entityType}
105
+ type="button"
106
+ className="semiont-form__entity-type-button"
107
+ disabled={inert}
108
+ onClick={() => void commit([...tags, entityType])}
109
+ >
110
+ {entityType}
111
+ </button>
112
+ ))}
113
+ </div>
114
+ )}
32
115
  </div>
33
116
  );
34
- }
117
+ }
@@ -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);
@@ -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
+ });