@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.
- 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 -15098
- 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/lib/anchored.d.ts +16 -0
- 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/DateField.d.ts +2 -2
- 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/lib/anchored.ts +57 -0
- 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 +17 -7
- package/src/ui/DateRangePicker.tsx +15 -5
- 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 +15 -5
- 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 +15 -5
- 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
|
@@ -9,6 +9,7 @@ import type { JSX } from 'solid-js'
|
|
|
9
9
|
import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js'
|
|
10
10
|
import { Portal } from 'solid-js/web'
|
|
11
11
|
|
|
12
|
+
import { createAnchorReflow } from '../lib/anchored'
|
|
12
13
|
import { cn } from '../lib/cn'
|
|
13
14
|
import { CalendarCore } from './internal/CalendarCore'
|
|
14
15
|
|
|
@@ -116,17 +117,25 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|
|
116
117
|
|
|
117
118
|
// Wire/tear-down the global listeners strictly around the open state. Scroll/
|
|
118
119
|
// resize close the popover (its fixed position would otherwise drift).
|
|
120
|
+
// Follow the trigger rather than closing. The panel is `fixed` at the
|
|
121
|
+
// trigger's rect, so a scroll makes that rect stale — it does not make the
|
|
122
|
+
// panel unwanted. Closing on any captured scroll made this unusable on a
|
|
123
|
+
// scrolling page: clicking a trigger below the fold scrolls it into view,
|
|
124
|
+
// and that scroll arrived right after the panel opened and shut it in the
|
|
125
|
+
// same gesture. See `createAnchorReflow`.
|
|
126
|
+
const reflow = createAnchorReflow({ anchor: () => btnRef, place, close })
|
|
127
|
+
|
|
119
128
|
const toggleListeners = (isOpen: boolean) => {
|
|
120
129
|
if (isOpen) {
|
|
121
130
|
document.addEventListener('mousedown', onDocPointer)
|
|
122
131
|
document.addEventListener('keydown', onKey)
|
|
123
|
-
window.addEventListener('scroll',
|
|
124
|
-
window.addEventListener('resize',
|
|
132
|
+
window.addEventListener('scroll', reflow, true)
|
|
133
|
+
window.addEventListener('resize', reflow)
|
|
125
134
|
} else {
|
|
126
135
|
document.removeEventListener('mousedown', onDocPointer)
|
|
127
136
|
document.removeEventListener('keydown', onKey)
|
|
128
|
-
window.removeEventListener('scroll',
|
|
129
|
-
window.removeEventListener('resize',
|
|
137
|
+
window.removeEventListener('scroll', reflow, true)
|
|
138
|
+
window.removeEventListener('resize', reflow)
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
// Reactively attach when open flips; always clean up on unmount.
|
|
@@ -184,8 +193,9 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|
|
184
193
|
<Portal>
|
|
185
194
|
<div
|
|
186
195
|
ref={popRef}
|
|
196
|
+
data-a4-floating
|
|
187
197
|
style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
|
|
188
|
-
class="z-50 w-72 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg"
|
|
198
|
+
class="pointer-events-auto z-50 w-72 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg"
|
|
189
199
|
>
|
|
190
200
|
<CalendarCore
|
|
191
201
|
rangeStart={startDate() ?? undefined}
|
package/src/ui/Drawer.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Edge-anchored slide-over (right by default; `side` picks left/top/bottom) on Kobalte's Dialog primitive (focus trap +
|
|
2
2
|
// presence for free, like Modal). The drawer-content class drives the
|
|
3
3
|
// slide-in/out keyframes in styles.css off Kobalte's data-expanded/data-closed.
|
|
4
4
|
// Pass a `title` for the sticky glass header, or omit it and render your own
|
|
@@ -20,10 +20,39 @@ interface DrawerProps extends ParentProps {
|
|
|
20
20
|
class?: string
|
|
21
21
|
/** Accessible label for the close button. @default 'Close' */
|
|
22
22
|
closeLabel?: string
|
|
23
|
+
/** Edge the panel is anchored to and slides in from. @default 'right' */
|
|
24
|
+
side?: DrawerSide
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
export type DrawerSide = 'right' | 'left' | 'top' | 'bottom'
|
|
28
|
+
|
|
29
|
+
const SIDE: Record<DrawerSide, { wrap: string; panel: string; offscreen: string }> = {
|
|
30
|
+
right: {
|
|
31
|
+
wrap: 'justify-end',
|
|
32
|
+
panel: 'h-full w-full max-w-[520px] border-l',
|
|
33
|
+
offscreen: 'translateX(100%)',
|
|
34
|
+
},
|
|
35
|
+
left: {
|
|
36
|
+
wrap: 'justify-start',
|
|
37
|
+
panel: 'h-full w-full max-w-[520px] border-r',
|
|
38
|
+
offscreen: 'translateX(-100%)',
|
|
39
|
+
},
|
|
40
|
+
top: {
|
|
41
|
+
wrap: 'flex-col justify-start',
|
|
42
|
+
panel: 'max-h-[85vh] w-full rounded-b-2xl border-b',
|
|
43
|
+
offscreen: 'translateY(-100%)',
|
|
44
|
+
},
|
|
45
|
+
bottom: {
|
|
46
|
+
wrap: 'flex-col justify-end',
|
|
47
|
+
panel: 'max-h-[85vh] w-full rounded-t-2xl border-t',
|
|
48
|
+
offscreen: 'translateY(100%)',
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type { DrawerProps }
|
|
53
|
+
|
|
25
54
|
/**
|
|
26
|
-
*
|
|
55
|
+
* Edge-anchored slide-over panel (right by default, or `side="left" | "top" | "bottom"`) built on Kobalte's `Dialog` (focus trap, portal,
|
|
27
56
|
* and open/close presence handled for you). Use for create/edit forms or detail
|
|
28
57
|
* panels that need more room than a centered `Modal`.
|
|
29
58
|
*
|
|
@@ -36,14 +65,18 @@ interface DrawerProps extends ParentProps {
|
|
|
36
65
|
* ```
|
|
37
66
|
*/
|
|
38
67
|
export function Drawer(props: DrawerProps): JSX.Element {
|
|
68
|
+
const side = (): (typeof SIDE)[DrawerSide] => SIDE[props.side ?? 'right']
|
|
39
69
|
return (
|
|
40
70
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
41
71
|
<Dialog.Portal>
|
|
42
72
|
<Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-black/50 backdrop-blur-[2px]" />
|
|
43
|
-
<div class=
|
|
73
|
+
<div class={cn('fixed inset-0 z-50 flex', side().wrap)}>
|
|
44
74
|
<Dialog.Content
|
|
75
|
+
data-side={props.side ?? 'right'}
|
|
76
|
+
style={{ '--drawer-offscreen': side().offscreen }}
|
|
45
77
|
class={cn(
|
|
46
|
-
'drawer-content flex
|
|
78
|
+
'drawer-content flex flex-col overflow-y-auto border-border bg-glass shadow-2xl',
|
|
79
|
+
side().panel,
|
|
47
80
|
props.class,
|
|
48
81
|
)}
|
|
49
82
|
>
|
package/src/ui/Faq.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Faq — frequently asked questions as an accordion under a heading.
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
5
|
+
import { Accordion } from './Accordion'
|
|
6
|
+
import { SectionHeading } from './internal/sectionKit'
|
|
7
|
+
|
|
8
|
+
/** One question/answer pair rendered by {@link Faq}. */
|
|
9
|
+
export interface FaqItem {
|
|
10
|
+
question: string
|
|
11
|
+
answer: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FaqProps {
|
|
15
|
+
title?: string
|
|
16
|
+
subtitle?: string
|
|
17
|
+
items: FaqItem[]
|
|
18
|
+
/** Allow several answers open at once. Defaults to `false`. */
|
|
19
|
+
multiple?: boolean
|
|
20
|
+
/** Anchor id for in-page navigation. */
|
|
21
|
+
id?: string
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Frequently-asked-questions section: an optional heading over an
|
|
27
|
+
* {@link Accordion} of questions on a glass panel; each answer expands in place.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <Faq
|
|
32
|
+
* title="Questions"
|
|
33
|
+
* items={[{ question: 'Can I cancel anytime?', answer: 'Yes, from your account settings.' }]}
|
|
34
|
+
* />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function Faq(props: FaqProps): JSX.Element {
|
|
38
|
+
return (
|
|
39
|
+
<section
|
|
40
|
+
id={props.id}
|
|
41
|
+
class={cn('mx-auto flex w-full max-w-3xl flex-col gap-10 px-5 py-20', props.class)}
|
|
42
|
+
>
|
|
43
|
+
<SectionHeading title={props.title} subtitle={props.subtitle} />
|
|
44
|
+
<div class="card rounded-xl px-5 sm:px-6 [&_button]:py-4 [&_button]:text-left [&_button]:text-base">
|
|
45
|
+
<Accordion
|
|
46
|
+
multiple={props.multiple}
|
|
47
|
+
class="[&>*:last-child]:border-b-0"
|
|
48
|
+
items={props.items.map((item, index) => ({
|
|
49
|
+
value: `faq-${index}`,
|
|
50
|
+
title: item.question,
|
|
51
|
+
content: item.answer,
|
|
52
|
+
}))}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// FeatureGrid — a heading plus a responsive grid of feature cards (icon, title,
|
|
2
|
+
// short text).
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { For, Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
import { Card } from './Card'
|
|
8
|
+
import { SectionHeading } from './internal/sectionKit'
|
|
9
|
+
|
|
10
|
+
/** One feature rendered by {@link FeatureGrid}. */
|
|
11
|
+
export interface FeatureItem {
|
|
12
|
+
title: string
|
|
13
|
+
text: string
|
|
14
|
+
/** Optional icon node, shown in a tinted tile above the title. */
|
|
15
|
+
icon?: JSX.Element
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FeatureGridProps {
|
|
19
|
+
title?: string
|
|
20
|
+
subtitle?: string
|
|
21
|
+
items: FeatureItem[]
|
|
22
|
+
/** Columns on wide screens (one column on phones). Defaults to `3`. */
|
|
23
|
+
columns?: 2 | 3 | 4
|
|
24
|
+
/** Anchor id for in-page navigation. */
|
|
25
|
+
id?: string
|
|
26
|
+
class?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const COLS: Record<2 | 3 | 4, string> = {
|
|
30
|
+
2: 'sm:grid-cols-2',
|
|
31
|
+
3: 'sm:grid-cols-2 lg:grid-cols-3',
|
|
32
|
+
4: 'sm:grid-cols-2 lg:grid-cols-4',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Section listing a product's features: an optional heading and a responsive
|
|
37
|
+
* grid of glass cards, each with an optional icon, a title and a short text.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <FeatureGrid
|
|
42
|
+
* title="Why teams switch"
|
|
43
|
+
* items={[
|
|
44
|
+
* { title: 'Fast', text: 'Pages load in under a second.' },
|
|
45
|
+
* { title: 'Accessible', text: 'Keyboard and screen-reader friendly.' },
|
|
46
|
+
* { title: 'Themeable', text: 'Swap palettes without touching CSS.' },
|
|
47
|
+
* ]}
|
|
48
|
+
* />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function FeatureGrid(props: FeatureGridProps): JSX.Element {
|
|
52
|
+
return (
|
|
53
|
+
<section
|
|
54
|
+
id={props.id}
|
|
55
|
+
class={cn('mx-auto flex w-full max-w-6xl flex-col gap-12 px-5 py-20', props.class)}
|
|
56
|
+
>
|
|
57
|
+
<SectionHeading title={props.title} subtitle={props.subtitle} />
|
|
58
|
+
<ul class={cn('grid gap-6', COLS[props.columns ?? 3])}>
|
|
59
|
+
<For each={props.items}>
|
|
60
|
+
{(item) => (
|
|
61
|
+
<li class="flex">
|
|
62
|
+
<Card glass class="flex w-full flex-col gap-3 p-6">
|
|
63
|
+
<Show when={item.icon}>
|
|
64
|
+
<div class="grid h-11 w-11 place-items-center rounded-lg bg-primary/15 text-primary [&_svg]:h-5 [&_svg]:w-5">
|
|
65
|
+
{item.icon}
|
|
66
|
+
</div>
|
|
67
|
+
</Show>
|
|
68
|
+
<h3 class="text-lg font-semibold text-foreground">{item.title}</h3>
|
|
69
|
+
<p class="text-sm leading-relaxed text-muted-foreground">{item.text}</p>
|
|
70
|
+
</Card>
|
|
71
|
+
</li>
|
|
72
|
+
)}
|
|
73
|
+
</For>
|
|
74
|
+
</ul>
|
|
75
|
+
</section>
|
|
76
|
+
)
|
|
77
|
+
}
|
package/src/ui/Field.tsx
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Field — composable form-field layout: FieldSet/FieldLegend/FieldGroup for
|
|
2
|
+
// structure, Field for one labelled control. A per-field context hands out one
|
|
3
|
+
// control id and collects description/error ids, so the label's `for`, the
|
|
4
|
+
// control's `aria-describedby`, `aria-errormessage` and `aria-invalid` are
|
|
5
|
+
// wired automatically. The control is discovered in the DOM after mount (first
|
|
6
|
+
// input/textarea/select/combobox/switch inside the field), so plain, Kobalte
|
|
7
|
+
// and InputGroup controls all work with no render-prop.
|
|
8
|
+
//
|
|
9
|
+
// Layout note: Tailwind 3.4 (the preset's version) has no built-in `@container`
|
|
10
|
+
// variants, so `orientation="responsive"` and FieldGroup use viewport
|
|
11
|
+
// breakpoints (`sm:`), not container queries.
|
|
12
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
13
|
+
import {
|
|
14
|
+
createContext,
|
|
15
|
+
createEffect,
|
|
16
|
+
createSignal,
|
|
17
|
+
createUniqueId,
|
|
18
|
+
For,
|
|
19
|
+
onCleanup,
|
|
20
|
+
onMount,
|
|
21
|
+
Show,
|
|
22
|
+
splitProps,
|
|
23
|
+
useContext,
|
|
24
|
+
} from 'solid-js'
|
|
25
|
+
|
|
26
|
+
import { cn } from '../lib/cn'
|
|
27
|
+
|
|
28
|
+
/** Layout direction of a {@link Field}. */
|
|
29
|
+
export type FieldOrientation = 'vertical' | 'horizontal' | 'responsive'
|
|
30
|
+
|
|
31
|
+
interface FieldContextValue {
|
|
32
|
+
/** Id of the control (adopted from the control if it already has one). */
|
|
33
|
+
controlId: () => string
|
|
34
|
+
registerDescription: (id: string) => () => void
|
|
35
|
+
registerError: (id: string) => () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FieldContext = createContext<FieldContextValue>()
|
|
39
|
+
|
|
40
|
+
const CONTROL_SELECTOR =
|
|
41
|
+
'[data-field-control], input:not([type="hidden"]), textarea, select, [role="combobox"], [role="switch"], [role="checkbox"]'
|
|
42
|
+
|
|
43
|
+
// Add an id to a signal-held list; returns the remover.
|
|
44
|
+
function register(setter: (fn: (prev: string[]) => string[]) => void, id: string): () => void {
|
|
45
|
+
setter((prev) => [...prev, id])
|
|
46
|
+
return () => setter((prev) => prev.filter((x) => x !== id))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ORIENTATION_CLASSES: Record<FieldOrientation, string> = {
|
|
50
|
+
vertical: 'flex-col',
|
|
51
|
+
horizontal: 'flex-row items-center justify-between gap-3',
|
|
52
|
+
responsive: 'flex-col sm:flex-row sm:items-center sm:justify-between sm:gap-3',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FieldSetProps extends ParentProps, JSX.FieldsetHTMLAttributes<HTMLFieldSetElement> {
|
|
56
|
+
class?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Semantic `<fieldset>` that groups related fields. Pair with {@link FieldLegend}.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* <FieldSet>
|
|
65
|
+
* <FieldLegend>Profile</FieldLegend>
|
|
66
|
+
* <FieldGroup>...</FieldGroup>
|
|
67
|
+
* </FieldSet>
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function FieldSet(props: FieldSetProps): JSX.Element {
|
|
71
|
+
const [local, rest] = splitProps(props, ['class', 'children'])
|
|
72
|
+
return (
|
|
73
|
+
<fieldset class={cn('m-0 flex min-w-0 flex-col gap-4 border-0 p-0', local.class)} {...rest}>
|
|
74
|
+
{local.children}
|
|
75
|
+
</fieldset>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FieldLegendProps extends ParentProps, JSX.HTMLAttributes<HTMLLegendElement> {
|
|
80
|
+
/** `legend` is a section heading; `label` is the smaller label size. Defaults to `legend`. */
|
|
81
|
+
variant?: 'legend' | 'label'
|
|
82
|
+
class?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Caption of a {@link FieldSet}.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```tsx
|
|
90
|
+
* <FieldLegend variant="label">Notifications</FieldLegend>
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function FieldLegend(props: FieldLegendProps): JSX.Element {
|
|
94
|
+
const [local, rest] = splitProps(props, ['class', 'variant', 'children'])
|
|
95
|
+
return (
|
|
96
|
+
<legend
|
|
97
|
+
data-variant={local.variant ?? 'legend'}
|
|
98
|
+
class={cn(
|
|
99
|
+
'mb-1 font-semibold text-foreground',
|
|
100
|
+
local.variant === 'label' ? 'text-sm' : 'text-base',
|
|
101
|
+
local.class,
|
|
102
|
+
)}
|
|
103
|
+
{...rest}
|
|
104
|
+
>
|
|
105
|
+
{local.children}
|
|
106
|
+
</legend>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FieldGroupProps extends ParentProps, JSX.HTMLAttributes<HTMLDivElement> {
|
|
111
|
+
class?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Vertical stack of {@link Field}s with consistent spacing.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* <FieldGroup><Field>...</Field><Field>...</Field></FieldGroup>
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function FieldGroup(props: FieldGroupProps): JSX.Element {
|
|
123
|
+
const [local, rest] = splitProps(props, ['class', 'children'])
|
|
124
|
+
return (
|
|
125
|
+
<div data-slot="field-group" class={cn('flex w-full flex-col gap-5', local.class)} {...rest}>
|
|
126
|
+
{local.children}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface FieldProps extends ParentProps, JSX.HTMLAttributes<HTMLDivElement> {
|
|
132
|
+
/** `vertical` (default), `horizontal`, or `responsive` (stacked below `sm`, a row above). */
|
|
133
|
+
orientation?: FieldOrientation
|
|
134
|
+
/** Marks the field invalid: sets `data-invalid` and `aria-invalid` on the control. */
|
|
135
|
+
invalid?: boolean
|
|
136
|
+
class?: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One labelled form field. Provides the id context used by {@link FieldLabel},
|
|
141
|
+
* {@link FieldDescription} and {@link FieldError}, and wires the control inside
|
|
142
|
+
* it (`id`, `aria-describedby`, `aria-errormessage`, `aria-invalid`).
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```tsx
|
|
146
|
+
* <Field invalid={!!error()}>
|
|
147
|
+
* <FieldLabel>Email</FieldLabel>
|
|
148
|
+
* <Input value={email()} onInput={setEmail} />
|
|
149
|
+
* <FieldDescription>We never share it.</FieldDescription>
|
|
150
|
+
* <FieldError>{error()}</FieldError>
|
|
151
|
+
* </Field>
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export function Field(props: FieldProps): JSX.Element {
|
|
155
|
+
const [local, rest] = splitProps(props, ['class', 'orientation', 'invalid', 'children'])
|
|
156
|
+
const uid = createUniqueId()
|
|
157
|
+
const [controlId, setControlId] = createSignal(`${uid}-control`)
|
|
158
|
+
const [descIds, setDescIds] = createSignal<string[]>([])
|
|
159
|
+
const [errIds, setErrIds] = createSignal<string[]>([])
|
|
160
|
+
let root!: HTMLDivElement
|
|
161
|
+
|
|
162
|
+
const ctx: FieldContextValue = {
|
|
163
|
+
controlId,
|
|
164
|
+
registerDescription: (id) => register(setDescIds, id),
|
|
165
|
+
registerError: (id) => register(setErrIds, id),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The control is found in the DOM (any input-like element), so it may appear
|
|
169
|
+
// after mount (e.g. inside a <Show>). A MutationObserver re-runs the wiring.
|
|
170
|
+
const [control, setControl] = createSignal<HTMLElement | null>(null)
|
|
171
|
+
const findControl = (): HTMLElement | null => root.querySelector<HTMLElement>(CONTROL_SELECTOR)
|
|
172
|
+
const syncControl = (): void => {
|
|
173
|
+
const el = findControl()
|
|
174
|
+
if (el && !el.id) el.id = controlId()
|
|
175
|
+
if (el) setControlId(el.id)
|
|
176
|
+
setControl(el)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onMount(() => {
|
|
180
|
+
syncControl()
|
|
181
|
+
const observer = new MutationObserver(syncControl)
|
|
182
|
+
observer.observe(root, { childList: true, subtree: true })
|
|
183
|
+
onCleanup(() => observer.disconnect())
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Re-apply ARIA wiring whenever the control, descriptions, errors or invalid change.
|
|
187
|
+
createEffect(() => {
|
|
188
|
+
const desc = descIds()
|
|
189
|
+
const err = errIds()
|
|
190
|
+
const invalid = !!local.invalid
|
|
191
|
+
const el = control()
|
|
192
|
+
if (!el) return
|
|
193
|
+
const ids = [...desc, ...err]
|
|
194
|
+
if (ids.length) el.setAttribute('aria-describedby', ids.join(' '))
|
|
195
|
+
else el.removeAttribute('aria-describedby')
|
|
196
|
+
if (invalid) el.setAttribute('aria-invalid', 'true')
|
|
197
|
+
else el.removeAttribute('aria-invalid')
|
|
198
|
+
if (invalid && err.length) el.setAttribute('aria-errormessage', err.join(' '))
|
|
199
|
+
else el.removeAttribute('aria-errormessage')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<FieldContext.Provider value={ctx}>
|
|
204
|
+
<div
|
|
205
|
+
ref={root}
|
|
206
|
+
role="group"
|
|
207
|
+
data-slot="field"
|
|
208
|
+
data-orientation={local.orientation ?? 'vertical'}
|
|
209
|
+
data-invalid={local.invalid ? '' : undefined}
|
|
210
|
+
class={cn(
|
|
211
|
+
'group/field flex w-full gap-2',
|
|
212
|
+
ORIENTATION_CLASSES[local.orientation ?? 'vertical'],
|
|
213
|
+
local.class,
|
|
214
|
+
)}
|
|
215
|
+
{...rest}
|
|
216
|
+
>
|
|
217
|
+
{local.children}
|
|
218
|
+
</div>
|
|
219
|
+
</FieldContext.Provider>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Wrapper for a title + description block next to a control, e.g. the text half
|
|
225
|
+
* of a horizontal switch row.
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* ```tsx
|
|
229
|
+
* <FieldContent><FieldTitle>Alerts</FieldTitle><FieldDescription>Email me.</FieldDescription></FieldContent>
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
export function FieldContent(props: ParentProps<{ class?: string }>): JSX.Element {
|
|
233
|
+
return <div class={cn('flex flex-1 flex-col gap-1', props.class)}>{props.children}</div>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface FieldLabelProps extends ParentProps, JSX.LabelHTMLAttributes<HTMLLabelElement> {
|
|
237
|
+
class?: string
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `<label>` bound to the enclosing {@link Field}'s control through `for`.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```tsx
|
|
245
|
+
* <FieldLabel>Full name</FieldLabel>
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export function FieldLabel(props: FieldLabelProps): JSX.Element {
|
|
249
|
+
const [local, rest] = splitProps(props, ['class', 'children', 'for'])
|
|
250
|
+
const ctx = useContext(FieldContext)
|
|
251
|
+
return (
|
|
252
|
+
<label
|
|
253
|
+
for={local.for ?? ctx?.controlId()}
|
|
254
|
+
class={cn(
|
|
255
|
+
'text-sm font-medium leading-none text-foreground group-data-[invalid]/field:text-destructive',
|
|
256
|
+
local.class,
|
|
257
|
+
)}
|
|
258
|
+
{...rest}
|
|
259
|
+
>
|
|
260
|
+
{local.children}
|
|
261
|
+
</label>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Non-label title text for a field (use where a `<label>` would be wrong).
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```tsx
|
|
270
|
+
* <FieldTitle>Two-factor authentication</FieldTitle>
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
export function FieldTitle(props: ParentProps<{ class?: string }>): JSX.Element {
|
|
274
|
+
return (
|
|
275
|
+
<div class={cn('text-sm font-medium leading-none text-foreground', props.class)}>{props.children}</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Helper text, linked to the control through `aria-describedby`.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```tsx
|
|
284
|
+
* <FieldDescription>Use 8 or more characters.</FieldDescription>
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
export function FieldDescription(props: ParentProps<{ class?: string; id?: string }>): JSX.Element {
|
|
288
|
+
const ctx = useContext(FieldContext)
|
|
289
|
+
// eslint-disable-next-line solid/reactivity -- id is stable for the element's lifetime
|
|
290
|
+
const id = props.id ?? createUniqueId()
|
|
291
|
+
if (ctx) onCleanup(ctx.registerDescription(id))
|
|
292
|
+
return (
|
|
293
|
+
<p id={id} class={cn('text-xs text-muted-foreground', props.class)}>
|
|
294
|
+
{props.children}
|
|
295
|
+
</p>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface FieldSeparatorProps {
|
|
300
|
+
/** Optional text shown in the middle of the rule. */
|
|
301
|
+
children?: JSX.Element
|
|
302
|
+
class?: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Horizontal rule between fields, optionally with text in the middle.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```tsx
|
|
310
|
+
* <FieldSeparator>or continue with</FieldSeparator>
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
export function FieldSeparator(props: FieldSeparatorProps): JSX.Element {
|
|
314
|
+
return (
|
|
315
|
+
<div
|
|
316
|
+
role="separator"
|
|
317
|
+
data-slot="field-separator"
|
|
318
|
+
class={cn('relative flex items-center gap-3 text-xs text-muted-foreground', props.class)}
|
|
319
|
+
>
|
|
320
|
+
<span class="h-px flex-1 bg-border" />
|
|
321
|
+
<Show when={props.children}>
|
|
322
|
+
<span>{props.children}</span>
|
|
323
|
+
<span class="h-px flex-1 bg-border" />
|
|
324
|
+
</Show>
|
|
325
|
+
</div>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface FieldErrorProps {
|
|
330
|
+
/** Error messages; entries may be strings or `{ message }` objects (e.g. schema issues). */
|
|
331
|
+
errors?: Array<string | { message?: string } | undefined>
|
|
332
|
+
/** Custom content, used when `errors` is not given. */
|
|
333
|
+
children?: JSX.Element
|
|
334
|
+
class?: string
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Validation message(s) for a field. Renders nothing when empty, a single line
|
|
339
|
+
* for one message and a list for several. `role="alert"` is set only while
|
|
340
|
+
* something is shown, so assistive tech announces it when it appears.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```tsx
|
|
344
|
+
* <FieldError errors={['Too short', 'Needs a number']} />
|
|
345
|
+
* ```
|
|
346
|
+
*/
|
|
347
|
+
export function FieldError(props: FieldErrorProps): JSX.Element {
|
|
348
|
+
const ctx = useContext(FieldContext)
|
|
349
|
+
const id = createUniqueId()
|
|
350
|
+
const messages = (): string[] => {
|
|
351
|
+
const out: string[] = []
|
|
352
|
+
for (const e of props.errors ?? []) {
|
|
353
|
+
const m = typeof e === 'string' ? e : e?.message
|
|
354
|
+
if (m) out.push(m)
|
|
355
|
+
}
|
|
356
|
+
return out
|
|
357
|
+
}
|
|
358
|
+
const visible = (): boolean => messages().length > 0 || !!props.children
|
|
359
|
+
createEffect(() => {
|
|
360
|
+
if (!ctx || !visible()) return
|
|
361
|
+
onCleanup(ctx.registerError(id))
|
|
362
|
+
})
|
|
363
|
+
return (
|
|
364
|
+
<Show when={visible()}>
|
|
365
|
+
<div id={id} role="alert" data-slot="field-error" class={cn('text-xs text-destructive', props.class)}>
|
|
366
|
+
<Show when={messages().length > 1} fallback={<>{messages()[0] ?? props.children}</>}>
|
|
367
|
+
<ul class="ml-4 list-disc space-y-0.5">
|
|
368
|
+
<For each={messages()}>{(m) => <li>{m}</li>}</For>
|
|
369
|
+
</ul>
|
|
370
|
+
</Show>
|
|
371
|
+
</div>
|
|
372
|
+
</Show>
|
|
373
|
+
)
|
|
374
|
+
}
|