@a4ui/core 0.37.1 → 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.
- 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/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/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 +37 -4
- 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/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 +41 -5
- 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
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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Scroll container with thin, token-colored scrollbars. Native scrolling (no JS
|
|
2
|
+
// thumb): wheel, touch, keyboard and screen readers behave as the platform does.
|
|
3
|
+
// Scrollbar styling is Tailwind arbitrary variants (Firefox `scrollbar-*`
|
|
4
|
+
// properties + WebKit pseudo-elements), so there is no component CSS file.
|
|
5
|
+
import { splitProps, type JSX, type ParentProps } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
export type ScrollAreaOrientation = 'vertical' | 'horizontal' | 'both'
|
|
10
|
+
|
|
11
|
+
interface ScrollAreaProps extends ParentProps {
|
|
12
|
+
/** Which axis scrolls. @default 'vertical' */
|
|
13
|
+
orientation?: ScrollAreaOrientation
|
|
14
|
+
/**
|
|
15
|
+
* Accessible name. When set, the area is exposed as a labelled `region`.
|
|
16
|
+
* Recommended whenever the content is more than decoration.
|
|
17
|
+
*/
|
|
18
|
+
label?: string
|
|
19
|
+
/** Receives the scrolling element (to read or set `scrollTop`, observe, …). */
|
|
20
|
+
ref?: (el: HTMLDivElement) => void
|
|
21
|
+
class?: string
|
|
22
|
+
style?: JSX.CSSProperties
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { ScrollAreaProps }
|
|
26
|
+
|
|
27
|
+
const AXIS: Record<ScrollAreaOrientation, string> = {
|
|
28
|
+
vertical: 'overflow-y-auto overflow-x-hidden',
|
|
29
|
+
horizontal: 'overflow-x-auto overflow-y-hidden',
|
|
30
|
+
both: 'overflow-auto',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scrollable container with thin scrollbars tinted from the theme tokens. Give it
|
|
35
|
+
* a bounded size (`class="h-64"`, `max-h-…`, or a flex parent) — it scrolls
|
|
36
|
+
* whatever overflows. It is keyboard-focusable so the content can be scrolled
|
|
37
|
+
* with the arrow keys.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <ScrollArea class="h-64 rounded-xl border border-border" label="Release notes">
|
|
42
|
+
* <div class="p-4">…long content…</div>
|
|
43
|
+
* </ScrollArea>
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function ScrollArea(props: ScrollAreaProps): JSX.Element {
|
|
47
|
+
const [local] = splitProps(props, ['orientation', 'label', 'ref', 'class', 'style', 'children'])
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
ref={(el) => local.ref?.(el)}
|
|
51
|
+
// A scrollable region must be reachable by keyboard (axe: scrollable-region-focusable).
|
|
52
|
+
tabIndex={0}
|
|
53
|
+
role={local.label ? 'region' : undefined}
|
|
54
|
+
aria-label={local.label}
|
|
55
|
+
data-orientation={local.orientation ?? 'vertical'}
|
|
56
|
+
style={local.style}
|
|
57
|
+
class={cn(
|
|
58
|
+
'relative overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
59
|
+
'[scrollbar-color:hsl(var(--muted-foreground)/0.35)_transparent] [scrollbar-width:thin]',
|
|
60
|
+
'[&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent',
|
|
61
|
+
'[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/35',
|
|
62
|
+
'hover:[&::-webkit-scrollbar-thumb]:bg-muted-foreground/55',
|
|
63
|
+
AXIS[local.orientation ?? 'vertical'],
|
|
64
|
+
local.class,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{local.children}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|