@beyondwork/docx-react-component 1.0.92 → 1.0.94

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.
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { createPortal } from "react-dom";
2
3
 
3
4
  import * as Popover from "@radix-ui/react-popover";
4
5
  import * as Select from "@radix-ui/react-select";
@@ -83,6 +84,13 @@ export interface TwToolbarProps {
83
84
  scopedChromePolicy?: ScopedChromePolicy;
84
85
  preset?: WordReviewEditorChromePreset;
85
86
  compactMode?: boolean;
87
+ /**
88
+ * True when the runtime has an active editable text/paragraph target
89
+ * (focused caret or range selection). Formatting, paragraph, list, and
90
+ * structural insert controls use this in addition to document-level edit
91
+ * capability so top chrome does not advertise commands against no target.
92
+ */
93
+ hasEditableSelectionTarget?: boolean;
86
94
  workspaceMode: WorkspaceMode;
87
95
  zoomLevel?: ZoomLevel;
88
96
  formattingState?: FormattingStateSnapshot;
@@ -231,6 +239,80 @@ const HIGHLIGHT_COLORS = [
231
239
  { value: null, label: "None" },
232
240
  ] as const;
233
241
 
242
+ function ToolbarPortalMenu(props: {
243
+ anchorRef: React.RefObject<HTMLButtonElement | null>;
244
+ align?: "start" | "end";
245
+ children: React.ReactNode;
246
+ className: string;
247
+ menuWidthPx: number;
248
+ open: boolean;
249
+ }): React.ReactPortal | null {
250
+ const style = useToolbarPortalPosition({
251
+ align: props.align ?? "start",
252
+ anchorRef: props.anchorRef,
253
+ menuWidthPx: props.menuWidthPx,
254
+ open: props.open,
255
+ });
256
+ const body = props.anchorRef.current?.ownerDocument?.body;
257
+ if (!props.open || !body) {
258
+ return null;
259
+ }
260
+ return createPortal(
261
+ <div className={props.className} style={style}>
262
+ {props.children}
263
+ </div>,
264
+ body,
265
+ );
266
+ }
267
+
268
+ function useToolbarPortalPosition(input: {
269
+ align: "start" | "end";
270
+ anchorRef: React.RefObject<HTMLButtonElement | null>;
271
+ menuWidthPx: number;
272
+ open: boolean;
273
+ }): React.CSSProperties {
274
+ const [style, setStyle] = React.useState<React.CSSProperties>({
275
+ left: 8,
276
+ position: "fixed",
277
+ top: 8,
278
+ zIndex: 50,
279
+ });
280
+
281
+ React.useLayoutEffect(() => {
282
+ if (!input.open) return;
283
+ const anchor = input.anchorRef.current;
284
+ const ownerWindow = anchor?.ownerDocument?.defaultView;
285
+ if (!anchor || !ownerWindow) return;
286
+
287
+ const update = () => {
288
+ const rect = anchor.getBoundingClientRect();
289
+ const viewportWidth = ownerWindow.innerWidth || input.menuWidthPx + 16;
290
+ const rawLeft =
291
+ input.align === "end" ? rect.right - input.menuWidthPx : rect.left;
292
+ const left = Math.min(
293
+ Math.max(8, rawLeft),
294
+ Math.max(8, viewportWidth - input.menuWidthPx - 8),
295
+ );
296
+ setStyle({
297
+ left,
298
+ position: "fixed",
299
+ top: Math.max(8, rect.bottom + 8),
300
+ zIndex: 50,
301
+ });
302
+ };
303
+
304
+ update();
305
+ ownerWindow.addEventListener("resize", update);
306
+ ownerWindow.addEventListener("scroll", update, true);
307
+ return () => {
308
+ ownerWindow.removeEventListener("resize", update);
309
+ ownerWindow.removeEventListener("scroll", update, true);
310
+ };
311
+ }, [input.align, input.anchorRef, input.menuWidthPx, input.open]);
312
+
313
+ return style;
314
+ }
315
+
234
316
  export function TwToolbar(props: TwToolbarProps) {
235
317
  const caps = props.capabilities;
236
318
  const preset = props.preset ?? "advanced";
@@ -238,8 +320,21 @@ export function TwToolbar(props: TwToolbarProps) {
238
320
  const workspaceMode = props.workspaceMode;
239
321
  const paragraphStyles = props.styleCatalog?.paragraphs ?? [];
240
322
  const zoomLevel = props.zoomLevel ?? 100;
241
- const canEdit = props.interactionPolicy?.canFormatText ?? (caps ? caps.canEdit : false);
242
- const canInsertStructural = props.interactionPolicy?.canInsertStructural ?? canEdit;
323
+ const hasEditableSelectionTarget = props.hasEditableSelectionTarget ?? true;
324
+ const baseCanFormatText = props.interactionPolicy?.canFormatText ?? (caps ? caps.canEdit : false);
325
+ const baseCanInsertStructural = props.interactionPolicy?.canInsertStructural ?? baseCanFormatText;
326
+ const selectionTargetDisabledReason = !hasEditableSelectionTarget
327
+ ? "Place the cursor in the document or select text first."
328
+ : undefined;
329
+ const editModeDisabledReason =
330
+ props.interactionPolicy && props.interactionPolicy.mode !== "edit"
331
+ ? `Not available in ${props.interactionPolicy.mode} mode.`
332
+ : "Editing is not available in this document.";
333
+ const editDisabledReason = selectionTargetDisabledReason ?? (!baseCanFormatText ? editModeDisabledReason : undefined);
334
+ const insertDisabledReason =
335
+ selectionTargetDisabledReason ?? (!baseCanInsertStructural ? editModeDisabledReason : undefined);
336
+ const canEdit = baseCanFormatText && hasEditableSelectionTarget;
337
+ const canInsertStructural = baseCanInsertStructural && hasEditableSelectionTarget;
243
338
  const canAddComment = props.interactionPolicy?.canAddComment ?? (caps ? caps.canAddComment : false);
244
339
  const showDiagnosticsChrome = props.showDiagnosticsChrome ?? true;
245
340
  const scopedChromePolicy = props.scopedChromePolicy ?? resolveScopedChromePolicy({
@@ -354,6 +449,7 @@ export function TwToolbar(props: TwToolbarProps) {
354
449
  <>
355
450
  <ToolbarParagraphStyleSelect
356
451
  disabled={!canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
452
+ disabledReason={editDisabledReason}
357
453
  styles={paragraphStyles}
358
454
  value={props.formattingState?.paragraphStyleId}
359
455
  hasMixedValue={props.hasMixedParagraphStyle ?? false}
@@ -362,12 +458,14 @@ export function TwToolbar(props: TwToolbarProps) {
362
458
 
363
459
  <ToolbarFontFamilySelect
364
460
  disabled={!canEdit || !props.onSetFontFamily}
461
+ disabledReason={editDisabledReason}
365
462
  value={props.formattingState?.fontFamily}
366
463
  hasMixedValue={props.hasMixedFontFamily ?? false}
367
464
  onValueChange={props.onSetFontFamily}
368
465
  />
369
466
  <ToolbarFontSizeSelect
370
467
  disabled={!canEdit || !props.onSetFontSize}
468
+ disabledReason={editDisabledReason}
371
469
  value={props.formattingState?.fontSize}
372
470
  hasMixedValue={props.hasMixedFontSize ?? false}
373
471
  onValueChange={props.onSetFontSize}
@@ -385,6 +483,7 @@ export function TwToolbar(props: TwToolbarProps) {
385
483
  shortcut="⌘B"
386
484
  active={props.formattingState?.bold ?? false}
387
485
  disabled={!canEdit || !props.onToggleBold}
486
+ disabledReason={editDisabledReason}
388
487
  onClick={props.onToggleBold}
389
488
  />
390
489
  <TwToolbarIconButton
@@ -393,6 +492,7 @@ export function TwToolbar(props: TwToolbarProps) {
393
492
  shortcut="⌘I"
394
493
  active={props.formattingState?.italic ?? false}
395
494
  disabled={!canEdit || !props.onToggleItalic}
495
+ disabledReason={editDisabledReason}
396
496
  onClick={props.onToggleItalic}
397
497
  />
398
498
  <TwToolbarIconButton
@@ -401,6 +501,7 @@ export function TwToolbar(props: TwToolbarProps) {
401
501
  shortcut="⌘U"
402
502
  active={props.formattingState?.underline ?? false}
403
503
  disabled={!canEdit || !props.onToggleUnderline}
504
+ disabledReason={editDisabledReason}
404
505
  onClick={props.onToggleUnderline}
405
506
  />
406
507
  {showAdvancedFormatting ? (
@@ -411,6 +512,7 @@ export function TwToolbar(props: TwToolbarProps) {
411
512
  !props.onToggleSuperscript &&
412
513
  !props.onToggleSubscript)
413
514
  }
515
+ disabledReason={editDisabledReason}
414
516
  formattingState={props.formattingState}
415
517
  onToggleStrikethrough={props.onToggleStrikethrough}
416
518
  onToggleSuperscript={props.onToggleSuperscript}
@@ -427,7 +529,9 @@ export function TwToolbar(props: TwToolbarProps) {
427
529
  ariaLabel="Text color"
428
530
  colors={TEXT_COLORS.map((value) => ({ value, label: value }))}
429
531
  disabled={!canEdit || !props.onSetTextColor}
532
+ disabledReason={editDisabledReason}
430
533
  icon={<Baseline className="h-3.5 w-3.5" />}
534
+ activeValue={props.formattingState?.textColor?.toLowerCase()}
431
535
  onSelect={(value) => {
432
536
  if (value) {
433
537
  props.onSetTextColor?.(value);
@@ -439,7 +543,9 @@ export function TwToolbar(props: TwToolbarProps) {
439
543
  ariaLabel="Highlight color"
440
544
  colors={HIGHLIGHT_COLORS.map((entry) => ({ value: entry.value, label: entry.label }))}
441
545
  disabled={!canEdit || !props.onSetHighlightColor}
546
+ disabledReason={editDisabledReason}
442
547
  icon={<Highlighter className="h-3.5 w-3.5" />}
548
+ activeValue={props.formattingState?.highlightColor?.toLowerCase() ?? null}
443
549
  onSelect={(value) => props.onSetHighlightColor?.(value)}
444
550
  title="Highlight color"
445
551
  />
@@ -449,6 +555,7 @@ export function TwToolbar(props: TwToolbarProps) {
449
555
  <ToolbarAlignmentPopover
450
556
  activeAlignment={props.formattingState?.alignment}
451
557
  disabled={!canEdit || !props.onSetAlignment}
558
+ disabledReason={editDisabledReason}
452
559
  onSelect={(alignment) => props.onSetAlignment?.(alignment)}
453
560
  />
454
561
  ) : null}
@@ -464,6 +571,7 @@ export function TwToolbar(props: TwToolbarProps) {
464
571
  label="Bulleted list"
465
572
  active={Boolean(props.activeListContext && !props.activeListContext.isOrdered)}
466
573
  disabled={!canEdit || !props.onToggleBulletedList}
574
+ disabledReason={editDisabledReason}
467
575
  onClick={props.onToggleBulletedList}
468
576
  />
469
577
  <TwToolbarIconButton
@@ -471,6 +579,7 @@ export function TwToolbar(props: TwToolbarProps) {
471
579
  label="Numbered list"
472
580
  active={Boolean(props.activeListContext?.isOrdered)}
473
581
  disabled={!canEdit || !props.onToggleNumberedList}
582
+ disabledReason={editDisabledReason}
474
583
  onClick={props.onToggleNumberedList}
475
584
  />
476
585
  </>
@@ -481,12 +590,14 @@ export function TwToolbar(props: TwToolbarProps) {
481
590
  icon={Outdent}
482
591
  label="Outdent"
483
592
  disabled={!canEdit || !props.onOutdent}
593
+ disabledReason={editDisabledReason}
484
594
  onClick={props.onOutdent}
485
595
  />
486
596
  <TwToolbarIconButton
487
597
  icon={Indent}
488
598
  label="Indent"
489
599
  disabled={!canEdit || !props.onIndent}
600
+ disabledReason={editDisabledReason}
490
601
  onClick={props.onIndent}
491
602
  />
492
603
  </>
@@ -497,6 +608,8 @@ export function TwToolbar(props: TwToolbarProps) {
497
608
  type="button"
498
609
  aria-label="Restart numbering"
499
610
  disabled={!canEdit || !props.onRestartNumbering}
611
+ title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
612
+ data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
500
613
  onMouseDown={preserveEditorSelectionMouseDown}
501
614
  onClick={props.onRestartNumbering}
502
615
  className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
@@ -507,6 +620,8 @@ export function TwToolbar(props: TwToolbarProps) {
507
620
  type="button"
508
621
  aria-label="Continue numbering"
509
622
  disabled={!canEdit || !props.onContinueNumbering}
623
+ title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
624
+ data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
510
625
  onMouseDown={preserveEditorSelectionMouseDown}
511
626
  onClick={props.onContinueNumbering}
512
627
  className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
@@ -518,6 +633,7 @@ export function TwToolbar(props: TwToolbarProps) {
518
633
  {showInsertMenu && showInsertActionsInRow ? (
519
634
  <ToolbarInsertMenu
520
635
  disabled={!canInsertStructural}
636
+ disabledReason={insertDisabledReason}
521
637
  disabledReasons={props.insertDisabledReasons}
522
638
  onInsertImage={props.onInsertImage}
523
639
  onInsertPageBreak={props.onInsertPageBreak}
@@ -531,6 +647,8 @@ export function TwToolbar(props: TwToolbarProps) {
531
647
  type="button"
532
648
  aria-label="Refresh fields"
533
649
  disabled={!canEdit || !props.onUpdateFields}
650
+ title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
651
+ data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
534
652
  onMouseDown={preserveEditorSelectionMouseDown}
535
653
  onClick={props.onUpdateFields}
536
654
  className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
@@ -541,6 +659,8 @@ export function TwToolbar(props: TwToolbarProps) {
541
659
  type="button"
542
660
  aria-label="Refresh table of contents"
543
661
  disabled={!canEdit || !props.onUpdateTableOfContents}
662
+ title={!canEdit && editDisabledReason ? `Not available: ${editDisabledReason}` : undefined}
663
+ data-disabled-reason={!canEdit && editDisabledReason ? editDisabledReason : undefined}
544
664
  onMouseDown={preserveEditorSelectionMouseDown}
545
665
  onClick={props.onUpdateTableOfContents}
546
666
  className={`inline-flex h-6 items-center rounded-md px-2 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
@@ -554,7 +674,9 @@ export function TwToolbar(props: TwToolbarProps) {
554
674
  activeListContext={props.activeListContext}
555
675
  canEdit={canEdit}
556
676
  canInsertStructural={canInsertStructural}
677
+ editDisabledReason={editDisabledReason}
557
678
  formattingState={props.formattingState}
679
+ insertDisabledReason={insertDisabledReason}
558
680
  paragraphStyles={paragraphStyles}
559
681
  showInsertMenu={getToolbarChromePlacement(scopedChromePolicy, "insert-actions") === "overflow"}
560
682
  showListActions={getToolbarChromePlacement(scopedChromePolicy, "list-actions") === "overflow"}
@@ -918,6 +1040,7 @@ function ToolbarParagraphStyleSelect(props: {
918
1040
  styles: StyleCatalogSnapshot["paragraphs"];
919
1041
  value?: string;
920
1042
  disabled: boolean;
1043
+ disabledReason?: string;
921
1044
  hasMixedValue?: boolean;
922
1045
  onValueChange?: (styleId: string) => void;
923
1046
  }) {
@@ -939,7 +1062,9 @@ function ToolbarParagraphStyleSelect(props: {
939
1062
  aria-label="Paragraph style"
940
1063
  aria-disabled={props.disabled || undefined}
941
1064
  data-disabled={props.disabled ? "" : undefined}
1065
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
942
1066
  data-mixed={isMixed ? "true" : undefined}
1067
+ title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
943
1068
  onMouseDown={preserveEditorSelectionMouseDown}
944
1069
  className={`inline-flex h-6 min-w-[7.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
945
1070
  >
@@ -984,6 +1109,7 @@ function ToolbarParagraphStyleSelect(props: {
984
1109
  function ToolbarFontFamilySelect(props: {
985
1110
  value?: string;
986
1111
  disabled: boolean;
1112
+ disabledReason?: string;
987
1113
  hasMixedValue?: boolean;
988
1114
  onValueChange?: (fontFamily: string) => void;
989
1115
  }) {
@@ -1003,7 +1129,9 @@ function ToolbarFontFamilySelect(props: {
1003
1129
  aria-label="Font family"
1004
1130
  aria-disabled={props.disabled || undefined}
1005
1131
  data-disabled={props.disabled ? "" : undefined}
1132
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1006
1133
  data-mixed={isMixed ? "true" : undefined}
1134
+ title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
1007
1135
  onMouseDown={preserveEditorSelectionMouseDown}
1008
1136
  className={`inline-flex h-6 min-w-[6.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1009
1137
  >
@@ -1048,6 +1176,7 @@ function ToolbarFontFamilySelect(props: {
1048
1176
  function ToolbarFontSizeSelect(props: {
1049
1177
  value?: number;
1050
1178
  disabled: boolean;
1179
+ disabledReason?: string;
1051
1180
  hasMixedValue?: boolean;
1052
1181
  onValueChange?: (fontSize: number) => void;
1053
1182
  }) {
@@ -1069,7 +1198,9 @@ function ToolbarFontSizeSelect(props: {
1069
1198
  aria-label="Font size"
1070
1199
  aria-disabled={props.disabled || undefined}
1071
1200
  data-disabled={props.disabled ? "" : undefined}
1201
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1072
1202
  data-mixed={isMixed ? "true" : undefined}
1203
+ title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
1073
1204
  onMouseDown={preserveEditorSelectionMouseDown}
1074
1205
  className={`inline-flex h-6 min-w-[3.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1075
1206
  >
@@ -1115,7 +1246,9 @@ function ToolbarCompactOverflow(props: {
1115
1246
  activeListContext?: ActiveListContext | null;
1116
1247
  canEdit: boolean;
1117
1248
  canInsertStructural: boolean;
1249
+ editDisabledReason?: string;
1118
1250
  formattingState?: FormattingStateSnapshot;
1251
+ insertDisabledReason?: string;
1119
1252
  paragraphStyles: StyleCatalogSnapshot["paragraphs"];
1120
1253
  showInsertMenu: boolean;
1121
1254
  showListActions: boolean;
@@ -1140,6 +1273,7 @@ function ToolbarCompactOverflow(props: {
1140
1273
  onUpdateTableOfContents?: () => void;
1141
1274
  }) {
1142
1275
  const [open, setOpen] = React.useState(false);
1276
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
1143
1277
 
1144
1278
  async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
1145
1279
  const file = event.target.files?.[0];
@@ -1159,19 +1293,28 @@ function ToolbarCompactOverflow(props: {
1159
1293
  }
1160
1294
 
1161
1295
  return (
1162
- <div className="relative">
1296
+ <Popover.Root open={open} onOpenChange={setOpen}>
1163
1297
  <Tooltip.Root>
1164
1298
  <Tooltip.Trigger asChild>
1165
- <button
1166
- type="button"
1167
- aria-label="More document tools"
1168
- aria-expanded={open}
1169
- onMouseDown={preserveEditorSelectionMouseDown}
1170
- onClick={() => setOpen((value) => !value)}
1171
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
1172
- >
1173
- <MoreHorizontal className="h-3.5 w-3.5" />
1174
- </button>
1299
+ <Popover.Anchor asChild>
1300
+ <button
1301
+ ref={triggerRef}
1302
+ type="button"
1303
+ aria-label="More document tools"
1304
+ aria-expanded={open}
1305
+ aria-haspopup="menu"
1306
+ onMouseDown={preserveEditorSelectionMouseDown}
1307
+ onClick={(event) => {
1308
+ event.preventDefault();
1309
+ setOpen((value) => !value);
1310
+ }}
1311
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none ${
1312
+ open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
1313
+ } ${focusRingClass}`}
1314
+ >
1315
+ <MoreHorizontal className="h-3.5 w-3.5" />
1316
+ </button>
1317
+ </Popover.Anchor>
1175
1318
  </Tooltip.Trigger>
1176
1319
  <Tooltip.Portal>
1177
1320
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1179,8 +1322,12 @@ function ToolbarCompactOverflow(props: {
1179
1322
  </Tooltip.Content>
1180
1323
  </Tooltip.Portal>
1181
1324
  </Tooltip.Root>
1182
- {open ? (
1183
- <div className="absolute left-0 top-9 z-50 w-[min(20rem,calc(100vw-2rem))] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1325
+ <ToolbarPortalMenu
1326
+ anchorRef={triggerRef}
1327
+ className="w-[min(20rem,calc(100vw-2rem))] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1328
+ menuWidthPx={320}
1329
+ open={open}
1330
+ >
1184
1331
  <div className="space-y-3">
1185
1332
  {props.showStyleSelectors ? (
1186
1333
  <div className="space-y-2">
@@ -1190,6 +1337,7 @@ function ToolbarCompactOverflow(props: {
1190
1337
  <div className="grid gap-2">
1191
1338
  <ToolbarParagraphStyleSelect
1192
1339
  disabled={!props.canEdit || props.paragraphStyles.length === 0 || !props.onSetParagraphStyle}
1340
+ disabledReason={props.editDisabledReason}
1193
1341
  styles={props.paragraphStyles}
1194
1342
  value={props.formattingState?.paragraphStyleId}
1195
1343
  onValueChange={(styleId) => {
@@ -1200,6 +1348,7 @@ function ToolbarCompactOverflow(props: {
1200
1348
  <div className="grid grid-cols-2 gap-2">
1201
1349
  <ToolbarFontFamilySelect
1202
1350
  disabled={!props.canEdit || !props.onSetFontFamily}
1351
+ disabledReason={props.editDisabledReason}
1203
1352
  value={props.formattingState?.fontFamily}
1204
1353
  onValueChange={(fontFamily) => {
1205
1354
  props.onSetFontFamily?.(fontFamily);
@@ -1208,6 +1357,7 @@ function ToolbarCompactOverflow(props: {
1208
1357
  />
1209
1358
  <ToolbarFontSizeSelect
1210
1359
  disabled={!props.canEdit || !props.onSetFontSize}
1360
+ disabledReason={props.editDisabledReason}
1211
1361
  value={props.formattingState?.fontSize}
1212
1362
  onValueChange={(fontSize) => {
1213
1363
  props.onSetFontSize?.(fontSize);
@@ -1227,6 +1377,7 @@ function ToolbarCompactOverflow(props: {
1227
1377
  <ToolbarMenuButton
1228
1378
  ariaLabel="Bulleted list"
1229
1379
  disabled={!props.canEdit || !props.onToggleBulletedList}
1380
+ disabledReason={props.editDisabledReason}
1230
1381
  icon={<List className="h-3.5 w-3.5" />}
1231
1382
  label="Bulleted list"
1232
1383
  onClick={() => {
@@ -1237,6 +1388,7 @@ function ToolbarCompactOverflow(props: {
1237
1388
  <ToolbarMenuButton
1238
1389
  ariaLabel="Numbered list"
1239
1390
  disabled={!props.canEdit || !props.onToggleNumberedList}
1391
+ disabledReason={props.editDisabledReason}
1240
1392
  icon={<Rows3 className="h-3.5 w-3.5" />}
1241
1393
  label="Numbered list"
1242
1394
  onClick={() => {
@@ -1257,6 +1409,7 @@ function ToolbarCompactOverflow(props: {
1257
1409
  <ToolbarMenuButton
1258
1410
  ariaLabel="Outdent"
1259
1411
  disabled={!props.canEdit || !props.onOutdent}
1412
+ disabledReason={props.editDisabledReason}
1260
1413
  icon={<Outdent className="h-3.5 w-3.5" />}
1261
1414
  label="Outdent"
1262
1415
  onClick={() => {
@@ -1267,6 +1420,7 @@ function ToolbarCompactOverflow(props: {
1267
1420
  <ToolbarMenuButton
1268
1421
  ariaLabel="Indent"
1269
1422
  disabled={!props.canEdit || !props.onIndent}
1423
+ disabledReason={props.editDisabledReason}
1270
1424
  icon={<Indent className="h-3.5 w-3.5" />}
1271
1425
  label="Indent"
1272
1426
  onClick={() => {
@@ -1281,6 +1435,7 @@ function ToolbarCompactOverflow(props: {
1281
1435
  <ToolbarMenuButton
1282
1436
  ariaLabel="Restart numbering"
1283
1437
  disabled={!props.canEdit || !props.onRestartNumbering}
1438
+ disabledReason={props.editDisabledReason}
1284
1439
  icon={<Rows3 className="h-3.5 w-3.5" />}
1285
1440
  label="Restart numbering"
1286
1441
  onClick={() => {
@@ -1291,6 +1446,7 @@ function ToolbarCompactOverflow(props: {
1291
1446
  <ToolbarMenuButton
1292
1447
  ariaLabel="Continue numbering"
1293
1448
  disabled={!props.canEdit || !props.onContinueNumbering}
1449
+ disabledReason={props.editDisabledReason}
1294
1450
  icon={<Rows3 className="h-3.5 w-3.5" />}
1295
1451
  label="Continue numbering"
1296
1452
  onClick={() => {
@@ -1311,6 +1467,7 @@ function ToolbarCompactOverflow(props: {
1311
1467
  <ToolbarMenuButton
1312
1468
  ariaLabel="Insert page break"
1313
1469
  disabled={!props.canInsertStructural || !props.onInsertPageBreak}
1470
+ disabledReason={props.insertDisabledReason}
1314
1471
  icon={<Minus className="h-3.5 w-3.5" />}
1315
1472
  label="Page break"
1316
1473
  onClick={() => {
@@ -1321,6 +1478,7 @@ function ToolbarCompactOverflow(props: {
1321
1478
  <ToolbarMenuButton
1322
1479
  ariaLabel="Insert table"
1323
1480
  disabled={!props.canInsertStructural || !props.onInsertTable}
1481
+ disabledReason={props.insertDisabledReason}
1324
1482
  icon={<Rows3 className="h-3.5 w-3.5" />}
1325
1483
  label="Table"
1326
1484
  onClick={() => {
@@ -1329,6 +1487,16 @@ function ToolbarCompactOverflow(props: {
1329
1487
  }}
1330
1488
  />
1331
1489
  <label
1490
+ title={
1491
+ !props.canInsertStructural && props.insertDisabledReason
1492
+ ? `Not available: ${props.insertDisabledReason}`
1493
+ : undefined
1494
+ }
1495
+ data-disabled-reason={
1496
+ !props.canInsertStructural && props.insertDisabledReason
1497
+ ? props.insertDisabledReason
1498
+ : undefined
1499
+ }
1332
1500
  className={`flex h-7 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-medium text-primary transition-colors hover:bg-surface ${
1333
1501
  !props.canInsertStructural || !props.onInsertImage ? "pointer-events-none opacity-40" : ""
1334
1502
  }`}
@@ -1349,6 +1517,7 @@ function ToolbarCompactOverflow(props: {
1349
1517
  <ToolbarMenuButton
1350
1518
  ariaLabel="Insert next-page section break"
1351
1519
  disabled={!props.canInsertStructural || !props.onInsertSectionBreak}
1520
+ disabledReason={props.insertDisabledReason}
1352
1521
  icon={<FileText className="h-3.5 w-3.5" />}
1353
1522
  label="Next-page section break"
1354
1523
  onClick={() => {
@@ -1367,6 +1536,7 @@ function ToolbarCompactOverflow(props: {
1367
1536
  <ToolbarMenuButton
1368
1537
  ariaLabel="Refresh fields"
1369
1538
  disabled={!props.canEdit || !props.onUpdateFields}
1539
+ disabledReason={props.editDisabledReason}
1370
1540
  icon={<RotateCcw className="h-3.5 w-3.5" />}
1371
1541
  label="Fields"
1372
1542
  onClick={() => {
@@ -1377,6 +1547,7 @@ function ToolbarCompactOverflow(props: {
1377
1547
  <ToolbarMenuButton
1378
1548
  ariaLabel="Refresh table of contents"
1379
1549
  disabled={!props.canEdit || !props.onUpdateTableOfContents}
1550
+ disabledReason={props.editDisabledReason}
1380
1551
  icon={<RotateCcw className="h-3.5 w-3.5" />}
1381
1552
  label="Table of contents"
1382
1553
  onClick={() => {
@@ -1387,36 +1558,50 @@ function ToolbarCompactOverflow(props: {
1387
1558
  </div>
1388
1559
  ) : null}
1389
1560
  </div>
1390
- </div>
1391
- ) : null}
1392
- </div>
1561
+ </ToolbarPortalMenu>
1562
+ </Popover.Root>
1393
1563
  );
1394
1564
  }
1395
1565
 
1396
1566
  function ToolbarFormattingOverflow(props: {
1397
1567
  disabled: boolean;
1568
+ disabledReason?: string;
1398
1569
  formattingState?: FormattingStateSnapshot;
1399
1570
  onToggleStrikethrough?: () => void;
1400
1571
  onToggleSuperscript?: () => void;
1401
1572
  onToggleSubscript?: () => void;
1402
1573
  }) {
1403
1574
  const [open, setOpen] = React.useState(false);
1575
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
1576
+ const disabledTitle =
1577
+ props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1404
1578
 
1405
1579
  return (
1406
- <div className="relative">
1580
+ <Popover.Root open={open} onOpenChange={setOpen}>
1407
1581
  <Tooltip.Root>
1408
1582
  <Tooltip.Trigger asChild>
1409
- <button
1410
- type="button"
1411
- aria-label="More text formatting"
1412
- aria-expanded={open}
1413
- disabled={props.disabled}
1414
- onMouseDown={preserveEditorSelectionMouseDown}
1415
- onClick={() => setOpen((value) => !value)}
1416
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1417
- >
1418
- <MoreHorizontal className="h-3.5 w-3.5" />
1419
- </button>
1583
+ <Popover.Anchor asChild>
1584
+ <button
1585
+ ref={triggerRef}
1586
+ type="button"
1587
+ aria-label="More text formatting"
1588
+ aria-expanded={open}
1589
+ aria-haspopup="menu"
1590
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1591
+ disabled={props.disabled}
1592
+ title={disabledTitle}
1593
+ onMouseDown={preserveEditorSelectionMouseDown}
1594
+ onClick={(event) => {
1595
+ event.preventDefault();
1596
+ setOpen((value) => !value);
1597
+ }}
1598
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1599
+ open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : ""
1600
+ } ${focusRingClass}`}
1601
+ >
1602
+ <MoreHorizontal className="h-3.5 w-3.5" />
1603
+ </button>
1604
+ </Popover.Anchor>
1420
1605
  </Tooltip.Trigger>
1421
1606
  <Tooltip.Portal>
1422
1607
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1424,8 +1609,12 @@ function ToolbarFormattingOverflow(props: {
1424
1609
  </Tooltip.Content>
1425
1610
  </Tooltip.Portal>
1426
1611
  </Tooltip.Root>
1427
- {open ? (
1428
- <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1612
+ <ToolbarPortalMenu
1613
+ anchorRef={triggerRef}
1614
+ className="w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1615
+ menuWidthPx={220}
1616
+ open={open}
1617
+ >
1429
1618
  <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
1430
1619
  Text styling
1431
1620
  </div>
@@ -1434,6 +1623,7 @@ function ToolbarFormattingOverflow(props: {
1434
1623
  active={props.formattingState?.strikethrough ?? false}
1435
1624
  ariaLabel="Strikethrough"
1436
1625
  disabled={props.disabled || !props.onToggleStrikethrough}
1626
+ disabledReason={props.disabledReason}
1437
1627
  icon={<Strikethrough className="h-3.5 w-3.5" />}
1438
1628
  onClick={() => {
1439
1629
  props.onToggleStrikethrough?.();
@@ -1444,6 +1634,7 @@ function ToolbarFormattingOverflow(props: {
1444
1634
  active={props.formattingState?.superscript ?? false}
1445
1635
  ariaLabel="Superscript"
1446
1636
  disabled={props.disabled || !props.onToggleSuperscript}
1637
+ disabledReason={props.disabledReason}
1447
1638
  icon={<Superscript className="h-3.5 w-3.5" />}
1448
1639
  onClick={() => {
1449
1640
  props.onToggleSuperscript?.();
@@ -1454,6 +1645,7 @@ function ToolbarFormattingOverflow(props: {
1454
1645
  active={props.formattingState?.subscript ?? false}
1455
1646
  ariaLabel="Subscript"
1456
1647
  disabled={props.disabled || !props.onToggleSubscript}
1648
+ disabledReason={props.disabledReason}
1457
1649
  icon={<Subscript className="h-3.5 w-3.5" />}
1458
1650
  onClick={() => {
1459
1651
  props.onToggleSubscript?.();
@@ -1461,9 +1653,8 @@ function ToolbarFormattingOverflow(props: {
1461
1653
  }}
1462
1654
  />
1463
1655
  </div>
1464
- </div>
1465
- ) : null}
1466
- </div>
1656
+ </ToolbarPortalMenu>
1657
+ </Popover.Root>
1467
1658
  );
1468
1659
  }
1469
1660
 
@@ -1471,27 +1662,44 @@ function ToolbarColorPopover(props: {
1471
1662
  ariaLabel: string;
1472
1663
  colors: ReadonlyArray<{ value: string | null; label: string }>;
1473
1664
  disabled: boolean;
1665
+ disabledReason?: string;
1474
1666
  icon: React.ReactNode;
1667
+ activeValue?: string | null;
1475
1668
  title: string;
1476
1669
  onSelect: (value: string | null) => void;
1477
1670
  }) {
1478
1671
  const [open, setOpen] = React.useState(false);
1672
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
1673
+ const activeValue = props.activeValue?.toLowerCase() ?? null;
1674
+ const disabledTitle =
1675
+ props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1479
1676
 
1480
1677
  return (
1481
- <div className="relative">
1678
+ <Popover.Root open={open} onOpenChange={setOpen}>
1482
1679
  <Tooltip.Root>
1483
1680
  <Tooltip.Trigger asChild>
1484
- <button
1485
- type="button"
1486
- aria-label={props.ariaLabel}
1487
- aria-expanded={open}
1488
- disabled={props.disabled}
1489
- onMouseDown={preserveEditorSelectionMouseDown}
1490
- onClick={() => setOpen((value) => !value)}
1491
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1492
- >
1493
- {props.icon}
1494
- </button>
1681
+ <Popover.Anchor asChild>
1682
+ <button
1683
+ ref={triggerRef}
1684
+ type="button"
1685
+ aria-label={props.ariaLabel}
1686
+ aria-expanded={open}
1687
+ aria-haspopup="menu"
1688
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1689
+ disabled={props.disabled}
1690
+ title={disabledTitle}
1691
+ onMouseDown={preserveEditorSelectionMouseDown}
1692
+ onClick={(event) => {
1693
+ event.preventDefault();
1694
+ setOpen((value) => !value);
1695
+ }}
1696
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1697
+ open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : ""
1698
+ } ${focusRingClass}`}
1699
+ >
1700
+ {props.icon}
1701
+ </button>
1702
+ </Popover.Anchor>
1495
1703
  </Tooltip.Trigger>
1496
1704
  <Tooltip.Portal>
1497
1705
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1499,44 +1707,59 @@ function ToolbarColorPopover(props: {
1499
1707
  </Tooltip.Content>
1500
1708
  </Tooltip.Portal>
1501
1709
  </Tooltip.Root>
1502
- {open ? (
1503
- <div className="absolute left-0 top-9 z-50 w-[180px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1710
+ <ToolbarPortalMenu
1711
+ anchorRef={triggerRef}
1712
+ className="w-[180px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1713
+ menuWidthPx={180}
1714
+ open={open}
1715
+ >
1504
1716
  <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
1505
1717
  {props.title}
1506
1718
  </div>
1507
1719
  <div className="grid grid-cols-3 gap-1">
1508
- {props.colors.map((color) => (
1509
- <button
1510
- key={`${props.ariaLabel}-${color.label}`}
1511
- type="button"
1512
- aria-label={`${props.title} ${color.label}`}
1513
- disabled={props.disabled}
1514
- onMouseDown={preserveEditorSelectionMouseDown}
1515
- onClick={() => {
1516
- props.onSelect(color.value);
1517
- setOpen(false);
1518
- }}
1519
- className={`inline-flex h-7 items-center justify-center rounded-md border border-border text-[10px] font-medium text-primary transition-transform hover:scale-[1.04] disabled:cursor-not-allowed disabled:opacity-40 ${
1520
- color.value ? "" : "bg-surface"
1521
- } ${focusRingClass}`}
1522
- style={color.value ? { backgroundColor: color.value } : undefined}
1523
- >
1524
- {color.value ? <span className="sr-only">{color.label}</span> : "None"}
1525
- </button>
1526
- ))}
1720
+ {props.colors.map((color) => {
1721
+ const normalizedValue = color.value?.toLowerCase() ?? null;
1722
+ const isActive = normalizedValue === activeValue;
1723
+ return (
1724
+ <button
1725
+ key={`${props.ariaLabel}-${color.label}`}
1726
+ type="button"
1727
+ aria-label={`${props.title} ${color.label}`}
1728
+ aria-pressed={isActive}
1729
+ data-active={isActive ? "true" : undefined}
1730
+ disabled={props.disabled}
1731
+ onMouseDown={preserveEditorSelectionMouseDown}
1732
+ onClick={() => {
1733
+ props.onSelect(color.value);
1734
+ setOpen(false);
1735
+ }}
1736
+ className={`inline-flex h-7 items-center justify-center rounded-md border text-[10px] font-medium text-primary transition-transform hover:scale-[1.04] disabled:cursor-not-allowed disabled:opacity-40 ${
1737
+ isActive
1738
+ ? "border-accent ring-2 ring-accent/35 shadow-sm"
1739
+ : "border-border"
1740
+ } ${color.value ? "" : "bg-surface"} ${focusRingClass}`}
1741
+ style={color.value ? { backgroundColor: color.value } : undefined}
1742
+ >
1743
+ {color.value ? <span className="sr-only">{color.label}</span> : "None"}
1744
+ </button>
1745
+ );
1746
+ })}
1527
1747
  </div>
1528
- </div>
1529
- ) : null}
1530
- </div>
1748
+ </ToolbarPortalMenu>
1749
+ </Popover.Root>
1531
1750
  );
1532
1751
  }
1533
1752
 
1534
1753
  function ToolbarAlignmentPopover(props: {
1535
1754
  activeAlignment?: FormattingAlignment;
1536
1755
  disabled: boolean;
1756
+ disabledReason?: string;
1537
1757
  onSelect: (alignment: FormattingAlignment) => void;
1538
1758
  }) {
1539
1759
  const [open, setOpen] = React.useState(false);
1760
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
1761
+ const disabledTitle =
1762
+ props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1540
1763
  const alignments = [
1541
1764
  { value: "left" as const, label: "Align left", icon: <AlignLeft className="h-3.5 w-3.5" /> },
1542
1765
  { value: "center" as const, label: "Align center", icon: <AlignCenter className="h-3.5 w-3.5" /> },
@@ -1545,20 +1768,31 @@ function ToolbarAlignmentPopover(props: {
1545
1768
  ];
1546
1769
 
1547
1770
  return (
1548
- <div className="relative">
1771
+ <Popover.Root open={open} onOpenChange={setOpen}>
1549
1772
  <Tooltip.Root>
1550
1773
  <Tooltip.Trigger asChild>
1551
- <button
1552
- type="button"
1553
- aria-label="Paragraph alignment"
1554
- aria-expanded={open}
1555
- disabled={props.disabled}
1556
- onMouseDown={preserveEditorSelectionMouseDown}
1557
- onClick={() => setOpen((value) => !value)}
1558
- className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1559
- >
1560
- {(alignments.find((entry) => entry.value === props.activeAlignment) ?? alignments[0])?.icon}
1561
- </button>
1774
+ <Popover.Anchor asChild>
1775
+ <button
1776
+ ref={triggerRef}
1777
+ type="button"
1778
+ aria-label="Paragraph alignment"
1779
+ aria-expanded={open}
1780
+ aria-haspopup="menu"
1781
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1782
+ disabled={props.disabled}
1783
+ title={disabledTitle}
1784
+ onMouseDown={preserveEditorSelectionMouseDown}
1785
+ onClick={(event) => {
1786
+ event.preventDefault();
1787
+ setOpen((value) => !value);
1788
+ }}
1789
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${
1790
+ open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : ""
1791
+ } ${focusRingClass}`}
1792
+ >
1793
+ {(alignments.find((entry) => entry.value === props.activeAlignment) ?? alignments[0])?.icon}
1794
+ </button>
1795
+ </Popover.Anchor>
1562
1796
  </Tooltip.Trigger>
1563
1797
  <Tooltip.Portal>
1564
1798
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1566,8 +1800,12 @@ function ToolbarAlignmentPopover(props: {
1566
1800
  </Tooltip.Content>
1567
1801
  </Tooltip.Portal>
1568
1802
  </Tooltip.Root>
1569
- {open ? (
1570
- <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1803
+ <ToolbarPortalMenu
1804
+ anchorRef={triggerRef}
1805
+ className="w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1806
+ menuWidthPx={220}
1807
+ open={open}
1808
+ >
1571
1809
  <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
1572
1810
  Paragraph alignment
1573
1811
  </div>
@@ -1578,6 +1816,7 @@ function ToolbarAlignmentPopover(props: {
1578
1816
  active={props.activeAlignment === entry.value}
1579
1817
  ariaLabel={entry.label}
1580
1818
  disabled={props.disabled}
1819
+ disabledReason={props.disabledReason}
1581
1820
  icon={entry.icon}
1582
1821
  onClick={() => {
1583
1822
  props.onSelect(entry.value);
@@ -1586,14 +1825,14 @@ function ToolbarAlignmentPopover(props: {
1586
1825
  />
1587
1826
  ))}
1588
1827
  </div>
1589
- </div>
1590
- ) : null}
1591
- </div>
1828
+ </ToolbarPortalMenu>
1829
+ </Popover.Root>
1592
1830
  );
1593
1831
  }
1594
1832
 
1595
1833
  function ToolbarInsertMenu(props: {
1596
1834
  disabled: boolean;
1835
+ disabledReason?: string;
1597
1836
  /**
1598
1837
  * Lane 6b §6b.U3 — optional per-item disabled-explanation reasons.
1599
1838
  * When the item is disabled (no handler or policy-gated), hovering it
@@ -1611,6 +1850,9 @@ function ToolbarInsertMenu(props: {
1611
1850
  onInsertImage?: (options: InsertImageOptions) => void;
1612
1851
  }) {
1613
1852
  const [open, setOpen] = React.useState(false);
1853
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
1854
+ const disabledTitle =
1855
+ props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined;
1614
1856
 
1615
1857
  async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
1616
1858
  const file = event.target.files?.[0];
@@ -1629,21 +1871,32 @@ function ToolbarInsertMenu(props: {
1629
1871
  }
1630
1872
 
1631
1873
  return (
1632
- <div className="relative">
1874
+ <Popover.Root open={open} onOpenChange={setOpen}>
1633
1875
  <Tooltip.Root>
1634
1876
  <Tooltip.Trigger asChild>
1635
- <button
1636
- type="button"
1637
- aria-label="Insert"
1638
- aria-expanded={open}
1639
- disabled={props.disabled}
1640
- onMouseDown={preserveEditorSelectionMouseDown}
1641
- onClick={() => setOpen((value) => !value)}
1642
- 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 disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1643
- >
1644
- Insert
1645
- <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
1646
- </button>
1877
+ <Popover.Anchor asChild>
1878
+ <button
1879
+ ref={triggerRef}
1880
+ type="button"
1881
+ aria-label="Insert"
1882
+ aria-expanded={open}
1883
+ aria-haspopup="menu"
1884
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
1885
+ disabled={props.disabled}
1886
+ title={disabledTitle}
1887
+ onMouseDown={preserveEditorSelectionMouseDown}
1888
+ onClick={(event) => {
1889
+ event.preventDefault();
1890
+ setOpen((value) => !value);
1891
+ }}
1892
+ 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 disabled:cursor-not-allowed disabled:opacity-40 ${
1893
+ open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
1894
+ } ${focusRingClass}`}
1895
+ >
1896
+ Insert
1897
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
1898
+ </button>
1899
+ </Popover.Anchor>
1647
1900
  </Tooltip.Trigger>
1648
1901
  <Tooltip.Portal>
1649
1902
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
@@ -1651,13 +1904,17 @@ function ToolbarInsertMenu(props: {
1651
1904
  </Tooltip.Content>
1652
1905
  </Tooltip.Portal>
1653
1906
  </Tooltip.Root>
1654
- {open ? (
1655
- <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
1907
+ <ToolbarPortalMenu
1908
+ anchorRef={triggerRef}
1909
+ className="w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border"
1910
+ menuWidthPx={220}
1911
+ open={open}
1912
+ >
1656
1913
  <div className="space-y-1">
1657
1914
  <ToolbarMenuButton
1658
1915
  ariaLabel="Insert page break"
1659
1916
  disabled={props.disabled || !props.onInsertPageBreak}
1660
- disabledReason={props.disabledReasons?.pageBreak}
1917
+ disabledReason={props.disabled ? props.disabledReason : props.disabledReasons?.pageBreak}
1661
1918
  icon={<Minus className="h-3.5 w-3.5" />}
1662
1919
  label="Page break"
1663
1920
  onClick={() => {
@@ -1668,7 +1925,7 @@ function ToolbarInsertMenu(props: {
1668
1925
  <ToolbarMenuButton
1669
1926
  ariaLabel="Insert table"
1670
1927
  disabled={props.disabled || !props.onInsertTable}
1671
- disabledReason={props.disabledReasons?.table}
1928
+ disabledReason={props.disabled ? props.disabledReason : props.disabledReasons?.table}
1672
1929
  icon={<Rows3 className="h-3.5 w-3.5" />}
1673
1930
  label="Table"
1674
1931
  onClick={() => {
@@ -1677,6 +1934,21 @@ function ToolbarInsertMenu(props: {
1677
1934
  }}
1678
1935
  />
1679
1936
  <label
1937
+ aria-disabled={props.disabled || !props.onInsertImage ? "true" : undefined}
1938
+ title={
1939
+ props.disabled
1940
+ ? disabledTitle
1941
+ : !props.onInsertImage && props.disabledReasons?.image
1942
+ ? `Not available: ${props.disabledReasons.image}`
1943
+ : undefined
1944
+ }
1945
+ data-disabled-reason={
1946
+ props.disabled
1947
+ ? props.disabledReason
1948
+ : !props.onInsertImage
1949
+ ? props.disabledReasons?.image
1950
+ : undefined
1951
+ }
1680
1952
  className={`flex h-7 cursor-pointer items-center gap-2 rounded-md px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface ${
1681
1953
  props.disabled || !props.onInsertImage ? "pointer-events-none opacity-40" : ""
1682
1954
  }`}
@@ -1697,7 +1969,7 @@ function ToolbarInsertMenu(props: {
1697
1969
  <ToolbarMenuButton
1698
1970
  ariaLabel="Insert next-page section break"
1699
1971
  disabled={props.disabled || !props.onInsertSectionBreak}
1700
- disabledReason={props.disabledReasons?.sectionBreak}
1972
+ disabledReason={props.disabled ? props.disabledReason : props.disabledReasons?.sectionBreak}
1701
1973
  icon={<FileText className="h-3.5 w-3.5" />}
1702
1974
  label="Next-page section break"
1703
1975
  onClick={() => {
@@ -1706,9 +1978,8 @@ function ToolbarInsertMenu(props: {
1706
1978
  }}
1707
1979
  />
1708
1980
  </div>
1709
- </div>
1710
- ) : null}
1711
- </div>
1981
+ </ToolbarPortalMenu>
1982
+ </Popover.Root>
1712
1983
  );
1713
1984
  }
1714
1985
 
@@ -1716,15 +1987,24 @@ function ToolbarPopoverActionButton(props: {
1716
1987
  active: boolean;
1717
1988
  ariaLabel: string;
1718
1989
  disabled: boolean;
1990
+ disabledReason?: string;
1719
1991
  icon: React.ReactNode;
1720
1992
  onClick?: () => void;
1721
1993
  }) {
1994
+ const titleAttr =
1995
+ props.disabled && props.disabledReason
1996
+ ? `Not available: ${props.disabledReason}`
1997
+ : undefined;
1722
1998
  return (
1723
1999
  <button
1724
2000
  type="button"
1725
2001
  aria-label={props.ariaLabel}
1726
2002
  aria-pressed={props.active}
1727
2003
  disabled={props.disabled}
2004
+ title={titleAttr}
2005
+ data-disabled-reason={
2006
+ props.disabled && props.disabledReason ? props.disabledReason : undefined
2007
+ }
1728
2008
  onMouseDown={preserveEditorSelectionMouseDown}
1729
2009
  onClick={props.onClick}
1730
2010
  className={`inline-flex h-7 items-center justify-center rounded-md border border-border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${