@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.
Files changed (65) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +789 -115
  5. package/dist/index.js +10404 -1099
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input.tsx +6 -3
  32. package/src/components/menu-item.tsx +156 -0
  33. package/src/components/mobile-drawer.tsx +50 -0
  34. package/src/components/nav-item.tsx +43 -0
  35. package/src/components/nav-menu.tsx +47 -0
  36. package/src/components/popover.tsx +3 -1
  37. package/src/components/radio-group.tsx +381 -35
  38. package/src/components/scroll-area.tsx +9 -2
  39. package/src/components/select.tsx +708 -121
  40. package/src/components/sheet.tsx +15 -6
  41. package/src/components/slider.tsx +1583 -20
  42. package/src/components/status-badge.tsx +2 -0
  43. package/src/components/switch.tsx +245 -28
  44. package/src/components/table.tsx +191 -77
  45. package/src/components/tabs-subtle.tsx +411 -0
  46. package/src/components/tabs.tsx +459 -49
  47. package/src/components/textarea.tsx +7 -3
  48. package/src/components/thinking-indicator.tsx +106 -0
  49. package/src/components/thinking-steps.tsx +382 -0
  50. package/src/components/tooltip.tsx +158 -33
  51. package/src/hooks/use-merge-split.tsx +359 -0
  52. package/src/hooks/use-proximity-hover.ts +208 -0
  53. package/src/index.ts +14 -0
  54. package/src/lib/elevated.tsx +52 -0
  55. package/src/lib/font-weight.ts +18 -0
  56. package/src/lib/icon-context.tsx +98 -0
  57. package/src/lib/icon-map.tsx +669 -0
  58. package/src/lib/interaction.ts +102 -0
  59. package/src/lib/scroll-fade.tsx +241 -0
  60. package/src/lib/shape-context.tsx +121 -0
  61. package/src/lib/springs.ts +30 -0
  62. package/src/lib/surface-classes.ts +43 -0
  63. package/src/lib/surface-context.tsx +17 -0
  64. package/src/tokens/tokens.ts +3 -0
  65. 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;