@beyondwork/docx-react-component 1.0.106 → 1.0.109

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 (190) hide show
  1. package/package.json +19 -5
  2. package/src/api/geometry-overlay-rects.ts +5 -0
  3. package/src/api/package-version.ts +1 -1
  4. package/src/api/page-anchor-id.ts +5 -0
  5. package/src/api/public-types.ts +16 -9
  6. package/src/api/table-node-specs.ts +6 -0
  7. package/src/api/v3/_create.ts +2 -1
  8. package/src/api/v3/_page-anchor-id.ts +52 -0
  9. package/src/api/v3/_runtime-handle.ts +92 -1
  10. package/src/api/v3/ai/_audit-time.ts +5 -0
  11. package/src/api/v3/ai/_pe2-evidence.ts +38 -0
  12. package/src/api/v3/ai/attach.ts +7 -2
  13. package/src/api/v3/ai/replacement.ts +101 -18
  14. package/src/api/v3/ai/resolve.ts +2 -2
  15. package/src/api/v3/ai/review.ts +177 -3
  16. package/src/api/v3/index.ts +1 -0
  17. package/src/api/v3/runtime/collab.ts +462 -0
  18. package/src/api/v3/runtime/document.ts +503 -20
  19. package/src/api/v3/runtime/geometry.ts +97 -0
  20. package/src/api/v3/runtime/layout.ts +744 -0
  21. package/src/api/v3/runtime/perf-probe.ts +14 -0
  22. package/src/api/v3/runtime/viewport.ts +9 -8
  23. package/src/api/v3/ui/_types.ts +149 -55
  24. package/src/api/v3/ui/chrome-preset-model.ts +5 -5
  25. package/src/api/v3/ui/debug.ts +115 -2
  26. package/src/api/v3/ui/index.ts +13 -0
  27. package/src/api/v3/ui/overlays.ts +0 -8
  28. package/src/api/v3/ui/surface.ts +56 -0
  29. package/src/api/v3/ui/viewport.ts +22 -9
  30. package/src/core/commands/image-commands.ts +1 -0
  31. package/src/core/commands/index.ts +6 -0
  32. package/src/core/schema/text-schema.ts +43 -5
  33. package/src/core/selection/mapping.ts +8 -1
  34. package/src/core/selection/review-anchors.ts +5 -1
  35. package/src/core/state/text-transaction.ts +8 -2
  36. package/src/io/export/serialize-revisions.ts +149 -1
  37. package/src/io/normalize/normalize-text.ts +6 -0
  38. package/src/io/ooxml/parse-bookmark-references.ts +55 -0
  39. package/src/io/ooxml/parse-fields.ts +24 -2
  40. package/src/io/ooxml/parse-headers-footers.ts +38 -5
  41. package/src/io/ooxml/parse-main-document.ts +153 -9
  42. package/src/io/ooxml/parse-numbering.ts +20 -0
  43. package/src/io/ooxml/parse-revisions.ts +19 -8
  44. package/src/io/opc/package-reader.ts +98 -8
  45. package/src/model/anchor.ts +4 -3
  46. package/src/model/canonical-document.ts +220 -2
  47. package/src/model/canonical-hash.ts +221 -0
  48. package/src/model/canonical-layout-inputs.ts +245 -6
  49. package/src/model/layout/index.ts +1 -0
  50. package/src/model/layout/page-graph-types.ts +118 -1
  51. package/src/model/review/revision-types.ts +14 -3
  52. package/src/preservation/store.ts +20 -4
  53. package/src/review/README.md +1 -1
  54. package/src/review/store/revision-actions.ts +14 -2
  55. package/src/runtime/collab/event-types.ts +67 -1
  56. package/src/runtime/collab/runtime-collab-sync.ts +177 -5
  57. package/src/runtime/diagnostics/layout-guard-warning.ts +18 -0
  58. package/src/runtime/document-heading-outline.ts +147 -0
  59. package/src/runtime/document-navigation.ts +8 -243
  60. package/src/runtime/document-runtime.ts +240 -97
  61. package/src/runtime/edit-dispatch/dispatch-text-command.ts +11 -0
  62. package/src/runtime/formatting/layout-inputs.ts +38 -5
  63. package/src/runtime/formatting/numbering/geometry.ts +28 -2
  64. package/src/runtime/geometry/adjacent-geometry-intake.ts +835 -0
  65. package/src/runtime/geometry/caret-geometry.ts +5 -6
  66. package/src/runtime/geometry/geometry-facet.ts +60 -10
  67. package/src/runtime/geometry/geometry-index.ts +591 -20
  68. package/src/runtime/geometry/geometry-types.ts +59 -0
  69. package/src/runtime/geometry/hit-test.ts +11 -1
  70. package/src/runtime/geometry/overlay-rects.ts +5 -3
  71. package/src/runtime/geometry/project-anchors.ts +1 -1
  72. package/src/runtime/geometry/word-layout-v2-line-intake.ts +323 -0
  73. package/src/runtime/layout/index.ts +6 -0
  74. package/src/runtime/layout/layout-engine-instance.ts +6 -1
  75. package/src/runtime/layout/layout-engine-version.ts +181 -16
  76. package/src/runtime/layout/layout-facet-types.ts +6 -0
  77. package/src/runtime/layout/page-graph.ts +21 -4
  78. package/src/runtime/layout/paginated-layout-engine.ts +139 -15
  79. package/src/runtime/layout/project-block-fragments.ts +265 -7
  80. package/src/runtime/layout/public-facet.ts +78 -24
  81. package/src/runtime/layout/table-row-continuation-contract.ts +107 -0
  82. package/src/runtime/layout/table-row-split.ts +92 -35
  83. package/src/runtime/prerender/cache-envelope.ts +2 -2
  84. package/src/runtime/prerender/cache-key.ts +5 -4
  85. package/src/runtime/prerender/customxml-cache.ts +0 -1
  86. package/src/runtime/render/render-kernel.ts +1 -1
  87. package/src/runtime/revision-runtime.ts +112 -10
  88. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  89. package/src/runtime/scopes/action-validation.ts +22 -2
  90. package/src/runtime/scopes/capabilities.ts +316 -0
  91. package/src/runtime/scopes/compile-scope-bundle.ts +14 -0
  92. package/src/runtime/scopes/compiler-service.ts +108 -4
  93. package/src/runtime/scopes/content-control-evidence.ts +79 -0
  94. package/src/runtime/scopes/create-issue.ts +5 -5
  95. package/src/runtime/scopes/evidence.ts +91 -0
  96. package/src/runtime/scopes/formatting/apply.ts +2 -0
  97. package/src/runtime/scopes/geometry-evidence.ts +130 -0
  98. package/src/runtime/scopes/index.ts +54 -0
  99. package/src/runtime/scopes/issue-lifecycle.ts +224 -0
  100. package/src/runtime/scopes/layout-evidence.ts +374 -0
  101. package/src/runtime/scopes/multi-paragraph-refusal.ts +37 -0
  102. package/src/runtime/scopes/preservation-boundary.ts +7 -1
  103. package/src/runtime/scopes/replacement/apply.ts +97 -34
  104. package/src/runtime/scopes/scope-kinds/paragraph.ts +108 -12
  105. package/src/runtime/scopes/semantic-scope-types.ts +242 -3
  106. package/src/runtime/scopes/visualization.ts +28 -0
  107. package/src/runtime/surface-projection.ts +44 -5
  108. package/src/runtime/telemetry/perf-probe.ts +216 -0
  109. package/src/runtime/virtualized-rendering.ts +36 -1
  110. package/src/runtime/workflow/ai-issue-lifecycle.ts +253 -0
  111. package/src/runtime/workflow/coordinator.ts +39 -11
  112. package/src/runtime/workflow/derived-scope-resolver.ts +63 -9
  113. package/src/runtime/workflow/index.ts +3 -0
  114. package/src/runtime/workflow/overlay-lane-types.ts +58 -0
  115. package/src/runtime/workflow/overlay-lanes.ts +168 -10
  116. package/src/runtime/workflow/overlay-store.ts +2 -2
  117. package/src/runtime/workflow/redline-posture-calibration.ts +257 -0
  118. package/src/runtime/workflow/word-field-matrix-calibration.ts +231 -0
  119. package/src/session/_sync-legacy.ts +17 -27
  120. package/src/session/import/loader.ts +6 -4
  121. package/src/session/import/source-package-evidence.ts +186 -2
  122. package/src/session/index.ts +5 -6
  123. package/src/session/session.ts +30 -56
  124. package/src/session/types.ts +8 -13
  125. package/src/shell/session-bootstrap.ts +155 -81
  126. package/src/ui/WordReviewEditor.tsx +520 -12
  127. package/src/ui/editor-shell-view.tsx +14 -4
  128. package/src/ui/editor-surface-controller.tsx +5 -3
  129. package/src/ui/headless/selection-tool-resolver.ts +1 -2
  130. package/src/ui/presence-overlay-lane.ts +0 -1
  131. package/src/ui/ui-controller-factory.ts +7 -0
  132. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +5 -1
  133. package/src/ui-tailwind/chrome/editor-action-registry.ts +105 -5
  134. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +7 -0
  135. package/src/ui-tailwind/chrome/layer-debug-contracts.ts +208 -0
  136. package/src/ui-tailwind/chrome/resolve-target-kind.ts +13 -0
  137. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +11 -3
  138. package/src/ui-tailwind/chrome/tw-command-palette.tsx +36 -6
  139. package/src/ui-tailwind/chrome/tw-context-menu.tsx +6 -1
  140. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +42 -109
  141. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +26 -6
  142. package/src/ui-tailwind/chrome/tw-navigation-command-bar.tsx +328 -0
  143. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +8 -4
  144. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +129 -1
  145. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +19 -5
  146. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  147. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +28 -12
  148. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +30 -3
  149. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +116 -10
  150. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +223 -94
  151. package/src/ui-tailwind/chrome-overlay/tw-presence-overlay-lane.tsx +157 -0
  152. package/src/ui-tailwind/chrome-overlay/tw-review-overlay-lane-markers.tsx +259 -0
  153. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +5 -2
  154. package/src/ui-tailwind/chrome-overlay/tw-substrate-overlay-lanes.tsx +314 -0
  155. package/src/ui-tailwind/debug/README.md +4 -1
  156. package/src/ui-tailwind/debug/layer11-consumer-readiness.ts +272 -0
  157. package/src/ui-tailwind/debug/layer11-word-field-matrix-evidence.ts +160 -0
  158. package/src/ui-tailwind/editor-surface/perf-probe.ts +14 -215
  159. package/src/ui-tailwind/editor-surface/pm-decorations.ts +42 -0
  160. package/src/ui-tailwind/editor-surface/pm-position-map.ts +38 -2
  161. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -4
  162. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +34 -5
  163. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +9 -19
  164. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -2
  165. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +145 -0
  166. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +16 -11
  167. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +8 -10
  168. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +3 -0
  169. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +4 -2
  170. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +60 -20
  171. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +16 -11
  172. package/src/ui-tailwind/review/tw-health-panel.tsx +36 -17
  173. package/src/ui-tailwind/review/tw-review-rail.tsx +7 -4
  174. package/src/ui-tailwind/review-workspace/diagnostics-visibility.ts +44 -0
  175. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +11 -0
  176. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +16 -1
  177. package/src/ui-tailwind/review-workspace/types.ts +26 -12
  178. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +40 -11
  179. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +2 -1
  180. package/src/ui-tailwind/review-workspace/use-page-markers.ts +15 -26
  181. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +35 -18
  182. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +41 -32
  183. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +2 -1
  184. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +2 -1
  185. package/src/ui-tailwind/status/tw-status-bar.tsx +6 -5
  186. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +52 -80
  187. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +12 -48
  188. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +9 -4
  189. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +328 -361
  190. package/src/ui-tailwind/tw-review-workspace.tsx +152 -286
