@a4ui/core 0.37.0 → 0.38.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 (105) hide show
  1. package/README.md +8 -7
  2. package/dist/Collapse-Cr5cDqAl.js +375 -0
  3. package/dist/charts/AreaChart.d.ts +38 -0
  4. package/dist/charts/BarChart.d.ts +17 -1
  5. package/dist/charts/PieChart.d.ts +29 -0
  6. package/dist/charts/RadialBarChart.d.ts +33 -0
  7. package/dist/charts/index.d.ts +5 -0
  8. package/dist/charts/shared.d.ts +111 -0
  9. package/dist/charts.js +1493 -780
  10. package/dist/commerce.js +625 -634
  11. package/dist/elements.css +1076 -8
  12. package/dist/elements.iife.js +1 -6
  13. package/dist/elements.js +7774 -9133
  14. package/dist/full.css +1076 -8
  15. package/dist/index.d.ts +30 -3
  16. package/dist/index.js +17502 -15098
  17. package/dist/layout/Aurora.d.ts +16 -0
  18. package/dist/layout/EffectsToggle.d.ts +1 -1
  19. package/dist/layout/SpaceBackground.d.ts +1 -1
  20. package/dist/lib/anchored.d.ts +16 -0
  21. package/dist/motion-CTIBwMCA.js +104 -0
  22. package/dist/styles.css +110 -3
  23. package/dist/ui/AspectRatio.d.ts +19 -0
  24. package/dist/ui/Attachment.d.ts +70 -0
  25. package/dist/ui/ButtonGroup.d.ts +37 -0
  26. package/dist/ui/ChatMarker.d.ts +20 -0
  27. package/dist/ui/ChatThread.d.ts +32 -8
  28. package/dist/ui/ContactSection.d.ts +49 -0
  29. package/dist/ui/CtaBanner.d.ts +28 -0
  30. package/dist/ui/DateField.d.ts +2 -2
  31. package/dist/ui/Drawer.d.ts +5 -2
  32. package/dist/ui/Faq.d.ts +29 -0
  33. package/dist/ui/FeatureGrid.d.ts +35 -0
  34. package/dist/ui/Field.d.ts +148 -0
  35. package/dist/ui/Hero.d.ts +48 -0
  36. package/dist/ui/InputGroup.d.ts +93 -0
  37. package/dist/ui/Item.d.ts +63 -0
  38. package/dist/ui/Label.d.ts +23 -0
  39. package/dist/ui/Menubar.d.ts +83 -0
  40. package/dist/ui/Message.d.ts +51 -5
  41. package/dist/ui/Modal.d.ts +8 -2
  42. package/dist/ui/NativeSelect.d.ts +26 -0
  43. package/dist/ui/NavigationMenu.d.ts +85 -0
  44. package/dist/ui/PromptComposer.d.ts +5 -0
  45. package/dist/ui/Prose.d.ts +21 -0
  46. package/dist/ui/Questionnaire.d.ts +71 -0
  47. package/dist/ui/ScrollArea.d.ts +30 -0
  48. package/dist/ui/Sidebar.d.ts +229 -0
  49. package/dist/ui/SiteFooter.d.ts +39 -0
  50. package/dist/ui/SiteNav.d.ts +35 -0
  51. package/dist/ui/TeamGrid.d.ts +33 -0
  52. package/dist/ui/Testimonials.d.ts +37 -0
  53. package/dist/ui/internal/sectionKit.d.ts +22 -0
  54. package/dist/ui/internal/socialIcons.d.ts +9 -0
  55. package/package.json +38 -26
  56. package/preset.d.ts +23 -0
  57. package/src/charts/AreaChart.tsx +206 -0
  58. package/src/charts/BarChart.tsx +241 -5
  59. package/src/charts/DonutChart.tsx +8 -11
  60. package/src/charts/LineChart.tsx +2 -3
  61. package/src/charts/PieChart.tsx +145 -0
  62. package/src/charts/RadialBarChart.tsx +141 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/shared.tsx +304 -0
  65. package/src/index.ts +192 -3
  66. package/src/layout/Aurora.tsx +36 -7
  67. package/src/layout/sceneEffects.ts +22 -3
  68. package/src/lib/anchored.ts +57 -0
  69. package/src/ui/AspectRatio.tsx +35 -0
  70. package/src/ui/Attachment.tsx +189 -0
  71. package/src/ui/ButtonGroup.tsx +89 -0
  72. package/src/ui/ChatMarker.tsx +58 -0
  73. package/src/ui/ChatThread.tsx +115 -23
  74. package/src/ui/ContactSection.tsx +199 -0
  75. package/src/ui/CtaBanner.tsx +62 -0
  76. package/src/ui/DateField.tsx +17 -7
  77. package/src/ui/DateRangePicker.tsx +15 -5
  78. package/src/ui/Drawer.tsx +37 -4
  79. package/src/ui/Faq.tsx +57 -0
  80. package/src/ui/FeatureGrid.tsx +77 -0
  81. package/src/ui/Field.tsx +374 -0
  82. package/src/ui/Hero.tsx +146 -0
  83. package/src/ui/InputGroup.tsx +219 -0
  84. package/src/ui/Item.tsx +185 -0
  85. package/src/ui/Label.tsx +50 -0
  86. package/src/ui/Menubar.tsx +212 -0
  87. package/src/ui/Message.tsx +139 -13
  88. package/src/ui/Modal.tsx +41 -5
  89. package/src/ui/MultiSelect.tsx +15 -5
  90. package/src/ui/NativeSelect.tsx +66 -0
  91. package/src/ui/NavigationMenu.tsx +262 -0
  92. package/src/ui/PromptComposer.tsx +11 -0
  93. package/src/ui/Prose.tsx +63 -0
  94. package/src/ui/Questionnaire.tsx +331 -0
  95. package/src/ui/ScrollArea.tsx +70 -0
  96. package/src/ui/Sidebar.tsx +753 -0
  97. package/src/ui/SiteFooter.tsx +112 -0
  98. package/src/ui/SiteNav.tsx +146 -0
  99. package/src/ui/TeamGrid.tsx +81 -0
  100. package/src/ui/Testimonials.tsx +103 -0
  101. package/src/ui/TimeField.tsx +15 -5
  102. package/src/ui/internal/sectionKit.tsx +72 -0
  103. package/src/ui/internal/socialIcons.tsx +41 -0
  104. package/dist/NumberInput-Bnh2u7pp.js +0 -381
  105. package/dist/motion-jYMWmZqB.js +0 -119
