@a4ui/core 0.37.1 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/dist/Collapse-Cr5cDqAl.js +375 -0
- package/dist/charts/AreaChart.d.ts +38 -0
- package/dist/charts/BarChart.d.ts +17 -1
- package/dist/charts/PieChart.d.ts +29 -0
- package/dist/charts/RadialBarChart.d.ts +33 -0
- package/dist/charts/index.d.ts +5 -0
- package/dist/charts/shared.d.ts +111 -0
- package/dist/charts.js +1493 -780
- package/dist/commerce.js +625 -634
- package/dist/elements.css +1076 -8
- package/dist/elements.iife.js +1 -6
- package/dist/elements.js +7774 -9133
- package/dist/full.css +1076 -8
- package/dist/index.d.ts +30 -3
- package/dist/index.js +17502 -15126
- package/dist/layout/Aurora.d.ts +16 -0
- package/dist/layout/EffectsToggle.d.ts +1 -1
- package/dist/layout/SpaceBackground.d.ts +1 -1
- package/dist/motion-CTIBwMCA.js +104 -0
- package/dist/styles.css +110 -3
- package/dist/ui/AspectRatio.d.ts +19 -0
- package/dist/ui/Attachment.d.ts +70 -0
- package/dist/ui/ButtonGroup.d.ts +37 -0
- package/dist/ui/ChatMarker.d.ts +20 -0
- package/dist/ui/ChatThread.d.ts +32 -8
- package/dist/ui/ContactSection.d.ts +49 -0
- package/dist/ui/CtaBanner.d.ts +28 -0
- package/dist/ui/Drawer.d.ts +5 -2
- package/dist/ui/Faq.d.ts +29 -0
- package/dist/ui/FeatureGrid.d.ts +35 -0
- package/dist/ui/Field.d.ts +148 -0
- package/dist/ui/Hero.d.ts +48 -0
- package/dist/ui/InputGroup.d.ts +93 -0
- package/dist/ui/Item.d.ts +63 -0
- package/dist/ui/Label.d.ts +23 -0
- package/dist/ui/Menubar.d.ts +83 -0
- package/dist/ui/Message.d.ts +51 -5
- package/dist/ui/Modal.d.ts +8 -2
- package/dist/ui/NativeSelect.d.ts +26 -0
- package/dist/ui/NavigationMenu.d.ts +85 -0
- package/dist/ui/PromptComposer.d.ts +5 -0
- package/dist/ui/Prose.d.ts +21 -0
- package/dist/ui/Questionnaire.d.ts +71 -0
- package/dist/ui/ScrollArea.d.ts +30 -0
- package/dist/ui/Sidebar.d.ts +229 -0
- package/dist/ui/SiteFooter.d.ts +39 -0
- package/dist/ui/SiteNav.d.ts +35 -0
- package/dist/ui/TeamGrid.d.ts +33 -0
- package/dist/ui/Testimonials.d.ts +37 -0
- package/dist/ui/internal/sectionKit.d.ts +22 -0
- package/dist/ui/internal/socialIcons.d.ts +9 -0
- package/package.json +38 -26
- package/preset.d.ts +23 -0
- package/src/charts/AreaChart.tsx +206 -0
- package/src/charts/BarChart.tsx +241 -5
- package/src/charts/DonutChart.tsx +8 -11
- package/src/charts/LineChart.tsx +2 -3
- package/src/charts/PieChart.tsx +145 -0
- package/src/charts/RadialBarChart.tsx +141 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/shared.tsx +304 -0
- package/src/index.ts +192 -3
- package/src/layout/Aurora.tsx +36 -7
- package/src/layout/sceneEffects.ts +22 -3
- package/src/ui/AlertDialog.tsx +6 -3
- package/src/ui/AspectRatio.tsx +35 -0
- package/src/ui/Attachment.tsx +189 -0
- package/src/ui/ButtonGroup.tsx +89 -0
- package/src/ui/ChatMarker.tsx +58 -0
- package/src/ui/ChatThread.tsx +115 -23
- package/src/ui/Command.tsx +6 -3
- package/src/ui/ContactSection.tsx +199 -0
- package/src/ui/CtaBanner.tsx +62 -0
- package/src/ui/DateField.tsx +2 -1
- package/src/ui/DateRangePicker.tsx +2 -1
- package/src/ui/Drawer.tsx +43 -7
- package/src/ui/Faq.tsx +57 -0
- package/src/ui/FeatureGrid.tsx +77 -0
- package/src/ui/Field.tsx +374 -0
- package/src/ui/Hero.tsx +146 -0
- package/src/ui/Image.tsx +6 -3
- package/src/ui/InputGroup.tsx +219 -0
- package/src/ui/Item.tsx +185 -0
- package/src/ui/Label.tsx +50 -0
- package/src/ui/Menubar.tsx +212 -0
- package/src/ui/Message.tsx +139 -13
- package/src/ui/Modal.tsx +47 -8
- package/src/ui/MultiSelect.tsx +2 -1
- package/src/ui/NativeSelect.tsx +66 -0
- package/src/ui/NavigationMenu.tsx +262 -0
- package/src/ui/PromptComposer.tsx +11 -0
- package/src/ui/Prose.tsx +63 -0
- package/src/ui/Questionnaire.tsx +331 -0
- package/src/ui/ScrollArea.tsx +70 -0
- package/src/ui/Sidebar.tsx +753 -0
- package/src/ui/SiteFooter.tsx +112 -0
- package/src/ui/SiteNav.tsx +146 -0
- package/src/ui/TeamGrid.tsx +81 -0
- package/src/ui/Testimonials.tsx +103 -0
- package/src/ui/TimeField.tsx +2 -1
- package/src/ui/internal/sectionKit.tsx +72 -0
- package/src/ui/internal/socialIcons.tsx +41 -0
- package/dist/NumberInput-Bnh2u7pp.js +0 -381
- 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"
|
package/src/ui/Prose.tsx
ADDED
|
@@ -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
|
+
}
|