@beyondwork/docx-react-component 1.0.93 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.93",
4
+ "version": "1.0.94",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -13,14 +13,15 @@
13
13
  * names sees the right option highlighted without code changes.
14
14
  */
15
15
 
16
- import React, { useState } from "react";
17
- import * as Popover from "@radix-ui/react-popover";
16
+ import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
17
+ import { createPortal } from "react-dom";
18
18
  import { ChevronDown, Eye, EyeOff, Highlighter, Scroll } from "lucide-react";
19
19
 
20
20
  import {
21
21
  normalizeMarkupDisplay,
22
22
  type MarkupDisplay,
23
23
  } from "../../ui/headless/comment-decoration-model";
24
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
24
25
 
25
26
  export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
26
27
 
@@ -67,70 +68,136 @@ const MODES: readonly ModeEntry[] = [
67
68
 
68
69
  export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
69
70
  const [open, setOpen] = useState(false);
71
+ const triggerRef = useRef<HTMLButtonElement>(null);
70
72
  const canonical = normalizeMarkupDisplay(props.value);
71
73
  const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
72
74
 
73
75
  return (
74
- <Popover.Root open={open} onOpenChange={setOpen}>
75
- <Popover.Trigger asChild>
76
+ <>
76
77
  <button
78
+ ref={triggerRef}
77
79
  type="button"
78
80
  disabled={props.disabled}
79
81
  data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
80
82
  aria-label={`Display mode: ${activeEntry.label}`}
81
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50"
83
+ aria-expanded={open}
84
+ aria-haspopup="menu"
85
+ onMouseDown={preserveEditorSelectionMouseDown}
86
+ onClick={(event) => {
87
+ event.preventDefault();
88
+ setOpen((value) => !value);
89
+ }}
90
+ className={[
91
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary",
92
+ "hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50",
93
+ open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : "",
94
+ ].join(" ")}
82
95
  >
83
96
  <activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
84
97
  <span>{activeEntry.label}</span>
85
98
  <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
86
99
  </button>
87
- </Popover.Trigger>
88
- <Popover.Portal>
89
- <Popover.Content
90
- className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
91
- sideOffset={8}
92
- align="end"
93
- data-testid="display-mode-selector-content"
94
- >
100
+ <DisplayModePortalMenu anchorRef={triggerRef} open={open}>
95
101
  <div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
96
102
  Display mode
97
103
  </div>
98
104
  {MODES.map((entry) => {
99
105
  const isActive = entry.mode === canonical;
100
106
  return (
101
- <Popover.Close key={entry.mode} asChild>
102
- <button
103
- type="button"
104
- role="menuitemradio"
105
- aria-checked={isActive}
106
- onClick={() => {
107
- props.onChange(entry.mode);
108
- }}
109
- className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
110
- data-testid={`display-mode-option-${entry.mode}`}
111
- data-mode={entry.mode}
112
- data-active={isActive ? "true" : undefined}
113
- >
114
- <entry.icon
115
- className={[
116
- "mt-0.5 h-3.5 w-3.5 shrink-0",
117
- isActive ? "text-accent" : "text-tertiary",
118
- ].join(" ")}
119
- />
120
- <span className="flex flex-col">
121
- <span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
122
- {entry.label}
123
- </span>
124
- <span className="text-[10px] text-secondary">{entry.hint}</span>
107
+ <button
108
+ key={entry.mode}
109
+ type="button"
110
+ role="menuitemradio"
111
+ aria-checked={isActive}
112
+ onClick={() => {
113
+ props.onChange(entry.mode);
114
+ setOpen(false);
115
+ }}
116
+ className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
117
+ data-testid={`display-mode-option-${entry.mode}`}
118
+ data-mode={entry.mode}
119
+ data-active={isActive ? "true" : undefined}
120
+ >
121
+ <entry.icon
122
+ className={[
123
+ "mt-0.5 h-3.5 w-3.5 shrink-0",
124
+ isActive ? "text-accent" : "text-tertiary",
125
+ ].join(" ")}
126
+ />
127
+ <span className="flex flex-col">
128
+ <span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
129
+ {entry.label}
125
130
  </span>
126
- </button>
127
- </Popover.Close>
131
+ <span className="text-[10px] text-secondary">{entry.hint}</span>
132
+ </span>
133
+ </button>
128
134
  );
129
135
  })}
130
- </Popover.Content>
131
- </Popover.Portal>
132
- </Popover.Root>
136
+ </DisplayModePortalMenu>
137
+ </>
138
+ );
139
+ }
140
+
141
+ function DisplayModePortalMenu(props: {
142
+ anchorRef: React.RefObject<HTMLButtonElement | null>;
143
+ children: React.ReactNode;
144
+ open: boolean;
145
+ }): React.ReactPortal | null {
146
+ const style = useDisplayModePortalPosition(props.anchorRef, props.open);
147
+ const body = props.anchorRef.current?.ownerDocument?.body;
148
+ if (!props.open || !body) return null;
149
+ return createPortal(
150
+ <div
151
+ className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
152
+ data-testid="display-mode-selector-content"
153
+ style={style}
154
+ >
155
+ {props.children}
156
+ </div>,
157
+ body,
133
158
  );
134
159
  }
