@plannotator/ui 0.37.0 → 0.38.1
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/HANDOFF.md +4 -2
- package/README.md +8 -1
- package/components/ActionMenu.tsx +22 -24
- package/components/AnnotationToolbar.tsx +3 -1
- package/components/ApproveDropdown.tsx +15 -18
- package/components/CommentPopover.tsx +9 -4
- package/components/DecisionControl.tsx +626 -0
- package/components/FloatingQuickLabelPicker.tsx +4 -1
- package/components/KeyboardShortcuts.tsx +1 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +67 -6
- package/components/ToolbarButtons.tsx +31 -9
- package/components/Viewer.tsx +5 -1
- package/components/blocks/AlertBlock.tsx +51 -4
- package/config/reviewView.ts +1 -0
- package/config/settings.ts +48 -3
- package/configure.ts +9 -0
- package/hooks/useDismissablePopover.ts +65 -0
- package/hooks/useVimDocumentFocus.ts +6 -0
- package/package.json +24 -24
- package/shortcuts/decisionControl.shortcuts.ts +31 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
- package/styles.css +1 -1
- package/theme.css +11 -0
- package/utils/alertTitle.ts +69 -0
- package/utils/decisionSpec.ts +430 -0
- package/utils/platform.ts +16 -0
- package/sprite_package_additional/index.html +0 -34
- package/sprite_package_new/index.html +0 -34
- package/sprite_package_pulluphang/index.html +0 -34
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { Check, ChevronDown, Loader2, Send } from 'lucide-react';
|
|
3
|
+
import { Button } from './ui/button';
|
|
4
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
|
5
|
+
import { Tooltip } from './Tooltip';
|
|
6
|
+
import { ActionMenuDivider, ActionMenuItem } from './ActionMenu';
|
|
7
|
+
import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
|
|
8
|
+
import { useDismissablePopover } from '../hooks/useDismissablePopover';
|
|
9
|
+
import { useCompactTouchLayout } from '../hooks/useIsMobile';
|
|
10
|
+
import { submitHint } from '../utils/platform';
|
|
11
|
+
import { cn } from '../lib/utils';
|
|
12
|
+
import type {
|
|
13
|
+
DecisionActionId,
|
|
14
|
+
DecisionComposer,
|
|
15
|
+
DecisionMenuItem,
|
|
16
|
+
DecisionSpec,
|
|
17
|
+
DecisionTone,
|
|
18
|
+
} from '../utils/decisionSpec';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The unified header decision control: one joined split pill — the incumbent
|
|
22
|
+
* primary on the left, a caret on the right whose popover carries the
|
|
23
|
+
* alternate decisions and the in-place note composer. Rendered entirely from a
|
|
24
|
+
* `DecisionSpec` (see `utils/decisionSpec.ts`); the host owns the handlers.
|
|
25
|
+
*
|
|
26
|
+
* NOT host-supported surface (like ActionMenu/ConfirmDialog): session/transport
|
|
27
|
+
* chrome for Plannotator's own decision endpoints, deliberately absent from
|
|
28
|
+
* the README supported-import list.
|
|
29
|
+
*
|
|
30
|
+
* Interaction contract (the load-bearing rules):
|
|
31
|
+
* - The left segment never opens the popover and never changes meaning within
|
|
32
|
+
* a state; the caret owns the popover.
|
|
33
|
+
* - Choosing a composer item MORPHS the same popover into the note composer —
|
|
34
|
+
* same element, children swap, never a second popover.
|
|
35
|
+
* - Esc ladder: composer → back to the menu keeping the draft; menu → close,
|
|
36
|
+
* focus returns to the caret. The event is consumed (stopPropagation) on
|
|
37
|
+
* exactly those two rungs; with nothing open the host app's own Escape
|
|
38
|
+
* ladder still runs.
|
|
39
|
+
* - Mod+Enter in the note field fires the composer's labelled action; plain
|
|
40
|
+
* Enter is a newline, always.
|
|
41
|
+
* - An empty note never submits and never grays the action: the click
|
|
42
|
+
* refocuses the field instead.
|
|
43
|
+
*/
|
|
44
|
+
export type DecisionHandler = (note?: string) => void;
|
|
45
|
+
|
|
46
|
+
export interface DecisionControlProps {
|
|
47
|
+
spec: DecisionSpec;
|
|
48
|
+
handlers: Record<DecisionActionId, DecisionHandler>;
|
|
49
|
+
/** isSubmitting || isExiting || isApproving — disables both segments and
|
|
50
|
+
* closes an open popover. */
|
|
51
|
+
busy: boolean;
|
|
52
|
+
/** Spinner on the primary only. */
|
|
53
|
+
isLoading: boolean;
|
|
54
|
+
labelBreakpoint?: 'md' | 'lg';
|
|
55
|
+
/** The host says the surface is framed (raw-HTML srcdoc / live-app proxy):
|
|
56
|
+
* clicks inside the iframe never reach the parent document, so iframe
|
|
57
|
+
* focus dismisses the popover instead. */
|
|
58
|
+
dismissOnIframeFocus?: boolean;
|
|
59
|
+
/** Confirm surface override; defaults to the ui ConfirmDialog. */
|
|
60
|
+
confirmDialog?: React.ComponentType<ConfirmDialogProps>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const NOTE_MAX_HEIGHT_PX = 144;
|
|
64
|
+
|
|
65
|
+
const ICONS: Record<'check' | 'send', React.ReactNode> = {
|
|
66
|
+
check: <Check className="size-3.5" />,
|
|
67
|
+
send: <Send className="size-3.5" />,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function toneButtonVariant(tone: Exclude<DecisionTone, 'destructive'>): 'success' | 'default' {
|
|
71
|
+
return tone === 'success' ? 'success' : 'default';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function itemToneClass(tone: DecisionTone): string | undefined {
|
|
75
|
+
if (tone === 'destructive') return 'text-destructive';
|
|
76
|
+
if (tone === 'success') return 'text-success';
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function useAutoGrow(text: string) {
|
|
81
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const el = ref.current;
|
|
84
|
+
if (!el) return;
|
|
85
|
+
el.style.height = 'auto';
|
|
86
|
+
el.style.height = `${Math.min(el.scrollHeight, NOTE_MAX_HEIGHT_PX)}px`;
|
|
87
|
+
}, [text]);
|
|
88
|
+
return ref;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DecisionNoteFieldProps {
|
|
92
|
+
text: string;
|
|
93
|
+
onTextChange: (value: string) => void;
|
|
94
|
+
/** Mod+Enter / the labelled action. The owner enforces the empty-note
|
|
95
|
+
* no-op-and-refocus rule, so this fires for any text. */
|
|
96
|
+
onSubmit: () => void;
|
|
97
|
+
/** Escape. Consumes the event; the owner decides what "back" means. */
|
|
98
|
+
onCancel: () => void;
|
|
99
|
+
placeholder?: string;
|
|
100
|
+
disabled?: boolean;
|
|
101
|
+
autoFocus?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The shared multi-line note field: two rows at rest, grows to 144px, then
|
|
106
|
+
* scrolls. Controlled — the owner keeps the text so stepping back does not
|
|
107
|
+
* discard a half-typed note. Exported separately because a third consumer
|
|
108
|
+
* (the review sidebar's "+ General comment") composes it outside the control.
|
|
109
|
+
*/
|
|
110
|
+
export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
|
|
111
|
+
text,
|
|
112
|
+
onTextChange,
|
|
113
|
+
onSubmit,
|
|
114
|
+
onCancel,
|
|
115
|
+
placeholder = 'Add a note...',
|
|
116
|
+
disabled = false,
|
|
117
|
+
autoFocus = true,
|
|
118
|
+
}) => {
|
|
119
|
+
const ref = useAutoGrow(text);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (autoFocus) ref.current?.focus();
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
}, [autoFocus]);
|
|
125
|
+
|
|
126
|
+
const handleKeyDown = useCallback(
|
|
127
|
+
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
128
|
+
if (event.key === 'Escape') {
|
|
129
|
+
// A consuming rung of the Esc ladder: the host apps run their own
|
|
130
|
+
// Escape ladders and must not also see this event.
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
onCancel();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
if (!disabled) onSubmit();
|
|
140
|
+
}
|
|
141
|
+
// Plain Enter falls through: the field is multi-line and Enter's job is
|
|
142
|
+
// a newline.
|
|
143
|
+
},
|
|
144
|
+
[disabled, onCancel, onSubmit],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<textarea
|
|
149
|
+
ref={ref}
|
|
150
|
+
rows={2}
|
|
151
|
+
value={text}
|
|
152
|
+
onChange={(event) => onTextChange(event.target.value)}
|
|
153
|
+
onKeyDown={handleKeyDown}
|
|
154
|
+
placeholder={placeholder}
|
|
155
|
+
aria-label={placeholder}
|
|
156
|
+
data-decision-note-input="true"
|
|
157
|
+
data-pn-mobile-editable
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
className="block w-full resize-none overflow-y-auto rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-snug text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring/40"
|
|
160
|
+
style={{ maxHeight: NOTE_MAX_HEIGHT_PX }}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The compact/touch note composer. The desktop composer morphs inside the
|
|
167
|
+
* caret popover, which is the wrong shape on touch (the header menu popup
|
|
168
|
+
* closes on outside pointerdown and its max-height fights the soft keyboard),
|
|
169
|
+
* so compact rows open this dialog instead.
|
|
170
|
+
*/
|
|
171
|
+
export const DecisionNoteDialog: React.FC<{
|
|
172
|
+
isOpen: boolean;
|
|
173
|
+
onClose: () => void;
|
|
174
|
+
composer: DecisionComposer;
|
|
175
|
+
onSubmit: (note: string) => void;
|
|
176
|
+
disabled?: boolean;
|
|
177
|
+
/** Free prose under the title, e.g. what the note rides along with. */
|
|
178
|
+
subtitle?: string;
|
|
179
|
+
}> = ({ isOpen, onClose, composer, onSubmit, disabled = false, subtitle }) => {
|
|
180
|
+
const isCompactTouchLayout = useCompactTouchLayout();
|
|
181
|
+
const [text, setText] = useState('');
|
|
182
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
183
|
+
|
|
184
|
+
// If the surface goes busy while the dialog is open, close it rather than
|
|
185
|
+
// leaving an editable field whose action can no longer do anything.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (disabled && isOpen) onClose();
|
|
188
|
+
}, [disabled, isOpen, onClose]);
|
|
189
|
+
|
|
190
|
+
const submit = useCallback(() => {
|
|
191
|
+
if (disabled) return;
|
|
192
|
+
const trimmed = text.trim();
|
|
193
|
+
if (trimmed.length === 0) {
|
|
194
|
+
// Same contract as the popover composer: the action stays full-strength
|
|
195
|
+
// and an empty-note tap refocuses the field — on touch this also raises
|
|
196
|
+
// the keyboard, which is what the tap was asking for.
|
|
197
|
+
contentRef.current
|
|
198
|
+
?.querySelector<HTMLTextAreaElement>('[data-decision-note-input]')
|
|
199
|
+
?.focus();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
onSubmit(trimmed);
|
|
203
|
+
setText('');
|
|
204
|
+
onClose();
|
|
205
|
+
}, [disabled, onClose, onSubmit, text]);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
209
|
+
<DialogContent
|
|
210
|
+
ref={contentRef}
|
|
211
|
+
data-decision-note-composer="dialog"
|
|
212
|
+
// Opening the sheet must not raise the touch keyboard on its own.
|
|
213
|
+
initialFocus={isCompactTouchLayout ? false : undefined}
|
|
214
|
+
className="max-w-md rounded-xl bg-card p-4 text-foreground"
|
|
215
|
+
>
|
|
216
|
+
<DialogTitle className="font-semibold mb-1">{composer.title}</DialogTitle>
|
|
217
|
+
{subtitle ? (
|
|
218
|
+
<DialogDescription className="text-sm text-muted-foreground mb-3">
|
|
219
|
+
{subtitle}
|
|
220
|
+
</DialogDescription>
|
|
221
|
+
) : null}
|
|
222
|
+
<DecisionNoteField
|
|
223
|
+
text={text}
|
|
224
|
+
onTextChange={setText}
|
|
225
|
+
onSubmit={submit}
|
|
226
|
+
onCancel={onClose}
|
|
227
|
+
placeholder={composer.placeholder}
|
|
228
|
+
disabled={disabled}
|
|
229
|
+
autoFocus={!isCompactTouchLayout}
|
|
230
|
+
/>
|
|
231
|
+
<div className="mt-3 flex items-center justify-between gap-2">
|
|
232
|
+
<span className="text-[11px] leading-snug text-muted-foreground">{submitHint}</span>
|
|
233
|
+
<Button
|
|
234
|
+
variant={toneButtonVariant(composer.tone)}
|
|
235
|
+
size="xs"
|
|
236
|
+
data-decision-composer-send="true"
|
|
237
|
+
onClick={submit}
|
|
238
|
+
disabled={disabled}
|
|
239
|
+
title={composer.actionLabel}
|
|
240
|
+
iconLeft={composer.icon ? ICONS[composer.icon] : undefined}
|
|
241
|
+
>
|
|
242
|
+
{composer.actionLabel}
|
|
243
|
+
</Button>
|
|
244
|
+
</div>
|
|
245
|
+
</DialogContent>
|
|
246
|
+
</Dialog>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
type PopoverState = null | 'menu' | 'composer';
|
|
251
|
+
|
|
252
|
+
export const DecisionControl: React.FC<DecisionControlProps> = ({
|
|
253
|
+
spec,
|
|
254
|
+
handlers,
|
|
255
|
+
busy,
|
|
256
|
+
isLoading,
|
|
257
|
+
labelBreakpoint = 'md',
|
|
258
|
+
dismissOnIframeFocus = false,
|
|
259
|
+
confirmDialog,
|
|
260
|
+
}) => {
|
|
261
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
262
|
+
const popoverRef = useRef<HTMLDivElement>(null);
|
|
263
|
+
const caretRef = useRef<HTMLButtonElement>(null);
|
|
264
|
+
const [popover, setPopover] = useState<PopoverState>(null);
|
|
265
|
+
const [activeItemId, setActiveItemId] = useState<DecisionMenuItem['id'] | null>(null);
|
|
266
|
+
// Drafts are keyed per item so stepping back from a composer (Esc, back
|
|
267
|
+
// button, even closing the popover) keeps the half-typed note.
|
|
268
|
+
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
269
|
+
// L2: only the ID is state — the confirm's title/message/handler resolve
|
|
270
|
+
// from the LIVE spec at render, so a spec update while the dialog is up
|
|
271
|
+
// can never confirm stale copy (e.g. an outdated count).
|
|
272
|
+
const [confirmItemId, setConfirmItemId] = useState<DecisionMenuItem['id'] | null>(null);
|
|
273
|
+
const confirmItem =
|
|
274
|
+
confirmItemId !== null
|
|
275
|
+
? spec.items.find((item) => item.id === confirmItemId && item.confirm) ?? null
|
|
276
|
+
: null;
|
|
277
|
+
|
|
278
|
+
const activeItem =
|
|
279
|
+
popover === 'composer' && activeItemId
|
|
280
|
+
? spec.items.find((item) => item.id === activeItemId) ?? null
|
|
281
|
+
: null;
|
|
282
|
+
|
|
283
|
+
const closePopover = useCallback((options?: { focusCaret?: boolean }) => {
|
|
284
|
+
setPopover(null);
|
|
285
|
+
setActiveItemId(null);
|
|
286
|
+
if (options?.focusCaret) caretRef.current?.focus();
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
// Outside-dismissal (outside pointerdown, iframe focus, or an Escape whose
|
|
290
|
+
// focus has already left the control) closes the WHOLE popover, composer
|
|
291
|
+
// included: the user's attention has left the control, so restoring the
|
|
292
|
+
// intermediate menu rung would fight where they are — and the draft is kept
|
|
293
|
+
// in `drafts` either way. The three-rung Esc ladder applies only while
|
|
294
|
+
// focus is inside the popover (handlePopoverKeyDown / the note field).
|
|
295
|
+
const dismiss = useCallback(() => closePopover(), [closePopover]);
|
|
296
|
+
|
|
297
|
+
useDismissablePopover({
|
|
298
|
+
enabled: popover !== null,
|
|
299
|
+
ref: rootRef,
|
|
300
|
+
onDismiss: dismiss,
|
|
301
|
+
dismissOnIframeFocus,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Busy = a decision is in flight: nothing in the popover can do anything
|
|
305
|
+
// anymore, so it must not hang over a disabled pill.
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (busy) {
|
|
308
|
+
setPopover(null);
|
|
309
|
+
setActiveItemId(null);
|
|
310
|
+
setConfirmItemId(null);
|
|
311
|
+
}
|
|
312
|
+
}, [busy]);
|
|
313
|
+
|
|
314
|
+
// F6: the spec is live — an annotation delete (or an external write) can
|
|
315
|
+
// remove the item the open composer or confirm belongs to. Morph gracefully
|
|
316
|
+
// instead of rendering a dead surface: both step back to the menu (the
|
|
317
|
+
// composer draft is kept, keyed by item id, in case the item returns).
|
|
318
|
+
// buildDecisionSpec guarantees at least one item, so the menu is always a
|
|
319
|
+
// valid fallback (L4 ruling: no empty-menu branch for a spec shape that
|
|
320
|
+
// cannot occur). Lives in the control rather than the adopting apps so the
|
|
321
|
+
// annotate and review wirings cannot diverge on it.
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (popover !== 'composer') return;
|
|
324
|
+
const stillPresent =
|
|
325
|
+
activeItemId !== null &&
|
|
326
|
+
spec.items.some((item) => item.id === activeItemId && item.composer);
|
|
327
|
+
if (stillPresent) return;
|
|
328
|
+
setPopover('menu');
|
|
329
|
+
setActiveItemId(null);
|
|
330
|
+
}, [activeItemId, popover, spec]);
|
|
331
|
+
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (confirmItemId === null) return;
|
|
334
|
+
if (spec.items.some((item) => item.id === confirmItemId && item.confirm)) return;
|
|
335
|
+
setConfirmItemId(null);
|
|
336
|
+
setPopover('menu');
|
|
337
|
+
}, [confirmItemId, spec]);
|
|
338
|
+
|
|
339
|
+
// Roving focus entry point: whenever the menu (re)appears — caret click,
|
|
340
|
+
// back from the composer, confirm cancel — focus its first row.
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (popover !== 'menu') return;
|
|
343
|
+
// :not(:disabled) — a muted (platform self-approval) row cannot take
|
|
344
|
+
// focus, so land on the first live row instead.
|
|
345
|
+
popoverRef.current
|
|
346
|
+
?.querySelector<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')
|
|
347
|
+
?.focus();
|
|
348
|
+
}, [popover]);
|
|
349
|
+
|
|
350
|
+
const selectItem = useCallback((item: DecisionMenuItem) => {
|
|
351
|
+
if (item.composer) {
|
|
352
|
+
setActiveItemId(item.id);
|
|
353
|
+
setPopover('composer');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (item.confirm) {
|
|
357
|
+
// The confirm replaces the popover; cancel reopens the menu so one
|
|
358
|
+
// decision stays one click away.
|
|
359
|
+
setConfirmItemId(item.id);
|
|
360
|
+
setPopover(null);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
closePopover();
|
|
364
|
+
handlers[item.id]?.();
|
|
365
|
+
}, [closePopover, handlers]);
|
|
366
|
+
|
|
367
|
+
const submitComposer = useCallback(() => {
|
|
368
|
+
if (!activeItem) return;
|
|
369
|
+
const trimmed = (drafts[activeItem.id] ?? '').trim();
|
|
370
|
+
if (trimmed.length === 0) {
|
|
371
|
+
// Empty note: the action stays visually enabled and the click puts the
|
|
372
|
+
// cursor back in the field — never a disabled-gray submit.
|
|
373
|
+
popoverRef.current
|
|
374
|
+
?.querySelector<HTMLTextAreaElement>('[data-decision-note-input]')
|
|
375
|
+
?.focus();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
setDrafts((prev) => {
|
|
379
|
+
const next = { ...prev };
|
|
380
|
+
delete next[activeItem.id];
|
|
381
|
+
return next;
|
|
382
|
+
});
|
|
383
|
+
closePopover();
|
|
384
|
+
handlers[activeItem.id]?.(trimmed);
|
|
385
|
+
}, [activeItem, closePopover, drafts, handlers]);
|
|
386
|
+
|
|
387
|
+
const backToMenu = useCallback(() => {
|
|
388
|
+
setPopover('menu');
|
|
389
|
+
setActiveItemId(null);
|
|
390
|
+
}, []);
|
|
391
|
+
|
|
392
|
+
const handlePopoverKeyDown = useCallback(
|
|
393
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
394
|
+
if (event.key === 'Escape') {
|
|
395
|
+
// The two consuming rungs of the Esc ladder. (The note field consumes
|
|
396
|
+
// its own Escape before it reaches here — same first rung.)
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
event.stopPropagation();
|
|
399
|
+
if (popover === 'composer') backToMenu();
|
|
400
|
+
else closePopover({ focusCaret: true });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (popover !== 'menu') return;
|
|
404
|
+
const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
|
|
405
|
+
if (!keys.includes(event.key)) return;
|
|
406
|
+
const items = Array.from(
|
|
407
|
+
popoverRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
|
|
408
|
+
);
|
|
409
|
+
if (items.length === 0) return;
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
event.stopPropagation();
|
|
412
|
+
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
413
|
+
let next = 0;
|
|
414
|
+
if (event.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length;
|
|
415
|
+
else if (event.key === 'ArrowUp') next = current <= 0 ? items.length - 1 : current - 1;
|
|
416
|
+
else if (event.key === 'Home') next = 0;
|
|
417
|
+
else next = items.length - 1;
|
|
418
|
+
items[next]?.focus();
|
|
419
|
+
},
|
|
420
|
+
[backToMenu, closePopover, popover],
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const primaryVariant = toneButtonVariant(spec.primary.tone);
|
|
424
|
+
const Confirm = confirmDialog ?? ConfirmDialog;
|
|
425
|
+
const mutedReasonId = useId();
|
|
426
|
+
|
|
427
|
+
// Left segment — the incumbent primary. It never opens the popover and,
|
|
428
|
+
// deliberately, never fades or disables while the popover is open: the
|
|
429
|
+
// popover holds only ALTERNATE decisions, so the primary keeps its
|
|
430
|
+
// meaning and stays clickable (reverses the held branches' fade, which
|
|
431
|
+
// existed because their panel duplicated the primary's own action).
|
|
432
|
+
const primaryButton = (
|
|
433
|
+
<Button
|
|
434
|
+
variant={primaryVariant}
|
|
435
|
+
size="xs"
|
|
436
|
+
onClick={() => {
|
|
437
|
+
// Muted (platform self-approval, PR6 §3.4): the click is a no-op.
|
|
438
|
+
// Deliberately NOT `disabled` — a disabled button would also lose
|
|
439
|
+
// hover/focus, and the Tooltip below carries the reason.
|
|
440
|
+
if (spec.primary.muted) return;
|
|
441
|
+
handlers.primary?.();
|
|
442
|
+
}}
|
|
443
|
+
disabled={busy}
|
|
444
|
+
aria-disabled={spec.primary.muted || undefined}
|
|
445
|
+
// Muted: the reason renders as a real Tooltip (hover + keyboard
|
|
446
|
+
// focus) and as an aria-describedby description — a native title on
|
|
447
|
+
// top of that would double the tooltip.
|
|
448
|
+
title={spec.primary.muted ? undefined : spec.primary.title}
|
|
449
|
+
aria-describedby={spec.primary.muted ? mutedReasonId : undefined}
|
|
450
|
+
data-decision-primary="true"
|
|
451
|
+
iconLeft={
|
|
452
|
+
isLoading
|
|
453
|
+
? <Loader2 className="size-3.5 animate-spin" />
|
|
454
|
+
: spec.primary.icon ? ICONS[spec.primary.icon] : undefined
|
|
455
|
+
}
|
|
456
|
+
className={cn(
|
|
457
|
+
'rounded-r-none border-r-0',
|
|
458
|
+
// Same mute treatment the old platform ApproveButton wore.
|
|
459
|
+
spec.primary.muted && 'opacity-40 cursor-not-allowed bg-muted text-muted-foreground hover:bg-muted',
|
|
460
|
+
)}
|
|
461
|
+
>
|
|
462
|
+
{spec.primary.shortLabel ? (
|
|
463
|
+
<>
|
|
464
|
+
{/* Responsive spans copied from FeedbackButton so the toolbar
|
|
465
|
+
width below xl is unchanged in review. */}
|
|
466
|
+
<span className={labelBreakpoint === 'lg' ? 'hidden lg:inline xl:hidden' : 'hidden md:inline lg:hidden'}>
|
|
467
|
+
{spec.primary.shortLabel}
|
|
468
|
+
</span>
|
|
469
|
+
<span className={labelBreakpoint === 'lg' ? 'hidden xl:inline' : 'hidden lg:inline'}>
|
|
470
|
+
{spec.primary.label}
|
|
471
|
+
</span>
|
|
472
|
+
</>
|
|
473
|
+
) : (
|
|
474
|
+
<span className={labelBreakpoint === 'lg' ? 'hidden lg:inline' : 'hidden md:inline'}>
|
|
475
|
+
{spec.primary.label}
|
|
476
|
+
</span>
|
|
477
|
+
)}
|
|
478
|
+
{typeof spec.primary.count === 'number' && (
|
|
479
|
+
// The count is the state indicator, not decoration: present at every
|
|
480
|
+
// breakpoint, unlike the labels.
|
|
481
|
+
<span
|
|
482
|
+
data-decision-count="true"
|
|
483
|
+
className="rounded-full bg-white/25 px-1.5 text-[10px] font-bold leading-4"
|
|
484
|
+
>
|
|
485
|
+
{spec.primary.count}
|
|
486
|
+
</span>
|
|
487
|
+
)}
|
|
488
|
+
</Button>
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<div ref={rootRef} className="relative inline-flex">
|
|
493
|
+
{spec.primary.muted ? (
|
|
494
|
+
<>
|
|
495
|
+
{/* The self-approval reason must be reachable by keyboard and AT,
|
|
496
|
+
not just mouse hover: the Tooltip opens on hover AND
|
|
497
|
+
focus-visible (Base UI wires floating-ui's useFocus on the
|
|
498
|
+
trigger), and the hidden span makes the same sentence the
|
|
499
|
+
button's persistent accessible description. */}
|
|
500
|
+
<Tooltip content={spec.primary.title} side="bottom" wide>
|
|
501
|
+
{primaryButton}
|
|
502
|
+
</Tooltip>
|
|
503
|
+
<span id={mutedReasonId} hidden>
|
|
504
|
+
{spec.primary.title}
|
|
505
|
+
</span>
|
|
506
|
+
</>
|
|
507
|
+
) : (
|
|
508
|
+
primaryButton
|
|
509
|
+
)}
|
|
510
|
+
|
|
511
|
+
{/* Right segment — the caret. */}
|
|
512
|
+
<Button
|
|
513
|
+
ref={caretRef}
|
|
514
|
+
variant={primaryVariant}
|
|
515
|
+
size="xs"
|
|
516
|
+
onClick={() => {
|
|
517
|
+
if (popover) closePopover();
|
|
518
|
+
else setPopover('menu');
|
|
519
|
+
}}
|
|
520
|
+
disabled={busy}
|
|
521
|
+
aria-haspopup="menu"
|
|
522
|
+
aria-expanded={popover !== null}
|
|
523
|
+
aria-label="More decisions"
|
|
524
|
+
data-decision-caret="true"
|
|
525
|
+
className="rounded-l-none px-1.5"
|
|
526
|
+
>
|
|
527
|
+
<ChevronDown
|
|
528
|
+
className={cn('size-3.5 transition-transform duration-150', popover && 'rotate-180')}
|
|
529
|
+
aria-hidden="true"
|
|
530
|
+
/>
|
|
531
|
+
</Button>
|
|
532
|
+
|
|
533
|
+
{popover !== null && (
|
|
534
|
+
// One popover element for both states: choosing a composer item swaps
|
|
535
|
+
// the children in place (morph), never mounts a second popover.
|
|
536
|
+
<div
|
|
537
|
+
ref={popoverRef}
|
|
538
|
+
role={popover === 'menu' ? 'menu' : undefined}
|
|
539
|
+
data-decision-popover={popover}
|
|
540
|
+
data-pn-dismissable-popover="true"
|
|
541
|
+
onKeyDown={handlePopoverKeyDown}
|
|
542
|
+
className="absolute right-0 top-full z-[70] mt-1.5 w-[22rem] max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-popover shadow-xl"
|
|
543
|
+
>
|
|
544
|
+
{popover === 'menu' ? (
|
|
545
|
+
<div className="py-1">
|
|
546
|
+
{spec.items.map((item) => (
|
|
547
|
+
<React.Fragment key={item.id}>
|
|
548
|
+
{item.dividerBefore ? <ActionMenuDivider /> : null}
|
|
549
|
+
<ActionMenuItem
|
|
550
|
+
role="menuitem"
|
|
551
|
+
icon={item.icon ? ICONS[item.icon] : undefined}
|
|
552
|
+
label={item.label}
|
|
553
|
+
subtitle={item.subtitle}
|
|
554
|
+
// Muted (platform self-approval): the row disables with
|
|
555
|
+
// the reason already in its subtitle; sibling rows stay
|
|
556
|
+
// live so the menu is never a dead end.
|
|
557
|
+
disabled={item.muted}
|
|
558
|
+
className={itemToneClass(item.tone)}
|
|
559
|
+
onClick={() => selectItem(item)}
|
|
560
|
+
/>
|
|
561
|
+
</React.Fragment>
|
|
562
|
+
))}
|
|
563
|
+
</div>
|
|
564
|
+
) : activeItem?.composer ? (
|
|
565
|
+
<div className="p-2">
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
data-decision-composer-back="true"
|
|
569
|
+
onClick={backToMenu}
|
|
570
|
+
className="mb-1.5 flex items-center gap-1 rounded px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
571
|
+
>
|
|
572
|
+
<ChevronDown className="size-3 rotate-90" aria-hidden="true" />
|
|
573
|
+
{activeItem.composer.title}
|
|
574
|
+
</button>
|
|
575
|
+
<DecisionNoteField
|
|
576
|
+
text={drafts[activeItem.id] ?? ''}
|
|
577
|
+
onTextChange={(value) =>
|
|
578
|
+
setDrafts((prev) => ({ ...prev, [activeItem.id]: value }))
|
|
579
|
+
}
|
|
580
|
+
onSubmit={submitComposer}
|
|
581
|
+
onCancel={backToMenu}
|
|
582
|
+
placeholder={activeItem.composer.placeholder}
|
|
583
|
+
/>
|
|
584
|
+
<div className="mt-2 flex items-center justify-between gap-2 px-0.5">
|
|
585
|
+
<span className="text-[11px] leading-snug text-muted-foreground">
|
|
586
|
+
{submitHint} send · Esc back, note kept
|
|
587
|
+
</span>
|
|
588
|
+
<Button
|
|
589
|
+
variant={toneButtonVariant(activeItem.composer.tone)}
|
|
590
|
+
size="xs"
|
|
591
|
+
data-decision-composer-send="true"
|
|
592
|
+
onClick={submitComposer}
|
|
593
|
+
title={activeItem.composer.actionLabel}
|
|
594
|
+
iconLeft={activeItem.composer.icon ? ICONS[activeItem.composer.icon] : undefined}
|
|
595
|
+
>
|
|
596
|
+
{activeItem.composer.actionLabel}
|
|
597
|
+
</Button>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
) : null}
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
|
|
604
|
+
{confirmItem?.confirm && (
|
|
605
|
+
<Confirm
|
|
606
|
+
isOpen
|
|
607
|
+
onClose={() => {
|
|
608
|
+
setConfirmItemId(null);
|
|
609
|
+
setPopover('menu');
|
|
610
|
+
}}
|
|
611
|
+
onConfirm={() => {
|
|
612
|
+
const id = confirmItem.id;
|
|
613
|
+
setConfirmItemId(null);
|
|
614
|
+
handlers[id]?.();
|
|
615
|
+
}}
|
|
616
|
+
title={confirmItem.confirm.title}
|
|
617
|
+
message={confirmItem.confirm.message}
|
|
618
|
+
confirmText={confirmItem.confirm.confirmText}
|
|
619
|
+
cancelText="Cancel"
|
|
620
|
+
variant="warning"
|
|
621
|
+
showCancel
|
|
622
|
+
/>
|
|
623
|
+
)}
|
|
624
|
+
</div>
|
|
625
|
+
);
|
|
626
|
+
};
|
|
@@ -88,7 +88,10 @@ export const FloatingQuickLabelPicker: React.FC<FloatingQuickLabelPickerProps> =
|
|
|
88
88
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
89
89
|
}, [onDismiss, onSelect, quickLabels]);
|
|
90
90
|
|
|
91
|
-
// Click outside to dismiss
|
|
91
|
+
// Click outside to dismiss. Deliberately NOT useDismissablePopover: this
|
|
92
|
+
// picker needs deferred capture-phase registration (the selection gesture
|
|
93
|
+
// that opens it must not instantly dismiss it), and Escape is handled in
|
|
94
|
+
// the digit-key listener above.
|
|
92
95
|
useEffect(() => {
|
|
93
96
|
const handlePointerDown = (e: PointerEvent) => {
|
|
94
97
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
@@ -88,6 +88,7 @@ const documentViewShortcuts: ShortcutSection = {
|
|
|
88
88
|
title: 'View',
|
|
89
89
|
shortcuts: [
|
|
90
90
|
{ keys: [modKey, '.'], desc: 'Toggle focus mode', hint: 'Collapses the Contents sidebar and the right-hand panel together; press again to restore whatever was open before. Markdown documents only; HTML pages keep their own layout.' },
|
|
91
|
+
{ keys: [modKey, 'E'], desc: 'Toggle edit mode', hint: 'Opens the markdown source editor in place, keeping your scroll position; press again to commit your edits and return to annotating. Markdown documents only; a source-backed document with unsaved changes keeps its two-step discard.' },
|
|
91
92
|
],
|
|
92
93
|
};
|
|
93
94
|
|
|
@@ -40,7 +40,7 @@ interface PlanHeaderMenuProps {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface CompactPlanAction {
|
|
43
|
-
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review';
|
|
43
|
+
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review' | 'note' | 'discard-finish';
|
|
44
44
|
label: string;
|
|
45
45
|
subtitle?: string;
|
|
46
46
|
onSelect: () => void;
|