@hilum/ui 3.4.0 → 3.5.0
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/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +789 -115
- package/dist/index.js +10404 -1099
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- package/dist/chunk-MAHPAP65.js.map +0 -1
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type HTMLAttributes,
|
|
12
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
15
|
+
import { cn } from "../lib/utils";
|
|
16
|
+
import { spring } from "../lib/springs";
|
|
17
|
+
import { fontWeights } from "../lib/font-weight";
|
|
18
|
+
import { useShape } from "../lib/shape-context";
|
|
19
|
+
import { useIcon } from "../lib/icon-context";
|
|
20
|
+
import { useProximityHover } from "../hooks/use-proximity-hover";
|
|
21
|
+
import { useMergeSplitBlocks, SelectionBackgrounds } from "../hooks/use-merge-split";
|
|
22
|
+
import { Button } from "./button";
|
|
23
|
+
|
|
24
|
+
export interface AskUserOption {
|
|
25
|
+
id?: string;
|
|
26
|
+
title: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AskUserQuestion {
|
|
31
|
+
id?: string;
|
|
32
|
+
title: string;
|
|
33
|
+
options: AskUserOption[];
|
|
34
|
+
multiSelect?: boolean;
|
|
35
|
+
allowOther?: boolean;
|
|
36
|
+
otherPlaceholder?: string;
|
|
37
|
+
skippable?: boolean;
|
|
38
|
+
nextLabel?: string;
|
|
39
|
+
/** Visual layout for each option row.
|
|
40
|
+
* - "inline" (default): title and description on one line.
|
|
41
|
+
* - "stacked": title above, description below — useful when descriptions
|
|
42
|
+
* are long enough to wrap. */
|
|
43
|
+
layout?: "inline" | "stacked";
|
|
44
|
+
/** Which side of the row the numbered chip sits on.
|
|
45
|
+
* - "right" (default): chip on the right; the single-select submit
|
|
46
|
+
* arrow overlays it on hover/focus.
|
|
47
|
+
* - "left": chip on the left, before the body. The submit arrow
|
|
48
|
+
* still appears on the right edge of the row, so the action
|
|
49
|
+
* affordance stays where the eye expects it.
|
|
50
|
+
* Works with every other option (single/multi-select, allowOther,
|
|
51
|
+
* inline/stacked layout). */
|
|
52
|
+
chipPosition?: "left" | "right";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AskUserAnswer {
|
|
56
|
+
questionId: string;
|
|
57
|
+
selectedIds: string[];
|
|
58
|
+
otherText?: string;
|
|
59
|
+
skipped?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AskUserQuestionsProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
63
|
+
questions: AskUserQuestion[];
|
|
64
|
+
currentIndex?: number;
|
|
65
|
+
defaultCurrentIndex?: number;
|
|
66
|
+
onCurrentIndexChange?: (index: number) => void;
|
|
67
|
+
answers?: Record<string, AskUserAnswer>;
|
|
68
|
+
defaultAnswers?: Record<string, AskUserAnswer>;
|
|
69
|
+
onAnswersChange?: (answers: Record<string, AskUserAnswer>) => void;
|
|
70
|
+
onComplete?: (answers: Record<string, AskUserAnswer>) => void;
|
|
71
|
+
onSkip?: (questionId: string, currentIndex: number) => void;
|
|
72
|
+
skipLabel?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function questionKey(q: AskUserQuestion, i: number) {
|
|
76
|
+
return q.id ?? `q-${i}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function optionKey(o: AskUserOption, i: number) {
|
|
80
|
+
return o.id ?? `o-${i}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const AskUserQuestions = forwardRef<HTMLDivElement, AskUserQuestionsProps>(
|
|
84
|
+
function AskUserQuestions(
|
|
85
|
+
{
|
|
86
|
+
questions,
|
|
87
|
+
currentIndex: controlledIndex,
|
|
88
|
+
defaultCurrentIndex = 0,
|
|
89
|
+
onCurrentIndexChange,
|
|
90
|
+
answers: controlledAnswers,
|
|
91
|
+
defaultAnswers,
|
|
92
|
+
onAnswersChange,
|
|
93
|
+
onComplete,
|
|
94
|
+
onSkip,
|
|
95
|
+
skipLabel = "Skip",
|
|
96
|
+
className,
|
|
97
|
+
...rest
|
|
98
|
+
},
|
|
99
|
+
ref,
|
|
100
|
+
) {
|
|
101
|
+
// ── Controlled / uncontrolled state ──────────────────────────
|
|
102
|
+
const [internalIndex, setInternalIndex] = useState(defaultCurrentIndex);
|
|
103
|
+
const isIndexControlled = controlledIndex !== undefined;
|
|
104
|
+
const index = isIndexControlled ? (controlledIndex as number) : internalIndex;
|
|
105
|
+
const setIndex = useCallback(
|
|
106
|
+
(next: number) => {
|
|
107
|
+
if (!isIndexControlled) setInternalIndex(next);
|
|
108
|
+
onCurrentIndexChange?.(next);
|
|
109
|
+
},
|
|
110
|
+
[isIndexControlled, onCurrentIndexChange],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const [internalAnswers, setInternalAnswers] = useState<Record<string, AskUserAnswer>>(
|
|
114
|
+
defaultAnswers ?? {},
|
|
115
|
+
);
|
|
116
|
+
const isAnswersControlled = controlledAnswers !== undefined;
|
|
117
|
+
const answers = isAnswersControlled
|
|
118
|
+
? (controlledAnswers as Record<string, AskUserAnswer>)
|
|
119
|
+
: internalAnswers;
|
|
120
|
+
|
|
121
|
+
const answersRef = useRef(answers);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
answersRef.current = answers;
|
|
124
|
+
}, [answers]);
|
|
125
|
+
|
|
126
|
+
const writeAnswers = useCallback(
|
|
127
|
+
(updater: (prev: Record<string, AskUserAnswer>) => Record<string, AskUserAnswer>) => {
|
|
128
|
+
const next = updater(answersRef.current);
|
|
129
|
+
answersRef.current = next;
|
|
130
|
+
if (!isAnswersControlled) setInternalAnswers(next);
|
|
131
|
+
onAnswersChange?.(next);
|
|
132
|
+
return next;
|
|
133
|
+
},
|
|
134
|
+
[isAnswersControlled, onAnswersChange],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const shape = useShape();
|
|
138
|
+
const ArrowLeft = useIcon("arrow-left");
|
|
139
|
+
const ArrowRight = useIcon("arrow-right");
|
|
140
|
+
|
|
141
|
+
// The footer ← / → icons hint at the ArrowLeft/ArrowRight keys, which
|
|
142
|
+
// mobile has no equivalent for, so render them desktop-only. (The inline
|
|
143
|
+
// submit arrows on option rows stay — those are tap affordances, not
|
|
144
|
+
// keyboard hints.)
|
|
145
|
+
const ArrowLeftKey = useMemo(
|
|
146
|
+
() =>
|
|
147
|
+
function ArrowLeftKey(p: { size?: number; strokeWidth?: number; className?: string }) {
|
|
148
|
+
return <ArrowLeft {...p} className={cn(p.className, "hidden sm:block")} />;
|
|
149
|
+
},
|
|
150
|
+
[ArrowLeft],
|
|
151
|
+
);
|
|
152
|
+
const ArrowRightKey = useMemo(
|
|
153
|
+
() =>
|
|
154
|
+
function ArrowRightKey(p: { size?: number; strokeWidth?: number; className?: string }) {
|
|
155
|
+
return <ArrowRight {...p} className={cn(p.className, "hidden sm:block")} />;
|
|
156
|
+
},
|
|
157
|
+
[ArrowRight],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Detect the platform so the Continue shortcut hint shows the right
|
|
161
|
+
// modifier: ⌘ on macOS, ⌃ (Control) elsewhere. Resolved after mount to
|
|
162
|
+
// avoid a hydration mismatch (the server can't know the platform).
|
|
163
|
+
const [isMac, setIsMac] = useState(false);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const nav = navigator as Navigator & {
|
|
166
|
+
userAgentData?: { platform?: string };
|
|
167
|
+
};
|
|
168
|
+
const platform = nav.userAgentData?.platform || nav.platform || "";
|
|
169
|
+
setIsMac(/mac/i.test(platform));
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
const reactId = useId();
|
|
173
|
+
const total = questions.length;
|
|
174
|
+
const safeIndex = Math.max(0, Math.min(index, Math.max(0, total - 1)));
|
|
175
|
+
const question = questions[safeIndex];
|
|
176
|
+
const qId = question ? questionKey(question, safeIndex) : "";
|
|
177
|
+
const currentAnswer = answers[qId];
|
|
178
|
+
|
|
179
|
+
const isMulti = !!question?.multiSelect;
|
|
180
|
+
const isSkippable = question?.skippable !== false;
|
|
181
|
+
const allowOther = !!question?.allowOther;
|
|
182
|
+
const selectedIds = useMemo(() => currentAnswer?.selectedIds ?? [], [currentAnswer]);
|
|
183
|
+
const otherText = currentAnswer?.otherText ?? "";
|
|
184
|
+
|
|
185
|
+
const options = question?.options ?? [];
|
|
186
|
+
const otherIndex = allowOther ? options.length : -1;
|
|
187
|
+
const rowCount = options.length + (allowOther ? 1 : 0);
|
|
188
|
+
|
|
189
|
+
// ── Refs & proximity hover ───────────────────────────────────
|
|
190
|
+
const rowsContainerRef = useRef<HTMLDivElement>(null);
|
|
191
|
+
// The Other field is a multi-line textarea — it auto-resizes to fit
|
|
192
|
+
// wrapped content and lets users press Enter for a newline.
|
|
193
|
+
const otherInputRef = useRef<HTMLTextAreaElement>(null);
|
|
194
|
+
// Stable IDs for contiguous-selection runs (see selectedGroups below).
|
|
195
|
+
const groupIdCounterRef = useRef(0);
|
|
196
|
+
const prevGroupMapRef = useRef(new Map<number, number>());
|
|
197
|
+
const {
|
|
198
|
+
activeIndex,
|
|
199
|
+
setActiveIndex,
|
|
200
|
+
itemRects,
|
|
201
|
+
sessionRef,
|
|
202
|
+
handlers,
|
|
203
|
+
registerItem,
|
|
204
|
+
measureItems,
|
|
205
|
+
} = useProximityHover(rowsContainerRef);
|
|
206
|
+
|
|
207
|
+
// Remeasure on row count change, question change, shape change
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
measureItems();
|
|
210
|
+
}, [measureItems, qId, rowCount, shape]);
|
|
211
|
+
|
|
212
|
+
// ── Other-row textarea auto-resize ──────────────────────────
|
|
213
|
+
// The Other field is a textarea so users can write a multi-line answer.
|
|
214
|
+
// Browsers don't auto-fit textarea height to content, so we set it
|
|
215
|
+
// manually: reset to 0 (so the field can shrink when lines are deleted),
|
|
216
|
+
// then expand to scrollHeight. Remeasure the proximity rows after — the
|
|
217
|
+
// hover, selected and focus indicators absolutely-position against
|
|
218
|
+
// itemRects, so they need fresh rects when the row's height changes.
|
|
219
|
+
//
|
|
220
|
+
// We also track whether the textarea is currently displaying more than
|
|
221
|
+
// one line (either via explicit \n or text that wraps). Only then do
|
|
222
|
+
// we switch the Other row to `topAlign`; in the 1-line state the row
|
|
223
|
+
// stays `items-center` so a single line sits at the row's optical
|
|
224
|
+
// centre, matching the surrounding option rows.
|
|
225
|
+
const [isOtherMultiline, setIsOtherMultiline] = useState(false);
|
|
226
|
+
// Reset the multi-line flag when the question changes so the new
|
|
227
|
+
// question's first paint of an empty Other row doesn't inherit a
|
|
228
|
+
// stale `true` from the previous question's multi-line draft (which
|
|
229
|
+
// would briefly apply `items-start` + the -5px chip nudge on an
|
|
230
|
+
// empty single-line row before the resize effect below corrects it).
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
setIsOtherMultiline(false);
|
|
233
|
+
}, [qId]);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const el = otherInputRef.current;
|
|
236
|
+
if (!el) return;
|
|
237
|
+
el.style.height = "0px";
|
|
238
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
239
|
+
// Threshold against the textarea's *measured* line-height, not a
|
|
240
|
+
// hard-coded 22px — so the flag stays correct at high browser
|
|
241
|
+
// font-size / zoom settings where line-height grows past 22 even
|
|
242
|
+
// for a single line. 1.5× line-height is a generous fudge below
|
|
243
|
+
// a true second wrapped line (2× line-height) but well above any
|
|
244
|
+
// single-line rounding artefact.
|
|
245
|
+
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight) || 18;
|
|
246
|
+
setIsOtherMultiline(el.scrollHeight > lineHeight * 1.5);
|
|
247
|
+
measureItems();
|
|
248
|
+
}, [otherText, measureItems, qId]);
|
|
249
|
+
|
|
250
|
+
// ── Animated height ──────────────────────────────────────────
|
|
251
|
+
// Track the natural height of the Q/A content and animate the wrapper's
|
|
252
|
+
// REAL height to it. Animating the actual height (not a layout transform)
|
|
253
|
+
// means the card border and the footer below reflow frame-by-frame, so the
|
|
254
|
+
// height morph and the footer move together. A ResizeObserver keeps the
|
|
255
|
+
// target in sync across question swaps, shape changes, and text wrapping.
|
|
256
|
+
const contentMeasureRef = useRef<HTMLDivElement>(null);
|
|
257
|
+
const [contentHeight, setContentHeight] = useState<number | "auto">("auto");
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
const el = contentMeasureRef.current;
|
|
260
|
+
if (!el) return;
|
|
261
|
+
const update = () => setContentHeight(el.offsetHeight);
|
|
262
|
+
update();
|
|
263
|
+
const ro = new ResizeObserver(update);
|
|
264
|
+
ro.observe(el);
|
|
265
|
+
return () => ro.disconnect();
|
|
266
|
+
}, []);
|
|
267
|
+
|
|
268
|
+
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
|
269
|
+
|
|
270
|
+
// Reset transient state when question changes
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
setActiveIndex(null);
|
|
273
|
+
setFocusedIndex(null);
|
|
274
|
+
}, [safeIndex, setActiveIndex]);
|
|
275
|
+
|
|
276
|
+
// ── Keyboard focus restoration across question changes ───────
|
|
277
|
+
// The question content remounts on qId, which destroys the focused row and
|
|
278
|
+
// drops focus to <body>. If we navigated *from within* the rows (i.e. the
|
|
279
|
+
// user was driving with the keyboard), refocus the new question's first row
|
|
280
|
+
// so focus-within is kept and arrows keep routing here instead of falling
|
|
281
|
+
// through to page-level navigation.
|
|
282
|
+
const restoreFocusRef = useRef(false);
|
|
283
|
+
const markFocusRestore = useCallback(() => {
|
|
284
|
+
if (rowsContainerRef.current?.contains(document.activeElement)) {
|
|
285
|
+
restoreFocusRef.current = true;
|
|
286
|
+
}
|
|
287
|
+
}, []);
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (!restoreFocusRef.current) return;
|
|
290
|
+
restoreFocusRef.current = false;
|
|
291
|
+
const firstRow = rowsContainerRef.current?.querySelector(
|
|
292
|
+
'[data-proximity-index="0"]',
|
|
293
|
+
) as HTMLElement | null;
|
|
294
|
+
firstRow?.focus();
|
|
295
|
+
}, [safeIndex]);
|
|
296
|
+
|
|
297
|
+
// ── Answer actions ───────────────────────────────────────────
|
|
298
|
+
const goNext = useCallback(
|
|
299
|
+
(snapshot: Record<string, AskUserAnswer>) => {
|
|
300
|
+
if (safeIndex >= total - 1) {
|
|
301
|
+
onComplete?.(snapshot);
|
|
302
|
+
} else {
|
|
303
|
+
markFocusRestore();
|
|
304
|
+
setIndex(safeIndex + 1);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
[safeIndex, total, onComplete, setIndex, markFocusRestore],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const handleSingleSelect = useCallback(
|
|
311
|
+
(optId: string) => {
|
|
312
|
+
if (!question) return;
|
|
313
|
+
const text = answers[qId]?.otherText;
|
|
314
|
+
const snapshot = writeAnswers((prev) => ({
|
|
315
|
+
...prev,
|
|
316
|
+
[qId]: {
|
|
317
|
+
questionId: qId,
|
|
318
|
+
selectedIds: [optId],
|
|
319
|
+
skipped: false,
|
|
320
|
+
...(text ? { otherText: text } : {}),
|
|
321
|
+
},
|
|
322
|
+
}));
|
|
323
|
+
goNext(snapshot);
|
|
324
|
+
},
|
|
325
|
+
[question, qId, answers, writeAnswers, goNext],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const handleMultiToggle = useCallback(
|
|
329
|
+
(optId: string) => {
|
|
330
|
+
if (!question) return;
|
|
331
|
+
writeAnswers((prev) => {
|
|
332
|
+
const existing = prev[qId];
|
|
333
|
+
const set = new Set(existing?.selectedIds ?? []);
|
|
334
|
+
if (set.has(optId)) set.delete(optId);
|
|
335
|
+
else set.add(optId);
|
|
336
|
+
return {
|
|
337
|
+
...prev,
|
|
338
|
+
[qId]: {
|
|
339
|
+
questionId: qId,
|
|
340
|
+
selectedIds: Array.from(set),
|
|
341
|
+
skipped: false,
|
|
342
|
+
...(existing?.otherText ? { otherText: existing.otherText } : {}),
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
},
|
|
347
|
+
[question, qId, writeAnswers],
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const handleOtherChange = useCallback(
|
|
351
|
+
(text: string) => {
|
|
352
|
+
if (!question) return;
|
|
353
|
+
writeAnswers((prev) => ({
|
|
354
|
+
...prev,
|
|
355
|
+
[qId]: {
|
|
356
|
+
questionId: qId,
|
|
357
|
+
selectedIds: prev[qId]?.selectedIds ?? [],
|
|
358
|
+
otherText: text,
|
|
359
|
+
skipped: false,
|
|
360
|
+
},
|
|
361
|
+
}));
|
|
362
|
+
},
|
|
363
|
+
[question, qId, writeAnswers],
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const handleOtherSubmit = useCallback(() => {
|
|
367
|
+
if (!question) return;
|
|
368
|
+
const text = (answers[qId]?.otherText ?? "").trim();
|
|
369
|
+
if (!text) return;
|
|
370
|
+
const snapshot = writeAnswers((prev) => ({
|
|
371
|
+
...prev,
|
|
372
|
+
[qId]: {
|
|
373
|
+
questionId: qId,
|
|
374
|
+
selectedIds: prev[qId]?.selectedIds ?? [],
|
|
375
|
+
otherText: text,
|
|
376
|
+
skipped: false,
|
|
377
|
+
},
|
|
378
|
+
}));
|
|
379
|
+
goNext(snapshot);
|
|
380
|
+
}, [question, qId, answers, writeAnswers, goNext]);
|
|
381
|
+
|
|
382
|
+
const handleSkip = useCallback(() => {
|
|
383
|
+
if (!question) return;
|
|
384
|
+
const snapshot = writeAnswers((prev) => ({
|
|
385
|
+
...prev,
|
|
386
|
+
[qId]: {
|
|
387
|
+
questionId: qId,
|
|
388
|
+
selectedIds: prev[qId]?.selectedIds ?? [],
|
|
389
|
+
skipped: true,
|
|
390
|
+
...(prev[qId]?.otherText ? { otherText: prev[qId].otherText } : {}),
|
|
391
|
+
},
|
|
392
|
+
}));
|
|
393
|
+
onSkip?.(qId, safeIndex);
|
|
394
|
+
goNext(snapshot);
|
|
395
|
+
}, [question, qId, writeAnswers, onSkip, safeIndex, goNext]);
|
|
396
|
+
|
|
397
|
+
const handleMultiNext = useCallback(() => {
|
|
398
|
+
goNext(answers);
|
|
399
|
+
}, [goNext, answers]);
|
|
400
|
+
|
|
401
|
+
const handleBack = useCallback(() => {
|
|
402
|
+
if (safeIndex > 0) {
|
|
403
|
+
markFocusRestore();
|
|
404
|
+
setIndex(safeIndex - 1);
|
|
405
|
+
}
|
|
406
|
+
}, [safeIndex, setIndex, markFocusRestore]);
|
|
407
|
+
|
|
408
|
+
// ── Keyboard shortcuts: 1-9 ──────────────────────────────────
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
if (!question) return;
|
|
411
|
+
const handler = (e: KeyboardEvent) => {
|
|
412
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
413
|
+
const target = e.target as HTMLElement | null;
|
|
414
|
+
if (!target) return;
|
|
415
|
+
const tag = target.tagName;
|
|
416
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable) return;
|
|
417
|
+
const code = e.key;
|
|
418
|
+
if (code < "1" || code > "9") return;
|
|
419
|
+
const idx = parseInt(code, 10) - 1;
|
|
420
|
+
if (idx >= 0 && idx < options.length) {
|
|
421
|
+
e.preventDefault();
|
|
422
|
+
const oid = optionKey(options[idx], idx);
|
|
423
|
+
if (isMulti) handleMultiToggle(oid);
|
|
424
|
+
else handleSingleSelect(oid);
|
|
425
|
+
} else if (idx === options.length && allowOther) {
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
otherInputRef.current?.focus();
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
document.addEventListener("keydown", handler);
|
|
431
|
+
return () => document.removeEventListener("keydown", handler);
|
|
432
|
+
}, [question, options, isMulti, allowOther, handleSingleSelect, handleMultiToggle]);
|
|
433
|
+
|
|
434
|
+
// ── Keyboard navigation ──────────────────────────────────────
|
|
435
|
+
// Up/Down move the highlight between rows using the SAME indicator as
|
|
436
|
+
// mouse hover (activeIndex → bg-hover), so keyboard and pointer focus look
|
|
437
|
+
// identical. Left = Back, Right = Skip. We stopPropagation on the arrows we
|
|
438
|
+
// handle so the doc page's ←/→ page-change nav (a window listener) doesn't
|
|
439
|
+
// also fire — important for multi-select, whose container is role="group"
|
|
440
|
+
// (not "radiogroup") and so isn't auto-skipped by that handler.
|
|
441
|
+
const focusRow = (idx: number) => {
|
|
442
|
+
const el = rowsContainerRef.current?.querySelector(
|
|
443
|
+
`[data-proximity-index="${idx}"]`,
|
|
444
|
+
) as HTMLElement | null;
|
|
445
|
+
el?.focus();
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const moveActive = useCallback(
|
|
449
|
+
(next: number) => {
|
|
450
|
+
setActiveIndex(next);
|
|
451
|
+
// The Other row is a text field — focus the input directly so typing
|
|
452
|
+
// works; everything else focuses the row for Enter/Space selection.
|
|
453
|
+
if (allowOther && next === otherIndex) otherInputRef.current?.focus();
|
|
454
|
+
else focusRow(next);
|
|
455
|
+
},
|
|
456
|
+
[allowOther, otherIndex, setActiveIndex],
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const handleNavKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
460
|
+
const target = e.target as HTMLElement;
|
|
461
|
+
const isTextInput =
|
|
462
|
+
target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable;
|
|
463
|
+
|
|
464
|
+
// Inside the Other text field, ←/→ and Home/End move the caret natively.
|
|
465
|
+
// ↑/↓ are dual-purpose in the textarea: when the caret has more lines
|
|
466
|
+
// to move to in that direction (there's a \n before/after it), let the
|
|
467
|
+
// browser handle native caret movement; only steal the keystroke to
|
|
468
|
+
// navigate to an adjacent option row when the caret is already at the
|
|
469
|
+
// first / last line — otherwise the user can't edit a multi-line draft
|
|
470
|
+
// without focus jumping out of the field.
|
|
471
|
+
if (isTextInput && e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
|
472
|
+
if (
|
|
473
|
+
isTextInput &&
|
|
474
|
+
(e.key === "ArrowUp" || e.key === "ArrowDown") &&
|
|
475
|
+
target.tagName === "TEXTAREA"
|
|
476
|
+
) {
|
|
477
|
+
// Position-bounds check — works for BOTH explicit `\n` AND visual
|
|
478
|
+
// line wraps. Only steal the key when the caret has nowhere left
|
|
479
|
+
// to go inside the textarea: ArrowUp at the very start, or
|
|
480
|
+
// ArrowDown at the very end. Anywhere else, let the textarea
|
|
481
|
+
// handle native caret movement (line-by-line up/down, including
|
|
482
|
+
// through wrapped lines without `\n`).
|
|
483
|
+
const ta = target as HTMLTextAreaElement;
|
|
484
|
+
if (e.key === "ArrowUp" && ta.selectionStart > 0) return;
|
|
485
|
+
if (e.key === "ArrowDown" && ta.selectionEnd < ta.value.length) return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
e.stopPropagation();
|
|
491
|
+
if (e.key === "ArrowLeft") {
|
|
492
|
+
if (safeIndex > 0) handleBack();
|
|
493
|
+
} else if (isSkippable && total > 1) {
|
|
494
|
+
handleSkip();
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (rowCount === 0) return;
|
|
500
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") {
|
|
501
|
+
e.preventDefault();
|
|
502
|
+
e.stopPropagation();
|
|
503
|
+
let next: number;
|
|
504
|
+
if (e.key === "Home") next = 0;
|
|
505
|
+
else if (e.key === "End") next = rowCount - 1;
|
|
506
|
+
else {
|
|
507
|
+
// When focus is in the Other field, treat it as the Other row.
|
|
508
|
+
const base = isTextInput ? otherIndex : (activeIndex ?? -1);
|
|
509
|
+
next = e.key === "ArrowDown" ? base + 1 : base - 1;
|
|
510
|
+
next = (next + rowCount) % rowCount;
|
|
511
|
+
}
|
|
512
|
+
moveActive(next);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// Cmd+Enter (macOS) / Ctrl+Enter (Windows/Linux) commits a multi-select
|
|
517
|
+
// question, mirroring the Continue button. Handled at the root so it works
|
|
518
|
+
// wherever focus sits inside the card, and scoped to this instance because
|
|
519
|
+
// the event has to bubble up from a focused descendant (no global listener,
|
|
520
|
+
// so stacked demos don't all fire at once).
|
|
521
|
+
const handleRootKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
522
|
+
if (e.key !== "Enter") return;
|
|
523
|
+
const mod = isMac ? e.metaKey : e.ctrlKey;
|
|
524
|
+
if (!mod || !isMulti) return;
|
|
525
|
+
e.preventDefault(); // keep a focused button/row from also activating
|
|
526
|
+
const hasAnswer = selectedIds.length > 0 || otherText.trim().length > 0;
|
|
527
|
+
if (hasAnswer) handleMultiNext();
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
if (!question) {
|
|
531
|
+
return (
|
|
532
|
+
<div
|
|
533
|
+
ref={ref}
|
|
534
|
+
className={cn(
|
|
535
|
+
"w-full max-w-[520px] p-5 bg-card border border-border",
|
|
536
|
+
shape.container,
|
|
537
|
+
className,
|
|
538
|
+
)}
|
|
539
|
+
{...rest}
|
|
540
|
+
>
|
|
541
|
+
<p className="text-[13px] text-muted-foreground">No questions.</p>
|
|
542
|
+
</div>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── Layout calculations for hover/focus indicators ───────────
|
|
547
|
+
const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;
|
|
548
|
+
// The blue morphing focus ring is intentionally suppressed for the Other
|
|
549
|
+
// field: that row has its own input-field treatment (the "type here" hint
|
|
550
|
+
// when empty, the merged selected bg once it has text), so the ring is
|
|
551
|
+
// redundant there and reads as noise while typing. focusedIndex is still
|
|
552
|
+
// tracked for the hint and submit-arrow visibility — we just don't draw a
|
|
553
|
+
// ring around it.
|
|
554
|
+
const focusRect =
|
|
555
|
+
focusedIndex !== null && !(allowOther && focusedIndex === otherIndex)
|
|
556
|
+
? itemRects[focusedIndex]
|
|
557
|
+
: null;
|
|
558
|
+
|
|
559
|
+
// ── Selected-row grouping (merges contiguous selections) ─────
|
|
560
|
+
// Mirrors the CheckboxGroup pattern: contiguous selected indices
|
|
561
|
+
// collapse into a single rounded background block; stable IDs let
|
|
562
|
+
// framer-motion morph block size/position when neighbours toggle.
|
|
563
|
+
// The Other row gets its own input-field-style indicator (see below) and
|
|
564
|
+
// is intentionally excluded here so it doesn't merge into a contiguous
|
|
565
|
+
// bg-accent block with adjacent selected options.
|
|
566
|
+
// Include the Other row in selectedIndices when it has text. This lets
|
|
567
|
+
// it merge into the same morphing bg block as adjacent selected options
|
|
568
|
+
// (instead of looking like a disconnected input field next to them).
|
|
569
|
+
const selectedIndices = useMemo(() => {
|
|
570
|
+
const set = new Set<number>();
|
|
571
|
+
options.forEach((opt, i) => {
|
|
572
|
+
if (selectedIds.includes(optionKey(opt, i))) set.add(i);
|
|
573
|
+
});
|
|
574
|
+
if (allowOther && otherText.length > 0) set.add(otherIndex);
|
|
575
|
+
return set;
|
|
576
|
+
}, [options, selectedIds, allowOther, otherText, otherIndex]);
|
|
577
|
+
|
|
578
|
+
const selectedGroups = useMemo(() => {
|
|
579
|
+
const runs: { start: number; end: number }[] = [];
|
|
580
|
+
const sorted = [...selectedIndices].sort((a, b) => a - b);
|
|
581
|
+
for (const idx of sorted) {
|
|
582
|
+
const last = runs[runs.length - 1];
|
|
583
|
+
if (last && idx === last.end + 1) last.end = idx;
|
|
584
|
+
else runs.push({ start: idx, end: idx });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Stable run IDs so a growing/shrinking run animates instead of
|
|
588
|
+
// exit+re-enter when neighbours flip.
|
|
589
|
+
const usedIds = new Set<number>();
|
|
590
|
+
const nextGroupMap = new Map<number, number>();
|
|
591
|
+
const groups = runs.map((run) => {
|
|
592
|
+
let stableId: number | null = null;
|
|
593
|
+
for (let i = run.start; i <= run.end; i++) {
|
|
594
|
+
const prev = prevGroupMapRef.current.get(i);
|
|
595
|
+
if (prev !== undefined && !usedIds.has(prev)) {
|
|
596
|
+
stableId = prev;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const id = stableId ?? ++groupIdCounterRef.current;
|
|
601
|
+
usedIds.add(id);
|
|
602
|
+
for (let i = run.start; i <= run.end; i++) nextGroupMap.set(i, id);
|
|
603
|
+
return { ...run, id };
|
|
604
|
+
});
|
|
605
|
+
prevGroupMapRef.current = nextGroupMap;
|
|
606
|
+
return groups;
|
|
607
|
+
}, [selectedIndices]);
|
|
608
|
+
|
|
609
|
+
// True when the user is hovering a row that ISN'T part of any selected
|
|
610
|
+
// run — we dim the selected backgrounds slightly to draw attention to
|
|
611
|
+
// the hover target.
|
|
612
|
+
const isHoveringNonSelected = activeIndex !== null && !selectedIndices.has(activeIndex);
|
|
613
|
+
|
|
614
|
+
// Selected backgrounds, with the merge/split boundary animation when one
|
|
615
|
+
// unselected row bridges or splits two selected runs. Selected backgrounds
|
|
616
|
+
// use shape.bg, so corners animate around its radius.
|
|
617
|
+
const blocks = useMergeSplitBlocks(selectedGroups, itemRects, shape.bgRadius);
|
|
618
|
+
|
|
619
|
+
const showBack = total > 1 && safeIndex > 0;
|
|
620
|
+
const showSkip = total > 1 && isSkippable;
|
|
621
|
+
const showFooter = showBack || showSkip || isMulti;
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<div
|
|
625
|
+
ref={ref}
|
|
626
|
+
className={cn(
|
|
627
|
+
// overflow-hidden crops the footer buttons to the card's rounded
|
|
628
|
+
// bounds, so a button animating out (e.g. Continue on exit) is
|
|
629
|
+
// clipped at the edge instead of visibly flying outside the card.
|
|
630
|
+
"relative w-full max-w-[520px] overflow-hidden bg-card border border-border",
|
|
631
|
+
shape.container,
|
|
632
|
+
className,
|
|
633
|
+
)}
|
|
634
|
+
{...rest}
|
|
635
|
+
onKeyDown={(e) => {
|
|
636
|
+
rest.onKeyDown?.(e);
|
|
637
|
+
handleRootKey(e);
|
|
638
|
+
}}
|
|
639
|
+
>
|
|
640
|
+
{/* Header — static top, fixed across questions; only the number
|
|
641
|
+
changes. Lives outside the morphing region so it never shifts. */}
|
|
642
|
+
<div className="flex items-center px-4 sm:px-5 pt-4 sm:pt-5 pb-2 text-[12px] text-muted-foreground">
|
|
643
|
+
<span>
|
|
644
|
+
Question {safeIndex + 1} of {total}
|
|
645
|
+
</span>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
{/* Morphing Q/A region — its REAL height animates to the measured
|
|
649
|
+
natural height of the content below, so the card border and the
|
|
650
|
+
footer reflow in lockstep with the spring. overflow-hidden clips
|
|
651
|
+
the instantly-swapped content, revealing it as the height opens.
|
|
652
|
+
Header and footer sit outside, so neither is clipped or yanked. */}
|
|
653
|
+
<motion.div
|
|
654
|
+
animate={{ height: contentHeight }}
|
|
655
|
+
initial={false}
|
|
656
|
+
transition={spring.slow}
|
|
657
|
+
className="overflow-hidden"
|
|
658
|
+
>
|
|
659
|
+
<div
|
|
660
|
+
ref={contentMeasureRef}
|
|
661
|
+
className={cn("px-4 sm:px-5", showFooter ? "pb-1" : "pb-2.5 sm:pb-3")}
|
|
662
|
+
>
|
|
663
|
+
<div key={qId} className="flex flex-col gap-2">
|
|
664
|
+
{/* Question title */}
|
|
665
|
+
<h3
|
|
666
|
+
id={`${reactId}-${qId}-title`}
|
|
667
|
+
className="text-[16px] text-foreground leading-snug"
|
|
668
|
+
style={{ fontVariationSettings: fontWeights.semibold }}
|
|
669
|
+
>
|
|
670
|
+
{question.title}
|
|
671
|
+
</h3>
|
|
672
|
+
|
|
673
|
+
{/* Options + Other (proximity-tracked container) */}
|
|
674
|
+
<div
|
|
675
|
+
ref={rowsContainerRef}
|
|
676
|
+
role={isMulti ? "group" : "radiogroup"}
|
|
677
|
+
aria-labelledby={`${reactId}-${qId}-title`}
|
|
678
|
+
onMouseEnter={handlers.onMouseEnter}
|
|
679
|
+
onMouseMove={handlers.onMouseMove}
|
|
680
|
+
onMouseLeave={handlers.onMouseLeave}
|
|
681
|
+
onKeyDown={handleNavKey}
|
|
682
|
+
className="relative flex flex-col gap-0.5 -mx-3"
|
|
683
|
+
>
|
|
684
|
+
{/* Other-row input hint — shown only when the Other input is
|
|
685
|
+
focused and still empty, to signal "type here". As soon as
|
|
686
|
+
text exists, the row joins selectedIndices and inherits the
|
|
687
|
+
selected merged bg, so it visually integrates with adjacent
|
|
688
|
+
selected options instead of looking like a standalone field. */}
|
|
689
|
+
<AnimatePresence>
|
|
690
|
+
{(() => {
|
|
691
|
+
if (!allowOther) return null;
|
|
692
|
+
const otherRect = itemRects[otherIndex];
|
|
693
|
+
const isEmptyFocused = focusedIndex === otherIndex && otherText.length === 0;
|
|
694
|
+
if (!otherRect || !isEmptyFocused) return null;
|
|
695
|
+
return (
|
|
696
|
+
<motion.div
|
|
697
|
+
key="other-input"
|
|
698
|
+
aria-hidden
|
|
699
|
+
className={cn(
|
|
700
|
+
"absolute pointer-events-none bg-card ring-1 ring-inset ring-border",
|
|
701
|
+
shape.bg,
|
|
702
|
+
)}
|
|
703
|
+
initial={{
|
|
704
|
+
opacity: 0,
|
|
705
|
+
top: otherRect.top,
|
|
706
|
+
left: otherRect.left,
|
|
707
|
+
width: otherRect.width,
|
|
708
|
+
height: otherRect.height,
|
|
709
|
+
}}
|
|
710
|
+
animate={{
|
|
711
|
+
opacity: 1,
|
|
712
|
+
top: otherRect.top,
|
|
713
|
+
left: otherRect.left,
|
|
714
|
+
width: otherRect.width,
|
|
715
|
+
height: otherRect.height,
|
|
716
|
+
}}
|
|
717
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
718
|
+
transition={{
|
|
719
|
+
...spring.fast,
|
|
720
|
+
opacity: { duration: 0.08 },
|
|
721
|
+
}}
|
|
722
|
+
/>
|
|
723
|
+
);
|
|
724
|
+
})()}
|
|
725
|
+
</AnimatePresence>
|
|
726
|
+
|
|
727
|
+
{/* Single morphing hover indicator (rendered below selected bg
|
|
728
|
+
so a hovered+selected row still reads as clearly selected) */}
|
|
729
|
+
<AnimatePresence>
|
|
730
|
+
{activeRect && (
|
|
731
|
+
<motion.div
|
|
732
|
+
key={`hover-${sessionRef.current}`}
|
|
733
|
+
aria-hidden
|
|
734
|
+
className={cn("absolute pointer-events-none bg-hover", shape.bg)}
|
|
735
|
+
initial={{
|
|
736
|
+
opacity: 0,
|
|
737
|
+
top: activeRect.top,
|
|
738
|
+
left: activeRect.left,
|
|
739
|
+
width: activeRect.width,
|
|
740
|
+
height: activeRect.height,
|
|
741
|
+
}}
|
|
742
|
+
animate={{
|
|
743
|
+
opacity: 1,
|
|
744
|
+
top: activeRect.top,
|
|
745
|
+
left: activeRect.left,
|
|
746
|
+
width: activeRect.width,
|
|
747
|
+
height: activeRect.height,
|
|
748
|
+
}}
|
|
749
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
750
|
+
transition={{
|
|
751
|
+
...spring.fast,
|
|
752
|
+
opacity: { duration: 0.08 },
|
|
753
|
+
}}
|
|
754
|
+
/>
|
|
755
|
+
)}
|
|
756
|
+
</AnimatePresence>
|
|
757
|
+
|
|
758
|
+
{/* Selected-row backgrounds (merged for contiguous selections).
|
|
759
|
+
A run is normally one block; mid merge/split it is drawn as two
|
|
760
|
+
abutting halves — see useMergeSplitBlocks. Uses bg-active
|
|
761
|
+
(overlay-aware) and renders ABOVE the hover indicator so the
|
|
762
|
+
selected state stays readable when mousing over a row. Corners
|
|
763
|
+
are driven numerically (around shape.bg's radius) so a single
|
|
764
|
+
selected row matches its hover. */}
|
|
765
|
+
<SelectionBackgrounds blocks={blocks} dimmed={isHoveringNonSelected} />
|
|
766
|
+
|
|
767
|
+
{/* Single morphing focus ring */}
|
|
768
|
+
<AnimatePresence>
|
|
769
|
+
{focusRect && (
|
|
770
|
+
<motion.div
|
|
771
|
+
aria-hidden
|
|
772
|
+
className={cn(
|
|
773
|
+
"absolute pointer-events-none border border-[#6B97FF] z-20",
|
|
774
|
+
shape.focusRing,
|
|
775
|
+
)}
|
|
776
|
+
initial={{
|
|
777
|
+
opacity: 0,
|
|
778
|
+
top: focusRect.top - 2,
|
|
779
|
+
left: focusRect.left - 2,
|
|
780
|
+
width: focusRect.width + 4,
|
|
781
|
+
height: focusRect.height + 4,
|
|
782
|
+
}}
|
|
783
|
+
animate={{
|
|
784
|
+
opacity: 1,
|
|
785
|
+
top: focusRect.top - 2,
|
|
786
|
+
left: focusRect.left - 2,
|
|
787
|
+
width: focusRect.width + 4,
|
|
788
|
+
height: focusRect.height + 4,
|
|
789
|
+
}}
|
|
790
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
791
|
+
transition={{
|
|
792
|
+
...spring.fast,
|
|
793
|
+
opacity: { duration: 0.08 },
|
|
794
|
+
}}
|
|
795
|
+
/>
|
|
796
|
+
)}
|
|
797
|
+
</AnimatePresence>
|
|
798
|
+
|
|
799
|
+
{options.map((opt, i) => {
|
|
800
|
+
const oid = optionKey(opt, i);
|
|
801
|
+
const isSelected = selectedIds.includes(oid);
|
|
802
|
+
const isHover = activeIndex === i;
|
|
803
|
+
const showArrow = !isMulti && isHover;
|
|
804
|
+
return (
|
|
805
|
+
<Row
|
|
806
|
+
key={oid}
|
|
807
|
+
index={i}
|
|
808
|
+
registerItem={registerItem}
|
|
809
|
+
role={isMulti ? "checkbox" : "radio"}
|
|
810
|
+
isSelected={isSelected}
|
|
811
|
+
tabIndex={
|
|
812
|
+
isMulti
|
|
813
|
+
? 0
|
|
814
|
+
: selectedIds[0] === oid || (!selectedIds.length && i === 0)
|
|
815
|
+
? 0
|
|
816
|
+
: -1
|
|
817
|
+
}
|
|
818
|
+
onFocusVisible={() => setActiveIndex(i)}
|
|
819
|
+
onBlurAny={() => setActiveIndex((prev) => (prev === i ? null : prev))}
|
|
820
|
+
onClick={() => (isMulti ? handleMultiToggle(oid) : handleSingleSelect(oid))}
|
|
821
|
+
onKeyDown={(e) => {
|
|
822
|
+
// Let ⌘/Ctrl+Enter fall through to the root handler
|
|
823
|
+
// (Continue) instead of toggling the focused row.
|
|
824
|
+
if ((e.key === " " || e.key === "Enter") && !e.metaKey && !e.ctrlKey) {
|
|
825
|
+
e.preventDefault();
|
|
826
|
+
if (isMulti) handleMultiToggle(oid);
|
|
827
|
+
else handleSingleSelect(oid);
|
|
828
|
+
}
|
|
829
|
+
}}
|
|
830
|
+
shape={shape}
|
|
831
|
+
aria-checked={isSelected}
|
|
832
|
+
chipContent={i + 1}
|
|
833
|
+
chipFilled={isSelected}
|
|
834
|
+
isMulti={isMulti}
|
|
835
|
+
showArrow={showArrow}
|
|
836
|
+
bodyLayout={question.layout === "stacked" ? "stacked" : "inline"}
|
|
837
|
+
// Anchor the chip to the first text line whenever the
|
|
838
|
+
// body can wrap to multiple lines (stacked layouts
|
|
839
|
+
// pair a title with a description that often wraps).
|
|
840
|
+
topAlign={question.layout === "stacked"}
|
|
841
|
+
chipPosition={question.chipPosition ?? "right"}
|
|
842
|
+
arrowIcon={<ArrowRight size={14} strokeWidth={2} className="h-3.5 w-3.5" />}
|
|
843
|
+
>
|
|
844
|
+
{question.layout === "stacked" ? (
|
|
845
|
+
<>
|
|
846
|
+
<span className="inline-grid">
|
|
847
|
+
<span
|
|
848
|
+
className="col-start-1 row-start-1 invisible"
|
|
849
|
+
style={{ fontVariationSettings: fontWeights.semibold }}
|
|
850
|
+
aria-hidden="true"
|
|
851
|
+
>
|
|
852
|
+
{opt.title}
|
|
853
|
+
</span>
|
|
854
|
+
<span
|
|
855
|
+
className="col-start-1 row-start-1 text-foreground transition-[color,font-variation-settings] duration-80"
|
|
856
|
+
style={{
|
|
857
|
+
fontVariationSettings: isSelected
|
|
858
|
+
? fontWeights.semibold
|
|
859
|
+
: fontWeights.medium,
|
|
860
|
+
}}
|
|
861
|
+
>
|
|
862
|
+
{opt.title}
|
|
863
|
+
</span>
|
|
864
|
+
</span>
|
|
865
|
+
{opt.description && (
|
|
866
|
+
<span className="text-[12px] text-muted-foreground leading-snug">
|
|
867
|
+
{opt.description}
|
|
868
|
+
</span>
|
|
869
|
+
)}
|
|
870
|
+
</>
|
|
871
|
+
) : (
|
|
872
|
+
<span>
|
|
873
|
+
<span className="inline-grid">
|
|
874
|
+
<span
|
|
875
|
+
className="col-start-1 row-start-1 invisible"
|
|
876
|
+
style={{ fontVariationSettings: fontWeights.semibold }}
|
|
877
|
+
aria-hidden="true"
|
|
878
|
+
>
|
|
879
|
+
{opt.title}
|
|
880
|
+
</span>
|
|
881
|
+
<span
|
|
882
|
+
className="col-start-1 row-start-1 text-foreground transition-[color,font-variation-settings] duration-80"
|
|
883
|
+
style={{
|
|
884
|
+
fontVariationSettings: isSelected
|
|
885
|
+
? fontWeights.semibold
|
|
886
|
+
: fontWeights.medium,
|
|
887
|
+
}}
|
|
888
|
+
>
|
|
889
|
+
{opt.title}
|
|
890
|
+
</span>
|
|
891
|
+
</span>
|
|
892
|
+
{opt.description && (
|
|
893
|
+
<>
|
|
894
|
+
{" "}
|
|
895
|
+
<span className="text-muted-foreground">{opt.description}</span>
|
|
896
|
+
</>
|
|
897
|
+
)}
|
|
898
|
+
</span>
|
|
899
|
+
)}
|
|
900
|
+
</Row>
|
|
901
|
+
);
|
|
902
|
+
})}
|
|
903
|
+
|
|
904
|
+
{allowOther && (
|
|
905
|
+
<Row
|
|
906
|
+
index={otherIndex}
|
|
907
|
+
registerItem={registerItem}
|
|
908
|
+
role={null}
|
|
909
|
+
isSelected={otherText.length > 0}
|
|
910
|
+
tabIndex={-1}
|
|
911
|
+
onFocusVisible={() => setFocusedIndex(otherIndex)}
|
|
912
|
+
onBlurAny={() => setFocusedIndex((prev) => (prev === otherIndex ? null : prev))}
|
|
913
|
+
onClick={() => otherInputRef.current?.focus()}
|
|
914
|
+
shape={shape}
|
|
915
|
+
chipContent={otherIndex + 1}
|
|
916
|
+
chipFilled={otherText.length > 0}
|
|
917
|
+
isMulti={isMulti}
|
|
918
|
+
// Other body is a textarea that may grow past one line;
|
|
919
|
+
// only switch to top-aligned when it actually wraps, so
|
|
920
|
+
// the 1-line empty / single-line state stays visually
|
|
921
|
+
// centred like the surrounding option rows.
|
|
922
|
+
topAlign={isOtherMultiline}
|
|
923
|
+
chipPosition={question.chipPosition ?? "right"}
|
|
924
|
+
ariaLabel={question.otherPlaceholder ?? "Describe in your own words"}
|
|
925
|
+
showArrow={
|
|
926
|
+
!isMulti &&
|
|
927
|
+
(focusedIndex === otherIndex || activeIndex === otherIndex) &&
|
|
928
|
+
otherText.trim().length > 0
|
|
929
|
+
}
|
|
930
|
+
arrowIcon={<ArrowRight size={14} strokeWidth={2} className="h-3.5 w-3.5" />}
|
|
931
|
+
{...(!isMulti && otherText.trim().length > 0
|
|
932
|
+
? { onArrowClick: handleOtherSubmit }
|
|
933
|
+
: {})}
|
|
934
|
+
>
|
|
935
|
+
<span className="inline-grid w-full">
|
|
936
|
+
<textarea
|
|
937
|
+
ref={otherInputRef}
|
|
938
|
+
rows={1}
|
|
939
|
+
value={otherText}
|
|
940
|
+
placeholder={question.otherPlaceholder ?? "Describe in your own words…"}
|
|
941
|
+
aria-label={question.otherPlaceholder ?? "Describe in your own words"}
|
|
942
|
+
onChange={(e) => handleOtherChange(e.target.value)}
|
|
943
|
+
onFocus={() => setFocusedIndex(otherIndex)}
|
|
944
|
+
onBlur={() =>
|
|
945
|
+
setFocusedIndex((prev) => (prev === otherIndex ? null : prev))
|
|
946
|
+
}
|
|
947
|
+
onKeyDown={(e) => {
|
|
948
|
+
// Standard chat pattern: plain Enter submits,
|
|
949
|
+
// Shift+Enter inserts a newline. Works for both
|
|
950
|
+
// desktop and mobile soft keyboards (where ⌘/⌃
|
|
951
|
+
// isn't reachable). In multi-select we leave plain
|
|
952
|
+
// Enter to the textarea (newline) and let the
|
|
953
|
+
// root handler catch ⌘/⌃+Enter for Continue —
|
|
954
|
+
// multi-select has its own Continue button as the
|
|
955
|
+
// primary submit affordance.
|
|
956
|
+
if (e.key !== "Enter") return;
|
|
957
|
+
if (e.shiftKey) return; // Shift+Enter = newline
|
|
958
|
+
if (!isMulti) {
|
|
959
|
+
e.preventDefault();
|
|
960
|
+
handleOtherSubmit();
|
|
961
|
+
}
|
|
962
|
+
}}
|
|
963
|
+
onClick={(e) => e.stopPropagation()}
|
|
964
|
+
className={cn(
|
|
965
|
+
// Reset every textarea default that would otherwise
|
|
966
|
+
// make the field taller/boxier than the single-line
|
|
967
|
+
// input it replaces — no border, no padding, no
|
|
968
|
+
// resize handle, no scrollbars (height is JS-driven,
|
|
969
|
+
// see the auto-resize effect above).
|
|
970
|
+
"col-start-1 row-start-1 block w-full bg-transparent border-0 p-0 m-0 outline-none resize-none overflow-hidden text-[13px] leading-snug text-foreground placeholder:text-muted-foreground",
|
|
971
|
+
)}
|
|
972
|
+
style={{ fontVariationSettings: fontWeights.medium }}
|
|
973
|
+
/>
|
|
974
|
+
</span>
|
|
975
|
+
</Row>
|
|
976
|
+
)}
|
|
977
|
+
</div>
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
</motion.div>
|
|
981
|
+
|
|
982
|
+
{/* Footer — outside the morphing region, so the animating height never
|
|
983
|
+
clips it. Because the height is a real layout value (not a
|
|
984
|
+
transform), the footer reflows frame-by-frame and rides the morph
|
|
985
|
+
in lockstep. */}
|
|
986
|
+
{showFooter && (
|
|
987
|
+
<div className="px-4 sm:px-5 pt-1 pb-2">
|
|
988
|
+
<div className="flex items-center justify-between gap-2 -mx-2 sm:-mx-3">
|
|
989
|
+
{/* Each button is wrapped in a motion.div so it fades + scales
|
|
990
|
+
when it appears/disappears (e.g. Continue on multi-select).
|
|
991
|
+
popLayout pops the exiting button out of flow so its
|
|
992
|
+
neighbours slide to their new spot *at the same time* it fades
|
|
993
|
+
(not sequentially). The group is `relative` so the popped
|
|
994
|
+
(absolutely positioned) button stays put instead of flying to
|
|
995
|
+
the page origin. */}
|
|
996
|
+
<div className="relative flex items-center gap-2">
|
|
997
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
998
|
+
{showBack && (
|
|
999
|
+
<motion.div
|
|
1000
|
+
key="back"
|
|
1001
|
+
layout="position"
|
|
1002
|
+
initial={{ opacity: 0, scale: 0.85 }}
|
|
1003
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
1004
|
+
exit={{ opacity: 0, scale: 0.85 }}
|
|
1005
|
+
transition={{
|
|
1006
|
+
...spring.fast,
|
|
1007
|
+
opacity: { duration: 0.1 },
|
|
1008
|
+
}}
|
|
1009
|
+
>
|
|
1010
|
+
{/* Bare ← icon via the Button's icon slot, so it gets the
|
|
1011
|
+
proper tighter icon-side padding. */}
|
|
1012
|
+
<Button
|
|
1013
|
+
variant="ghost"
|
|
1014
|
+
size="sm"
|
|
1015
|
+
leadingIcon={ArrowLeftKey}
|
|
1016
|
+
onClick={handleBack}
|
|
1017
|
+
// Arrow is desktop-only; restore symmetric padding on
|
|
1018
|
+
// mobile where it's hidden, tighten for the icon on ≥sm.
|
|
1019
|
+
className="pl-3 sm:pl-[6px]"
|
|
1020
|
+
>
|
|
1021
|
+
Back
|
|
1022
|
+
</Button>
|
|
1023
|
+
</motion.div>
|
|
1024
|
+
)}
|
|
1025
|
+
</AnimatePresence>
|
|
1026
|
+
</div>
|
|
1027
|
+
<div className="relative flex items-center gap-2">
|
|
1028
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
1029
|
+
{showSkip && (
|
|
1030
|
+
<motion.div
|
|
1031
|
+
key="skip"
|
|
1032
|
+
layout="position"
|
|
1033
|
+
initial={{ opacity: 0, scale: 0.85 }}
|
|
1034
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
1035
|
+
exit={{ opacity: 0, scale: 0.85 }}
|
|
1036
|
+
transition={{
|
|
1037
|
+
...spring.fast,
|
|
1038
|
+
opacity: { duration: 0.1 },
|
|
1039
|
+
}}
|
|
1040
|
+
>
|
|
1041
|
+
{/* Bare → icon via the Button's icon slot (mirror of Back). */}
|
|
1042
|
+
<Button
|
|
1043
|
+
variant="ghost"
|
|
1044
|
+
size="sm"
|
|
1045
|
+
trailingIcon={ArrowRightKey}
|
|
1046
|
+
onClick={handleSkip}
|
|
1047
|
+
// Arrow is desktop-only; restore symmetric padding on
|
|
1048
|
+
// mobile where it's hidden, tighten for the icon on ≥sm.
|
|
1049
|
+
className="pr-3 sm:pr-[6px]"
|
|
1050
|
+
>
|
|
1051
|
+
{skipLabel}
|
|
1052
|
+
</Button>
|
|
1053
|
+
</motion.div>
|
|
1054
|
+
)}
|
|
1055
|
+
{isMulti && (
|
|
1056
|
+
<motion.div
|
|
1057
|
+
key="continue"
|
|
1058
|
+
layout="position"
|
|
1059
|
+
initial={{ opacity: 0, scale: 0.85 }}
|
|
1060
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
1061
|
+
exit={{ opacity: 0, scale: 0.85 }}
|
|
1062
|
+
transition={{
|
|
1063
|
+
...spring.fast,
|
|
1064
|
+
opacity: { duration: 0.1 },
|
|
1065
|
+
}}
|
|
1066
|
+
>
|
|
1067
|
+
<Button
|
|
1068
|
+
variant="primary"
|
|
1069
|
+
size="sm"
|
|
1070
|
+
onClick={handleMultiNext}
|
|
1071
|
+
disabled={selectedIds.length === 0 && otherText.trim().length === 0}
|
|
1072
|
+
// The shortcut chip acts as a trailing icon, so tighten
|
|
1073
|
+
// the right padding to match the Button's iconRight on
|
|
1074
|
+
// desktop. The chip is hidden on mobile, so restore
|
|
1075
|
+
// symmetric padding there.
|
|
1076
|
+
className="pr-3 sm:pr-[6px]"
|
|
1077
|
+
>
|
|
1078
|
+
<span className="inline-flex items-center gap-1.5">
|
|
1079
|
+
{question.nextLabel ?? (safeIndex >= total - 1 ? "Finish" : "Continue")}
|
|
1080
|
+
{/* Shortcut hint — replaces the trailing arrow. Sits
|
|
1081
|
+
inside the button so it dims with the disabled
|
|
1082
|
+
state. ⌘↵ on macOS, ⌃↵ elsewhere. Desktop-only:
|
|
1083
|
+
mobile has no physical keyboard to trigger it. */}
|
|
1084
|
+
<span className="hidden sm:contents">
|
|
1085
|
+
<ShortcutChip shape={shape} tone="inverted">
|
|
1086
|
+
{isMac ? "⌘" : "⌃"}
|
|
1087
|
+
{"↵"}
|
|
1088
|
+
</ShortcutChip>
|
|
1089
|
+
</span>
|
|
1090
|
+
</span>
|
|
1091
|
+
</Button>
|
|
1092
|
+
</motion.div>
|
|
1093
|
+
)}
|
|
1094
|
+
</AnimatePresence>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
</div>
|
|
1098
|
+
)}
|
|
1099
|
+
</div>
|
|
1100
|
+
);
|
|
1101
|
+
},
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
AskUserQuestions.displayName = "AskUserQuestions";
|
|
1105
|
+
|
|
1106
|
+
// ── Shortcut chip ─────────────────────────────────────────────
|
|
1107
|
+
// Small keycap showing the keyboard shortcut for an action, so Back (←),
|
|
1108
|
+
// Skip (→) and Continue (⌘↵ / ⌃↵) all read consistently. `tone="inverted"`
|
|
1109
|
+
// sits on the dark primary button; the default reads on quiet ghost buttons.
|
|
1110
|
+
function ShortcutChip({
|
|
1111
|
+
children,
|
|
1112
|
+
tone = "muted",
|
|
1113
|
+
shape,
|
|
1114
|
+
}: {
|
|
1115
|
+
children: React.ReactNode;
|
|
1116
|
+
tone?: "muted" | "inverted";
|
|
1117
|
+
shape: ReturnType<typeof useShape>;
|
|
1118
|
+
}) {
|
|
1119
|
+
return (
|
|
1120
|
+
<kbd
|
|
1121
|
+
aria-hidden
|
|
1122
|
+
className={cn(
|
|
1123
|
+
"inline-flex items-center justify-center gap-0.5 px-1 min-w-[18px] h-[18px] text-[11px] leading-none font-sans tracking-wide",
|
|
1124
|
+
tone === "inverted"
|
|
1125
|
+
? "bg-background/15 text-background"
|
|
1126
|
+
: "bg-foreground/10 text-muted-foreground",
|
|
1127
|
+
shape.bg,
|
|
1128
|
+
)}
|
|
1129
|
+
>
|
|
1130
|
+
{children}
|
|
1131
|
+
</kbd>
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ── Row sub-component ─────────────────────────────────────────
|
|
1136
|
+
|
|
1137
|
+
interface RowProps {
|
|
1138
|
+
index: number;
|
|
1139
|
+
registerItem: (index: number, element: HTMLElement | null) => void;
|
|
1140
|
+
role: "radio" | "checkbox" | null;
|
|
1141
|
+
isSelected: boolean;
|
|
1142
|
+
tabIndex: number;
|
|
1143
|
+
onFocusVisible: () => void;
|
|
1144
|
+
onBlurAny: () => void;
|
|
1145
|
+
onClick: () => void;
|
|
1146
|
+
onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void;
|
|
1147
|
+
shape: ReturnType<typeof useShape>;
|
|
1148
|
+
chipContent: React.ReactNode;
|
|
1149
|
+
chipFilled: boolean;
|
|
1150
|
+
isMulti: boolean;
|
|
1151
|
+
ariaLabel?: string;
|
|
1152
|
+
"aria-checked"?: boolean;
|
|
1153
|
+
showArrow?: boolean;
|
|
1154
|
+
arrowIcon?: React.ReactNode;
|
|
1155
|
+
onArrowClick?: () => void;
|
|
1156
|
+
/** Body content layout. "inline" keeps title + description on one line;
|
|
1157
|
+
* "stacked" puts description below the title with extra vertical padding. */
|
|
1158
|
+
bodyLayout?: "inline" | "stacked";
|
|
1159
|
+
/** Anchor the chip to the first line of the body instead of vertically
|
|
1160
|
+
* centering it on the row. Use when the body can grow taller than one
|
|
1161
|
+
* line (Other row's textarea, stacked title + description, or any
|
|
1162
|
+
* wrapping content) — otherwise the chip drifts toward the middle of a
|
|
1163
|
+
* tall row and stops reading as a marker for the row's title. */
|
|
1164
|
+
topAlign?: boolean;
|
|
1165
|
+
/** Mirrors the per-question `chipPosition`. "left" moves the chip to
|
|
1166
|
+
* the leading edge of the row; the trailing arrow slot still sits on
|
|
1167
|
+
* the right. Defaults to "right". */
|
|
1168
|
+
chipPosition?: "left" | "right";
|
|
1169
|
+
children: React.ReactNode;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function Row({
|
|
1173
|
+
index,
|
|
1174
|
+
registerItem,
|
|
1175
|
+
role,
|
|
1176
|
+
isSelected,
|
|
1177
|
+
tabIndex,
|
|
1178
|
+
onFocusVisible,
|
|
1179
|
+
onBlurAny,
|
|
1180
|
+
onClick,
|
|
1181
|
+
onKeyDown,
|
|
1182
|
+
shape,
|
|
1183
|
+
chipContent,
|
|
1184
|
+
chipFilled,
|
|
1185
|
+
isMulti,
|
|
1186
|
+
ariaLabel,
|
|
1187
|
+
showArrow,
|
|
1188
|
+
arrowIcon,
|
|
1189
|
+
onArrowClick,
|
|
1190
|
+
bodyLayout = "inline",
|
|
1191
|
+
topAlign = false,
|
|
1192
|
+
chipPosition = "right",
|
|
1193
|
+
children,
|
|
1194
|
+
...aria
|
|
1195
|
+
}: RowProps) {
|
|
1196
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
1197
|
+
|
|
1198
|
+
useEffect(() => {
|
|
1199
|
+
registerItem(index, rowRef.current);
|
|
1200
|
+
return () => registerItem(index, null);
|
|
1201
|
+
}, [index, registerItem]);
|
|
1202
|
+
|
|
1203
|
+
// The arrow keeps the same animation regardless of which slot it lands
|
|
1204
|
+
// in — pull it out so the chip-on-right (overlay) and chip-on-left
|
|
1205
|
+
// (separate right slot) paths can reuse the exact same element.
|
|
1206
|
+
const arrowOverlay = (
|
|
1207
|
+
<AnimatePresence>
|
|
1208
|
+
{showArrow && (
|
|
1209
|
+
<motion.span
|
|
1210
|
+
aria-hidden={!onArrowClick}
|
|
1211
|
+
role={onArrowClick ? "button" : undefined}
|
|
1212
|
+
onClick={
|
|
1213
|
+
onArrowClick
|
|
1214
|
+
? (e) => {
|
|
1215
|
+
e.stopPropagation();
|
|
1216
|
+
onArrowClick();
|
|
1217
|
+
}
|
|
1218
|
+
: undefined
|
|
1219
|
+
}
|
|
1220
|
+
className={cn(
|
|
1221
|
+
"absolute inset-0 inline-flex items-center justify-center bg-foreground text-background",
|
|
1222
|
+
shape.bg,
|
|
1223
|
+
onArrowClick && "cursor-pointer",
|
|
1224
|
+
)}
|
|
1225
|
+
initial={{ opacity: 0, scale: 0.6 }}
|
|
1226
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
1227
|
+
exit={{
|
|
1228
|
+
opacity: 0,
|
|
1229
|
+
scale: 0.6,
|
|
1230
|
+
transition: spring.fast.exit,
|
|
1231
|
+
}}
|
|
1232
|
+
transition={{
|
|
1233
|
+
...spring.fast,
|
|
1234
|
+
opacity: { duration: 0.08 },
|
|
1235
|
+
}}
|
|
1236
|
+
>
|
|
1237
|
+
{arrowIcon}
|
|
1238
|
+
</motion.span>
|
|
1239
|
+
)}
|
|
1240
|
+
</AnimatePresence>
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
// The chip "slot" is a fixed 28×28 cell holding the chip number/circle.
|
|
1244
|
+
// When topAlign is on, the slot floats up so the chip's vertical centre
|
|
1245
|
+
// lines up with the centre of a `text-[13px] leading-snug` first line
|
|
1246
|
+
// (line-height ≈ 18px → centre 9px; chip centre 14px → diff 5px).
|
|
1247
|
+
// Stacked rows pair a title with a description, so we add 4px of
|
|
1248
|
+
// breathing room back on top (effective shift -1px) — that lands the
|
|
1249
|
+
// chip near the title's baseline rather than its optical centre, which
|
|
1250
|
+
// reads as "row marker" instead of "title label" when descriptions wrap.
|
|
1251
|
+
// The arrow overlay only co-renders here when `chipPosition === "right"`
|
|
1252
|
+
// — in chip-on-left mode the arrow has its own right-edge slot so the
|
|
1253
|
+
// chip stays visible while the submit affordance lives where users
|
|
1254
|
+
// expect it (the trailing end of the row).
|
|
1255
|
+
const chipSlot = (
|
|
1256
|
+
<span
|
|
1257
|
+
className={cn(
|
|
1258
|
+
"shrink-0 w-7 h-7 relative inline-flex items-center justify-center",
|
|
1259
|
+
topAlign && (bodyLayout === "stacked" ? "-mt-[1px]" : "-mt-[5px]"),
|
|
1260
|
+
)}
|
|
1261
|
+
>
|
|
1262
|
+
<span
|
|
1263
|
+
aria-hidden
|
|
1264
|
+
className={cn(
|
|
1265
|
+
"absolute inline-flex items-center justify-center w-5 h-5 text-[11px] transition-[opacity,font-variation-settings] duration-80",
|
|
1266
|
+
isMulti && shape.bg,
|
|
1267
|
+
isMulti
|
|
1268
|
+
? chipFilled
|
|
1269
|
+
? "bg-foreground text-background"
|
|
1270
|
+
: "border border-border text-muted-foreground"
|
|
1271
|
+
: chipFilled
|
|
1272
|
+
? "text-foreground"
|
|
1273
|
+
: "text-muted-foreground",
|
|
1274
|
+
// Only fade the chip when it shares a slot with the arrow — for
|
|
1275
|
+
// chip-on-left the arrow has its own slot on the right, so the
|
|
1276
|
+
// chip stays in place.
|
|
1277
|
+
chipPosition === "right" && showArrow && "opacity-0",
|
|
1278
|
+
)}
|
|
1279
|
+
style={{
|
|
1280
|
+
fontVariationSettings: chipFilled ? fontWeights.semibold : fontWeights.medium,
|
|
1281
|
+
}}
|
|
1282
|
+
>
|
|
1283
|
+
{chipContent}
|
|
1284
|
+
</span>
|
|
1285
|
+
{chipPosition === "right" && arrowOverlay}
|
|
1286
|
+
</span>
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
// Right-edge arrow slot — only used when the chip is on the LEFT and
|
|
1290
|
+
// the row can show an arrow (single-select only; in multi-select
|
|
1291
|
+
// showArrow is always false and there's nothing to anchor here). Mirrors
|
|
1292
|
+
// the chip slot's stacked-vs-inline shift so both end markers stay on
|
|
1293
|
+
// the same horizontal line at all times.
|
|
1294
|
+
const rightArrowSlot = chipPosition === "left" && !isMulti && (
|
|
1295
|
+
<span
|
|
1296
|
+
className={cn(
|
|
1297
|
+
"shrink-0 w-7 h-7 relative inline-flex items-center justify-center",
|
|
1298
|
+
topAlign && (bodyLayout === "stacked" ? "-mt-[1px]" : "-mt-[5px]"),
|
|
1299
|
+
)}
|
|
1300
|
+
>
|
|
1301
|
+
{arrowOverlay}
|
|
1302
|
+
</span>
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
return (
|
|
1306
|
+
<div
|
|
1307
|
+
ref={rowRef}
|
|
1308
|
+
data-proximity-index={index}
|
|
1309
|
+
data-state={isSelected ? "checked" : "unchecked"}
|
|
1310
|
+
role={role ?? undefined}
|
|
1311
|
+
aria-checked={role === "radio" || role === "checkbox" ? !!aria["aria-checked"] : undefined}
|
|
1312
|
+
aria-label={ariaLabel}
|
|
1313
|
+
tabIndex={tabIndex}
|
|
1314
|
+
onFocus={(e) => {
|
|
1315
|
+
if ((e.target as HTMLElement).matches(":focus-visible")) {
|
|
1316
|
+
onFocusVisible();
|
|
1317
|
+
}
|
|
1318
|
+
}}
|
|
1319
|
+
onBlur={onBlurAny}
|
|
1320
|
+
onClick={onClick}
|
|
1321
|
+
onKeyDown={onKeyDown}
|
|
1322
|
+
className={cn(
|
|
1323
|
+
"relative z-10 flex cursor-pointer select-none outline-none",
|
|
1324
|
+
// Tighter gap when the chip sits on the left — it reads as a
|
|
1325
|
+
// leading list marker, so coupling it close to the title looks
|
|
1326
|
+
// more intentional than the larger right-side gap (where the
|
|
1327
|
+
// chip is a trailing affordance instead).
|
|
1328
|
+
chipPosition === "left" ? "gap-2" : "gap-3",
|
|
1329
|
+
// items-start when the body may exceed one line (stacked layouts,
|
|
1330
|
+
// multi-line textareas) so the chip tracks the first line instead
|
|
1331
|
+
// of sliding to the row's vertical centre. When topAlign is OFF,
|
|
1332
|
+
// items-center keeps a 1-line row visually centred — that's why
|
|
1333
|
+
// the Other row defers topAlign until its textarea actually wraps.
|
|
1334
|
+
topAlign ? "items-start" : "items-center",
|
|
1335
|
+
bodyLayout === "stacked" ? "min-h-14 py-2" : "min-h-10 py-1.5",
|
|
1336
|
+
// Mirror the horizontal padding based on chip side so the row
|
|
1337
|
+
// reads visually balanced in both orientations. For chip-on-left
|
|
1338
|
+
// + multi-select there's no right slot, so widen the right padding
|
|
1339
|
+
// to match the chip-on-right's 12px / 6px asymmetry mirrored.
|
|
1340
|
+
chipPosition === "left" ? (isMulti ? "pl-1.5 pr-3" : "pl-1.5 pr-1.5") : "pl-3 pr-1.5",
|
|
1341
|
+
shape.item,
|
|
1342
|
+
)}
|
|
1343
|
+
>
|
|
1344
|
+
{/* Selected background is drawn at the container level so contiguous
|
|
1345
|
+
selections can merge into a single block (see AskUserQuestions's
|
|
1346
|
+
selectedGroups / merged-bg block). Row keeps z-10 to sit above it. */}
|
|
1347
|
+
|
|
1348
|
+
{chipPosition === "left" && chipSlot}
|
|
1349
|
+
|
|
1350
|
+
{/* Body — fills row */}
|
|
1351
|
+
<span
|
|
1352
|
+
className={cn(
|
|
1353
|
+
"min-w-0 flex-1 text-[13px] leading-snug",
|
|
1354
|
+
bodyLayout === "stacked" ? "flex flex-col gap-0.5" : "inline-flex items-center gap-0",
|
|
1355
|
+
)}
|
|
1356
|
+
>
|
|
1357
|
+
{children}
|
|
1358
|
+
</span>
|
|
1359
|
+
|
|
1360
|
+
{chipPosition === "right" ? chipSlot : rightArrowSlot}
|
|
1361
|
+
</div>
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export { AskUserQuestions };
|
|
1366
|
+
export default AskUserQuestions;
|