|
1 | 1 | 'use client' |
2 | 2 |
|
3 | | -import { useEffect, useRef, useState } from 'react' |
| 3 | +import { useRef, useState } from 'react' |
4 | 4 | import { |
5 | 5 | ArrowRight, |
6 | 6 | Button, |
@@ -52,15 +52,6 @@ const OPTION_ROW_CLASSES = |
52 | 52 | /** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ |
53 | 53 | const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' |
54 | 54 |
|
55 | | -/** Leading number slot matching the suggested follow-ups rows. */ |
56 | | -function RowNumber({ value }: { value: number }) { |
57 | | - return ( |
58 | | - <div className='flex size-[16px] flex-shrink-0 items-center justify-center'> |
59 | | - <span className='text-[var(--text-icon)] text-sm'>{value}</span> |
60 | | - </div> |
61 | | - ) |
62 | | -} |
63 | | - |
64 | 55 | /** |
65 | 56 | * Leading checkbox slot for multi_select rows. Purely presentational — it |
66 | 57 | * reuses the emcn Checkbox chrome via its exported variants, but the row |
@@ -101,38 +92,32 @@ interface QuestionDisplayProps { |
101 | 92 | * Inline renderer for the `<question>` special tag: a chat-inline div with the |
102 | 93 | * user input's chrome, the current question's prompt at the top left, dismiss |
103 | 94 | * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and |
104 | | - * suggested-action option rows beneath, always followed by a "Something else" |
105 | | - * row that reads as a plain option until clicked and then becomes the focused |
106 | | - * text box. `single_select` answers and advances on click (or on submitting |
107 | | - * typed text); `multi_select` rows toggle checkboxes and an option-styled |
108 | | - * Submit row confirms the step. Answering the last question sends one |
109 | | - * combined user message and collapses the div to a question/answer recap. |
| 95 | + * suggested-action option rows beneath, always followed by a custom-answer |
| 96 | + * text field whose placeholder reads "Something else". `single_select` |
| 97 | + * answers and advances on click (or on submitting typed text); `multi_select` |
| 98 | + * rows toggle checkboxes and an option-styled Submit row confirms the step. |
| 99 | + * Answering the last question sends one combined user message and collapses |
| 100 | + * the div to a question/answer recap. |
110 | 101 | */ |
111 | 102 | export function QuestionDisplay({ |
112 | 103 | data, |
113 | 104 | answers: transcriptAnswers, |
114 | 105 | onSelect, |
115 | 106 | }: QuestionDisplayProps) { |
116 | 107 | const freeTextInputRef = useRef<HTMLInputElement>(null) |
| 108 | + const freeTextCheckboxRef = useRef<HTMLButtonElement>(null) |
117 | 109 | const disabled = !onSelect |
118 | 110 | const [phase, setPhase] = useState<QuestionPhase>('active') |
119 | 111 | const [step, setStep] = useState(0) |
120 | 112 | const [selectedByStep, setSelectedByStep] = useState<string[][]>(() => data.map(() => [])) |
121 | 113 | const [customByStep, setCustomByStep] = useState<string[]>(() => data.map(() => '')) |
122 | 114 | const [freeText, setFreeText] = useState('') |
123 | | - // The "Something else" row reads as a plain option until clicked, then |
124 | | - // becomes the focused text box (and reverts when left empty). |
125 | | - const [freeTextEditing, setFreeTextEditing] = useState(false) |
126 | 115 | // multi_select only: whether the typed "Something else" text is included in |
127 | 116 | // the answer. Unchecking keeps the text; it just stops counting. |
128 | 117 | const [customCheckedByStep, setCustomCheckedByStep] = useState<boolean[]>(() => |
129 | 118 | data.map(() => false) |
130 | 119 | ) |
131 | 120 |
|
132 | | - useEffect(() => { |
133 | | - if (freeTextEditing) freeTextInputRef.current?.focus() |
134 | | - }, [freeTextEditing, step]) |
135 | | - |
136 | 121 | // The typed text that actually joins a step's answer: multi_select customs |
137 | 122 | // only count while checked; single_select customs always count. |
138 | 123 | const customFor = (i: number, customs: string[]): string => |
@@ -187,15 +172,13 @@ export function QuestionDisplay({ |
187 | 172 | setStep(next) |
188 | 173 | const prefill = customByStep[next] ?? '' |
189 | 174 | setFreeText(prefill) |
190 | | - setFreeTextEditing(prefill.trim().length > 0) |
191 | 175 | } |
192 | 176 |
|
193 | 177 | const finishStep = (selections: string[][], customs: string[]) => { |
194 | 178 | if (!isLast) { |
195 | 179 | setStep(step + 1) |
196 | 180 | const prefill = customs[step + 1] ?? '' |
197 | 181 | setFreeText(prefill) |
198 | | - setFreeTextEditing(prefill.trim().length > 0) |
199 | 182 | return |
200 | 183 | } |
201 | 184 | setPhase('answered') |
@@ -239,6 +222,12 @@ export function QuestionDisplay({ |
239 | 222 | setCustomCheckedByStep(next) |
240 | 223 | } |
241 | 224 |
|
| 225 | + const toggleCustomChecked = () => { |
| 226 | + const isChecked = customCheckedByStep[step] ?? false |
| 227 | + setCustomChecked(!isChecked) |
| 228 | + if (!isChecked) freeTextInputRef.current?.focus() |
| 229 | + } |
| 230 | + |
242 | 231 | /** single_select free-text arrow: the typed text IS the answer. */ |
243 | 232 | const submitSingleFreeText = () => { |
244 | 233 | const customs = commitCustom() |
@@ -333,118 +322,88 @@ export function QuestionDisplay({ |
333 | 322 | isSelected && 'bg-[var(--surface-5)]' |
334 | 323 | )} |
335 | 324 | > |
336 | | - {isMulti ? ( |
337 | | - <RowCheckbox checked={isSelected} disabled={disabled} /> |
338 | | - ) : ( |
339 | | - <RowNumber value={i + 1} /> |
340 | | - )} |
| 325 | + {isMulti ? <RowCheckbox checked={isSelected} disabled={disabled} /> : null} |
341 | 326 | <span className='min-w-0 flex-1 whitespace-normal break-words text-[var(--text-body)] text-sm'> |
342 | 327 | {option.label} |
343 | 328 | </span> |
344 | 329 | {!isMulti && <ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />} |
345 | 330 | </button> |
346 | 331 | ) |
347 | 332 | })} |
348 | | - {freeTextEditing ? ( |
349 | | - <div className={cn(OPTION_ROW_CLASSES, options.length > 0 && 'border-t')}> |
350 | | - {isMulti ? ( |
351 | | - // Checked from the moment the row is clicked into; blur with |
352 | | - // nothing typed reverts to the plain option row. A real button |
353 | | - // (the editing row is a div, so no nesting hazard) so the box |
354 | | - // can be toggled even after typing — unchecking keeps the text, |
355 | | - // it just stops counting toward the answer. |
356 | | - <div className='flex size-[16px] flex-shrink-0 items-center justify-center'> |
357 | | - <button |
358 | | - type='button' |
359 | | - aria-label='Include "Something else" in the answer' |
360 | | - disabled={disabled} |
361 | | - onClick={() => setCustomChecked(!(customCheckedByStep[step] ?? false))} |
362 | | - data-state={(customCheckedByStep[step] ?? false) ? 'checked' : 'unchecked'} |
363 | | - data-disabled={disabled ? '' : undefined} |
364 | | - className={checkboxVariants({ size: 'sm' })} |
365 | | - > |
366 | | - {(customCheckedByStep[step] ?? false) && ( |
367 | | - <Check |
368 | | - className={cn( |
369 | | - checkboxIconVariants({ size: 'sm' }), |
370 | | - 'text-[var(--surface-2)]' |
371 | | - )} |
372 | | - /> |
373 | | - )} |
374 | | - </button> |
375 | | - </div> |
376 | | - ) : ( |
377 | | - <RowNumber value={options.length + 1} /> |
378 | | - )} |
379 | | - <input |
380 | | - ref={freeTextInputRef} |
381 | | - type='text' |
382 | | - value={freeText} |
383 | | - disabled={disabled} |
384 | | - onChange={(e) => setFreeText(e.target.value)} |
385 | | - onBlur={() => { |
386 | | - if (freeText.trim().length === 0) { |
387 | | - setFreeTextEditing(false) |
388 | | - if (isMulti) setCustomChecked(false) |
389 | | - } |
390 | | - }} |
391 | | - onKeyDown={(e) => { |
392 | | - if (e.key === 'Escape') { |
393 | | - e.currentTarget.blur() |
394 | | - return |
395 | | - } |
396 | | - if (e.key === 'Enter' && canSubmitStep) { |
397 | | - e.preventDefault() |
398 | | - if (isMulti) { |
399 | | - submitMultiStep() |
400 | | - } else { |
401 | | - submitSingleFreeText() |
402 | | - } |
403 | | - } |
404 | | - }} |
405 | | - aria-label={question.prompt} |
406 | | - className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none disabled:cursor-not-allowed' |
407 | | - /> |
408 | | - {!isMulti && ( |
| 333 | + <div className={cn(OPTION_ROW_CLASSES, options.length > 0 && 'border-t')}> |
| 334 | + {isMulti && ( |
| 335 | + <div className='flex size-[16px] flex-shrink-0 items-center justify-center'> |
409 | 336 | <button |
| 337 | + ref={freeTextCheckboxRef} |
410 | 338 | type='button' |
411 | | - aria-label='Submit answer' |
412 | | - disabled={!canSubmitStep} |
413 | | - onClick={submitSingleFreeText} |
414 | | - className='disabled:cursor-default' |
| 339 | + aria-label='Include "Something else" in the answer' |
| 340 | + disabled={disabled} |
| 341 | + onClick={toggleCustomChecked} |
| 342 | + data-state={(customCheckedByStep[step] ?? false) ? 'checked' : 'unchecked'} |
| 343 | + data-disabled={disabled ? '' : undefined} |
| 344 | + className={checkboxVariants({ size: 'sm' })} |
415 | 345 | > |
416 | | - <ArrowRight |
417 | | - className={cn( |
418 | | - 'size-[16px] shrink-0 transition-colors', |
419 | | - canSubmitStep ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]' |
420 | | - )} |
421 | | - /> |
| 346 | + {(customCheckedByStep[step] ?? false) && ( |
| 347 | + <Check |
| 348 | + className={cn(checkboxIconVariants({ size: 'sm' }), 'text-[var(--surface-2)]')} |
| 349 | + /> |
| 350 | + )} |
422 | 351 | </button> |
423 | | - )} |
424 | | - </div> |
425 | | - ) : ( |
426 | | - <button |
427 | | - type='button' |
| 352 | + </div> |
| 353 | + )} |
| 354 | + <input |
| 355 | + ref={freeTextInputRef} |
| 356 | + type='text' |
| 357 | + value={freeText} |
| 358 | + placeholder='Something else' |
428 | 359 | disabled={disabled} |
429 | | - onClick={() => { |
430 | | - setFreeTextEditing(true) |
| 360 | + onFocus={() => { |
431 | 361 | if (isMulti) setCustomChecked(true) |
432 | 362 | }} |
433 | | - className={cn( |
434 | | - OPTION_ROW_CLASSES, |
435 | | - options.length > 0 && 'border-t', |
436 | | - disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]' |
437 | | - )} |
438 | | - > |
439 | | - {isMulti ? ( |
440 | | - <RowCheckbox checked={false} disabled={disabled} /> |
441 | | - ) : ( |
442 | | - <RowNumber value={options.length + 1} /> |
443 | | - )} |
444 | | - <span className='flex-1 truncate text-[var(--text-body)] text-sm'>Something else</span> |
445 | | - {!isMulti && <ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />} |
446 | | - </button> |
447 | | - )} |
| 363 | + onChange={(e) => setFreeText(e.target.value)} |
| 364 | + onBlur={(event) => { |
| 365 | + if ( |
| 366 | + isMulti && |
| 367 | + event.relatedTarget !== freeTextCheckboxRef.current && |
| 368 | + freeText.trim().length === 0 |
| 369 | + ) { |
| 370 | + setCustomChecked(false) |
| 371 | + } |
| 372 | + }} |
| 373 | + onKeyDown={(e) => { |
| 374 | + if (e.key === 'Escape') { |
| 375 | + e.currentTarget.blur() |
| 376 | + return |
| 377 | + } |
| 378 | + if (e.key === 'Enter' && canSubmitStep) { |
| 379 | + e.preventDefault() |
| 380 | + if (isMulti) { |
| 381 | + submitMultiStep() |
| 382 | + } else { |
| 383 | + submitSingleFreeText() |
| 384 | + } |
| 385 | + } |
| 386 | + }} |
| 387 | + aria-label={question.prompt} |
| 388 | + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' |
| 389 | + /> |
| 390 | + {!isMulti && ( |
| 391 | + <button |
| 392 | + type='button' |
| 393 | + aria-label='Submit answer' |
| 394 | + disabled={!canSubmitStep} |
| 395 | + onClick={submitSingleFreeText} |
| 396 | + className='disabled:cursor-default' |
| 397 | + > |
| 398 | + <ArrowRight |
| 399 | + className={cn( |
| 400 | + 'size-[16px] shrink-0 transition-colors', |
| 401 | + canSubmitStep ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]' |
| 402 | + )} |
| 403 | + /> |
| 404 | + </button> |
| 405 | + )} |
| 406 | + </div> |
448 | 407 | {isMulti && ( |
449 | 408 | <button |
450 | 409 | type='button' |
|
0 commit comments