@joewinke/jatui 0.38.6 → 0.40.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 (98) hide show
  1. package/CLAUDE.md +54 -5
  2. package/THIRD_PARTY_NOTICES.md +67 -0
  3. package/dist/actions/scrollEdge.d.ts +55 -0
  4. package/dist/actions/scrollEdge.js +144 -0
  5. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
  6. package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
  7. package/dist/components/BottomSheet.svelte +7 -6
  8. package/dist/components/BottomSheet.svelte.d.ts +2 -0
  9. package/dist/components/BulkActionBar.svelte +32 -2
  10. package/dist/components/BulkActionBar.svelte.d.ts +5 -1
  11. package/dist/components/CloseButton.svelte +75 -0
  12. package/dist/components/CloseButton.svelte.d.ts +29 -0
  13. package/dist/components/Drawer.svelte +10 -11
  14. package/dist/components/Drawer.svelte.d.ts +2 -8
  15. package/dist/components/FloatingActionBar.svelte +102 -34
  16. package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
  17. package/dist/components/IconButton.svelte +200 -0
  18. package/dist/components/IconButton.svelte.d.ts +46 -0
  19. package/dist/components/LinkButton.svelte +117 -0
  20. package/dist/components/LinkButton.svelte.d.ts +32 -0
  21. package/dist/components/MediaExplorer.svelte +249 -0
  22. package/dist/components/MediaExplorer.svelte.d.ts +37 -0
  23. package/dist/components/MediaLightbox.svelte +2 -7
  24. package/dist/components/Modal.svelte +2 -10
  25. package/dist/components/NotificationCenter.svelte +481 -0
  26. package/dist/components/NotificationCenter.svelte.d.ts +29 -0
  27. package/dist/components/PinInput.svelte +94 -31
  28. package/dist/components/PinInput.svelte.d.ts +4 -0
  29. package/dist/components/SearchDropdown.svelte +54 -8
  30. package/dist/components/SearchDropdown.svelte.d.ts +12 -0
  31. package/dist/components/TextArea.svelte +67 -5
  32. package/dist/components/TextArea.svelte.d.ts +4 -6
  33. package/dist/components/ThemeSelector.svelte +29 -9
  34. package/dist/components/ThemeSelector.svelte.d.ts +6 -0
  35. package/dist/components/ThemeToggle.svelte +28 -3
  36. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  37. package/dist/components/agent/AgentLog.svelte +217 -0
  38. package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
  39. package/dist/components/agent/AgentLogRow.svelte +281 -0
  40. package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
  41. package/dist/components/agent/AgentTaskList.svelte +607 -0
  42. package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
  43. package/dist/components/agent/AgentThinking.svelte +280 -0
  44. package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
  45. package/dist/components/agent/ComposerGlow.svelte +265 -0
  46. package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
  47. package/dist/components/agent/ContextMeter.svelte +277 -0
  48. package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
  49. package/dist/components/agent/Questionnaire.svelte +767 -0
  50. package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
  51. package/dist/components/agent/ShimmerText.svelte +100 -0
  52. package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
  53. package/dist/components/agent/StreamedText.svelte +135 -0
  54. package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
  55. package/dist/components/agent/agentLog.d.ts +22 -0
  56. package/dist/components/agent/agentLog.js +26 -0
  57. package/dist/components/agent/agentTaskList.d.ts +90 -0
  58. package/dist/components/agent/agentTaskList.js +93 -0
  59. package/dist/components/agent/agentThinking.d.ts +46 -0
  60. package/dist/components/agent/agentThinking.js +78 -0
  61. package/dist/components/agent/contextMeter.d.ts +97 -0
  62. package/dist/components/agent/contextMeter.js +156 -0
  63. package/dist/components/agent/questionnaire.d.ts +104 -0
  64. package/dist/components/agent/questionnaire.js +225 -0
  65. package/dist/components/agent/revealCore.d.ts +74 -0
  66. package/dist/components/agent/revealCore.js +160 -0
  67. package/dist/components/agent/revealTicker.d.ts +34 -0
  68. package/dist/components/agent/revealTicker.js +73 -0
  69. package/dist/components/agent/streamedText.d.ts +28 -0
  70. package/dist/components/agent/streamedText.js +59 -0
  71. package/dist/components/basic/Breadcrumb.svelte +226 -11
  72. package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
  73. package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
  74. package/dist/components/basic/breadcrumbCollapse.js +12 -0
  75. package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
  76. package/dist/components/messaging/MessageInput.svelte +34 -4
  77. package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
  78. package/dist/components/messaging/MessageItem.svelte +2 -2
  79. package/dist/components/notificationCenter.d.ts +65 -0
  80. package/dist/components/notificationCenter.js +110 -0
  81. package/dist/components/progress/Steps.svelte +269 -5
  82. package/dist/components/progress/Steps.svelte.d.ts +16 -1
  83. package/dist/components/progress/stepsSummary.d.ts +14 -0
  84. package/dist/components/progress/stepsSummary.js +32 -0
  85. package/dist/index.d.ts +48 -1
  86. package/dist/index.js +39 -1
  87. package/dist/selection/clickSelection.d.ts +89 -0
  88. package/dist/selection/clickSelection.js +152 -0
  89. package/dist/selection/createClickSelection.d.ts +79 -0
  90. package/dist/selection/createClickSelection.js +134 -0
  91. package/dist/styles/tokens.css +80 -1
  92. package/dist/utils/pinInput.d.ts +33 -0
  93. package/dist/utils/pinInput.js +63 -0
  94. package/dist/utils/textareaRows.d.ts +23 -0
  95. package/dist/utils/textareaRows.js +13 -0
  96. package/dist/utils/themeWipe.d.ts +36 -17
  97. package/dist/utils/themeWipe.js +145 -35
  98. package/package.json +3 -2