135
160
 
161
+ function useDisplayModePortalPosition(
162
+ anchorRef: React.RefObject<HTMLButtonElement | null>,
163
+ open: boolean,
164
+ ): CSSProperties {
165
+ const [style, setStyle] = useState<CSSProperties>({
166
+ left: 8,
167
+ position: "fixed",
168
+ top: 8,
169
+ zIndex: 50,
170
+ });
171
+
172
+ useLayoutEffect(() => {
173
+ if (!open) return;
174
+ const anchor = anchorRef.current;
175
+ const ownerWindow = anchor?.ownerDocument?.defaultView;
176
+ if (!anchor || !ownerWindow) return;
177
+ const update = () => {
178
+ const rect = anchor.getBoundingClientRect();
179
+ const width = 260;
180
+ const left = Math.min(
181
+ Math.max(8, rect.right - width),
182
+ Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
183
+ );
184
+ setStyle({
185
+ left,
186
+ position: "fixed",
187
+ top: Math.max(8, rect.bottom + 8),
188
+ zIndex: 50,
189
+ });
190
+ };
191
+ update();
192
+ ownerWindow.addEventListener("resize", update);
193
+ ownerWindow.addEventListener("scroll", update, true);
194
+ return () => {
195
+ ownerWindow.removeEventListener("resize", update);
196
+ ownerWindow.removeEventListener("scroll", update, true);
197
+ };
198
+ }, [anchorRef, open]);
199
+
200
+ return style;
201
+ }
202
+
136
203
  export default TwDisplayModeSelector;
@@ -13,8 +13,8 @@
13
13
  * traversal + claim/skip/complete.
14
14
  */
15
15
 
16
- import React, { useState } from "react";
17
- import * as Popover from "@radix-ui/react-popover";
16
+ import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
17
+ import { createPortal } from "react-dom";
18
18
  import * as Toggle from "@radix-ui/react-toggle";
19
19
  import * as Tooltip from "@radix-ui/react-tooltip";