@@ -33,6 +33,9 @@
33
33
  * - hyperlink → `<a>` tag.
34
34
  * - opaque-block → `data-block-kind="opaque"`
35
35
  * added to `TwOpaqueBlock` in Phase D.5 as the explicit marker.
36
+ * - template-slot → `data-node-type="sdt_block"` OR
37
+ * `data-block-kind="sdt"` / `data-sdt-type`
38
+ * emitted by the PM/page-stack SDT renderers.
36
39
  *
37
40
  * Multiple kinds can apply — a table cell that contains a hyperlink
38
41
  * returns both `table-cell` and `hyperlink`. Caller merges the
@@ -215,6 +218,16 @@ export function resolveTargetKind(
215
218
  if (hasAncestorAttributeValue(el, "data-block-kind", "opaque", root)) {
216
219
  kinds.push("opaque-block");
217
220
  }
221
+
222
+ // SDT / content-control template slots. These are user-facing chrome
223
+ // affordances only; import/export/model truth stays with the lower layers.
224
+ if (
225
+ hasAncestorAttributeValue(el, "data-node-type", "sdt_block", root) ||
226
+ hasAncestorAttributeValue(el, "data-block-kind", "sdt", root) ||
227
+ hasAncestorAttribute(el, ["data-sdt-type"], root)
228
+ ) {
229
+ if (!kinds.includes("template-slot")) kinds.push("template-slot");
230
+ }
218
231
  }
