返回 DeepSeek-Reasonix
PromptAction.tsx
根目录 / desktop / frontend / src / components / PromptAction.tsx
1 import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2 import type { ReactNode } from "react";
3 import { useT } from "../lib/i18n";
4
5 export function PromptDescriptionToggle({
6 descriptionId,
7 expanded,
8 onToggle,
9 disabled = false,
10 }: {
11 descriptionId: string;
12 expanded: boolean;
13 onToggle: () => void;
14 disabled?: boolean;
15 }) {
16 const t = useT();
17 return (
18 <button
19 type="button"
20 className="prompt-action__description-toggle"
21 aria-expanded={expanded}
22 aria-controls={descriptionId}
23 onClick={onToggle}
24 onKeyDown={(event) => {
25 // Decision surfaces also own document-level Enter shortcuts. Keep
26 // disclosure activation local so the same key press cannot confirm
27 // the currently selected decision.
28 if (event.key !== "Enter" && event.key !== " ") return;
29 event.preventDefault();
30 event.stopPropagation();
31 onToggle();
32 }}
33 disabled={disabled}
34 >
35 {t(expanded ? "decision.hideFullDescription" : "decision.showFullDescription")}
36 </button>
37 );
38 }
39
40 export function PromptDescriptionDisclosure({
41 descriptionId,
42 label,
43 description,
44 expanded,
45 onToggle,
46 disabled = false,
47 alwaysVisible = false,
48 }: {
49 descriptionId: string;
50 label?: ReactNode;
51 description?: ReactNode;
52 expanded: boolean;
53 onToggle?: () => void;
54 disabled?: boolean;
55 alwaysVisible?: boolean;
56 }) {
57 const visible = alwaysVisible || expanded;
58 const detailRef = useRef<HTMLDivElement | null>(null);
59
60 useEffect(() => {
61 // The decision footer stays fixed while the card content scrolls. When a
62 // user explicitly opens details near that boundary, bring the separate
63 // region into the nearest visible area instead of leaving it behind the
64 // footer. Auto-open safety consequences do not steal the initial scroll.
65 if (!expanded || alwaysVisible) return;
66 detailRef.current?.scrollIntoView?.({ block: "nearest" });
67 }, [alwaysVisible, expanded]);
68
69 return (
70 <div className="prompt-description-disclosure">
71 {!alwaysVisible && onToggle && (
72 <PromptDescriptionToggle
73 descriptionId={descriptionId}
74 expanded={expanded}
75 onToggle={onToggle}
76 disabled={disabled}
77 />
78 )}
79 <div
80 ref={detailRef}
81 id={descriptionId}
82 className="prompt-description-detail"
83 role="region"
84 hidden={!visible}
85 >
86 {label != null && label !== "" && (
87 <strong className="prompt-description-detail__label">{label}</strong>
88 )}
89 {description != null && description !== "" && (
90 <span className="prompt-description-detail__text">{description}</span>
91 )}
92 </div>
93 </div>
94 );
95 }
96
97 export function PromptAction({
98 actionId,
99 keyLabel,
100 label,
101 description,
102 descriptionId,
103 descriptionDisclosure = false,
104 descriptionAlwaysVisible = false,
105 onDescriptionOverflowChange,
106 onClick,
107 ariaLabel,
108 title,
109 onHoverChange,
110 primary = false,
111 selected = false,
112 // Keyboard cursor without implying a committed answer (multi-select).
113 active = false,
114 quiet = false,
115 disabled = false,
116 tone = "default",
117 role = "option",
118 }: {
119 actionId?: string;
120 keyLabel: string;
121 label?: ReactNode;
122 description?: ReactNode;
123 descriptionId?: string;
124 // Keep supplementary copy to one stable summary line and reveal the complete
125 // text in a separate detail region. Option/listbox surfaces render that
126 // region outside the listbox; immediate button groups own it here.
127 descriptionDisclosure?: boolean;
128 // Safety-critical consequences open automatically when the stable summary
129 // row is actually truncated. Complete summaries are not repeated.
130 descriptionAlwaysVisible?: boolean;
131 onDescriptionOverflowChange?: (overflowing: boolean) => void;
132 onClick: () => void;
133 ariaLabel?: string;
134 // Native tooltip is a desktop convenience; the disclosure remains the
135 // keyboard/touch accessible path to the complete description.
136 title?: string;
137 // Fires on mouse enter/focus (true) and mouse leave/blur (false) so the
138 // parent can drive a focus-following detail preview.
139 onHoverChange?: (hovering: boolean) => void;
140 primary?: boolean;
141 selected?: boolean;
142 active?: boolean;
143 quiet?: boolean;
144 disabled?: boolean;
145 // Danger options (deny / clear) use semantic color but are never default-selected.
146 tone?: "default" | "danger";
147 role?: "option" | "button";
148 }) {
149 const generatedDescriptionId = useId();
150 const actionRef = useRef<HTMLButtonElement | null>(null);
151 const labelRef = useRef<HTMLSpanElement | null>(null);
152 const descriptionRef = useRef<HTMLSpanElement | null>(null);
153 const overflowCallbackRef = useRef(onDescriptionOverflowChange);
154 const [internalExpanded, setInternalExpanded] = useState(false);
155 const [copyOverflowing, setCopyOverflowing] = useState(false);
156 const [labelOverflowing, setLabelOverflowing] = useState(false);
157 overflowCallbackRef.current = onDescriptionOverflowChange;
158
159 const hasLabel = label != null && label !== "";
160 const hasDescription = description != null && description !== "";
161 const hasCopy = hasDescription || hasLabel;
162 const resolvedDescriptionId = hasCopy
163 ? (descriptionId ?? `${generatedDescriptionId}-description`)
164 : undefined;
165 const detailDescriptionId = resolvedDescriptionId ? `${resolvedDescriptionId}-detail` : undefined;
166 const expanded = internalExpanded;
167 const labelText = typeof label === "string" ? label : undefined;
168 const descriptionText = typeof description === "string" ? description : undefined;
169 const fullCopyTitle = [labelText, descriptionText].filter(Boolean).join(" — ");
170 const resolvedTitle = title ?? (descriptionDisclosure
171 ? (labelOverflowing ? fullCopyTitle : descriptionText)
172 : undefined);
173
174 useEffect(() => {
175 setInternalExpanded(false);
176 }, [description, label]);
177
178 useLayoutEffect(() => {
179 const elements = [labelRef.current, descriptionRef.current].filter(
180 (element): element is HTMLSpanElement => element !== null,
181 );
182 if (!descriptionDisclosure || elements.length === 0) {
183 setCopyOverflowing(false);
184 setLabelOverflowing(false);
185 overflowCallbackRef.current?.(false);
186 return;
187 }
188 // Once expanded, retain the known overflow state so the Collapse action
189 // remains available even though the unclamped element no longer overflows.
190 if (expanded) return;
191
192 const measure = () => {
193 const overflows = (element: HTMLSpanElement | null) => element !== null && (
194 element.scrollHeight > element.clientHeight + 1
195 || element.scrollWidth > element.clientWidth + 1
196 );
197 const nextLabelOverflowing = overflows(labelRef.current);
198 const overflowing = nextLabelOverflowing || overflows(descriptionRef.current);
199 setLabelOverflowing((current) => current === nextLabelOverflowing ? current : nextLabelOverflowing);
200 setCopyOverflowing((current) => current === overflowing ? current : overflowing);
201 overflowCallbackRef.current?.(overflowing);
202 };
203 measure();
204 // The shelf can finish constraining its grid after this layout effect.
205 // Recheck over the next two frames so an initially content-sized summary
206 // cannot hide the disclosure once the final row width is known.
207 let cancelled = false;
208 let settledFrame = 0;
209 const layoutFrame = window.requestAnimationFrame(() => {
210 measure();
211 settledFrame = window.requestAnimationFrame(measure);
212 });
213 void document.fonts?.ready.then(() => {
214 if (!cancelled) measure();
215 });
216 const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
217 elements.forEach((element) => observer?.observe(element));
218 window.addEventListener("resize", measure);
219 return () => {
220 cancelled = true;
221 window.cancelAnimationFrame(layoutFrame);
222 if (settledFrame) window.cancelAnimationFrame(settledFrame);
223 observer?.disconnect();
224 window.removeEventListener("resize", measure);
225 };
226 }, [descriptionDisclosure, expanded, resolvedDescriptionId, description, label, Boolean(onDescriptionOverflowChange)]);
227
228 useEffect(() => {
229 if (!selected && !active && !expanded) return;
230 actionRef.current?.scrollIntoView?.({ block: "nearest" });
231 }, [active, expanded, selected]);
232
233 const action = (
234 <button
235 ref={actionRef}
236 id={actionId}
237 type="button"
238 role={role}
239 aria-selected={role === "option" ? selected : undefined}
240 data-active={active ? "true" : undefined}
241 className={[
242 "prompt-action",
243 primary || selected ? " prompt-action--selected" : "",
244 active ? " prompt-action--active" : "",
245 quiet ? " prompt-action--quiet" : "",
246 hasDescription ? " prompt-action--descriptive" : "",
247 descriptionDisclosure ? " prompt-action--description-collapsible" : "",
248 !hasCopy ? " prompt-action--key-only" : "",
249 tone === "danger" ? " prompt-action--danger" : "",
250 ].join("")}
251 onClick={onClick}
252 disabled={disabled}
253 aria-label={ariaLabel}
254 title={resolvedTitle}
255 onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
256 onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
257 onFocus={onHoverChange ? () => onHoverChange(true) : undefined}
258 onBlur={onHoverChange ? () => onHoverChange(false) : undefined}
259 >
260 {keyLabel && <span className="prompt-action__key">{keyLabel}</span>}
261 {hasCopy && (
262 <span className="prompt-action__copy">
263 {hasLabel && (
264 <span
265 ref={labelRef}
266 id={!hasDescription ? resolvedDescriptionId : undefined}
267 className="prompt-action__label"
268 >
269 {label}
270 </span>
271 )}
272 {hasDescription && (
273 <span ref={descriptionRef} id={resolvedDescriptionId} className="prompt-action__desc">
274 {description}
275 </span>
276 )}
277 </span>
278 )}
279 </button>
280 );
281
282 // A listbox may only own options, so its explicit disclosure is rendered by
283 // the parent in PromptShelf.note. Button groups can safely keep the control
284 // next to the corresponding immediate action.
285 if (role !== "button" || (!descriptionDisclosure && !descriptionAlwaysVisible)) return action;
286
287 return (
288 <div className={[
289 "prompt-action-row",
290 expanded || (descriptionAlwaysVisible && copyOverflowing) ? " prompt-action-row--description-expanded" : "",
291 ].join("")}>
292 {action}
293 {detailDescriptionId && copyOverflowing && (
294 <PromptDescriptionDisclosure
295 descriptionId={detailDescriptionId}
296 label={label}
297 description={description}
298 expanded={expanded}
299 onToggle={() => setInternalExpanded((current) => !current)}
300 disabled={disabled}
301 alwaysVisible={descriptionAlwaysVisible}
302 />
303 )}
304 </div>
305 );
306 }
307
307 lines Plain Text