20
20
  import {
@@ -433,38 +433,100 @@ function RoleActionOverflow({
433
433
  props,
434
434
  }: RoleActionOverflowProps): React.JSX.Element {
435
435
  const [open, setOpen] = useState(false);
436
+ const triggerRef = useRef<HTMLButtonElement>(null);
436
437
 
437
438
  return (
438
- <Popover.Root open={open} onOpenChange={setOpen}>
439
- <Popover.Trigger asChild>
439
+ <>
440
440
  <button
441
+ ref={triggerRef}
441
442
  type="button"
442
443
  aria-label="More role actions"
443
444
  aria-expanded={open}
445
+ aria-haspopup="menu"
444
446
  onMouseDown={preserveEditorSelectionMouseDown}
447
+ onClick={(event) => {
448
+ event.preventDefault();
449
+ setOpen((value) => !value);
450
+ }}
445
451
  title="More role actions"
446
- 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas"
452
+ 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
453
+ open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
454
+ }`}
447
455
  data-testid="role-action-overflow-trigger"
448
456
  >
449
457
  <MoreHorizontal className="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
450
458
  </button>
451
- </Popover.Trigger>
452
- <Popover.Portal>
453
- <Popover.Content
454
- className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
455
- sideOffset={8}
456
- align="start"
457
- data-testid="role-action-overflow-content"
458
- >
459
+ <RoleActionPortalMenu anchorRef={triggerRef} open={open}>
459
460
  {ids.map((id) => (
460
461
  <OverflowAction key={id} id={id} props={props} onClose={() => setOpen(false)} />
461
462
  ))}
462
- </Popover.Content>
463
- </Popover.Portal>
464
- </Popover.Root>
463
+ </RoleActionPortalMenu>
464
+ </>
465
+ );
466
+ }
467
+
468
+ function RoleActionPortalMenu(props: {
469
+ anchorRef: React.RefObject<HTMLButtonElement | null>;
470
+ children: React.ReactNode;
471
+ open: boolean;
472
+ }): React.ReactPortal | null {
473
+ const style = useRoleActionPortalPosition(props.anchorRef, props.open);
474
+ const body = props.anchorRef.current?.ownerDocument?.body;
475
+ if (!props.open || !body) return null;
476
+ return createPortal(
477
+ <div
478
+ className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
479
+ data-testid="role-action-overflow-content"
480
+ style={style}
481
+ >
482
+ {props.children}
483
+ </div>,
484
+ body,
465
485
  );
466
486
  }
467
487
 
488
+ function useRoleActionPortalPosition(
489
+ anchorRef: React.RefObject<HTMLButtonElement | null>,
490
+ open: boolean,
491
+ ): CSSProperties {
492
+ const [style, setStyle] = useState<CSSProperties>({
493
+ left: 8,
494
+ position: "fixed",
495
+ top: 8,
496
+ zIndex: 50,
497
+ });
498
+
499
+ useLayoutEffect(() => {
500
+ if (!open) return;
501
+ const anchor = anchorRef.current;
502
+ const ownerWindow = anchor?.ownerDocument?.defaultView;
503
+ if (!anchor || !ownerWindow) return;
504
+ const update = () => {
505
+ const rect = anchor.getBoundingClientRect();
506
+ const width = 220;
507
+ const left = Math.min(
508
+ Math.max(8, rect.left),
509
+ Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
510
+ );
511
+ setStyle({
512
+ left,
513
+ position: "fixed",
514
+ top: Math.max(8, rect.bottom + 8),
515
+ zIndex: 50,
516
+ });
517
+ };
518
+ update();
519
+ ownerWindow.addEventListener("resize", update);
520
+ ownerWindow.addEventListener("scroll", update, true);
521
+ return () => {
522
+ ownerWindow.removeEventListener("resize", update);
523
+ ownerWindow.removeEventListener("scroll", update, true);
524
+ };
525
+ }, [anchorRef, open]);
526
+
527
+ return style;
528
+ }
529
+
468
530
  function OverflowAction(arg: {
469
531
  id: ToolbarChromeItemId;
470
532
  props: TwRoleActionRegionProps;
@@ -27,6 +27,8 @@ export interface TwToolbarIconButtonProps {
27
27
  * vs. Windows however they like.
28
28
  */
29
29
  shortcut?: string;
30
+ /** Explanation surfaced when the button is disabled by selection/mode state. */
31
+ disabledReason?: string;
30
32
  onClick?: () => void;
31
33
  }
32
34
 
@@ -42,7 +44,9 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
42
44
  aria-label={props.label}
43
45
  aria-pressed={props.active ?? undefined}
44
46
  data-active={props.active ? "true" : undefined}
47
+ data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
45
48
  disabled={props.disabled}
49
+ title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
46
50
  onMouseDown={preserveEditorSelectionMouseDown}
47
51
  onClick={props.onClick}
48
52
  className={[