219
232
 
220
233
  // Plain-text baseline so clipboard remains available regardless of
@@ -18,6 +18,12 @@ export interface TwAlertBannerProps {
18
18
  * the prop preserves the signal-only banner.
19
19
  */
20
20
  onShowDetail?: () => void;
21
+ /**
22
+ * Opaque/preserve-only diagnostics are debug/operator UX. Default
23
+ * product chrome keeps them silent because the document content is
24
+ * preserved for export, not actionable for the reader.
25
+ */
26
+ showPreservationDiagnostics?: boolean;
21
27
  }
22
28
 
23
29
  /**
@@ -25,7 +31,8 @@ export interface TwAlertBannerProps {
25
31
  *
26
32
  * Lane 6b §6b.S5 contract:
27
33
  * (1) ONE banner at a time, highest severity wins.
28
- * (2) Precedence: fatalError > blockExport > workflowBlocked > preserveOnly.
34
+ * (2) Precedence: fatalError > blockExport > workflowBlocked; preserveOnly
35
+ * is debug/operator-only via `showPreservationDiagnostics`.
29
36
  * (3) Severity tones bind Lane 6a `--color-semantic-*` tokens — no legacy
30
37
  * `bg-danger-soft` / `bg-warning-soft` / `bg-amber-50` class names.
31
38
  * (4) Error (fatal / blockExport) → `--color-semantic-error(-soft)`
@@ -84,6 +91,7 @@ export function TwAlertBanner(
84
91
  preserveOnlyCount,
85
92
  workflowBlockedReasons = [],
86
93
  onShowDetail,
94
+ showPreservationDiagnostics = false,
87
95
  } = props;
88
96
 
89
97
  const showDetailProp: Pick<BannerRender, "onShowDetail"> = onShowDetail
@@ -143,8 +151,8 @@ export function TwAlertBanner(
143
151
  });
144
152
  }
145
153
 
146
- // 4. Preserve-only features — lowest precedence warning.
147
- if (preserveOnlyCount > 0) {
154
+ // 4. Preserve-only features — debug/operator-only warning.
155
+ if (showPreservationDiagnostics && preserveOnlyCount > 0) {
148
156
  return renderBanner({
149
157
  severity: "warning",
150
158
  icon: (
@@ -72,6 +72,30 @@ interface FlatItem extends CommandPaletteItem {
72
72
  groupLabel: string;
73
73
  }
74
74
 
75
+ function firstEnabledIndex(items: readonly FlatItem[]): number {
76
+ return items.findIndex((item) => !item.disabled);
77
+ }
78
+
79
+ function nextEnabledIndex(
80
+ items: readonly FlatItem[],
81
+ currentIndex: number,
82
+ direction: 1 | -1,
83
+ ): number {
84
+ if (items.length === 0) return 0;
85
+ const start =
86
+ currentIndex >= 0 && currentIndex < items.length
87
+ ? currentIndex
88
+ : direction > 0
89
+ ? -1
90
+ : 0;
91
+ for (let offset = 1; offset <= items.length; offset += 1) {
92
+ const index = (start + direction * offset + items.length) % items.length;
93
+ if (!items[index]?.disabled) return index;
94
+ }
95
+ const first = firstEnabledIndex(items);
96
+ return first >= 0 ? first : 0;
97
+ }
98
+
75
99
  /**
76
100
  * Fuzzy matcher — returns `true` if the needle matches the haystack as
77
101
  * substring OR as a character subsequence. Cheap, deterministic, and
@@ -145,8 +169,16 @@ export function TwCommandPalette(
145
169
 
146
170
  // Keep activeIndex inside the filtered result range.
147
171
  useEffect(() => {
148
- if (activeIndex >= flat.length) setActiveIndex(flat.length > 0 ? 0 : 0);
149
- }, [activeIndex, flat.length]);
172
+ if (flat.length === 0) {
173
+ if (activeIndex !== 0) setActiveIndex(0);
174
+ return;
175
+ }
176
+ const active = flat[activeIndex];
177
+ if (!active || active.disabled) {
178
+ const firstEnabled = firstEnabledIndex(flat);
179
+ setActiveIndex(firstEnabled >= 0 ? firstEnabled : 0);
180
+ }
181
+ }, [activeIndex, flat]);
150
182
 
151
183
  const close = useCallback(() => onOpenChange(false), [onOpenChange]);
152
184
 
@@ -169,14 +201,12 @@ export function TwCommandPalette(
169
201
  }
170
202
  if (event.key === "ArrowDown") {
171
203
  event.preventDefault();
172
- setActiveIndex((i) => (flat.length === 0 ? 0 : (i + 1) % flat.length));
204
+ setActiveIndex((i) => nextEnabledIndex(flat, i, 1));
173
205
  return;
174
206
  }
175
207
  if (event.key === "ArrowUp") {
176
208
  event.preventDefault();
177
- setActiveIndex((i) =>
178
- flat.length === 0 ? 0 : (i - 1 + flat.length) % flat.length,
179
- );
209
+ setActiveIndex((i) => nextEnabledIndex(flat, i, -1));
180
210
  return;
181
211
  }
182
212
  if (event.key === "Enter") {
@@ -46,6 +46,8 @@ export interface ContextMenuItem {
46
46
  id: string;
47
47
  /** Display label. */
