Skip to content

Commit bd2e959

Browse files
authored
Organize Storybook modals and factor shared modal primitives (#78)
2 parents 18ebc04 + 729f728 commit bd2e959

11 files changed

Lines changed: 403 additions & 271 deletions
Lines changed: 85 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { useRef } from 'react';
2-
import { ProhibitIcon, WarningOctagonIcon, XIcon } from '@phosphor-icons/react';
2+
import { ProhibitIcon, WarningOctagonIcon } from '@phosphor-icons/react';
33
import type { DisplayMatchVerdict, ExternalUriDecision } from '../lib/external-links';
44
import {
5-
ModalOverlay,
6-
ModalSurface,
5+
ModalCloseButton,
6+
ModalFrame,
7+
ModalReviewBlock,
78
modalActionButton,
8-
modalIconButton,
9-
useModalFocusTrap,
109
} from './design';
1110

12-
export interface ExternalLinkDialogRequest {
11+
export interface ExternalLinkModalRequest {
1312
uri: string;
1413
displayText: string;
1514
verdict: DisplayMatchVerdict;
@@ -41,16 +40,15 @@ function schemePrefix(scheme: string, uri: string): string {
4140
return uri.slice(scheme.length + 1).startsWith('//') ? `${scheme}://` : `${scheme}:`;
4241
}
4342

44-
export function ExternalLinkDialog({
43+
export function ExternalLinkModal({
4544
request,
4645
onCancel,
4746
onConfirm,
4847
}: {
49-
request: ExternalLinkDialogRequest;
48+
request: ExternalLinkModalRequest;
5049
onCancel: () => void;
5150
onConfirm: () => void;
5251
}) {
53-
const dialogRef = useRef<HTMLDivElement>(null);
5452
const primaryButtonRef = useRef<HTMLButtonElement>(null);
5553
const secondaryButtonRef = useRef<HTMLButtonElement>(null);
5654

@@ -63,111 +61,99 @@ export function ExternalLinkDialog({
6361
? pickOpenButtonNoun(openableDecision.scheme, openableDecision.uri)
6462
: 'URL';
6563

66-
useModalFocusTrap(dialogRef, {
67-
// Deceptive case: focus the copy action so a default Enter doesn't dismiss
68-
// silently. Everywhere else: focus the safe affordance (Cancel/Close).
69-
initialFocusRef: isDeceptive ? primaryButtonRef : secondaryButtonRef,
70-
onEscape: onCancel,
71-
});
72-
7364
const handleCopy = () => {
7465
void navigator.clipboard.writeText(request.uri);
7566
onCancel();
7667
};
7768

7869
return (
79-
<ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6">
80-
<ModalSurface
81-
ref={dialogRef}
82-
role="dialog"
83-
aria-modal="true"
84-
aria-labelledby="external-link-dialog-title"
85-
elevation="modal"
86-
className="w-full max-w-[34rem]"
87-
>
88-
<div className="flex items-start gap-3">
89-
<h2
90-
id="external-link-dialog-title"
91-
className="min-w-0 flex-1 text-sm leading-5 text-foreground"
92-
>
93-
{isDeceptive ? (
94-
<DeceptiveTitle displayText={request.displayText} />
95-
) : blockedDecision ? (
96-
<BlockedTitle reason={blockedDecision.reason} />
97-
) : (
98-
<OpenTitle verdict={verdict} displayText={request.displayText} />
99-
)}
100-
</h2>
101-
<button
102-
type="button"
103-
aria-label="Close"
104-
className={modalIconButton()}
105-
onClick={onCancel}
106-
>
107-
<XIcon size={13} weight="bold" />
108-
</button>
109-
</div>
110-
111-
{/* Bordered nested box: explicit exception to the bg-only chrome rule
112-
in DESIGN.md. The URL is the literal artifact the user is being
113-
asked to scrutinize, and a framed box reads better than a bare
114-
bg-shift in this high-stakes context. */}
115-
<div className="mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded border border-border bg-app-bg px-2.5 py-2 text-sm leading-relaxed text-foreground">
116-
{displayUri}
117-
</div>
118-
119-
<div className="mt-4 flex justify-end gap-2 text-xs">
70+
<ModalFrame
71+
titleId="external-link-modal-title"
72+
layer="critical"
73+
backdrop="strong"
74+
elevation="modal"
75+
overlayClassName="px-4 py-6"
76+
className="w-full max-w-[34rem]"
77+
// Deceptive case: focus the copy action so a default Enter doesn't dismiss
78+
// silently. Everywhere else: focus the safe affordance (Cancel/Close).
79+
initialFocusRef={isDeceptive ? primaryButtonRef : secondaryButtonRef}
80+
onEscape={onCancel}
81+
>
82+
<div className="flex items-start gap-3">
83+
<h2
84+
id="external-link-modal-title"
85+
className="min-w-0 flex-1 text-sm leading-5 text-foreground"
86+
>
12087
{isDeceptive ? (
121-
<>
122-
<button
123-
ref={secondaryButtonRef}
124-
type="button"
125-
onClick={onCancel}
126-
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
127-
>
128-
Close
129-
</button>
130-
<button
131-
ref={primaryButtonRef}
132-
type="button"
133-
onClick={handleCopy}
134-
className={modalActionButton({ tone: 'primary' })}
135-
>
136-
Copy deceptive URL to clipboard
137-
</button>
138-
</>
139-
) : openableDecision ? (
140-
<>
141-
<button
142-
ref={secondaryButtonRef}
143-
type="button"
144-
onClick={onCancel}
145-
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
146-
>
147-
Cancel
148-
</button>
149-
<button
150-
ref={primaryButtonRef}
151-
type="button"
152-
onClick={onConfirm}
153-
className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`}
154-
>
155-
{'Open '}{buttonNoun}
156-
</button>
157-
</>
88+
<DeceptiveTitle displayText={request.displayText} />
89+
) : blockedDecision ? (
90+
<BlockedTitle reason={blockedDecision.reason} />
15891
) : (
92+
<OpenTitle verdict={verdict} displayText={request.displayText} />
93+
)}
94+
</h2>
95+
<ModalCloseButton onClick={onCancel} />
96+
</div>
97+
98+
{/* Bordered nested box: explicit exception to the bg-only chrome rule
99+
in DESIGN.md. The URL is the literal artifact the user is being
100+
asked to scrutinize, and a framed box reads better than a bare
101+
bg-shift in this high-stakes context. */}
102+
<ModalReviewBlock className="mt-3" wrap="breakAll">
103+
{displayUri}
104+
</ModalReviewBlock>
105+
106+
<div className="mt-4 flex justify-end gap-2 text-xs">
107+
{isDeceptive ? (
108+
<>
159109
<button
160110
ref={secondaryButtonRef}
161111
type="button"
162112
onClick={onCancel}
163-
className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`}
113+
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
164114
>
165115
Close
166116
</button>
167-
)}
168-
</div>
169-
</ModalSurface>
170-
</ModalOverlay>
117+
<button
118+
ref={primaryButtonRef}
119+
type="button"
120+
onClick={handleCopy}
121+
className={modalActionButton({ tone: 'primary' })}
122+
>
123+
Copy deceptive URL to clipboard
124+
</button>
125+
</>
126+
) : openableDecision ? (
127+
<>
128+
<button
129+
ref={secondaryButtonRef}
130+
type="button"
131+
onClick={onCancel}
132+
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
133+
>
134+
Cancel
135+
</button>
136+
<button
137+
ref={primaryButtonRef}
138+
type="button"
139+
onClick={onConfirm}
140+
className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`}
141+
>
142+
{'Open '}{buttonNoun}
143+
</button>
144+
</>
145+
) : (
146+
<button
147+
ref={secondaryButtonRef}
148+
type="button"
149+
onClick={onCancel}
150+
className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`}
151+
>
152+
Close
153+
</button>
154+
)}
155+
</div>
156+
</ModalFrame>
171157
);
172158
}
173159

lib/src/components/ExternalLinkDialogHost.tsx renamed to lib/src/components/ExternalLinkModalHost.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { useCallback, useEffect, useSyncExternalStore } from 'react';
2-
import { ExternalLinkDialog } from './ExternalLinkDialog';
2+
import { ExternalLinkModal } from './ExternalLinkModal';
33
import {
44
clearExternalLinkConfirmation,
55
getExternalLinkConfirmationSnapshot,
66
subscribeExternalLinkConfirmation,
77
} from '../lib/external-link-confirmation';
88
import { getPlatform } from '../lib/platform';
99

10-
export function ExternalLinkDialogHost({
10+
export function ExternalLinkModalHost({
1111
onKeyboardActiveChange,
1212
}: {
1313
onKeyboardActiveChange: (active: boolean) => void;
@@ -37,7 +37,7 @@ export function ExternalLinkDialogHost({
3737
if (!pending) return null;
3838

3939
return (
40-
<ExternalLinkDialog
40+
<ExternalLinkModal
4141
request={{
4242
uri: pending.uri,
4343
displayText: pending.displayText,

lib/src/components/KillConfirm.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { useRef } from 'react';
12
import { resolvePaneElement } from '../lib/spatial-nav';
2-
import { ModalOverlay, ModalSurface, Shortcut } from './design';
3+
import { ModalFrame, Shortcut } from './design';
34

45
export type KillExit = 'shake' | 'confirm';
56

@@ -18,10 +19,32 @@ export function randomKillChar(): string {
1819
return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)];
1920
}
2021

21-
export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCancel?: () => void; exit?: KillExit }) {
22+
export function KillConfirmModal({
23+
char,
24+
onCancel,
25+
exit,
26+
targetElement,
27+
}: {
28+
char: string;
29+
onCancel?: () => void;
30+
exit?: KillExit;
31+
targetElement?: HTMLElement | null;
32+
}) {
33+
const cancelButtonRef = useRef<HTMLButtonElement>(null);
2234
return (
23-
<ModalSurface padding="spacious" align="center" className={exit === 'shake' ? 'motion-safe:animate-shake-x' : undefined}>
24-
<h2 className="text-base font-bold mb-3 text-foreground">Confirm kill</h2>
35+
<ModalFrame
36+
titleId="kill-confirm-title"
37+
targetElement={targetElement}
38+
padding="spacious"
39+
align="center"
40+
className={exit === 'shake' ? 'motion-safe:animate-shake-x' : undefined}
41+
overlayClassName={exit === 'confirm' ? 'kill-overlay-confirm' : undefined}
42+
initialFocusRef={cancelButtonRef}
43+
onEscape={onCancel}
44+
>
45+
<h2 id="kill-confirm-title" className="text-base font-bold mb-3 text-foreground">
46+
Confirm kill
47+
</h2>
2548
<div className="bg-app-bg py-2 px-6 rounded border border-border inline-block mb-2">
2649
<span
2750
className={`text-xl font-bold${exit === 'confirm' ? ' kill-letter-flash' : ''}`}
@@ -33,12 +56,17 @@ export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCanc
3356
<div className="text-sm text-muted leading-relaxed grid grid-cols-[auto_auto] gap-x-2 justify-center">
3457
<Shortcut className="justify-self-end">{char}</Shortcut>
3558
<span className="justify-self-start">to confirm</span>
36-
<button type="button" onClick={onCancel} className="contents group cursor-pointer">
59+
<button
60+
ref={cancelButtonRef}
61+
type="button"
62+
onClick={onCancel}
63+
className="contents group cursor-pointer"
64+
>
3765
<Shortcut className="justify-self-end group-hover:text-foreground transition-colors">Esc</Shortcut>
3866
<span className="justify-self-start group-hover:text-foreground transition-colors">to cancel</span>
3967
</button>
4068
</div>
41-
</ModalSurface>
69+
</ModalFrame>
4270
);
4371
}
4472

@@ -49,12 +77,11 @@ export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: {
4977
}) {
5078
const panelEl = resolvePaneElement(paneElements.get(confirmKill.id));
5179
return (
52-
<ModalOverlay
80+
<KillConfirmModal
81+
char={confirmKill.char}
82+
onCancel={onCancel}
83+
exit={confirmKill.exit}
5384
targetElement={panelEl}
54-
className={confirmKill.exit === 'confirm' ? 'kill-overlay-confirm' : undefined}
55-
>
56-
<KillConfirmCard char={confirmKill.char} onCancel={onCancel} exit={confirmKill.exit} />
57-
</ModalOverlay>
85+
/>
5886
);
5987
}
60-

lib/src/components/Wall.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from 'dockview-react';
99
import 'dockview-react/dist/styles/dockview.css';
1010
import { Baseboard } from './Baseboard';
11-
import { ExternalLinkDialogHost } from './ExternalLinkDialogHost';
11+
import { ExternalLinkModalHost } from './ExternalLinkModalHost';
1212
import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm';
1313
import {
1414
clearSessionAttention,
@@ -773,7 +773,7 @@ export function Wall({
773773
version={paneElementsVersion}
774774
/>
775775

776-
<ExternalLinkDialogHost onKeyboardActiveChange={setDialogKeyboardActive} />
776+
<ExternalLinkModalHost onKeyboardActiveChange={setDialogKeyboardActive} />
777777

778778
</div>
779779
</DialogKeyboardContext.Provider>

0 commit comments

Comments
 (0)