@@ -0,0 +1,262 @@
1
+ // Site navigation with rich dropdown panels on Kobalte's NavigationMenu
2
+ // primitive. Desktop: triggers open panels rendered into ONE shared viewport
3
+ // that animates its size between panels. Mobile (< md): collapses to a
4
+ // hamburger button that opens a Drawer with an Accordion of the sections.
5
+ import { NavigationMenu as KNav } from '@kobalte/core/navigation-menu'
6
+ import { ChevronDown, Menu as MenuIcon } from 'lucide-solid'
7
+ import type { JSX, ParentProps } from 'solid-js'
8
+ import { createSignal, For, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { useMediaQuery } from '../lib/media'
12
+ import { Accordion } from './Accordion'
13
+ import { Button } from './Button'
14
+ import { Drawer } from './Drawer'
15
+
16
+ const TRIGGER =
17
+ 'inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted data-[expanded]:bg-muted focus-visible:ring-2 focus-visible:ring-ring'
18
+
19
+ /** One section of the collapsed (mobile) navigation. */
20
+ export interface NavigationMenuMobileSection {
21
+ /** Section title shown on the accordion trigger (or the link label). */
22
+ label: string
23
+ /** Destination when the section is a plain link with no children. */
24
+ href?: string
25
+ /** Child links; when present the section becomes an accordion entry. */
26
+ links?: { title: string; href: string; description?: string }[]
27
+ }
28
+
29
+ /** Props of {@link NavigationMenu}. */
30
+ export interface NavigationMenuProps extends ParentProps {
31
+ /** Accessible name of the navigation landmark. @default 'Main' */
32
+ label?: string
33
+ /**
34
+ * The same sections as `children`, as data. Used below the `md` breakpoint,
35
+ * where the menu collapses into a hamburger + drawer. Required: the mobile
36
+ * layout cannot be derived from the desktop JSX.
37
+ */
38
+ mobileSections: NavigationMenuMobileSection[]
39
+ /** Drawer title. @default 'Menu' */
40
+ mobileTitle?: string
41
+ /** Accessible label of the hamburger button. @default 'Open navigation' */
42
+ mobileButtonLabel?: string
43
+ class?: string
44
+ }
45
+
46
+ /**
47
+ * Site navigation bar whose triggers open rich panels (links with icon, title
48
+ * and description, featured cards) inside a shared, size-animating viewport.
49
+ * Hover or click opens a panel, Escape closes it, and the links inside are
50
+ * reachable with Tab. The root is the `<ul>` list of items.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * <NavigationMenu mobileSections={[{ label: 'Docs', href: '/docs' }]}>
55
+ * <NavigationMenuItem>
56
+ * <NavigationMenuTrigger>Product</NavigationMenuTrigger>
57
+ * <NavigationMenuContent>
58
+ * <NavigationMenuPanelLink href="/tour" title="Tour" description="A quick walkthrough" />
59
+ * </NavigationMenuContent>
60
+ * </NavigationMenuItem>
61
+ * <NavigationMenuLink href="/docs">Docs</NavigationMenuLink>
62
+ * </NavigationMenu>
63
+ * ```
64
+ */
65
+ export function NavigationMenu(props: NavigationMenuProps): JSX.Element {
66
+ const isDesktop = useMediaQuery('(min-width: 768px)')
67
+ return (
68
+ <Show when={isDesktop()} fallback={<MobileNavigation {...props} />}>
69
+ <KNav aria-label={props.label ?? 'Main'} class={cn('relative flex items-center gap-1', props.class)}>
70
+ {props.children}
71
+ <KNav.Viewport class="navmenu-viewport bg-glass relative z-50 mt-2 h-[var(--kb-navigation-menu-viewport-height)] w-[var(--kb-navigation-menu-viewport-width)] overflow-hidden rounded-2xl shadow-2xl transition-[width,height] duration-300 ease-out">
72
+ <KNav.Arrow class="hidden" />
73
+ </KNav.Viewport>
74
+ </KNav>
75
+ </Show>
76
+ )
77
+ }
78
+
79
+ function MobileNavigation(props: NavigationMenuProps): JSX.Element {
80
+ const [open, setOpen] = createSignal(false)
81
+ const withLinks = () => props.mobileSections.filter((s) => s.links?.length)
82
+ const plain = () => props.mobileSections.filter((s) => !s.links?.length)
83
+ return (
84
+ <nav aria-label={props.label ?? 'Main'} class={props.class}>
85
+ <Button
86
+ variant="outline"
87
+ aria-label={props.mobileButtonLabel ?? 'Open navigation'}
88
+ aria-haspopup="dialog"
89
+ aria-expanded={open()}
90
+ onClick={() => setOpen(true)}
91
+ >
92
+ <MenuIcon class="h-4 w-4" />
93
+ </Button>
94
+ <Drawer side="left" open={open()} onOpenChange={setOpen} title={props.mobileTitle ?? 'Menu'}>
95
+ <div
96
+ class="p-5"
97
+ onKeyDown={(e) => {
98
+ // Escape typed while an accordion trigger has focus does not reach
99
+ // the dialog's own dismiss handler, so close explicitly.
100
+ if (e.key === 'Escape') setOpen(false)
101
+ }}
102
+ >
103
+ <Accordion
104
+ items={withLinks().map((s) => ({
105
+ value: s.label,
106
+ title: s.label,
107
+ content: (
108
+ <ul class="space-y-1">
109
+ <For each={s.links}>
110
+ {(l) => (
111
+ <li>
112
+ <a
113
+ href={l.href}
114
+ class="block rounded-md px-2 py-1.5 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
115
+ >
116
+ <span class="block text-sm font-medium text-foreground">{l.title}</span>
117
+ <Show when={l.description}>
118
+ <span class="block text-xs text-muted-foreground">{l.description}</span>
119
+ </Show>
120
+ </a>
121
+ </li>
122
+ )}
123
+ </For>
124
+ </ul>
125
+ ),
126
+ }))}
127
+ />
128
+ <ul class="mt-2 space-y-1">
129
+ <For each={plain()}>
130
+ {(s) => (
131
+ <li>
132
+ <a
133
+ href={s.href}
134
+ class="block rounded-md px-2 py-3 text-sm font-medium text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
135
+ >
136
+ {s.label}
137
+ </a>
138
+ </li>
139
+ )}
140
+ </For>
141
+ </ul>
142
+ </div>
143
+ </Drawer>
144
+ </nav>
145
+ )
146
+ }
147
+
148
+ /** One top-level entry: pairs a trigger with its panel. */
149
+ export function NavigationMenuItem(props: ParentProps): JSX.Element {
150
+ return <KNav.Menu>{props.children}</KNav.Menu>
151
+ }
152
+
153
+ /** Button that opens the sibling {@link NavigationMenuContent}; shows a chevron. */
154
+ export function NavigationMenuTrigger(props: ParentProps & { class?: string }): JSX.Element {
155
+ return (
156
+ <KNav.Trigger class={cn(TRIGGER, 'group', props.class)}>
157
+ {props.children}
158
+ <KNav.Icon class="transition-transform duration-200 group-data-[expanded]:rotate-180">
159
+ <ChevronDown class="h-3.5 w-3.5" />
160
+ </KNav.Icon>
161
+ </KNav.Trigger>
162
+ )
163
+ }
164
+
165
+ /** Props of {@link NavigationMenuContent}. */
166
+ export interface NavigationMenuContentProps extends ParentProps {
167
+ class?: string
168
+ }
169
+
170
+ /**
171
+ * The panel of one item. Rendered inside the shared viewport, which resizes to
172
+ * fit it. Put {@link NavigationMenuPanelLink}s in it, laid out with a grid.
173
+ */
174
+ export function NavigationMenuContent(props: NavigationMenuContentProps): JSX.Element {
175
+ // Kobalte's menu content swallows Tab, which would make the links unreachable
176
+ // with a plain Tab press. Walk the panel's links with Tab / Shift+Tab, and
177
+ // when the end is passed close the menu (focus returns to its trigger) so
178
+ // the key never traps focus.
179
+ const onTab = (e: KeyboardEvent & { currentTarget: HTMLElement }): void => {
180
+ if (e.key !== 'Tab') return
181
+ const links = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('a[role="menuitem"]'))
182
+ const at = links.indexOf(document.activeElement as HTMLElement)
183
+ const next = at + (e.shiftKey ? -1 : 1)
184
+ e.preventDefault()
185
+ // Deferred: Kobalte's own Shift+Tab handling re-focuses the panel right after this handler.
186
+ const target = links[next]
187
+ if (target) setTimeout(() => target.focus(), 0)
188
+ else e.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
189
+ }
190
+ return (
191
+ <KNav.Portal>
192
+ <KNav.Content
193
+ class={cn(
194
+ 'navmenu-content absolute left-0 top-0 w-max max-w-[min(90vw,40rem)] p-3 outline-none',
195
+ props.class,
196
+ )}
197
+ onKeyDown={onTab}
198
+ >
199
+ {props.children}
200
+ </KNav.Content>
201
+ </KNav.Portal>
202
+ )
203
+ }
204
+
205
+ /** Props of {@link NavigationMenuLink}. */
206
+ export interface NavigationMenuLinkProps extends ParentProps {
207
+ href: string
208
+ class?: string
209
+ }
210
+
211
+ /** A top-level link with no panel; takes part in the trigger roving focus. */
212
+ export function NavigationMenuLink(props: NavigationMenuLinkProps): JSX.Element {
213
+ return (
214
+ <KNav.Menu value={`link-trigger-${props.href}`}>
215
+ <KNav.Trigger as="a" href={props.href} class={cn(TRIGGER, props.class)}>
216
+ {props.children}
217
+ </KNav.Trigger>
218
+ </KNav.Menu>
219
+ )
220
+ }
221
+
222
+ /** Props of {@link NavigationMenuPanelLink}. */
223
+ export interface NavigationMenuPanelLinkProps {
224
+ href: string
225
+ title: string
226
+ description?: string
227
+ /** Leading icon element (e.g. a lucide icon). */
228
+ icon?: JSX.Element
229
+ /** Renders as a large highlighted card, typically spanning several grid rows. */
230
+ featured?: boolean
231
+ class?: string
232
+ }
233
+
234
+ /** A link inside a panel: icon + title + description, or a featured card. */
235
+ export function NavigationMenuPanelLink(props: NavigationMenuPanelLinkProps): JSX.Element {
236
+ return (
237
+ <KNav.Item
238
+ href={props.href}
239
+ class={cn(
240
+ 'flex gap-3 rounded-xl p-3 text-left no-underline outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring',
241
+ props.featured
242
+ ? 'flex-col justify-end bg-gradient-to-br from-primary/30 to-accent/20 hover:from-primary/40'
243
+ : 'items-start hover:bg-muted data-[highlighted]:bg-muted',
244
+ props.class,
245
+ )}
246
+ >
247
+ <Show when={props.icon}>
248
+ <span class="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary/15 text-primary">
249
+ {props.icon}
250
+ </span>
251
+ </Show>
252
+ <span class="min-w-0">
253
+ <span class={cn('block font-medium text-foreground', props.featured ? 'text-base' : 'text-sm')}>
254
+ {props.title}
255
+ </span>
256
+ <Show when={props.description}>
257
+ <span class="mt-0.5 block text-xs leading-snug text-muted-foreground">{props.description}</span>
258
+ </Show>
259
+ </span>
260
+ </KNav.Item>
261
+ )
262
+ }
@@ -18,6 +18,11 @@ export interface PromptComposerProps {
18
18
  /** Attachment labels/filenames shown as removable chips above the input. */
19
19
  attachments?: string[]
20
20
  onRemoveAttachment?: (index: number) => void
21
+ /**
22
+ * Rich attachment slot rendered above the input (alongside the chips above).
23
+ * Pass `Attachment`s, ideally wrapped in an `AttachmentGroup`.
24
+ */
25
+ attachmentItems?: JSX.Element
21
26
  /** When set, shows a paperclip button that calls this on click. */
22
27
  onAttach?: () => void
23
28
  class?: string
@@ -92,6 +97,12 @@ export function PromptComposer(props: PromptComposerProps): JSX.Element {
92
97
  </div>
93
98
  </Show>
94
99
 
100
+ <Show when={props.attachmentItems}>
101
+ <div data-slot="composer-attachments" class="mb-2">
102
+ {props.attachmentItems}
103
+ </div>
104
+ </Show>
105
+
95
106
  <textarea
96
107
  ref={textareaRef}
97
108
  aria-label="Message"
@@ -0,0 +1,63 @@
1
+ import { splitProps, type JSX, type ParentProps } from 'solid-js'
2
+ import { Dynamic } from 'solid-js/web'
3
+
4
+ import { cn } from '../lib/cn'
5
+
6
+ export type ProseSize = 'sm' | 'base' | 'lg'
7
+
8
+ interface ProseProps extends ParentProps {
9
+ /** Body text scale. @default 'base' */
10
+ size?: ProseSize
11
+ /** Element to render. @default 'div' */
12
+ as?: 'div' | 'article' | 'section'
13
+ class?: string
14
+ }
15
+
16
+ export type { ProseProps }
17
+
18
+ const SIZE: Record<ProseSize, string> = {
19
+ sm: 'text-sm [&_h1]:text-2xl [&_h2]:text-xl [&_h3]:text-lg [&_h4]:text-base [&_.lead]:text-base',
20
+ base: 'text-base [&_h1]:text-4xl [&_h2]:text-2xl [&_h3]:text-xl [&_h4]:text-lg [&_.lead]:text-xl',
21
+ lg: 'text-lg [&_h1]:text-5xl [&_h2]:text-3xl [&_h3]:text-2xl [&_h4]:text-xl [&_.lead]:text-2xl',
22
+ }
23
+
24
+ const PROSE =
25
+ 'max-w-none leading-7 text-foreground ' +
26
+ '[&_h1]:mb-4 [&_h1]:font-bold [&_h1]:tracking-tight ' +
27
+ '[&_h2]:mb-3 [&_h2]:mt-8 [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-2 [&_h2]:font-semibold [&_h2]:tracking-tight ' +
28
+ '[&_h3]:mb-2 [&_h3]:mt-6 [&_h3]:font-semibold ' +
29
+ '[&_h4]:mb-2 [&_h4]:mt-4 [&_h4]:font-semibold ' +
30
+ '[&_p]:my-4 [&_.lead]:text-muted-foreground ' +
31
+ '[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 [&_a:hover]:opacity-80 ' +
32
+ '[&_blockquote]:my-6 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground ' +
33
+ '[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:my-1 ' +
34
+ '[&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.875em] ' +
35
+ '[&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-4 ' +
36
+ '[&_pre_code]:bg-transparent [&_pre_code]:p-0 ' +
37
+ '[&_hr]:my-8 [&_hr]:border-border ' +
38
+ '[&_table]:my-6 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm ' +
39
+ '[&_th]:border-b [&_th]:border-border [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold ' +
40
+ '[&_td]:border-b [&_td]:border-border [&_td]:px-3 [&_td]:py-2'
41
+
42
+ /**
43
+ * Typography wrapper that styles raw HTML or rendered markdown children
44
+ * (headings, paragraphs, `.lead`, links, quotes, lists, code, tables) with
45
+ * token colors. No CSS file and no typography plugin.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * <Prose size="lg"><div innerHTML={renderedMarkdown} /></Prose>
50
+ * ```
51
+ */
52
+ export function Prose(props: ProseProps): JSX.Element {
53
+ const [local, rest] = splitProps(props, ['size', 'as', 'class', 'children'])
54
+ return (
55
+ <Dynamic
56
+ component={local.as ?? 'div'}
57
+ class={cn(PROSE, SIZE[local.size ?? 'base'], local.class)}
58
+ {...rest}
59
+ >
60
+ {local.children}
61
+ </Dynamic>
62
+ )
63
+ }
@@ -0,0 +1,331 @@
1
+ // Multi-step, data-driven questionnaire: one question per step.
2
+ import type { JSX } from 'solid-js'
3
+ import { createEffect, createSignal, createUniqueId, For, on, Show } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+ import { Button } from './Button'
7
+ import { Progress } from './Progress'
8
+ import { Textarea } from './Textarea'
9
+
10
+ /** A selectable choice of a `single` / `multiple` {@link QuestionnaireQuestion}. */
11
+ export interface QuestionnaireOption {
12
+ value: string
13
+ label: string
14
+ description?: string
15
+ }
16
+
17
+ /** One step of a {@link Questionnaire}. */
18
+ export interface QuestionnaireQuestion {
19
+ /** Key of the answer in the submitted object. Must be unique. */
20
+ id: string
21
+ title: string
22
+ description?: string
23
+ /** `single` = pick one, `multiple` = pick any, `text` = free text. */
24
+ type: 'single' | 'multiple' | 'text'
25
+ /** Choices; required for `single` and `multiple`. */
26
+ options?: QuestionnaireOption[]
27
+ /** Required questions block "Next" until answered and cannot be skipped. */
28
+ required?: boolean
29
+ /** Placeholder of the `text` field. */
30
+ placeholder?: string
31
+ }
32
+
33
+ /** Answers keyed by question id. Unanswered / skipped questions have no key. */
34
+ export type QuestionnaireAnswers = Record<string, string | string[]>
35
+
36
+ /** Overridable UI strings (i18n). `stepOf` accepts `{current}` and `{total}`. */
37
+ export interface QuestionnaireLabels {
38
+ next: string
39
+ previous: string
40
+ skip: string
41
+ submit: string
42
+ required: string
43
+ stepOf: string
44
+ thanks: string
45
+ thanksDescription: string
46
+ }
47
+
48
+ const DEFAULT_LABELS: QuestionnaireLabels = {
49
+ next: 'Next',
50
+ previous: 'Previous',
51
+ skip: 'Skip',
52
+ submit: 'Submit',
53
+ required: 'This question is required.',
54
+ stepOf: '{current} of {total}',
55
+ thanks: 'Thanks!',
56
+ thanksDescription: 'Your answers have been submitted.',
57
+ }
58
+
59
+ export interface QuestionnaireProps {
60
+ questions: QuestionnaireQuestion[]
61
+ /** Called once with the answers when the last step is submitted. */
62
+ onSubmit: (answers: QuestionnaireAnswers) => void
63
+ /** Called with the full answers object after every change. */
64
+ onChange?: (answers: QuestionnaireAnswers) => void
65
+ /** Answers to start from (read once on mount). */
66
+ initialAnswers?: QuestionnaireAnswers
67
+ labels?: Partial<QuestionnaireLabels>
68
+ /** Replaces the default "Thanks" panel shown after submit. */
69
+ summary?: (answers: QuestionnaireAnswers) => JSX.Element
70
+ class?: string
71
+ }
72
+
73
+ function isAnswered(value: string | string[] | undefined): boolean {
74
+ if (value === undefined) return false
75
+ return Array.isArray(value) ? value.length > 0 : value.trim() !== ''
76
+ }
77
+
78
+ /**
79
+ * Multi-step questionnaire / survey, driven by a `questions` array: one question
80
+ * per step with a progress indicator, required validation (inline error linked
81
+ * via `aria-describedby`), Previous / Skip / Next / Submit, and a final panel.
82
+ *
83
+ * Keyboard: digits `1`-`9` pick the matching option on choice steps, `Enter`
84
+ * advances (in the free-text field use `Ctrl`/`Cmd` + `Enter`, since `Enter`
85
+ * inserts a newline there). Focus moves to the step heading on every step change.
86
+ * Step transitions are CSS animations, neutralised under `prefers-reduced-motion`.
87
+ *
88
+ * @example
89
+ * ```tsx
90
+ * <Questionnaire
91
+ * questions={[
92
+ * { id: 'role', title: 'Your role?', type: 'single', required: true,
93
+ * options: [{ value: 'dev', label: 'Developer' }, { value: 'pm', label: 'Manager' }] },
94
+ * { id: 'notes', title: 'Anything else?', type: 'text' },
95
+ * ]}
96
+ * onSubmit={(answers) => save(answers)}
97
+ * />
98
+ * ```
99
+ */
100
+ export function Questionnaire(props: QuestionnaireProps): JSX.Element {
101
+ const labels = () => ({ ...DEFAULT_LABELS, ...props.labels })
102
+ const [answers, setAnswers] = createSignal<QuestionnaireAnswers>({ ...props.initialAnswers })
103
+ const [index, setIndex] = createSignal(0)
104
+ const [direction, setDirection] = createSignal<'forward' | 'back'>('forward')
105
+ const [error, setError] = createSignal(false)
106
+ const [submitted, setSubmitted] = createSignal(false)
107
+ const uid = createUniqueId()
108
+ let stepEl: HTMLDivElement | undefined
109
+
110
+ const total = () => props.questions.length
111
+ const question = () => props.questions[index()]
112
+ const isLast = () => index() === total() - 1
113
+ const errorId = () => `${uid}-error`
114
+ const current = () => answers()[question().id]
115
+
116
+ const setAnswer = (id: string, value: string | string[]) => {
117
+ const next = { ...answers() }
118
+ if (isAnswered(value)) next[id] = value
119
+ else delete next[id]
120
+ setAnswers(next)
121
+ setError(false)
122
+ props.onChange?.(next)
123
+ }
124
+
125
+ const selectOption = (q: QuestionnaireQuestion, value: string) => {
126
+ if (q.type === 'single') {
127
+ setAnswer(q.id, value)
128
+ return
129
+ }
130
+ const picked = (answers()[q.id] as string[] | undefined) ?? []
131
+ setAnswer(q.id, picked.includes(value) ? picked.filter((v) => v !== value) : [...picked, value])
132
+ }
133
+
134
+ const go = (to: number, dir: 'forward' | 'back') => {
135
+ setDirection(dir)
136
+ setError(false)
137
+ setIndex(to)
138
+ }
139
+
140
+ const next = () => {
141
+ if (question().required && !isAnswered(current())) {
142
+ setError(true)
143
+ queueMicrotask(() => stepEl?.querySelector<HTMLElement>('input, textarea')?.focus())
144
+ return
145
+ }
146
+ if (isLast()) {
147
+ setSubmitted(true)
148
+ props.onSubmit(answers())
149
+ } else go(index() + 1, 'forward')
150
+ }
151
+ const previous = () => {
152
+ if (index() > 0) go(index() - 1, 'back')
153
+ }
154
+ const skip = () => {
155
+ if (!question().required && !isLast()) go(index() + 1, 'forward')
156
+ }
157
+
158
+ // Announce the new step by moving focus to its heading (not on first render).
159
+ createEffect(
160
+ on(
161
+ index,
162
+ () => queueMicrotask(() => stepEl?.querySelector<HTMLElement>('[data-questionnaire-heading]')?.focus()),
163
+ { defer: true },
164
+ ),
165
+ )
166
+
167
+ const onKeyDown = (ev: KeyboardEvent) => {
168
+ if (ev.altKey || submitted()) return
169
+ const target = ev.target as HTMLElement
170
+ const typing =
171
+ target instanceof HTMLTextAreaElement || (target instanceof HTMLInputElement && target.type === 'text')
172
+ const q = question()
173
+ if (/^[1-9]$/.test(ev.key) && !ev.ctrlKey && !ev.metaKey && !typing && q.type !== 'text') {
174
+ const option = q.options?.[Number(ev.key) - 1]
175
+ if (!option) return
176
+ ev.preventDefault()
177
+ selectOption(q, option.value)
178
+ } else if (ev.key === 'Enter' && !(target instanceof HTMLButtonElement)) {
179
+ if (target instanceof HTMLTextAreaElement && !ev.ctrlKey && !ev.metaKey) return
180
+ ev.preventDefault()
181
+ next()
182
+ }
183
+ }
184
+
185
+ return (
186
+ <div class={cn('card flex w-full max-w-xl flex-col gap-5 p-5', props.class)} onKeyDown={onKeyDown}>
187
+ <Show
188
+ when={!submitted()}
189
+ fallback={
190
+ <div data-testid="questionnaire-done" class="flex flex-col gap-2">
191
+ <Show
192
+ when={props.summary}
193
+ fallback={
194
+ <>
195
+ <h2
196
+ ref={(el) => queueMicrotask(() => el.focus())}
197
+ tabindex="-1"
198
+ class="text-lg font-semibold text-foreground outline-none"
199
+ >
200
+ {labels().thanks}
201
+ </h2>
202
+ <p class="text-sm text-muted-foreground">{labels().thanksDescription}</p>
203
+ </>
204
+ }
205
+ >
206
+ {(summary) => summary()(answers())}
207
+ </Show>
208
+ </div>
209
+ }
210
+ >
211
+ <Progress
212
+ value={index() + 1}
213
+ max={total()}
214
+ label={labels()
215
+ .stepOf.replace('{current}', String(index() + 1))
216
+ .replace('{total}', String(total()))}
217
+ />
218
+ <div ref={stepEl}>
219
+ <Show when={question()} keyed>
220
+ {(q) => (
221
+ <div
222
+ class={cn(
223
+ 'flex flex-col gap-4',
224
+ direction() === 'forward'
225
+ ? 'animate-[questionnaire-step-in_0.25s_ease-out]'
226
+ : 'animate-[questionnaire-step-in-back_0.25s_ease-out]',
227
+ )}
228
+ >
229
+ <div class="flex flex-col gap-1">
230
+ <h2
231
+ tabindex="-1"
232
+ data-questionnaire-heading
233
+ class="text-lg font-semibold text-foreground outline-none"
234
+ >
235
+ {q.title}
236
+ <Show when={q.required}>
237
+ <span aria-hidden="true" class="text-destructive">
238
+ {' *'}
239
+ </span>
240
+ </Show>
241
+ </h2>
242
+ <Show when={q.description}>
243
+ <p class="text-sm text-muted-foreground">{q.description}</p>
244
+ </Show>
245
+ </div>
246
+
247
+ <Show
248
+ when={q.type === 'text'}
249
+ fallback={
250
+ <fieldset class="flex flex-col gap-2" aria-describedby={error() ? errorId() : undefined}>
251
+ <legend class="sr-only">{q.title}</legend>
252
+ <For each={q.options}>
253
+ {(option, i) => {
254
+ const checked = () => {
255
+ const a = answers()[q.id]
256
+ return Array.isArray(a) ? a.includes(option.value) : a === option.value
257
+ }
258
+ return (
259
+ <label
260
+ class={cn(
261
+ 'flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring',
262
+ checked() && 'border-primary bg-primary/10',
263
+ )}
264
+ >
265
+ <input
266
+ type={q.type === 'single' ? 'radio' : 'checkbox'}
267
+ name={`${uid}-${q.id}`}
268
+ value={option.value}
269
+ checked={checked()}
270
+ aria-invalid={error() || undefined}
271
+ class="mt-0.5 h-4 w-4 accent-primary"
272
+ onChange={() => selectOption(q, option.value)}
273
+ />
274
+ <span class="flex flex-1 flex-col">
275
+ <span class="font-medium">{option.label}</span>
276
+ <Show when={option.description}>
277
+ <span class="text-muted-foreground">{option.description}</span>
278
+ </Show>
279
+ </span>
280
+ <Show when={i() < 9}>
281
+ <kbd
282
+ aria-hidden="true"
283
+ class="rounded border border-border px-1.5 text-xs text-muted-foreground"
284
+ >
285
+ {i() + 1}
286
+ </kbd>
287
+ </Show>
288
+ </label>
289
+ )
290
+ }}
291
+ </For>
292
+ </fieldset>
293
+ }
294
+ >
295
+ <Textarea
296
+ value={(current() as string | undefined) ?? ''}
297
+ onInput={(v) => setAnswer(q.id, v)}
298
+ aria-label={q.title}
299
+ placeholder={q.placeholder}
300
+ aria-invalid={error() || undefined}
301
+ aria-describedby={error() ? errorId() : undefined}
302
+ />
303
+ </Show>
304
+
305
+ <Show when={error()}>
306
+ <p id={errorId()} role="alert" class="text-sm text-destructive">
307
+ {labels().required}
308
+ </p>
309
+ </Show>
310
+ </div>
311
+ )}
312
+ </Show>
313
+ </div>
314
+
315
+ <div class="flex items-center justify-between gap-2">
316
+ <Button variant="ghost" onClick={previous} disabled={index() === 0}>
317
+ {labels().previous}
318
+ </Button>
319
+ <div class="flex items-center gap-2">
320
+ <Show when={!question().required && !isLast()}>
321
+ <Button variant="outline" onClick={skip}>
322
+ {labels().skip}
323
+ </Button>
324
+ </Show>
325
+ <Button onClick={next}>{isLast() ? labels().submit : labels().next}</Button>
326
+ </div>
327
+ </div>
328
+ </Show>
329
+ </div>
330
+ )
331
+ }