48
48
  label: string;
49
+ /** Optional explanation, used for disabled product-signpost rows. */
50
+ description?: string;
49
51
  /** Optional icon element rendered left of the label. */
50
52
  icon?: React.ReactNode;
51
53
  /** Shortcut key sequence shown right-aligned via TwShortcutHint. */
@@ -210,13 +212,16 @@ interface ContextMenuRowProps {
210
212
  }
211
213
 
212
214
  function ContextMenuRow({ item, platform }: ContextMenuRowProps): React.JSX.Element {
213
- const { label, icon, shortcut, disabled, onSelect } = item;
215
+ const { label, description, icon, shortcut, disabled, onSelect } = item;
216
+ const disabledExplanation = disabled && description ? description : undefined;
214
217
 
215
218
  return (
216
219
  <button
217
220
  role="menuitem"
218
221
  data-testid={`context-menu-item-${item.id}`}
219
222
  disabled={disabled}
223
+ title={disabledExplanation}
224
+ data-disabled-reason={disabledExplanation}
220
225
  onClick={disabled ? undefined : onSelect}
221
226
  className={[
222
227
  "flex items-center justify-between h-[30px] w-full px-3",
@@ -13,15 +13,14 @@
13
13
  * names sees the right option highlighted without code changes.
14
14
  */
15
15
 
16
- import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
17
- import { createPortal } from "react-dom";
16
+ import React, { useState } from "react";
17
+ import * as Popover from "@radix-ui/react-popover";
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";
25
24
 
26
25
  export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
27
26
 
@@ -68,136 +67,70 @@ const MODES: readonly ModeEntry[] = [
68
67
 
69
68
  export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
70
69
  const [open, setOpen] = useState(false);
71
- const triggerRef = useRef<HTMLButtonElement>(null);
72
70
  const canonical = normalizeMarkupDisplay(props.value);
73
71
  const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
74
72
 
75
73
  return (
76
- <>
74
+ <Popover.Root open={open} onOpenChange={setOpen}>
75
+ <Popover.Trigger asChild>
77
76
  <button
78
- ref={triggerRef}
79
77
  type="button"
80
78
  disabled={props.disabled}
81
79
  data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
82
80
  aria-label={`Display mode: ${activeEntry.label}`}
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(" ")}
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"
95
82
  >
96
83
  <activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
97
84
  <span>{activeEntry.label}</span>
98
85
  <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
99
86
  </button>
100
- <DisplayModePortalMenu anchorRef={triggerRef} open={open}>
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
+ >
101
95
  <div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
102
96
  Display mode
103
97
  </div>
104
98
  {MODES.map((entry) => {
105
99
  const isActive = entry.mode === canonical;
106
100
  return (
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}
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>
130
125
  </span>
131
- <span className="text-[10px] text-secondary">{entry.hint}</span>
132
- </span>
133
- </button>
126
+ </button>
127
+ </Popover.Close>
134
128
  );
135
129
  })}
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,
130
+ </Popover.Content>
131
+ </Popover.Portal>
132
+ </Popover.Root>
158
133
  );
159
134
  }
160
135
 
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
-
203
136
  export default TwDisplayModeSelector;
@@ -16,6 +16,16 @@ export interface TwInlineFindBarProps {
16
16
  export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element {
17
17
  const inputRef = React.useRef<HTMLInputElement | null>(null);
18
18
  const hasResults = props.resultCount > 0;
19
+ const hasQuery = props.query.trim().length > 0;
20
+ const resultState = hasQuery ? (hasResults ? "matches" : "empty") : "idle";
21
+ const statusLabel = hasQuery
22
+ ? hasResults
23
+ ? `${props.activeIndex + 1}/${props.resultCount}`
24
+ : "No results"
25
+ : "Find";
26
+ const navDisabledReason = hasQuery
27
+ ? "No matches to navigate."
28
+ : "Enter a query to navigate matches.";
19
29
 
20
30
  React.useEffect(() => {
21
31
  inputRef.current?.focus();
@@ -26,6 +36,7 @@ export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element
26
36
  <div
27
37
  className="pointer-events-auto flex w-[min(420px,calc(100vw-2rem))] items-center gap-2 rounded-2xl border border-[color:color-mix(in_srgb,var(--color-accent-primary)_28%,var(--color-border-subtle))] bg-[color:color-mix(in_srgb,var(--color-bg-canvas)_94%,white)] px-2.5 py-2 shadow-[0_18px_50px_rgba(20,31,29,0.18)]"
28
38
  data-testid="inline-find-bar"
39
+ data-result-state={resultState}
29
40
  role="search"
30
41
  aria-label="Find in document"
31
42
  >
@@ -33,6 +44,7 @@ export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element
33
44
  <input
34
45
  ref={inputRef}
35
46
  aria-label="Find text"
47
+ aria-describedby="inline-find-bar-status"
36
48
  className="min-w-0 flex-1 bg-transparent px-1 text-[13px] font-medium text-primary outline-none placeholder:text-tertiary"
37
49
  placeholder="Find in document"
38
50
  type="search"
@@ -55,19 +67,24 @@ export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element
55
67
  }}
56
68
  />
57
69
  <span
58
- className="min-w-[56px] rounded-full bg-[color:color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)] px-2 py-1 text-center text-[11px] font-semibold tabular-nums text-accent"
70
+ id="inline-find-bar-status"
71
+ data-testid="inline-find-bar-status"
72
+ className={`min-w-[72px] rounded-full px-2 py-1 text-center text-[11px] font-semibold tabular-nums ${
73
+ resultState === "empty"
74
+ ? "bg-[var(--color-semantic-warning-soft)] text-[var(--color-semantic-warning)]"
75
+ : "bg-[color:color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)] text-accent"
76
+ }`}
59
77
  aria-live="polite"
60
78
  >
61
- {props.query.trim()
62
- ? hasResults
63
- ? `${props.activeIndex + 1}/${props.resultCount}`
64
- : "0/0"
65
- : "Find"}
79
+ {statusLabel}
66
80
  </span>
67
81
  <button
68
82
  type="button"
69
83
  aria-label="Previous match"
84
+ aria-keyshortcuts="Shift+Enter"
70
85
  disabled={!hasResults}
86
+ title={hasResults ? "Previous match (Shift+Enter)" : navDisabledReason}
87
+ data-testid="inline-find-bar-previous"
71
88
  className="inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-35"
72
89
  onMouseDown={preserveEditorSelectionMouseDown}
73
90
  onClick={props.onPrevious}
@@ -77,7 +94,10 @@ export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element
77
94
  <button
78
95
  type="button"
79
96
  aria-label="Next match"
97
+ aria-keyshortcuts="Enter"
80
98
  disabled={!hasResults}
99
+ title={hasResults ? "Next match (Enter)" : navDisabledReason}
100
+ data-testid="inline-find-bar-next"
81
101
  className="inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-35"
82
102
  onMouseDown={preserveEditorSelectionMouseDown}
83
103
  onClick={props.onNext}