@beyondwork/docx-react-component 1.0.38 → 1.0.40
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 +41 -31
- package/src/api/public-types.ts +305 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/index.ts +9 -0
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +141 -18
- package/src/runtime/layout/docx-font-loader.ts +30 -11
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +3 -0
- package/src/runtime/layout/layout-engine-instance.ts +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +81 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/workflow-rail-segments.ts +149 -1
- package/src/ui/WordReviewEditor.tsx +302 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +22 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +6 -5
- package/src/ui-tailwind/theme/editor-theme.css +108 -15
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -15,9 +15,15 @@
|
|
|
15
15
|
import * as React from "react";
|
|
16
16
|
import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
|
|
17
17
|
import type { ScopeRailSegment } from "../../runtime/layout";
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
ScopeIssueAction,
|
|
20
|
+
TableStructureContextSnapshot,
|
|
21
|
+
WordReviewEditorLayoutFacet,
|
|
22
|
+
WorkflowScopeMode,
|
|
23
|
+
} from "../../api/public-types";
|
|
19
24
|
import { TwScopeRailLayer } from "./tw-scope-rail-layer";
|
|
20
|
-
import {
|
|
25
|
+
import { TwScopeCardLayer } from "./tw-scope-card-layer";
|
|
26
|
+
import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
|
|
21
27
|
|
|
22
28
|
export interface TwChromeOverlayProps {
|
|
23
29
|
/** Layout facet the overlay layers read from. */
|
|
@@ -26,18 +32,54 @@ export interface TwChromeOverlayProps {
|
|
|
26
32
|
space?: OverlayCoordinateSpace;
|
|
27
33
|
/** Active scope id (for emphasis + rail tab sync). */
|
|
28
34
|
activeScopeId?: string | null;
|
|
29
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Click handler fired when the user clicks a scope rail stripe.
|
|
37
|
+
* P0 wires this to open the scope card (P1 ships the card layer).
|
|
38
|
+
*/
|
|
39
|
+
onScopeStripeClick?: (segment: ScopeRailSegment) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Legacy click handler kept for compatibility with consumers that
|
|
42
|
+
* subscribed before the stripe affordance existed. Called alongside
|
|
43
|
+
* `onScopeStripeClick` on a stripe click.
|
|
44
|
+
*/
|
|
30
45
|
onScopeSegmentClick?: (segment: ScopeRailSegment) => void;
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Fires when the scope card is dismissed (close button, Escape, or
|
|
48
|
+
* click-outside). The owner uses this to clear `activeScopeId`.
|
|
49
|
+
*/
|
|
50
|
+
onScopeCardClose?: () => void;
|
|
51
|
+
/**
|
|
52
|
+
* Fires when a mode button inside the scope card is clicked. The
|
|
53
|
+
* owner relays this into a `scope-mode-change-requested` event for
|
|
54
|
+
* the host (which then drives the overlay-apply path).
|
|
55
|
+
*/
|
|
56
|
+
onScopeCardModeChange?: (scopeId: string, mode: WorkflowScopeMode) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Fires when an issue action button (Resolve/Waive/Escalate) inside
|
|
59
|
+
* the scope card is clicked. The owner relays this into a
|
|
60
|
+
* `scope-issue-action-requested` event for the host.
|
|
61
|
+
*/
|
|
62
|
+
onScopeCardIssueAction?: (
|
|
63
|
+
scopeId: string,
|
|
64
|
+
issueId: string,
|
|
65
|
+
action: ScopeIssueAction,
|
|
66
|
+
) => void;
|
|
37
67
|
/** Test id applied to the overlay root. */
|
|
38
68
|
"data-testid"?: string;
|
|
39
69
|
/** Optional extra children (e.g., future comment balloon layer). */
|
|
40
70
|
children?: React.ReactNode;
|
|
71
|
+
|
|
72
|
+
// Table grip props (P6) -----------------------------------------------
|
|
73
|
+
/** Active table context — when present, column/row resize grips are shown. */
|
|
74
|
+
tableContext?: TableStructureContextSnapshot | null;
|
|
75
|
+
/** Fires when a column grip drag completes. */
|
|
76
|
+
onSetColumnWidth?: (columnIndex: number, twips: number) => void;
|
|
77
|
+
/** Fires when a row grip drag completes. */
|
|
78
|
+
onSetRowHeight?: (
|
|
79
|
+
rowIndex: number,
|
|
80
|
+
twips: number,
|
|
81
|
+
rule: "auto" | "atLeast" | "exact",
|
|
82
|
+
) => void;
|
|
41
83
|
}
|
|
42
84
|
|
|
43
85
|
/**
|
|
@@ -53,12 +95,16 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
53
95
|
facet,
|
|
54
96
|
space,
|
|
55
97
|
activeScopeId,
|
|
98
|
+
onScopeStripeClick,
|
|
56
99
|
onScopeSegmentClick,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
onScopeCardClose,
|
|
101
|
+
onScopeCardModeChange,
|
|
102
|
+
onScopeCardIssueAction,
|
|
60
103
|
"data-testid": testId,
|
|
61
104
|
children,
|
|
105
|
+
tableContext,
|
|
106
|
+
onSetColumnWidth,
|
|
107
|
+
onSetRowHeight,
|
|
62
108
|
}) => {
|
|
63
109
|
return (
|
|
64
110
|
<div
|
|
@@ -70,17 +116,35 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
70
116
|
facet={facet}
|
|
71
117
|
space={space}
|
|
72
118
|
activeScopeId={activeScopeId}
|
|
119
|
+
onStripeClick={onScopeStripeClick}
|
|
73
120
|
onSegmentClick={onScopeSegmentClick}
|
|
74
121
|
/>
|
|
122
|
+
<TwScopeCardLayer
|
|
123
|
+
facet={facet}
|
|
124
|
+
activeScopeId={activeScopeId ?? null}
|
|
125
|
+
onClose={onScopeCardClose ?? noop}
|
|
126
|
+
onModeChange={onScopeCardModeChange ?? noopModeChange}
|
|
127
|
+
onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
|
|
128
|
+
space={space}
|
|
129
|
+
/>
|
|
130
|
+
<TwTableGripLayer
|
|
131
|
+
facet={facet}
|
|
132
|
+
tableContext={tableContext ?? null}
|
|
133
|
+
space={space}
|
|
134
|
+
onSetColumnWidth={onSetColumnWidth}
|
|
135
|
+
onSetRowHeight={onSetRowHeight}
|
|
136
|
+
/>
|
|
75
137
|
{children}
|
|
76
|
-
{showWorkspaceDock ? (
|
|
77
|
-
<TwWorkspaceViewSwitcher
|
|
78
|
-
activeView={activeWorkspaceView ?? "review"}
|
|
79
|
-
onViewChange={onWorkspaceViewChange}
|
|
80
|
-
/>
|
|
81
|
-
) : null}
|
|
82
138
|
</div>
|
|
83
139
|
);
|
|
84
140
|
};
|
|
85
141
|
|
|
142
|
+
const noop = () => undefined;
|
|
143
|
+
const noopModeChange = (_scopeId: string, _mode: WorkflowScopeMode) => undefined;
|
|
144
|
+
const noopIssueAction = (
|
|
145
|
+
_scopeId: string,
|
|
146
|
+
_issueId: string,
|
|
147
|
+
_action: ScopeIssueAction,
|
|
148
|
+
) => undefined;
|
|
149
|
+
|
|
86
150
|
export default TwChromeOverlay;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwScopeCardLayer — renders at most one scope card at a time, chosen
|
|
3
|
+
* by `activeScopeId`. Consumes `facet.getAllScopeCardModels()` to
|
|
4
|
+
* look up the model, then positions the card 8px above the model's
|
|
5
|
+
* `primaryAnchorRect`.
|
|
6
|
+
*
|
|
7
|
+
* Per docs/plans/scope-card-overlay.md P1, the layer keeps
|
|
8
|
+
* positioning pure: it never calls `getBoundingClientRect` or reads
|
|
9
|
+
* DOM rects. Coordinates come from the render kernel's anchor index
|
|
10
|
+
* through `ScopeCardModel.primaryAnchorRect`, projected into the
|
|
11
|
+
* overlay's own coordinate space via the shared projector.
|
|
12
|
+
*
|
|
13
|
+
* Auto-flip, pin, and overlapping-scope stacking land in P3. P1
|
|
14
|
+
* renders a single card above the scope's first line and falls back
|
|
15
|
+
* to a top-left overlay placement when no rect is resolvable.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import {
|
|
20
|
+
projectRectToOverlay,
|
|
21
|
+
type OverlayCoordinateSpace,
|
|
22
|
+
} from "./chrome-overlay-projector";
|
|
23
|
+
import { TwScopeCard } from "./tw-scope-card";
|
|
24
|
+
import type {
|
|
25
|
+
ScopeIssueAction,
|
|
26
|
+
WorkflowScopeMode,
|
|
27
|
+
} from "../../api/public-types";
|
|
28
|
+
import type { RenderFrameRect } from "../../runtime/render";
|
|
29
|
+
import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface TwScopeCardLayerProps {
|
|
36
|
+
facet: WordReviewEditorLayoutFacet;
|
|
37
|
+
activeScopeId: string | null;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
|
|
40
|
+
onIssueAction: (
|
|
41
|
+
scopeId: string,
|
|
42
|
+
issueId: string,
|
|
43
|
+
action: ScopeIssueAction,
|
|
44
|
+
) => void;
|
|
45
|
+
space?: OverlayCoordinateSpace;
|
|
46
|
+
"data-testid"?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Component
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const CARD_GAP_PX = 8;
|
|
54
|
+
const CARD_ESTIMATED_HEIGHT_PX = 160;
|
|
55
|
+
const CARD_FALLBACK_LEFT_PX = 16;
|
|
56
|
+
const CARD_FALLBACK_TOP_PX = 16;
|
|
57
|
+
|
|
58
|
+
export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
59
|
+
facet,
|
|
60
|
+
activeScopeId,
|
|
61
|
+
onClose,
|
|
62
|
+
onModeChange,
|
|
63
|
+
onIssueAction,
|
|
64
|
+
space,
|
|
65
|
+
"data-testid": testId,
|
|
66
|
+
}) => {
|
|
67
|
+
if (!activeScopeId) return null;
|
|
68
|
+
const models =
|
|
69
|
+
typeof facet.getAllScopeCardModels === "function"
|
|
70
|
+
? facet.getAllScopeCardModels()
|
|
71
|
+
: [];
|
|
72
|
+
const model = models.find((m) => m.scopeId === activeScopeId);
|
|
73
|
+
if (!model) return null;
|
|
74
|
+
|
|
75
|
+
const projectorSpace: OverlayCoordinateSpace =
|
|
76
|
+
space ?? { originLeftPx: 0, originTopPx: 0 };
|
|
77
|
+
const positioned = resolveCardPosition(
|
|
78
|
+
model.primaryAnchorRect,
|
|
79
|
+
projectorSpace,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className="wre-scope-card-layer pointer-events-none absolute inset-0 z-30"
|
|
85
|
+
data-testid={testId ?? "scope-card-layer"}
|
|
86
|
+
data-active-scope-id={activeScopeId}
|
|
87
|
+
>
|
|
88
|
+
<div style={positioned} className="absolute">
|
|
89
|
+
<TwScopeCard
|
|
90
|
+
model={model}
|
|
91
|
+
onClose={onClose}
|
|
92
|
+
onModeChange={(mode) => onModeChange(model.scopeId, mode)}
|
|
93
|
+
onIssueAction={(action) => {
|
|
94
|
+
if (!model.issue) return;
|
|
95
|
+
onIssueAction(model.scopeId, model.issue.issueId, action);
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Positioning
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function resolveCardPosition(
|
|
108
|
+
anchor: RenderFrameRect | null,
|
|
109
|
+
space: OverlayCoordinateSpace,
|
|
110
|
+
): React.CSSProperties {
|
|
111
|
+
if (!anchor) {
|
|
112
|
+
return {
|
|
113
|
+
left: `${CARD_FALLBACK_LEFT_PX}px`,
|
|
114
|
+
top: `${CARD_FALLBACK_TOP_PX}px`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Position the card above the anchor's first line, leaving
|
|
118
|
+
// `CARD_GAP_PX` between anchor top and card bottom. P3 adds auto-
|
|
119
|
+
// flip based on viewport clipping.
|
|
120
|
+
const targetRect: RenderFrameRect = {
|
|
121
|
+
leftPx: anchor.leftPx,
|
|
122
|
+
topPx: Math.max(0, anchor.topPx - CARD_ESTIMATED_HEIGHT_PX - CARD_GAP_PX),
|
|
123
|
+
widthPx: 1,
|
|
124
|
+
heightPx: 1,
|
|
125
|
+
};
|
|
126
|
+
const projected = projectRectToOverlay(targetRect, space);
|
|
127
|
+
return {
|
|
128
|
+
left: projected.left,
|
|
129
|
+
top: projected.top,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default TwScopeCardLayer;
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwScopeCard — inline floating card shown above a scoped region when
|
|
3
|
+
* the user activates the rail stripe. Displays scope label + mode
|
|
4
|
+
* selector + (when an `IssueMetadataValue` is attached via
|
|
5
|
+
* `ScopeCardModel.issue`) the R2 issue severity, owner, title, and
|
|
6
|
+
* resolve/waive/escalate actions.
|
|
7
|
+
*
|
|
8
|
+
* Per docs/plans/scope-card-overlay.md P1, the card never mutates
|
|
9
|
+
* runtime state directly — it fires `onModeChange` / `onIssueAction`
|
|
10
|
+
* callbacks that bubble up to `scope-mode-change-requested` /
|
|
11
|
+
* `scope-issue-action-requested` events on WordReviewEditorEvent.
|
|
12
|
+
*
|
|
13
|
+
* A11y contract:
|
|
14
|
+
* - role="dialog", aria-modal="false" (not a hard-focus capture; the
|
|
15
|
+
* editor surface remains interactive while the card is open)
|
|
16
|
+
* - aria-labelledby points at the header id
|
|
17
|
+
* - Escape closes; focus-trap wraps Tab / Shift-Tab
|
|
18
|
+
* - An aria-live="polite" region announces the attached issue's
|
|
19
|
+
* severity when the card opens with an issue
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import type {
|
|
24
|
+
IssueMetadataValue,
|
|
25
|
+
IssueOwner,
|
|
26
|
+
IssueSeverity,
|
|
27
|
+
ScopeCardModel,
|
|
28
|
+
ScopeIssueAction,
|
|
29
|
+
WorkflowScopeMode,
|
|
30
|
+
} from "../../api/public-types";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Types
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface TwScopeCardProps {
|
|
37
|
+
model: ScopeCardModel;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
onModeChange: (mode: WorkflowScopeMode) => void;
|
|
40
|
+
onIssueAction: (action: ScopeIssueAction) => void;
|
|
41
|
+
/** Test id applied to the root node. */
|
|
42
|
+
"data-testid"?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MODE_OPTIONS: ReadonlyArray<{ mode: WorkflowScopeMode; label: string }> = [
|
|
46
|
+
{ mode: "edit", label: "Edit" },
|
|
47
|
+
{ mode: "suggest", label: "Suggest" },
|
|
48
|
+
{ mode: "comment", label: "Comment" },
|
|
49
|
+
{ mode: "view", label: "View" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const SEVERITY_COLOR: Record<IssueSeverity, string> = {
|
|
53
|
+
low: "var(--color-secondary)",
|
|
54
|
+
medium: "var(--color-warning)",
|
|
55
|
+
high: "var(--color-warning)",
|
|
56
|
+
blocker: "var(--color-danger)",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const SEVERITY_LABEL: Record<IssueSeverity, string> = {
|
|
60
|
+
low: "Low",
|
|
61
|
+
medium: "Medium",
|
|
62
|
+
high: "High",
|
|
63
|
+
blocker: "Blocker",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const OWNER_LABEL: Record<IssueOwner, string> = {
|
|
67
|
+
procurement: "Procurement",
|
|
68
|
+
legal: "Legal",
|
|
69
|
+
risk: "Risk",
|
|
70
|
+
finance: "Finance",
|
|
71
|
+
sustainability: "Sustainability",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Component
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
79
|
+
model,
|
|
80
|
+
onClose,
|
|
81
|
+
onModeChange,
|
|
82
|
+
onIssueAction,
|
|
83
|
+
"data-testid": testId,
|
|
84
|
+
}) => {
|
|
85
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
86
|
+
const headerId = React.useId();
|
|
87
|
+
const liveRegionId = React.useId();
|
|
88
|
+
|
|
89
|
+
// --- Focus management ----------------------------------------------------
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
const root = rootRef.current;
|
|
92
|
+
if (!root) return undefined;
|
|
93
|
+
const first = getFocusable(root)[0];
|
|
94
|
+
first?.focus();
|
|
95
|
+
return undefined;
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// --- Escape + click-outside ---------------------------------------------
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
const onKey = (event: KeyboardEvent) => {
|
|
101
|
+
if (event.key === "Escape") {
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
onClose();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
107
|
+
const root = rootRef.current;
|
|
108
|
+
if (!root) return;
|
|
109
|
+
if (event.target instanceof Node && root.contains(event.target)) return;
|
|
110
|
+
onClose();
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener("keydown", onKey, true);
|
|
113
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
114
|
+
return () => {
|
|
115
|
+
window.removeEventListener("keydown", onKey, true);
|
|
116
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
117
|
+
};
|
|
118
|
+
}, [onClose]);
|
|
119
|
+
|
|
120
|
+
// --- Focus trap ----------------------------------------------------------
|
|
121
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
122
|
+
if (event.key !== "Tab") return;
|
|
123
|
+
const root = rootRef.current;
|
|
124
|
+
if (!root) return;
|
|
125
|
+
const focusables = getFocusable(root);
|
|
126
|
+
if (focusables.length === 0) return;
|
|
127
|
+
const first = focusables[0];
|
|
128
|
+
const last = focusables[focusables.length - 1];
|
|
129
|
+
const active = document.activeElement;
|
|
130
|
+
if (event.shiftKey) {
|
|
131
|
+
if (active === first || !root.contains(active)) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
last.focus();
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
if (active === last) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
first.focus();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const issue = model.issue;
|
|
144
|
+
const postureLabel = posturePresentationLabel(model.posture);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
ref={rootRef}
|
|
149
|
+
className="wre-scope-card pointer-events-auto absolute flex w-80 max-w-[22rem] flex-col gap-2 rounded-lg border border-border bg-canvas p-3 text-sm shadow-[var(--shadow-float)]"
|
|
150
|
+
role="dialog"
|
|
151
|
+
aria-modal="false"
|
|
152
|
+
aria-labelledby={headerId}
|
|
153
|
+
data-testid={testId ?? "scope-card"}
|
|
154
|
+
data-scope-id={model.scopeId}
|
|
155
|
+
data-posture={model.posture}
|
|
156
|
+
onKeyDown={onKeyDown}
|
|
157
|
+
>
|
|
158
|
+
{/* Header --------------------------------------------------------- */}
|
|
159
|
+
<div className="flex items-center justify-between gap-2">
|
|
160
|
+
<div
|
|
161
|
+
id={headerId}
|
|
162
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-primary"
|
|
163
|
+
>
|
|
164
|
+
<span
|
|
165
|
+
className={`wre-scope-rail-icon wre-scope-rail-icon-${posturePresentationIcon(model.posture)}`}
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
style={{ color: postureTokenColor(model.posture) }}
|
|
168
|
+
/>
|
|
169
|
+
<span className="truncate uppercase tracking-[0.06em]">
|
|
170
|
+
{postureLabel}
|
|
171
|
+
</span>
|
|
172
|
+
{model.label ? (
|
|
173
|
+
<span className="truncate text-tertiary">· {model.label}</span>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
aria-label="Close scope card"
|
|
179
|
+
className="flex h-6 w-6 items-center justify-center rounded-sm text-tertiary transition-colors hover:bg-surface hover:text-primary"
|
|
180
|
+
onClick={onClose}
|
|
181
|
+
data-testid="scope-card-close"
|
|
182
|
+
>
|
|
183
|
+
×
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Mode row ------------------------------------------------------- */}
|
|
188
|
+
<div
|
|
189
|
+
role="group"
|
|
190
|
+
aria-label="Scope mode"
|
|
191
|
+
className="flex gap-1 rounded-md border border-border bg-surface p-0.5"
|
|
192
|
+
>
|
|
193
|
+
{MODE_OPTIONS.map(({ mode, label }) => {
|
|
194
|
+
const active = model.posture === mode;
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
key={mode}
|
|
198
|
+
type="button"
|
|
199
|
+
aria-pressed={active ? "true" : "false"}
|
|
200
|
+
className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
|
|
201
|
+
active
|
|
202
|
+
? "bg-canvas text-primary shadow-sm"
|
|
203
|
+
: "text-secondary hover:bg-canvas hover:text-primary"
|
|
204
|
+
}`}
|
|
205
|
+
onClick={() => onModeChange(mode)}
|
|
206
|
+
data-testid={`scope-card-mode-${mode}`}
|
|
207
|
+
>
|
|
208
|
+
{label}
|
|
209
|
+
</button>
|
|
210
|
+
);
|
|
211
|
+
})}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Issue row (R2) ------------------------------------------------- */}
|
|
215
|
+
{issue ? <IssueRow issue={issue} onAction={onIssueAction} /> : null}
|
|
216
|
+
|
|
217
|
+
{/* A11y live region ---------------------------------------------- */}
|
|
218
|
+
<span
|
|
219
|
+
id={liveRegionId}
|
|
220
|
+
role="status"
|
|
221
|
+
aria-live="polite"
|
|
222
|
+
className="sr-only"
|
|
223
|
+
>
|
|
224
|
+
{issue
|
|
225
|
+
? `${postureLabel} scope, ${SEVERITY_LABEL[issue.severity]} severity issue attached: ${issue.title}`
|
|
226
|
+
: `${postureLabel} scope opened`}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Issue row
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
const IssueRow: React.FC<{
|
|
237
|
+
issue: IssueMetadataValue;
|
|
238
|
+
onAction: (action: ScopeIssueAction) => void;
|
|
239
|
+
}> = ({ issue, onAction }) => {
|
|
240
|
+
const canResolve = issue.checklistState === "open" || issue.checklistState === "acknowledged";
|
|
241
|
+
const canWaive = issue.checklistState !== "waived";
|
|
242
|
+
const canEscalate = issue.escalationState !== "requested";
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
className="flex flex-col gap-1.5 rounded-md bg-surface p-2"
|
|
247
|
+
data-testid="scope-card-issue"
|
|
248
|
+
>
|
|
249
|
+
<div className="flex items-center gap-1.5 text-[11px]">
|
|
250
|
+
<span
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
className="inline-block h-2 w-2 rounded-full"
|
|
253
|
+
style={{ background: SEVERITY_COLOR[issue.severity] }}
|
|
254
|
+
/>
|
|
255
|
+
<span className="font-semibold text-primary">
|
|
256
|
+
{SEVERITY_LABEL[issue.severity]}
|
|
257
|
+
</span>
|
|
258
|
+
{issue.owner ? (
|
|
259
|
+
<span
|
|
260
|
+
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] text-secondary"
|
|
261
|
+
data-testid="scope-card-issue-owner"
|
|
262
|
+
>
|
|
263
|
+
{OWNER_LABEL[issue.owner]}
|
|
264
|
+
</span>
|
|
265
|
+
) : null}
|
|
266
|
+
</div>
|
|
267
|
+
<div
|
|
268
|
+
className="text-xs font-medium leading-snug text-primary"
|
|
269
|
+
data-testid="scope-card-issue-title"
|
|
270
|
+
>
|
|
271
|
+
{issue.title}
|
|
272
|
+
</div>
|
|
273
|
+
{issue.summary ? (
|
|
274
|
+
<div className="text-[11px] leading-snug text-secondary">{issue.summary}</div>
|
|
275
|
+
) : null}
|
|
276
|
+
<div className="flex gap-1">
|
|
277
|
+
<IssueActionButton
|
|
278
|
+
label="Resolve"
|
|
279
|
+
testId="scope-card-issue-resolve"
|
|
280
|
+
disabled={!canResolve}
|
|
281
|
+
onClick={() => onAction("resolve")}
|
|
282
|
+
/>
|
|
283
|
+
<IssueActionButton
|
|
284
|
+
label="Waive"
|
|
285
|
+
testId="scope-card-issue-waive"
|
|
286
|
+
disabled={!canWaive}
|
|
287
|
+
onClick={() => onAction("waive")}
|
|
288
|
+
/>
|
|
289
|
+
<IssueActionButton
|
|
290
|
+
label="Escalate"
|
|
291
|
+
testId="scope-card-issue-escalate"
|
|
292
|
+
disabled={!canEscalate}
|
|
293
|
+
onClick={() => onAction("escalate")}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const IssueActionButton: React.FC<{
|
|
301
|
+
label: string;
|
|
302
|
+
testId: string;
|
|
303
|
+
disabled: boolean;
|
|
304
|
+
onClick: () => void;
|
|
305
|
+
}> = ({ label, testId, disabled, onClick }) => (
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
className="flex-1 rounded-sm border border-border bg-canvas px-1.5 py-1 text-[11px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
310
|
+
onClick={onClick}
|
|
311
|
+
data-testid={testId}
|
|
312
|
+
>
|
|
313
|
+
{label}
|
|
314
|
+
</button>
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Helpers
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
function getFocusable(root: HTMLElement): HTMLElement[] {
|
|
322
|
+
const selector =
|
|
323
|
+
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
|
|
324
|
+
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
|
|
325
|
+
(el) => !el.hasAttribute("inert") && el.offsetParent !== null,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function posturePresentationLabel(posture: ScopeCardModel["posture"]): string {
|
|
330
|
+
switch (posture) {
|
|
331
|
+
case "edit":
|
|
332
|
+
return "Edit";
|
|
333
|
+
case "suggest":
|
|
334
|
+
return "Suggest";
|
|
335
|
+
case "comment":
|
|
336
|
+
return "Comment";
|
|
337
|
+
case "view":
|
|
338
|
+
return "View";
|
|
339
|
+
case "candidate":
|
|
340
|
+
return "Proposed";
|
|
341
|
+
case "preserve-only":
|
|
342
|
+
case "blocked-import":
|
|
343
|
+
return "Blocked";
|
|
344
|
+
default:
|
|
345
|
+
return "Scope";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function posturePresentationIcon(posture: ScopeCardModel["posture"]): string {
|
|
350
|
+
switch (posture) {
|
|
351
|
+
case "edit":
|
|
352
|
+
return "pencil";
|
|
353
|
+
case "suggest":
|
|
354
|
+
return "sparkles";
|
|
355
|
+
case "comment":
|
|
356
|
+
return "message";
|
|
357
|
+
case "view":
|
|
358
|
+
return "eye";
|
|
359
|
+
case "candidate":
|
|
360
|
+
return "flag";
|
|
361
|
+
case "preserve-only":
|
|
362
|
+
case "blocked-import":
|
|
363
|
+
return "lock";
|
|
364
|
+
default:
|
|
365
|
+
return "eye";
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function postureTokenColor(posture: ScopeCardModel["posture"]): string {
|
|
370
|
+
switch (posture) {
|
|
371
|
+
case "edit":
|
|
372
|
+
return "var(--color-accent)";
|
|
373
|
+
case "suggest":
|
|
374
|
+
case "candidate":
|
|
375
|
+
return "var(--color-warning)";
|
|
376
|
+
case "comment":
|
|
377
|
+
return "var(--color-insert)";
|
|
378
|
+
case "preserve-only":
|
|
379
|
+
case "blocked-import":
|
|
380
|
+
return "var(--color-danger)";
|
|
381
|
+
default:
|
|
382
|
+
return "var(--color-secondary)";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export default TwScopeCard;
|