@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
@@ -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', close, true)
124
- window.addEventListener('resize', close)
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', close, true)
129
- window.removeEventListener('resize', close)
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
- // Right-anchored slide-over on Kobalte's Dialog primitive (focus trap +
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
- * Right-anchored slide-over panel built on Kobalte's `Dialog` (focus trap, portal,
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="fixed inset-0 z-50 flex justify-end">
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 h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border bg-glass shadow-2xl',
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
+ }
@@ -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
+ }