@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Role-scoped inline action region rendered inside `TwToolbar`.
3
+ *
4
+ * Per runtime-rendering-and-chrome-phase.md §6.4 the top chrome carries
5
+ * a role-scoped action set between the left formatting cluster and the
6
+ * right view cluster. This component renders the primary actions for
7
+ * the active role, driving them from host-supplied callbacks.
8
+ *
9
+ * Review-role actions here collapse what used to live in
10
+ * `TwReviewQueueBar` as a second strip — the review prev/next, counts,
11
+ * active-item label, accept/reject, markup-mode, and batch operations.
12
+ * Editor-role actions surface the scope posture menu. Workflow-role
13
+ * actions surface work-item traversal + claim/skip/complete.
14
+ */
15
+
16
+ import React, { useState } from "react";
17
+ import * as Popover from "@radix-ui/react-popover";
18
+ import * as Toggle from "@radix-ui/react-toggle";
19
+ import * as Tooltip from "@radix-ui/react-tooltip";
20
+ import {
21
+ BookmarkCheck,
22
+ Check,
23
+ CheckCheck,
24
+ ChevronDown,
25
+ ChevronLeft,
26
+ ChevronRight,
27
+ CircleOff,
28
+ Eye,
29
+ EyeOff,
30
+ FileDiff,
31
+ Flag,
32
+ Hand,
33
+ MessageSquare,
34
+ MessageSquareDot,
35
+ MessageSquareText,
36
+ Rows3,
37
+ SkipForward,
38
+ Target,
39
+ X,
40
+ XCircle,
41
+ } from "lucide-react";
42
+
43
+ import type {
44
+ EditorRole,
45
+ ReviewQueueSnapshot,
46
+ ScopeRailPosture,
47
+ } from "../../api/public-types";
48
+ import type { SessionCapabilities } from "../../runtime/session-capabilities";
49
+ import type { ScopedChromePolicy } from "../../ui/headless/scoped-chrome-policy";
50
+ import type { ToolbarChromeItemId } from "../../ui/headless/chrome-registry";
51
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
52
+ import { ROLE_ACTION_SETS } from "../chrome/role-action-sets";
53
+ import { TwScopePostureMenu } from "./tw-scope-posture-menu";
54
+
55
+ export type MarkupDisplayMode = "clean" | "simple" | "all";
56
+
57
+ export interface WorkflowWorkItemSnapshot {
58
+ workItemId: string;
59
+ title?: string;
60
+ status?: string;
61
+ hasPrevious: boolean;
62
+ hasNext: boolean;
63
+ canClaim: boolean;
64
+ canComplete: boolean;
65
+ canSkip: boolean;
66
+ canMarkBlocked: boolean;
67
+ }
68
+
69
+ export interface TwRoleActionRegionProps {
70
+ role: EditorRole;
71
+ policy: ScopedChromePolicy;
72
+ compactMode: boolean;
73
+ /** Review-queue snapshot for the review role. */
74
+ reviewQueue?: ReviewQueueSnapshot;
75
+ /** Active workflow work item for the workflow role. */
76
+ workflowItem?: WorkflowWorkItemSnapshot | null;
77
+ markupDisplay?: MarkupDisplayMode;
78
+
79
+ // Shared: editor + review role
80
+ canAddComment?: boolean;
81
+ showTrackedChanges?: boolean;
82
+ onAddComment?: () => void;
83
+ onShowTrackedChangesChange?: (show: boolean) => void;
84
+ /**
85
+ * Session capabilities used to gate the role-region tracked-changes
86
+ * toggle (mirrors the right-cluster gate at tw-toolbar.tsx). When
87
+ * `capabilities.trackChangesSupported` is false, the toggle is disabled.
88
+ */
89
+ capabilities?: SessionCapabilities;
90
+
91
+ // Review sidebar panel (optional — hidden when not provided)
92
+ onReviewSidebarTrackedChanges?: () => void;
93
+ onReviewSidebarComments?: () => void;
94
+
95
+ // Workflow + review role: scope posture menu
96
+ onMarkScopePosture?: (posture: ScopeRailPosture) => void;
97
+
98
+ // Review role
99
+ onReviewPrev?: () => void;
100
+ onReviewNext?: () => void;
101
+ onReviewAccept?: () => void;
102
+ onReviewReject?: () => void;
103
+ onReviewAcceptAll?: () => void;
104
+ onReviewRejectAll?: () => void;
105
+ onReviewMarkupMode?: (mode: MarkupDisplayMode) => void;
106
+
107
+ // Workflow role
108
+ onWorkflowPrev?: () => void;
109
+ onWorkflowNext?: () => void;
110
+ onWorkflowMarkComplete?: () => void;
111
+ onWorkflowClaim?: () => void;
112
+ onWorkflowSkip?: () => void;
113
+ onWorkflowMarkBlocked?: () => void;
114
+ onWorkflowJumpToScope?: () => void;
115
+
116
+ "data-testid"?: string;
117
+ }
118
+
119
+ export function TwRoleActionRegion(
120
+ props: TwRoleActionRegionProps,
121
+ ): React.JSX.Element | null {
122
+ const order = ROLE_ACTION_SETS[props.role];
123
+ const inlineIds: ToolbarChromeItemId[] = [];
124
+ const overflowIds: ToolbarChromeItemId[] = [];
125
+
126
+ for (const id of order) {
127
+ const entry = props.policy.toolbar[id];
128
+ if (!entry?.visible) continue;
129
+ if (entry.placement === "overflow") {
130
+ overflowIds.push(id);
131
+ } else if (entry.placement === "inline") {
132
+ inlineIds.push(id);
133
+ }
134
+ }
135
+
136
+ if (inlineIds.length === 0 && overflowIds.length === 0) {
137
+ return null;
138
+ }
139
+
140
+ return (
141
+ <div
142
+ className="flex items-center gap-1 border-l border-r border-border/40 px-1.5"
143
+ data-testid={props["data-testid"] ?? `role-action-region-${props.role}`}
144
+ data-role={props.role}
145
+ >
146
+ {inlineIds.map((id) => (
147
+ <RoleActionButton
148
+ key={id}
149
+ id={id}
150
+ compact={props.compactMode}
151
+ props={props}
152
+ />
153
+ ))}
154
+ {overflowIds.length > 0 ? (
155
+ <RoleActionOverflow ids={overflowIds} props={props} />
156
+ ) : null}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ interface RoleActionButtonProps {
162
+ id: ToolbarChromeItemId;
163
+ compact: boolean;
164
+ props: TwRoleActionRegionProps;
165
+ }
166
+
167
+ function RoleActionButton(arg: RoleActionButtonProps): React.JSX.Element | null {
168
+ const { id, props } = arg;
169
+ const focusRingClass =
170
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
171
+ switch (id) {
172
+ case "comment":
173
+ return (
174
+ <Tooltip.Root>
175
+ <Tooltip.Trigger asChild>
176
+ <button
177
+ type="button"
178
+ aria-label="Add comment"
179
+ disabled={!props.canAddComment || !props.onAddComment}
180
+ onMouseDown={preserveEditorSelectionMouseDown}
181
+ onClick={props.onAddComment}
182
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 outline-none ${focusRingClass}`}
183
+ data-testid="role-add-comment"
184
+ >
185
+ <MessageSquare className="h-3.5 w-3.5" />
186
+ </button>
187
+ </Tooltip.Trigger>
188
+ <Tooltip.Portal>
189
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
190
+ Add comment
191
+ </Tooltip.Content>
192
+ </Tooltip.Portal>
193
+ </Tooltip.Root>
194
+ );
195
+ case "tracked-changes-toggle":
196
+ return (
197
+ <Tooltip.Root>
198
+ <Tooltip.Trigger asChild>
199
+ <Toggle.Root
200
+ pressed={props.showTrackedChanges ?? false}
201
+ onPressedChange={(v) => props.onShowTrackedChangesChange?.(v)}
202
+ disabled={props.capabilities ? !props.capabilities.trackChangesSupported : false}
203
+ onMouseDown={preserveEditorSelectionMouseDown}
204
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-canvas data-[state=on]:text-accent data-[state=on]:ring-1 data-[state=on]:ring-accent/30 data-[state=on]:shadow-sm outline-none disabled:opacity-40 ${focusRingClass}`}
205
+ data-testid="role-tracked-changes-toggle"
206
+ >
207
+ {(props.showTrackedChanges ?? false) ? (
208
+ <Eye className="h-3.5 w-3.5" />
209
+ ) : (
210
+ <EyeOff className="h-3.5 w-3.5" />
211
+ )}
212
+ </Toggle.Root>
213
+ </Tooltip.Trigger>
214
+ <Tooltip.Portal>
215
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
216
+ {(props.showTrackedChanges ?? false) ? "Hide tracked changes" : "Show tracked changes"}
217
+ </Tooltip.Content>
218
+ </Tooltip.Portal>
219
+ </Tooltip.Root>
220
+ );
221
+ case "review-sidebar-tracked-changes":
222
+ return (
223
+ <Tooltip.Root>
224
+ <Tooltip.Trigger asChild>
225
+ <button
226
+ type="button"
227
+ aria-label="Show tracked changes in sidebar"
228
+ disabled={!props.onReviewSidebarTrackedChanges}
229
+ onMouseDown={preserveEditorSelectionMouseDown}
230
+ onClick={props.onReviewSidebarTrackedChanges}
231
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 outline-none ${focusRingClass}`}
232
+ data-testid="role-sidebar-tracked-changes"
233
+ >
234
+ <FileDiff className="h-3.5 w-3.5" />
235
+ </button>
236
+ </Tooltip.Trigger>
237
+ <Tooltip.Portal>
238
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
239
+ Tracked changes panel
240
+ </Tooltip.Content>
241
+ </Tooltip.Portal>
242
+ </Tooltip.Root>
243
+ );
244
+ case "review-sidebar-comments":
245
+ return (
246
+ <Tooltip.Root>
247
+ <Tooltip.Trigger asChild>
248
+ <button
249
+ type="button"
250
+ aria-label="Show comments in sidebar"
251
+ disabled={!props.onReviewSidebarComments}
252
+ onMouseDown={preserveEditorSelectionMouseDown}
253
+ onClick={props.onReviewSidebarComments}
254
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 outline-none ${focusRingClass}`}
255
+ data-testid="role-sidebar-comments"
256
+ >
257
+ <MessageSquareDot className="h-3.5 w-3.5" />
258
+ </button>
259
+ </Tooltip.Trigger>
260
+ <Tooltip.Portal>
261
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
262
+ Comments panel
263
+ </Tooltip.Content>
264
+ </Tooltip.Portal>
265
+ </Tooltip.Root>
266
+ );
267
+ case "editor-scope-posture-menu":
268
+ return (
269
+ <TwScopePostureMenu
270
+ onSelect={(posture) => props.onMarkScopePosture?.(posture)}
271
+ disabled={!props.onMarkScopePosture}
272
+ />
273
+ );
274
+ case "review-queue-prev":
275
+ return (
276
+ <IconTextButton
277
+ icon={ChevronLeft}
278
+ text="Prev"
279
+ ariaLabel="Previous review item"
280
+ disabled={(props.reviewQueue?.totalCount ?? 0) === 0 || !props.onReviewPrev}
281
+ onClick={props.onReviewPrev}
282
+ data-testid="role-review-prev"
283
+ />
284
+ );
285
+ case "review-queue-next":
286
+ return (
287
+ <IconTextButton
288
+ icon={ChevronRight}
289
+ iconAfter
290
+ text="Next"
291
+ ariaLabel="Next review item"
292
+ disabled={(props.reviewQueue?.totalCount ?? 0) === 0 || !props.onReviewNext}
293
+ onClick={props.onReviewNext}
294
+ data-testid="role-review-next"
295
+ />
296
+ );
297
+ case "review-queue-counts":
298
+ return props.reviewQueue ? <ReviewQueueCounts queue={props.reviewQueue} /> : null;
299
+ case "review-queue-active-label":
300
+ return props.reviewQueue ? <ReviewActiveLabel queue={props.reviewQueue} /> : null;
301
+ case "review-accept":
302
+ return (
303
+ <IconTextButton
304
+ icon={Check}
305
+ text="Accept"
306
+ tone="accent"
307
+ ariaLabel="Accept current change"
308
+ disabled={!props.onReviewAccept}
309
+ onClick={props.onReviewAccept}
310
+ data-testid="role-review-accept"
311
+ />
312
+ );
313
+ case "review-reject":
314
+ return (
315
+ <IconTextButton
316
+ icon={X}
317
+ text="Reject"
318
+ tone="danger"
319
+ ariaLabel="Reject current change"
320
+ disabled={!props.onReviewReject}
321
+ onClick={props.onReviewReject}
322
+ data-testid="role-review-reject"
323
+ />
324
+ );
325
+ case "review-markup-mode":
326
+ return (
327
+ <MarkupModeSelect
328
+ mode={props.markupDisplay ?? "simple"}
329
+ onChange={(mode) => props.onReviewMarkupMode?.(mode)}
330
+ disabled={!props.onReviewMarkupMode}
331
+ />
332
+ );
333
+ case "workflow-prev":
334
+ return (
335
+ <IconTextButton
336
+ icon={ChevronLeft}
337
+ text="Prev"
338
+ ariaLabel="Previous work item"
339
+ disabled={!(props.workflowItem?.hasPrevious && props.onWorkflowPrev)}
340
+ onClick={props.onWorkflowPrev}
341
+ data-testid="role-workflow-prev"
342
+ />
343
+ );
344
+ case "workflow-next":
345
+ return (
346
+ <IconTextButton
347
+ icon={ChevronRight}
348
+ iconAfter
349
+ text="Next"
350
+ ariaLabel="Next work item"
351
+ disabled={!(props.workflowItem?.hasNext && props.onWorkflowNext)}
352
+ onClick={props.onWorkflowNext}
353
+ data-testid="role-workflow-next"
354
+ />
355
+ );
356
+ case "workflow-mark-complete":
357
+ return (
358
+ <IconTextButton
359
+ icon={CheckCheck}
360
+ text="Complete"
361
+ tone="accent"
362
+ ariaLabel="Mark work item complete"
363
+ disabled={!(props.workflowItem?.canComplete && props.onWorkflowMarkComplete)}
364
+ onClick={props.onWorkflowMarkComplete}
365
+ data-testid="role-workflow-complete"
366
+ />
367
+ );
368
+ case "workflow-claim":
369
+ return (
370
+ <IconTextButton
371
+ icon={Hand}
372
+ text="Claim"
373
+ ariaLabel="Claim work item"
374
+ disabled={!(props.workflowItem?.canClaim && props.onWorkflowClaim)}
375
+ onClick={props.onWorkflowClaim}
376
+ data-testid="role-workflow-claim"
377
+ />
378
+ );
379
+ default:
380
+ return null;
381
+ }
382
+ }
383
+
384
+ interface RoleActionOverflowProps {
385
+ ids: ToolbarChromeItemId[];
386
+ props: TwRoleActionRegionProps;
387
+ }
388
+
389
+ function RoleActionOverflow({
390
+ ids,
391
+ props,
392
+ }: RoleActionOverflowProps): React.JSX.Element {
393
+ const [open, setOpen] = useState(false);
394
+
395
+ return (
396
+ <Popover.Root open={open} onOpenChange={setOpen}>
397
+ <Popover.Trigger asChild>
398
+ <button
399
+ type="button"
400
+ aria-label="More role actions"
401
+ aria-expanded={open}
402
+ onMouseDown={preserveEditorSelectionMouseDown}
403
+ className="inline-flex h-6 items-center gap-1 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas"
404
+ data-testid="role-action-overflow-trigger"
405
+ >
406
+ More
407
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
408
+ </button>
409
+ </Popover.Trigger>
410
+ <Popover.Portal>
411
+ <Popover.Content
412
+ className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
413
+ sideOffset={8}
414
+ align="start"
415
+ data-testid="role-action-overflow-content"
416
+ >
417
+ {ids.map((id) => (
418
+ <OverflowAction key={id} id={id} props={props} onClose={() => setOpen(false)} />
419
+ ))}
420
+ </Popover.Content>
421
+ </Popover.Portal>
422
+ </Popover.Root>
423
+ );
424
+ }
425
+
426
+ function OverflowAction(arg: {
427
+ id: ToolbarChromeItemId;
428
+ props: TwRoleActionRegionProps;
429
+ onClose: () => void;
430
+ }): React.JSX.Element | null {
431
+ const { id, props, onClose } = arg;
432
+ switch (id) {
433
+ case "review-accept-all":
434
+ return (
435
+ <MenuRow
436
+ icon={CheckCheck}
437
+ label="Accept all in scope"
438
+ ariaLabel="Accept all reviewable changes in scope"
439
+ disabled={!props.onReviewAcceptAll}
440
+ onClick={() => {
441
+ props.onReviewAcceptAll?.();
442
+ onClose();
443
+ }}
444
+ />
445
+ );
446
+ case "review-reject-all":
447
+ return (
448
+ <MenuRow
449
+ icon={XCircle}
450
+ label="Reject all in scope"
451
+ ariaLabel="Reject all reviewable changes in scope"
452
+ disabled={!props.onReviewRejectAll}
453
+ onClick={() => {
454
+ props.onReviewRejectAll?.();
455
+ onClose();
456
+ }}
457
+ />
458
+ );
459
+ case "review-markup-mode":
460
+ return (
461
+ <MenuRow
462
+ icon={Rows3}
463
+ label={`Markup: ${props.markupDisplay ?? "simple"}`}
464
+ ariaLabel="Change markup display mode"
465
+ disabled={!props.onReviewMarkupMode}
466
+ onClick={() => {
467
+ const next: MarkupDisplayMode =
468
+ props.markupDisplay === "all"
469
+ ? "clean"
470
+ : props.markupDisplay === "simple"
471
+ ? "all"
472
+ : "simple";
473
+ props.onReviewMarkupMode?.(next);
474
+ onClose();
475
+ }}
476
+ />
477
+ );
478
+ case "workflow-skip":
479
+ return (
480
+ <MenuRow
481
+ icon={SkipForward}
482
+ label="Skip work item"
483
+ ariaLabel="Skip work item"
484
+ disabled={!(props.workflowItem?.canSkip && props.onWorkflowSkip)}
485
+ onClick={() => {
486
+ props.onWorkflowSkip?.();
487
+ onClose();
488
+ }}
489
+ />
490
+ );
491
+ case "workflow-mark-blocked":
492
+ return (
493
+ <MenuRow
494
+ icon={CircleOff}
495
+ label="Mark blocked"
496
+ ariaLabel="Mark work item blocked"
497
+ disabled={!(props.workflowItem?.canMarkBlocked && props.onWorkflowMarkBlocked)}
498
+ onClick={() => {
499
+ props.onWorkflowMarkBlocked?.();
500
+ onClose();
501
+ }}
502
+ />
503
+ );
504
+ case "workflow-jump-to-scope":
505
+ return (
506
+ <MenuRow
507
+ icon={Target}
508
+ label="Jump to scope"
509
+ ariaLabel="Jump to workflow scope"
510
+ disabled={!props.onWorkflowJumpToScope}
511
+ onClick={() => {
512
+ props.onWorkflowJumpToScope?.();
513
+ onClose();
514
+ }}
515
+ />
516
+ );
517
+ case "review-queue-counts":
518
+ return props.reviewQueue ? (
519
+ <div className="px-2 py-1.5">
520
+ <ReviewQueueCounts queue={props.reviewQueue} compact />
521
+ </div>
522
+ ) : null;
523
+ case "review-queue-active-label":
524
+ return props.reviewQueue ? (
525
+ <div className="px-2 py-1.5">
526
+ <ReviewActiveLabel queue={props.reviewQueue} />
527
+ </div>
528
+ ) : null;
529
+ default:
530
+ return null;
531
+ }
532
+ }
533
+
534
+ function IconTextButton(arg: {
535
+ icon: React.ComponentType<{ className?: string }>;
536
+ iconAfter?: boolean;
537
+ text: string;
538
+ ariaLabel: string;
539
+ tone?: "default" | "accent" | "danger";
540
+ disabled?: boolean;
541
+ onClick?: () => void;
542
+ "data-testid"?: string;
543
+ }): React.JSX.Element {
544
+ const Icon = arg.icon;
545
+ const tone = arg.tone ?? "default";
546
+ const toneClasses =
547
+ tone === "accent"
548
+ ? "text-accent hover:bg-accent/10"
549
+ : tone === "danger"
550
+ ? "text-danger hover:bg-danger/10"
551
+ : "text-secondary hover:bg-surface";
552
+ return (
553
+ <button
554
+ type="button"
555
+ aria-label={arg.ariaLabel}
556
+ disabled={arg.disabled}
557
+ onMouseDown={preserveEditorSelectionMouseDown}
558
+ onClick={arg.onClick}
559
+ data-testid={arg["data-testid"]}
560
+ className={[
561
+ "inline-flex h-6 items-center gap-1 rounded-md border border-transparent px-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas",
562
+ toneClasses,
563
+ ].join(" ")}
564
+ >
565
+ {!arg.iconAfter ? <Icon className="h-3.5 w-3.5" /> : null}
566
+ <span>{arg.text}</span>
567
+ {arg.iconAfter ? <Icon className="h-3.5 w-3.5" /> : null}
568
+ </button>
569
+ );
570
+ }
571
+
572
+ function MenuRow(arg: {
573
+ icon: React.ComponentType<{ className?: string }>;
574
+ label: string;
575
+ ariaLabel: string;
576
+ disabled?: boolean;
577
+ onClick: () => void;
578
+ }): React.JSX.Element {
579
+ const Icon = arg.icon;
580
+ return (
581
+ <button
582
+ type="button"
583
+ aria-label={arg.ariaLabel}
584
+ disabled={arg.disabled}
585
+ onMouseDown={preserveEditorSelectionMouseDown}
586
+ onClick={arg.onClick}
587
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:bg-surface"
588
+ >
589
+ <Icon className="h-3.5 w-3.5 text-secondary" />
590
+ <span>{arg.label}</span>
591
+ </button>
592
+ );
593
+ }
594
+
595
+ function ReviewQueueCounts({
596
+ queue,
597
+ compact,
598
+ }: {
599
+ queue: ReviewQueueSnapshot;
600
+ compact?: boolean;
601
+ }): React.JSX.Element {
602
+ return (
603
+ <span
604
+ className={[
605
+ "inline-flex items-center gap-1.5 text-[10px] text-secondary",
606
+ compact ? "px-0" : "px-1",
607
+ ].join(" ")}
608
+ data-testid="role-review-counts"
609
+ >
610
+ <span className="inline-flex items-center gap-1 rounded-full bg-surface px-1.5 py-0.5 font-medium">
611
+ <MessageSquareText className="h-3 w-3 text-comment" />
612
+ {queue.openCount} open
613
+ </span>
614
+ <span className="inline-flex items-center gap-1 rounded-full bg-surface px-1.5 py-0.5 font-medium">
615
+ <Rows3 className="h-3 w-3 text-accent" />
616
+ {queue.totalCount} total
617
+ </span>
618
+ </span>
619
+ );
620
+ }
621
+
622
+ function ReviewActiveLabel({
623
+ queue,
624
+ }: {
625
+ queue: ReviewQueueSnapshot;
626
+ }): React.JSX.Element {
627
+ const active = queue.items[queue.activeIndex] ?? null;
628
+ const label =
629
+ active?.label ??
630
+ (queue.totalCount > 0 ? "Review queue" : "No review items");
631
+ const kind =
632
+ active?.kind === "comment"
633
+ ? "Comment"
634
+ : active?.kind === "change"
635
+ ? "Redline"
636
+ : active?.kind === "section_mark"
637
+ ? "Tag"
638
+ : null;
639
+ return (
640
+ <span
641
+ className="inline-flex min-w-0 items-center gap-1.5 truncate text-[11px] text-primary"
642
+ data-testid="role-review-active-label"
643
+ >
644
+ {kind ? (
645
+ <span className="shrink-0 rounded-full bg-canvas px-1.5 py-0.5 text-[9px] font-medium text-secondary ring-1 ring-border/50">
646
+ {kind}
647
+ </span>
648
+ ) : null}
649
+ <span className="truncate">{label}</span>
650
+ </span>
651
+ );
652
+ }
653
+
654
+ function MarkupModeSelect(arg: {
655
+ mode: MarkupDisplayMode;
656
+ onChange: (mode: MarkupDisplayMode) => void;
657
+ disabled?: boolean;
658
+ }): React.JSX.Element {
659
+ const Icon = arg.mode === "clean" ? BookmarkCheck : arg.mode === "all" ? Flag : Rows3;
660
+ return (
661
+ <button
662
+ type="button"
663
+ aria-label={`Markup display: ${arg.mode}`}
664
+ disabled={arg.disabled}
665
+ onMouseDown={preserveEditorSelectionMouseDown}
666
+ onClick={() => {
667
+ const next: MarkupDisplayMode =
668
+ arg.mode === "all" ? "clean" : arg.mode === "clean" ? "simple" : "all";
669
+ arg.onChange(next);
670
+ }}
671
+ data-testid="role-review-markup-mode"
672
+ className="inline-flex h-6 items-center gap-1 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
673
+ >
674
+ <Icon className="h-3.5 w-3.5" />
675
+ <span className="capitalize">{arg.mode}</span>
676
+ </button>
677
+ );
678
+ }
679
+
680
+ export default TwRoleActionRegion;