@@ -0,0 +1,767 @@
1
+ <!--
2
+ Questionnaire — a step-by-step question card for an agent's plan-mode
3
+ interview. Shaped like Claude Code's AskUserQuestion payload so a host can
4
+ pass `questions` straight through and read `{ values, other? }[]` back.
5
+
6
+ One question per step. Single-select = a radiogroup with a roving tabindex
7
+ (arrows move focus, Space/Enter/click pick, then auto-advance after
8
+ `advanceDelay`); multi-select = a checkbox group. Digit keys 1..N pick
9
+ (N+1 = Other) — handled on the component's own keydown, never a window
10
+ listener, and never while typing in the Other field. Footer: step pills
11
+ (jump anywhere, `aria-current="step"`) + Previous / Next, "Done" on the last
12
+ step fires `onComplete`.
13
+
14
+ Motion: the card height follows the current step (measured, eased on
15
+ --motion-move) and content glides in the direction of travel. Under
16
+ prefers-reduced-motion both render their end state.
17
+
18
+ Answer-state logic lives in ./questionnaire.ts (pure, unit-tested).
19
+ -->
20
+ <script lang="ts">
21
+ import { tick } from 'svelte';
22
+ import { glide } from '../../transitions/glide';
23
+ import { isTypingTarget } from '../../utils/keyboardShortcutRegistry';
24
+ import {
25
+ digitToRow,
26
+ finalizeAnswers,
27
+ firstUnanswered,
28
+ hasPreviews,
29
+ isAnswered,
30
+ isComplete,
31
+ isLastStep,
32
+ isOtherTicked,
33
+ moveRow,
34
+ normalizeAnswers,
35
+ pickOption,
36
+ previewOption,
37
+ rowDigit,
38
+ setOther,
39
+ stepDirection,
40
+ stepLabel,
41
+ tabStopRow,
42
+ toggleOption,
43
+ toggleOther,
44
+ clampStep,
45
+ type QuestionnaireAnswers,
46
+ type QuestionnaireQuestion
47
+ } from './questionnaire';
48
+
49
+ interface Props {
50
+ /** AskUserQuestion-shaped questions. */
51
+ questions: QuestionnaireQuestion[];
52
+ /** Answers aligned with `questions` (bindable). */
53
+ answers?: QuestionnaireAnswers;
54
+ /** Current question index (bindable). */
55
+ step?: number;
56
+ /** Show the free-text "Other" row on every question. */
57
+ allowOther?: boolean;
58
+ otherLabel?: string;
59
+ otherPlaceholder?: string;
60
+ /** Auto-advance delay (ms) after a single-select pick; `false` disables. */
61
+ advanceDelay?: number | false;
62
+ submitLabel?: string;
63
+ nextLabel?: string;
64
+ previousLabel?: string;
65
+ /** Card heading, e.g. "A few questions before I start". */
66
+ title?: string;
67
+ /** Done stays disabled until every question has an answer. */
68
+ requireAll?: boolean;
69
+ disabled?: boolean;
70
+ onChange?: (answers: QuestionnaireAnswers) => void;
71
+ onComplete?: (answers: QuestionnaireAnswers) => void;
72
+ class?: string;
73
+ }
74
+
75
+ let {
76
+ questions,
77
+ answers = $bindable(),
78
+ step = $bindable(0),
79
+ allowOther = true,
80
+ otherLabel = 'Other',
81
+ otherPlaceholder = 'Type your own answer',
82
+ advanceDelay = 180,
83
+ submitLabel = 'Done',
84
+ nextLabel = 'Next',
85
+ previousLabel = 'Previous',
86
+ title,
87
+ requireAll = true,
88
+ disabled = false,
89
+ onChange,
90
+ onComplete,
91
+ class: className = ''
92
+ }: Props = $props();
93
+
94
+ const uid = $props.id();
95
+
96
+ const count = $derived(questions.length);
97
+ const current = $derived(normalizeAnswers(questions, answers));
98
+ const idx = $derived(clampStep(step, count));
99
+ const last = $derived(isLastStep(idx, count));
100
+ const complete = $derived(isComplete(questions, current));
101
+ const answeredCount = $derived(current.filter((a) => isAnswered(a)).length);
102
+
103
+ let root = $state<HTMLElement>();
104
+ let direction = $state<1 | -1>(1);
105
+ let focusedRow = $state<number | 'other' | null>(null);
106
+ let height = $state<number | null>(null);
107
+ let advanceTimer: ReturnType<typeof setTimeout> | undefined;
108
+
109
+ $effect(() => () => clearTimeout(advanceTimer));
110
+
111
+ function commit(next: QuestionnaireAnswers) {
112
+ answers = next;
113
+ onChange?.(next);
114
+ }
115
+
116
+ function rowEl(k: number, row: number | 'other'): HTMLElement | null {
117
+ return root?.querySelector<HTMLElement>(`[data-slide="${k}"] [data-row="${row}"]`) ?? null;
118
+ }
119
+
120
+ function otherInput(k: number): HTMLInputElement | null {
121
+ return root?.querySelector<HTMLInputElement>(`[data-slide="${k}"] [data-other-input]`) ?? null;
122
+ }
123
+
124
+ /** Focus the step's natural entry point: the picked row, else the first. */
125
+ function focusEntry(k: number) {
126
+ const q = questions[k];
127
+ const target = q?.multiSelect
128
+ ? rowEl(k, 0)
129
+ : rowEl(k, tabStopRow(q, current[k]));
130
+ target?.focus();
131
+ }
132
+
133
+ async function go(to: number) {
134
+ clearTimeout(advanceTimer);
135
+ const target = clampStep(to, count);
136
+ if (target === idx) return;
137
+ const hadFocus = !!root && root.contains(document.activeElement);
138
+ const old = root?.querySelector<HTMLElement>(`[data-slide="${idx}"]`);
139
+ const focusWasInSlide = !!old && old.contains(document.activeElement);
140
+ direction = stepDirection(idx, target) === -1 ? -1 : 1;
141
+ // The outgoing slide stays mounted while it glides away — keep it out
142
+ // of the tab order and the accessibility tree.
143
+ old?.setAttribute('inert', '');
144
+ focusedRow = null;
145
+ step = target;
146
+ await tick();
147
+ // A quick back-and-forth can revive a slide mid-outro; it is live again.
148
+ root?.querySelector(`[data-slide="${target}"]`)?.removeAttribute('inert');
149
+ if (hadFocus && focusWasInSlide) focusEntry(target);
150
+ }
151
+
152
+ function finish() {
153
+ clearTimeout(advanceTimer);
154
+ if (disabled) return;
155
+ if (requireAll && !complete) {
156
+ go(firstUnanswered(questions, current));
157
+ return;
158
+ }
159
+ onComplete?.(finalizeAnswers(questions, current));
160
+ }
161
+
162
+ function scheduleAdvance(k: number) {
163
+ clearTimeout(advanceTimer);
164
+ if (advanceDelay === false || isLastStep(k, count)) return;
165
+ advanceTimer = setTimeout(() => {
166
+ if (k === idx) go(k + 1);
167
+ }, Math.max(0, advanceDelay));
168
+ }
169
+
170
+ function pick(k: number, label: string) {
171
+ if (disabled) return;
172
+ commit(pickOption(questions, current, k, label));
173
+ scheduleAdvance(k);
174
+ }
175
+
176
+ function toggle(k: number, label: string) {
177
+ if (disabled) return;
178
+ commit(toggleOption(questions, current, k, label));
179
+ }
180
+
181
+ async function tickOther(k: number) {
182
+ if (disabled) return;
183
+ clearTimeout(advanceTimer);
184
+ const q = questions[k];
185
+ const wasTicked = isOtherTicked(current[k]);
186
+ commit(toggleOther(questions, current, k));
187
+ if (!q?.multiSelect || !wasTicked) {
188
+ await tick();
189
+ otherInput(k)?.focus();
190
+ }
191
+ }
192
+
193
+ function typeOther(k: number, text: string) {
194
+ if (disabled) return;
195
+ clearTimeout(advanceTimer);
196
+ commit(setOther(questions, current, k, text));
197
+ }
198
+
199
+ function onOtherKeydown(e: KeyboardEvent, k: number) {
200
+ if (e.key !== 'Enter' || e.isComposing) return;
201
+ e.preventDefault();
202
+ if (questions[k]?.multiSelect || !isAnswered(current[k])) return;
203
+ if (isLastStep(k, count)) finish();
204
+ else go(k + 1);
205
+ }
206
+
207
+ /** Roving focus inside a single-select radiogroup. */
208
+ function onGroupKeydown(e: KeyboardEvent, k: number) {
209
+ const q = questions[k];
210
+ if (!q || q.multiSelect) return;
211
+ if (e.target instanceof HTMLElement && e.target.hasAttribute('data-other-input')) return;
212
+ const rows = q.options.length + (allowOther ? 1 : 0);
213
+ const raw = (e.target as HTMLElement | null)?.getAttribute('data-row');
214
+ const cur = raw === 'other' ? q.options.length : Number(raw ?? 0);
215
+ const next = moveRow(e.key, cur, rows);
216
+ if (next === null) return;
217
+ e.preventDefault();
218
+ rowEl(k, next === q.options.length && allowOther ? 'other' : next)?.focus();
219
+ }
220
+
221
+ /** Digit keys — scoped to this component's subtree. */
222
+ function onRootKeydown(e: KeyboardEvent) {
223
+ if (disabled || e.defaultPrevented) return;
224
+ if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
225
+ if (isTypingTarget(e.target)) return;
226
+ const q = questions[idx];
227
+ if (!q) return;
228
+ const row = digitToRow(e.key, q.options.length, allowOther);
229
+ if (row === null) return;
230
+ e.preventDefault();
231
+ if (row === 'other') {
232
+ if (!q.multiSelect && isOtherTicked(current[idx])) otherInput(idx)?.focus();
233
+ else tickOther(idx);
234
+ return;
235
+ }
236
+ const label = q.options[row].label;
237
+ if (q.multiSelect) toggle(idx, label);
238
+ else {
239
+ pick(idx, label);
240
+ rowEl(idx, row)?.focus();
241
+ }
242
+ }
243
+
244
+ /** Track the current step's rendered height so the card can ease between steps. */
245
+ function measure(node: HTMLElement, k: number) {
246
+ let key = k;
247
+ const ro = new ResizeObserver(() => {
248
+ if (key === idx) height = node.offsetHeight;
249
+ });
250
+ ro.observe(node);
251
+ return {
252
+ update(next: number) {
253
+ key = next;
254
+ },
255
+ destroy() {
256
+ ro.disconnect();
257
+ }
258
+ };
259
+ }
260
+
261
+ const fromSide = (dir: 1 | -1, entering: boolean) =>
262
+ (dir === 1) === entering ? ('right' as const) : ('left' as const);
263
+
264
+ const pillLabel = (i: number) => {
265
+ const q = questions[i];
266
+ const name = q?.header ?? `Question ${i + 1}`;
267
+ return `${name}${isAnswered(current[i]) ? ' (answered)' : ''}`;
268
+ };
269
+ </script>
270
+
271
+ <!-- Delegated key handling: digit shortcuts for the rows inside, scoped to this subtree. -->
272
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
273
+ <section
274
+ bind:this={root}
275
+ class="jatui-questionnaire card bg-base-100 border-base-300 rounded-box border {className}"
276
+ aria-labelledby={title ? `${uid}-title` : undefined}
277
+ aria-label={title ? undefined : 'Questions'}
278
+ onkeydown={onRootKeydown}
279
+ >
280
+ <div class="q-body">
281
+ <header class="q-header">
282
+ {#if title}
283
+ <h3 id="{uid}-title" class="q-title">{title}</h3>
284
+ {/if}
285
+ <p class="q-counter text-base-content/60" aria-hidden="true">
286
+ {#if questions[idx]?.header}
287
+ <span class="badge badge-sm badge-ghost">{questions[idx].header}</span>
288
+ {/if}
289
+ <span>{stepLabel(idx, count)}</span>
290
+ </p>
291
+ <span class="sr-only" aria-live="polite" aria-atomic="true">{count ? stepLabel(idx, count) : ''}</span>
292
+ </header>
293
+
294
+ <div class="q-viewport" style:height={height === null ? undefined : `${height}px`}>
295
+ <div class="q-track">
296
+ {#each [idx] as k (k)}
297
+ {@const q = questions[k]}
298
+ {@const a = current[k]}
299
+ {@const showPreviews = hasPreviews(q)}
300
+ {@const pv = previewOption(q, a, typeof focusedRow === 'number' ? focusedRow : null)}
301
+ {#if q}
302
+ <div
303
+ class="q-slide"
304
+ data-slide={k}
305
+ use:measure={k}
306
+ in:glide={{ from: fromSide(direction, true), distance: 24 }}
307
+ out:glide={{ from: fromSide(direction, false), distance: 24 }}
308
+ >
309
+ <fieldset class="q-fieldset" {disabled}>
310
+ <legend class="q-legend" id="{uid}-q{k}-legend">{q.question}</legend>
311
+ <p class="q-mode text-base-content/60" id="{uid}-q{k}-mode">
312
+ {q.multiSelect ? 'Select all that apply' : 'Choose one'}
313
+ </p>
314
+ <div class="q-layout" class:has-preview={showPreviews}>
315
+ {#if q.multiSelect}
316
+ <div class="q-rows" aria-describedby="{uid}-q{k}-mode">
317
+ {#each q.options as opt, i (opt.label)}
318
+ {@const checked = a.values.includes(opt.label)}
319
+ <label
320
+ class="q-row border-base-300 rounded-field"
321
+ class:is-selected={checked}
322
+ >
323
+ <input
324
+ type="checkbox"
325
+ class="checkbox checkbox-sm checkbox-primary"
326
+ data-row={i}
327
+ {checked}
328
+ aria-describedby={opt.description ? `${uid}-q${k}-o${i}-d` : undefined}
329
+ onchange={() => toggle(k, opt.label)}
330
+ onfocus={() => (focusedRow = i)}
331
+ onblur={() => (focusedRow = null)}
332
+ />
333
+ <span class="q-text">
334
+ <span class="q-label">{opt.label}</span>
335
+ {#if opt.description}
336
+ <span class="q-desc text-base-content/60" id="{uid}-q{k}-o{i}-d"
337
+ >{opt.description}</span
338
+ >
339
+ {/if}
340
+ </span>
341
+ {#if rowDigit(i)}<kbd class="kbd kbd-xs" aria-hidden="true">{rowDigit(i)}</kbd>{/if}
342
+ </label>
343
+ {/each}
344
+ {#if allowOther}
345
+ {@const ticked = isOtherTicked(a)}
346
+ <div class="q-row q-other border-base-300 rounded-field" class:is-selected={ticked}>
347
+ <input
348
+ type="checkbox"
349
+ class="checkbox checkbox-sm checkbox-primary"
350
+ data-row="other"
351
+ checked={ticked}
352
+ aria-label={otherLabel}
353
+ onchange={() => tickOther(k)}
354
+ />
355
+ <span class="q-label" aria-hidden="true">{otherLabel}</span>
356
+ <input
357
+ type="text"
358
+ class="input input-sm q-other-input"
359
+ data-other-input
360
+ placeholder={otherPlaceholder}
361
+ aria-label="{otherLabel}: your answer"
362
+ value={a.other ?? ''}
363
+ oninput={(e) => typeOther(k, e.currentTarget.value)}
364
+ onkeydown={(e) => onOtherKeydown(e, k)}
365
+ />
366
+ {#if rowDigit(q.options.length)}<kbd class="kbd kbd-xs" aria-hidden="true"
367
+ >{rowDigit(q.options.length)}</kbd
368
+ >{/if}
369
+ </div>
370
+ {/if}
371
+ </div>
372
+ {:else}
373
+ {@const stop = tabStopRow(q, a)}
374
+ <div
375
+ class="q-rows"
376
+ role="radiogroup"
377
+ aria-labelledby="{uid}-q{k}-legend"
378
+ aria-describedby="{uid}-q{k}-mode"
379
+ tabindex="-1"
380
+ onkeydown={(e) => onGroupKeydown(e, k)}
381
+ >
382
+ {#each q.options as opt, i (opt.label)}
383
+ {@const checked = a.values[0] === opt.label}
384
+ <button
385
+ type="button"
386
+ role="radio"
387
+ class="q-row border-base-300 rounded-field"
388
+ class:is-selected={checked}
389
+ data-row={i}
390
+ aria-checked={checked}
391
+ aria-describedby={opt.description ? `${uid}-q${k}-o${i}-d` : undefined}
392
+ tabindex={stop === i ? 0 : -1}
393
+ {disabled}
394
+ onclick={() => pick(k, opt.label)}
395
+ onfocus={() => (focusedRow = i)}
396
+ onblur={() => (focusedRow = null)}
397
+ >
398
+ <span class="q-dot" aria-hidden="true"></span>
399
+ <span class="q-text">
400
+ <span class="q-label">{opt.label}</span>
401
+ {#if opt.description}
402
+ <span class="q-desc text-base-content/60" id="{uid}-q{k}-o{i}-d"
403
+ >{opt.description}</span
404
+ >
405
+ {/if}
406
+ </span>
407
+ {#if rowDigit(i)}<kbd class="kbd kbd-xs" aria-hidden="true">{rowDigit(i)}</kbd>{/if}
408
+ </button>
409
+ {/each}
410
+ {#if allowOther}
411
+ {@const ticked = isOtherTicked(a)}
412
+ <div class="q-row q-other border-base-300 rounded-field" class:is-selected={ticked}>
413
+ <button
414
+ type="button"
415
+ role="radio"
416
+ class="q-other-radio"
417
+ data-row="other"
418
+ aria-checked={ticked}
419
+ tabindex={stop === 'other' ? 0 : -1}
420
+ {disabled}
421
+ onclick={() => tickOther(k)}
422
+ >
423
+ <span class="q-dot" aria-hidden="true"></span>
424
+ <span class="q-label">{otherLabel}</span>
425
+ </button>
426
+ <input
427
+ type="text"
428
+ class="input input-sm q-other-input"
429
+ data-other-input
430
+ placeholder={otherPlaceholder}
431
+ aria-label="{otherLabel}: your answer"
432
+ tabindex={ticked ? 0 : -1}
433
+ value={a.other ?? ''}
434
+ oninput={(e) => typeOther(k, e.currentTarget.value)}
435
+ onkeydown={(e) => onOtherKeydown(e, k)}
436
+ />
437
+ {#if rowDigit(q.options.length)}<kbd class="kbd kbd-xs" aria-hidden="true"
438
+ >{rowDigit(q.options.length)}</kbd
439
+ >{/if}
440
+ </div>
441
+ {/if}
442
+ </div>
443
+ {/if}
444
+
445
+ {#if showPreviews}
446
+ <div class="q-preview border-base-300 bg-base-200 rounded-field">
447
+ {#if pv !== null}
448
+ <p class="q-preview-label text-base-content/60">Preview · {q.options[pv].label}</p>
449
+ <pre class="q-preview-code">{q.options[pv].preview}</pre>
450
+ {:else}
451
+ <p class="q-preview-label text-base-content/60">Focus an option to preview it</p>
452
+ {/if}
453
+ </div>
454
+ {/if}
455
+ </div>
456
+ </fieldset>
457
+ </div>
458
+ {/if}
459
+ {/each}
460
+ </div>
461
+ </div>
462
+
463
+ <footer class="q-footer border-base-300">
464
+ <nav class="q-pills" aria-label="Questions">
465
+ <ol>
466
+ {#each questions as _, i (i)}
467
+ <li>
468
+ <button
469
+ type="button"
470
+ class="q-pill rounded-field"
471
+ class:is-current={i === idx}
472
+ class:is-answered={isAnswered(current[i])}
473
+ aria-current={i === idx ? 'step' : undefined}
474
+ aria-label={pillLabel(i)}
475
+ title={questions[i].question}
476
+ {disabled}
477
+ onclick={() => go(i)}
478
+ >
479
+ <span aria-hidden="true">{questions[i].header ?? i + 1}</span>
480
+ </button>
481
+ </li>
482
+ {/each}
483
+ </ol>
484
+ </nav>
485
+ <div class="q-actions">
486
+ {#if requireAll && last && !complete}
487
+ <span class="q-hint text-base-content/60">{answeredCount} of {count} answered</span>
488
+ {/if}
489
+ <button type="button" class="btn btn-sm btn-ghost" disabled={disabled || idx === 0} onclick={() => go(idx - 1)}>
490
+ {previousLabel}
491
+ </button>
492
+ {#if last}
493
+ <button
494
+ type="button"
495
+ class="btn btn-sm btn-primary"
496
+ disabled={disabled || (requireAll && !complete)}
497
+ onclick={finish}
498
+ >
499
+ {submitLabel}
500
+ </button>
501
+ {:else}
502
+ <button type="button" class="btn btn-sm btn-primary" {disabled} onclick={() => go(idx + 1)}>
503
+ {nextLabel}
504
+ </button>
505
+ {/if}
506
+ </div>
507
+ </footer>
508
+ </div>
509
+ </section>
510
+
511
+ <style>
512
+ .jatui-questionnaire {
513
+ border-width: var(--jatui-border-w, 1px);
514
+ }
515
+ .q-body {
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: var(--jatui-space-lg, 12px);
519
+ padding: var(--jatui-space-xl, 16px);
520
+ }
521
+ .q-header {
522
+ display: flex;
523
+ flex-direction: column;
524
+ gap: var(--jatui-space-sm, 4px);
525
+ }
526
+ .q-title {
527
+ font-weight: 600;
528
+ font-size: 0.9375rem;
529
+ }
530
+ .q-counter {
531
+ display: flex;
532
+ align-items: center;
533
+ gap: var(--jatui-space-base, 8px);
534
+ font-size: 0.75rem;
535
+ font-variant-numeric: tabular-nums;
536
+ }
537
+
538
+ /* Height follows the current slide. Padding + negative margin give focus
539
+ rings room inside the clip box. */
540
+ .q-viewport {
541
+ box-sizing: content-box;
542
+ padding: 4px;
543
+ margin: -4px;
544
+ overflow: hidden;
545
+ transition: height var(--motion-move, 300ms) var(--motion-standard, ease);
546
+ }
547
+ .q-track {
548
+ display: grid;
549
+ }
550
+ .q-slide {
551
+ grid-area: 1 / 1;
552
+ min-width: 0;
553
+ }
554
+ .q-fieldset {
555
+ min-width: 0;
556
+ margin: 0;
557
+ padding: 0;
558
+ border: 0;
559
+ }
560
+ .q-legend {
561
+ padding: 0;
562
+ font-weight: 600;
563
+ font-size: 1rem;
564
+ line-height: 1.4;
565
+ }
566
+ .q-mode {
567
+ margin-top: var(--jatui-space-xs, 2px);
568
+ margin-bottom: var(--jatui-space-base, 8px);
569
+ font-size: 0.75rem;
570
+ }
571
+ .q-layout {
572
+ display: grid;
573
+ gap: var(--jatui-space-lg, 12px);
574
+ }
575
+ @media (min-width: 48rem) {
576
+ .q-layout.has-preview {
577
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
578
+ align-items: start;
579
+ }
580
+ }
581
+ .q-rows {
582
+ display: flex;
583
+ flex-direction: column;
584
+ gap: var(--jatui-space-md, 6px);
585
+ outline: none;
586
+ }
587
+ .q-row {
588
+ display: flex;
589
+ align-items: flex-start;
590
+ gap: var(--jatui-space-lg, 12px);
591
+ width: 100%;
592
+ padding: var(--jatui-space-base, 8px) var(--jatui-space-lg, 12px);
593
+ border-width: var(--jatui-border-w, 1px);
594
+ border-style: solid;
595
+ background: var(--color-base-100);
596
+ text-align: start;
597
+ cursor: pointer;
598
+ transition:
599
+ border-color var(--motion-fast, 150ms) var(--motion-glide, ease),
600
+ background-color var(--motion-fast, 150ms) var(--motion-glide, ease);
601
+ }
602
+ .q-row:hover {
603
+ background: color-mix(in oklab, var(--color-base-content) 4%, var(--color-base-100));
604
+ }
605
+ .q-row.is-selected {
606
+ border-color: var(--color-primary);
607
+ background: color-mix(in oklab, var(--color-primary) 7%, var(--color-base-100));
608
+ }
609
+ .q-row:focus-visible,
610
+ .q-other-radio:focus-visible,
611
+ .q-pill:focus-visible {
612
+ outline: 2px solid var(--color-primary);
613
+ outline-offset: 2px;
614
+ }
615
+ .q-row:has(input[type='checkbox']:focus-visible) {
616
+ outline: 2px solid var(--color-primary);
617
+ outline-offset: 2px;
618
+ }
619
+ fieldset:disabled .q-row,
620
+ fieldset:disabled .q-other-radio {
621
+ cursor: not-allowed;
622
+ opacity: 0.6;
623
+ }
624
+ .q-row > input[type='checkbox'] {
625
+ margin-top: 0.125rem;
626
+ flex: none;
627
+ }
628
+ .q-text {
629
+ display: flex;
630
+ flex: 1;
631
+ min-width: 0;
632
+ flex-direction: column;
633
+ gap: var(--jatui-space-xs, 2px);
634
+ }
635
+ .q-label {
636
+ font-size: 0.875rem;
637
+ font-weight: 500;
638
+ }
639
+ .q-desc {
640
+ font-size: 0.8125rem;
641
+ line-height: 1.4;
642
+ }
643
+ .q-row kbd {
644
+ flex: none;
645
+ margin-top: 0.0625rem;
646
+ }
647
+ .q-dot {
648
+ flex: none;
649
+ box-sizing: border-box;
650
+ width: 1rem;
651
+ height: 1rem;
652
+ margin-top: 0.125rem;
653
+ border-radius: 9999px;
654
+ border: 1.5px solid color-mix(in oklab, var(--color-base-content) 35%, transparent);
655
+ transition:
656
+ border-color var(--motion-fast, 150ms) var(--motion-glide, ease),
657
+ border-width var(--motion-fast, 150ms) var(--motion-glide, ease);
658
+ }
659
+ .is-selected .q-dot {
660
+ border-color: var(--color-primary);
661
+ border-width: 5px;
662
+ }
663
+ .q-other {
664
+ align-items: center;
665
+ cursor: default;
666
+ }
667
+ .q-other-radio {
668
+ display: flex;
669
+ flex: none;
670
+ align-items: center;
671
+ gap: var(--jatui-space-lg, 12px);
672
+ border-radius: var(--jatui-radius-field, 0.25rem);
673
+ cursor: pointer;
674
+ background: none;
675
+ padding: 0;
676
+ }
677
+ .q-other-radio .q-dot {
678
+ margin-top: 0;
679
+ }
680
+ .q-other-input {
681
+ flex: 1;
682
+ min-width: 0;
683
+ }
684
+ .q-preview {
685
+ border-width: var(--jatui-border-w, 1px);
686
+ border-style: solid;
687
+ padding: var(--jatui-space-base, 8px) var(--jatui-space-lg, 12px);
688
+ min-width: 0;
689
+ }
690
+ .q-preview-label {
691
+ font-size: 0.75rem;
692
+ margin-bottom: var(--jatui-space-sm, 4px);
693
+ }
694
+ .q-preview-code {
695
+ margin: 0;
696
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
697
+ font-size: 0.75rem;
698
+ line-height: 1.5;
699
+ white-space: pre;
700
+ overflow-x: auto;
701
+ }
702
+ .q-footer {
703
+ display: flex;
704
+ flex-wrap: wrap;
705
+ align-items: center;
706
+ justify-content: space-between;
707
+ gap: var(--jatui-space-base, 8px);
708
+ padding-top: var(--jatui-space-lg, 12px);
709
+ border-top-width: var(--jatui-border-w, 1px);
710
+ border-top-style: solid;
711
+ }
712
+ .q-pills ol {
713
+ display: flex;
714
+ flex-wrap: wrap;
715
+ gap: var(--jatui-space-sm, 4px);
716
+ margin: 0;
717
+ padding: 0;
718
+ list-style: none;
719
+ }
720
+ .q-pill {
721
+ min-width: 1.75rem;
722
+ padding: 0.125rem var(--jatui-space-base, 8px);
723
+ font-size: 0.75rem;
724
+ border: var(--jatui-border-w, 1px) solid var(--color-base-300);
725
+ background: var(--color-base-100);
726
+ color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
727
+ cursor: pointer;
728
+ transition:
729
+ background-color var(--motion-fast, 150ms) var(--motion-glide, ease),
730
+ color var(--motion-fast, 150ms) var(--motion-glide, ease);
731
+ }
732
+ .q-pill.is-answered {
733
+ border-color: color-mix(in oklab, var(--color-primary) 45%, var(--color-base-300));
734
+ color: var(--color-base-content);
735
+ }
736
+ .q-pill.is-answered::before {
737
+ content: '✓ ';
738
+ color: var(--color-primary);
739
+ }
740
+ .q-pill.is-current {
741
+ background: var(--color-primary);
742
+ border-color: var(--color-primary);
743
+ color: var(--color-primary-content);
744
+ }
745
+ .q-pill.is-current.is-answered::before {
746
+ color: inherit;
747
+ }
748
+ .q-actions {
749
+ display: flex;
750
+ align-items: center;
751
+ gap: var(--jatui-space-sm, 4px);
752
+ margin-inline-start: auto;
753
+ }
754
+ .q-hint {
755
+ font-size: 0.75rem;
756
+ margin-inline-end: var(--jatui-space-sm, 4px);
757
+ }
758
+
759
+ @media (prefers-reduced-motion: reduce) {
760
+ .q-viewport,
761
+ .q-row,
762
+ .q-dot,
763
+ .q-pill {
764
+ transition: none;
765
+ }
766
+ }
767
+ </style>