@a4ui/core 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +8 -7
  2. package/dist/Collapse-Cr5cDqAl.js +375 -0
  3. package/dist/charts/AreaChart.d.ts +38 -0
  4. package/dist/charts/BarChart.d.ts +17 -1
  5. package/dist/charts/PieChart.d.ts +29 -0
  6. package/dist/charts/RadialBarChart.d.ts +33 -0
  7. package/dist/charts/index.d.ts +5 -0
  8. package/dist/charts/shared.d.ts +111 -0
  9. package/dist/charts.js +1493 -780
  10. package/dist/commerce.js +625 -634
  11. package/dist/elements.css +1076 -8
  12. package/dist/elements.iife.js +1 -6
  13. package/dist/elements.js +7774 -9133
  14. package/dist/full.css +1076 -8
  15. package/dist/index.d.ts +30 -3
  16. package/dist/index.js +17502 -15098
  17. package/dist/layout/Aurora.d.ts +16 -0
  18. package/dist/layout/EffectsToggle.d.ts +1 -1
  19. package/dist/layout/SpaceBackground.d.ts +1 -1
  20. package/dist/lib/anchored.d.ts +16 -0
  21. package/dist/motion-CTIBwMCA.js +104 -0
  22. package/dist/styles.css +110 -3
  23. package/dist/ui/AspectRatio.d.ts +19 -0
  24. package/dist/ui/Attachment.d.ts +70 -0
  25. package/dist/ui/ButtonGroup.d.ts +37 -0
  26. package/dist/ui/ChatMarker.d.ts +20 -0
  27. package/dist/ui/ChatThread.d.ts +32 -8
  28. package/dist/ui/ContactSection.d.ts +49 -0
  29. package/dist/ui/CtaBanner.d.ts +28 -0
  30. package/dist/ui/DateField.d.ts +2 -2
  31. package/dist/ui/Drawer.d.ts +5 -2
  32. package/dist/ui/Faq.d.ts +29 -0
  33. package/dist/ui/FeatureGrid.d.ts +35 -0
  34. package/dist/ui/Field.d.ts +148 -0
  35. package/dist/ui/Hero.d.ts +48 -0
  36. package/dist/ui/InputGroup.d.ts +93 -0
  37. package/dist/ui/Item.d.ts +63 -0
  38. package/dist/ui/Label.d.ts +23 -0
  39. package/dist/ui/Menubar.d.ts +83 -0
  40. package/dist/ui/Message.d.ts +51 -5
  41. package/dist/ui/Modal.d.ts +8 -2
  42. package/dist/ui/NativeSelect.d.ts +26 -0
  43. package/dist/ui/NavigationMenu.d.ts +85 -0
  44. package/dist/ui/PromptComposer.d.ts +5 -0
  45. package/dist/ui/Prose.d.ts +21 -0
  46. package/dist/ui/Questionnaire.d.ts +71 -0
  47. package/dist/ui/ScrollArea.d.ts +30 -0
  48. package/dist/ui/Sidebar.d.ts +229 -0
  49. package/dist/ui/SiteFooter.d.ts +39 -0
  50. package/dist/ui/SiteNav.d.ts +35 -0
  51. package/dist/ui/TeamGrid.d.ts +33 -0
  52. package/dist/ui/Testimonials.d.ts +37 -0
  53. package/dist/ui/internal/sectionKit.d.ts +22 -0
  54. package/dist/ui/internal/socialIcons.d.ts +9 -0
  55. package/package.json +38 -26
  56. package/preset.d.ts +23 -0
  57. package/src/charts/AreaChart.tsx +206 -0
  58. package/src/charts/BarChart.tsx +241 -5
  59. package/src/charts/DonutChart.tsx +8 -11
  60. package/src/charts/LineChart.tsx +2 -3
  61. package/src/charts/PieChart.tsx +145 -0
  62. package/src/charts/RadialBarChart.tsx +141 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/shared.tsx +304 -0
  65. package/src/index.ts +192 -3
  66. package/src/layout/Aurora.tsx +36 -7
  67. package/src/layout/sceneEffects.ts +22 -3
  68. package/src/lib/anchored.ts +57 -0
  69. package/src/ui/AspectRatio.tsx +35 -0
  70. package/src/ui/Attachment.tsx +189 -0
  71. package/src/ui/ButtonGroup.tsx +89 -0
  72. package/src/ui/ChatMarker.tsx +58 -0
  73. package/src/ui/ChatThread.tsx +115 -23
  74. package/src/ui/ContactSection.tsx +199 -0
  75. package/src/ui/CtaBanner.tsx +62 -0
  76. package/src/ui/DateField.tsx +17 -7
  77. package/src/ui/DateRangePicker.tsx +15 -5
  78. package/src/ui/Drawer.tsx +37 -4
  79. package/src/ui/Faq.tsx +57 -0
  80. package/src/ui/FeatureGrid.tsx +77 -0
  81. package/src/ui/Field.tsx +374 -0
  82. package/src/ui/Hero.tsx +146 -0
  83. package/src/ui/InputGroup.tsx +219 -0
  84. package/src/ui/Item.tsx +185 -0
  85. package/src/ui/Label.tsx +50 -0
  86. package/src/ui/Menubar.tsx +212 -0
  87. package/src/ui/Message.tsx +139 -13
  88. package/src/ui/Modal.tsx +41 -5
  89. package/src/ui/MultiSelect.tsx +15 -5
  90. package/src/ui/NativeSelect.tsx +66 -0
  91. package/src/ui/NavigationMenu.tsx +262 -0
  92. package/src/ui/PromptComposer.tsx +11 -0
  93. package/src/ui/Prose.tsx +63 -0
  94. package/src/ui/Questionnaire.tsx +331 -0
  95. package/src/ui/ScrollArea.tsx +70 -0
  96. package/src/ui/Sidebar.tsx +753 -0
  97. package/src/ui/SiteFooter.tsx +112 -0
  98. package/src/ui/SiteNav.tsx +146 -0
  99. package/src/ui/TeamGrid.tsx +81 -0
  100. package/src/ui/Testimonials.tsx +103 -0
  101. package/src/ui/TimeField.tsx +15 -5
  102. package/src/ui/internal/sectionKit.tsx +72 -0
  103. package/src/ui/internal/socialIcons.tsx +41 -0
  104. package/dist/NumberInput-Bnh2u7pp.js +0 -381
  105. package/dist/motion-jYMWmZqB.js +0 -119
@@ -0,0 +1,89 @@
1
+ // Joins sibling buttons into one segmented control. Pure Tailwind child
2
+ // selectors: works with Button, anchors and a Dropdown trigger as children.
3
+ import { splitProps, type JSX, type ParentProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ export type ButtonGroupOrientation = 'horizontal' | 'vertical'
8
+
9
+ interface ButtonGroupProps extends ParentProps {
10
+ /** Layout direction. @default 'horizontal' */
11
+ orientation?: ButtonGroupOrientation
12
+ /** Accessible name for the group (recommended). */
13
+ 'aria-label'?: string
14
+ class?: string
15
+ }
16
+
17
+ export type { ButtonGroupProps }
18
+
19
+ const HORIZONTAL =
20
+ 'flex-row [&>*:not(:first-child)]:-ml-px [&>*:not(:first-child)]:rounded-l-none [&>*:not(:last-child)]:rounded-r-none'
21
+ const VERTICAL =
22
+ 'flex-col [&>*]:w-full [&>*:not(:first-child)]:-mt-px [&>*:not(:first-child)]:rounded-t-none [&>*:not(:last-child)]:rounded-b-none'
23
+
24
+ /**
25
+ * Groups buttons so they share borders; only the outer corners stay rounded.
26
+ * Renders `role="group"` — pass `aria-label` so it has a name. Children can be
27
+ * `Button`s, links, a `Dropdown` trigger, `ButtonGroupText` or
28
+ * `ButtonGroupSeparator`.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <ButtonGroup aria-label="Text alignment">
33
+ * <Button variant="outline">Left</Button>
34
+ * <Button variant="outline">Center</Button>
35
+ * <Button variant="outline">Right</Button>
36
+ * </ButtonGroup>
37
+ * ```
38
+ */
39
+ export function ButtonGroup(props: ButtonGroupProps): JSX.Element {
40
+ const [local, rest] = splitProps(props, ['orientation', 'class', 'children'])
41
+ return (
42
+ <div
43
+ role="group"
44
+ data-orientation={local.orientation ?? 'horizontal'}
45
+ class={cn(
46
+ 'inline-flex items-stretch [&>*]:relative [&>*:focus-visible]:z-10 [&>*:hover]:z-[1]',
47
+ local.orientation === 'vertical' ? VERTICAL : HORIZONTAL,
48
+ local.class,
49
+ )}
50
+ {...rest}
51
+ >
52
+ {local.children}
53
+ </div>
54
+ )
55
+ }
56
+
57
+ interface ButtonGroupTextProps extends ParentProps {
58
+ class?: string
59
+ }
60
+
61
+ /** Non-interactive label cell that sits inside a {@link ButtonGroup}. */
62
+ export function ButtonGroupText(props: ButtonGroupTextProps): JSX.Element {
63
+ return (
64
+ <div
65
+ class={cn(
66
+ 'inline-flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-sm font-medium text-muted-foreground',
67
+ props.class,
68
+ )}
69
+ >
70
+ {props.children}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ interface ButtonGroupSeparatorProps {
76
+ class?: string
77
+ }
78
+
79
+ /** Thin divider between buttons that have no border of their own (e.g. primary). */
80
+ export function ButtonGroupSeparator(props: ButtonGroupSeparatorProps): JSX.Element {
81
+ return (
82
+ <div
83
+ role="separator"
84
+ class={cn('z-[1] -mx-px w-px self-stretch bg-primary-foreground/30', props.class)}
85
+ />
86
+ )
87
+ }
88
+
89
+ export type { ButtonGroupTextProps, ButtonGroupSeparatorProps }
@@ -0,0 +1,58 @@
1
+ // ChatMarker — an inline system note inside a conversation ("Nova joined",
2
+ // "Yesterday", "Conversation archived").
3
+ import { Show, type JSX, type ParentProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ export type ChatMarkerVariant = 'default' | 'border' | 'separator'
8
+
9
+ interface ChatMarkerProps extends ParentProps {
10
+ /** `default`: muted text. `border`: bordered pill. `separator`: text between two lines. @default 'default' */
11
+ variant?: ChatMarkerVariant
12
+ /** Optional leading icon; decorative (`aria-hidden`). */
13
+ icon?: JSX.Element
14
+ class?: string
15
+ }
16
+
17
+ export type { ChatMarkerProps }
18
+
19
+ /**
20
+ * Inline system note in a thread, e.g. a date divider or a status change.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <ChatMarker variant="separator">Yesterday</ChatMarker>
25
+ * <ChatMarker variant="border" icon={<UserPlus />}>Nova joined</ChatMarker>
26
+ * ```
27
+ */
28
+ export function ChatMarker(props: ChatMarkerProps): JSX.Element {
29
+ const variant = (): ChatMarkerVariant => props.variant ?? 'default'
30
+ const label = (): JSX.Element => (
31
+ <span
32
+ class={cn(
33
+ 'inline-flex items-center gap-1.5 text-xs text-muted-foreground [&>span>svg]:h-3.5 [&>span>svg]:w-3.5',
34
+ variant() === 'border' && 'rounded-full border border-border bg-muted/40 px-3 py-1',
35
+ )}
36
+ >
37
+ <Show when={props.icon}>
38
+ <span aria-hidden="true" class="inline-flex">
39
+ {props.icon}
40
+ </span>
41
+ </Show>
42
+ {props.children}
43
+ </span>
44
+ )
45
+ return (
46
+ <div
47
+ data-slot="chat-marker"
48
+ data-variant={variant()}
49
+ class={cn('flex w-full items-center justify-center gap-3 py-1', props.class)}
50
+ >
51
+ <Show when={variant() === 'separator'} fallback={label()}>
52
+ <span aria-hidden="true" class="h-px flex-1 bg-border" />
53
+ {label()}
54
+ <span aria-hidden="true" class="h-px flex-1 bg-border" />
55
+ </Show>
56
+ </div>
57
+ )
58
+ }
@@ -1,59 +1,151 @@
1
1
  // ChatThread — layout shell for AI/conversation UIs: a centered, scrollable
2
- // reading column that stacks chat messages. Purely a container: it has no
3
- // idea what a "message" looks like, so any element can be stacked inside it.
4
- import { createEffect, onMount, type JSX } from 'solid-js'
2
+ // reading column that stacks chat messages. It has no idea what a "message"
3
+ // looks like, but it owns the scroll behaviour of a conversation: it follows
4
+ // new output only while the reader is at the bottom, keeps the view steady when
5
+ // older messages are prepended, and offers a "Jump to latest" button.
6
+ import { ArrowDown } from 'lucide-solid'
7
+ import { createSignal, onCleanup, onMount, Show, type JSX } from 'solid-js'
5
8
 
6
9
  import { cn } from '../lib/cn'
7
10
  import { motionReduced } from '../lib/motion'
8
11
 
12
+ /** Imperative handle handed out through {@link ChatThreadProps.apiRef}. */
13
+ export interface ChatThreadApi {
14
+ /** Scroll the element carrying `data-message-id={id}` to the top of the view. Throws if there is none. */
15
+ scrollToMessage: (id: string, options?: { behavior?: ScrollBehavior }) => void
16
+ /** Scroll to the newest content and re-pin the thread to the bottom. */
17
+ scrollToEnd: (options?: { behavior?: ScrollBehavior }) => void
18
+ }
19
+
9
20
  export interface ChatThreadProps {
10
21
  children: JSX.Element
11
22
  /** Max width of the reading column, in px. Defaults to `768`. */
12
23
  maxWidth?: number
13
- /** Auto-scroll to the newest message on mount and whenever children change. Defaults to `true`. */
24
+ /**
25
+ * Follow new content (appended messages and streamed text) to the bottom,
26
+ * but only while the reader is already at the bottom. Defaults to `true`.
27
+ */
14
28
  stickToBottom?: boolean
29
+ /** Receives the imperative API once on mount. */
30
+ apiRef?: (api: ChatThreadApi) => void
31
+ /** Label of the button shown when new content arrives off-screen. Defaults to `'Jump to latest'`. */
32
+ jumpLabel?: string
15
33
  class?: string
16
34
  }
17
35
 
36
+ /** Within this many px of the end counts as "at the bottom". */
37
+ const PIN_THRESHOLD = 24
38
+
18
39
  /**
19
- * Scrollable, centered reading column that stacks chat messages. Layout-only:
20
- * it renders whatever is passed as `children`, so pair it with your own
21
- * message bubbles/components.
40
+ * Scrollable, centered reading column that stacks chat messages. Give message
41
+ * elements a `data-message-id` (`<Message id="…">` does) to target them with
42
+ * `apiRef`'s `scrollToMessage`.
43
+ *
44
+ * - Appended or streamed content scrolls into view only while the reader is
45
+ * pinned to the bottom; otherwise the view stays put and a **Jump to latest**
46
+ * button appears.
47
+ * - Prepending older messages keeps the visible messages where they were.
22
48
  *
23
49
  * @example
24
50
  * ```tsx
25
- * <ChatThread maxWidth={640}>
26
- * <div>Hi! Ask me anything about your itinerary.</div>
27
- * <div>What's the weather like in Rivertown this weekend?</div>
28
- * <div>Sunny and mild, 18–22°C, light breeze on Saturday.</div>
51
+ * let api!: ChatThreadApi
52
+ * <ChatThread maxWidth={640} apiRef={(a) => (api = a)} class="h-96">
53
+ * <Message id="m1" role="user">What changed in v2?</Message>
54
+ * <Message id="m2" role="assistant">Three things…</Message>
29
55
  * </ChatThread>
56
+ * // later: api.scrollToMessage('m1')
30
57
  * ```
31
58
  */
32
59
  export function ChatThread(props: ChatThreadProps): JSX.Element {
33
- let scrollEl: HTMLDivElement | undefined
60
+ let scrollEl!: HTMLDivElement
61
+ const [showJump, setShowJump] = createSignal(false)
62
+ let pinned = true
63
+ // Snapshot taken after every change, to detect prepends and measure deltas.
64
+ let lastScrollHeight = 0
65
+ let lastFirst: Element | null = null
34
66
 
35
- const scrollToBottom = () => {
36
- if (!scrollEl) return
37
- scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: motionReduced() ? 'auto' : 'smooth' })
67
+ const behavior = (b?: ScrollBehavior): ScrollBehavior => b ?? (motionReduced() ? 'auto' : 'smooth')
68
+ const distanceFromEnd = (): number => scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
69
+ const firstMessage = (): Element | null => scrollEl.firstElementChild
70
+ const snapshot = (): void => {
71
+ lastScrollHeight = scrollEl.scrollHeight
72
+ lastFirst = firstMessage()
38
73
  }
39
74
 
40
- onMount(() => {
41
- if (props.stickToBottom ?? true) scrollToBottom()
42
- })
75
+ const scrollToEnd = (options?: { behavior?: ScrollBehavior }): void => {
76
+ pinned = true
77
+ setShowJump(false)
78
+ scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: behavior(options?.behavior) })
79
+ }
43
80
 
44
- createEffect(() => {
45
- // Track children so newly appended messages pull the thread down.
46
- void props.children
47
- if (props.stickToBottom ?? true) queueMicrotask(scrollToBottom)
81
+ const scrollToMessage = (id: string, options?: { behavior?: ScrollBehavior }): void => {
82
+ const target = scrollEl.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
83
+ if (!target) throw new Error(`ChatThread.scrollToMessage: no element with data-message-id="${id}"`)
84
+ const top = target.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop
85
+ scrollEl.scrollTo({ top: Math.max(0, top - 8), behavior: behavior(options?.behavior) })
86
+ }
87
+
88
+ const onScroll = (): void => {
89
+ pinned = distanceFromEnd() <= PIN_THRESHOLD
90
+ if (pinned) setShowJump(false)
91
+ }
92
+
93
+ const onContentChange = (): void => {
94
+ const first = firstMessage()
95
+ const prepended = lastFirst !== null && first !== lastFirst && scrollEl.contains(lastFirst)
96
+ if (prepended) {
97
+ // Older messages were inserted above: shift by the height they added so
98
+ // what the reader was looking at does not move.
99
+ scrollEl.scrollTop += scrollEl.scrollHeight - lastScrollHeight
100
+ } else if (scrollEl.scrollHeight !== lastScrollHeight) {
101
+ if (pinned && (props.stickToBottom ?? true)) {
102
+ scrollEl.scrollTop = scrollEl.scrollHeight
103
+ } else if (!pinned) {
104
+ setShowJump(true)
105
+ }
106
+ }
107
+ snapshot()
108
+ }
109
+
110
+ onMount(() => {
111
+ props.apiRef?.({ scrollToMessage, scrollToEnd })
112
+ if (props.stickToBottom ?? true) scrollEl.scrollTop = scrollEl.scrollHeight
113
+ pinned = distanceFromEnd() <= PIN_THRESHOLD
114
+ snapshot()
115
+ const observer = new MutationObserver(onContentChange)
116
+ observer.observe(scrollEl, { childList: true, subtree: true, characterData: true })
117
+ onCleanup(() => observer.disconnect())
48
118
  })
49
119
 
50
120
  return (
51
121
  <div
52
122
  ref={scrollEl}
53
- class={cn('mx-auto flex w-full flex-col gap-4 overflow-y-auto', props.class)}
123
+ onScroll={onScroll}
124
+ tabIndex={0}
125
+ // Scroll anchoring would double-apply the prepend correction above.
126
+ class={cn(
127
+ 'mx-auto flex w-full flex-col gap-4 overflow-y-auto outline-none [overflow-anchor:none] focus-visible:ring-2 focus-visible:ring-ring',
128
+ props.class,
129
+ )}
54
130
  style={{ 'max-width': `${props.maxWidth ?? 768}px` }}
55
131
  >
56
132
  {props.children}
133
+ <Show when={showJump()}>
134
+ {/* Zero-height sticky row so the button floats over the bottom edge without adding scroll height. */}
135
+ <div
136
+ data-slot="chat-jump"
137
+ class="pointer-events-none sticky bottom-3 -mt-4 flex h-0 shrink-0 justify-center"
138
+ >
139
+ <button
140
+ type="button"
141
+ onClick={() => scrollToEnd()}
142
+ class="pointer-events-auto absolute bottom-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-card-foreground shadow-lg transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
143
+ >
144
+ <ArrowDown class="h-3.5 w-3.5" aria-hidden="true" />
145
+ {props.jumpLabel ?? 'Jump to latest'}
146
+ </button>
147
+ </div>
148
+ </Show>
57
149
  </div>
58
150
  )
59
151
  }
@@ -0,0 +1,199 @@
1
+ // ContactSection — a contact form plus the business's direct contact details.
2
+ // The form posts natively to `action` (a URL or a `mailto:` link); there is no
3
+ // backend in the component.
4
+ import { Mail, MapPin, Phone } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { createSignal, createUniqueId, For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Button } from './Button'
10
+ import { Card } from './Card'
11
+ import { Input } from './Input'
12
+ import { SectionHeading } from './internal/sectionKit'
13
+ import { Label } from './Label'
14
+ import { Textarea } from './Textarea'
15
+
16
+ /** A field {@link ContactSection} can render. */
17
+ export type ContactField = 'name' | 'email' | 'phone' | 'subject' | 'message'
18
+
19
+ /** Submitted values, keyed by field. Only the rendered fields are present. */
20
+ export type ContactValues = Partial<Record<ContactField, string>>
21
+
22
+ export interface ContactSectionProps {
23
+ title?: string
24
+ subtitle?: string
25
+ /** Fields to render, in order. Defaults to `['name', 'email', 'message']`. */
26
+ fields?: ContactField[]
27
+ /** Override field and contact-detail labels, e.g. for another language. */
28
+ labels?: Partial<Record<ContactField | 'address', string>>
29
+ /** Submit button text. Defaults to `'Send message'`. */
30
+ submitLabel?: string
31
+ /**
32
+ * Where the form posts (`method="post"`): an endpoint URL, or a `mailto:` link
33
+ * (sent as plain text). Omit it to handle submission yourself with `onSubmit`.
34
+ */
35
+ action?: string
36
+ /** Called with the values when the form submits and there is no `action`. */
37
+ onSubmit?: (values: ContactValues) => void
38
+ /** Email shown (and linked) beside the form. */
39
+ email?: string
40
+ /** Phone number shown (and linked) beside the form. */
41
+ phone?: string
42
+ /** Postal address shown beside the form. */
43
+ address?: string
44
+ /** Anchor id for in-page navigation. */
45
+ id?: string
46
+ class?: string
47
+ }
48
+
49
+ const DEFAULT_FIELDS: ContactField[] = ['name', 'email', 'message']
50
+ const DEFAULT_LABELS: Record<ContactField | 'address', string> = {
51
+ name: 'Name',
52
+ email: 'Email',
53
+ phone: 'Phone',
54
+ subject: 'Subject',
55
+ message: 'Message',
56
+ address: 'Address',
57
+ }
58
+ const INPUT_TYPE: Record<Exclude<ContactField, 'message'>, string> = {
59
+ name: 'text',
60
+ email: 'email',
61
+ phone: 'tel',
62
+ subject: 'text',
63
+ }
64
+ const AUTOCOMPLETE: Partial<Record<ContactField, string>> = { name: 'name', email: 'email', phone: 'tel' }
65
+ const REQUIRED: ReadonlySet<ContactField> = new Set(['name', 'email', 'message'])
66
+
67
+ /**
68
+ * Contact section: a form (any of name, email, phone, subject, message) beside
69
+ * the business's email, phone and address. With `action` the form posts
70
+ * natively to that URL or `mailto:` link; without it, `onSubmit` receives the
71
+ * values. Name, email and message are required.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * <ContactSection
76
+ * title="Talk to us"
77
+ * fields={['name', 'email', 'subject', 'message']}
78
+ * action="https://formspree.io/f/your-id"
79
+ * email="hello@example.com"
80
+ * phone="+52 55 1234 5678"
81
+ * />
82
+ * ```
83
+ */
84
+ export function ContactSection(props: ContactSectionProps): JSX.Element {
85
+ const uid = createUniqueId()
86
+ const fields = () => props.fields ?? DEFAULT_FIELDS
87
+ const [values, setValues] = createSignal<ContactValues>({})
88
+ const label = (field: ContactField | 'address') => props.labels?.[field] ?? DEFAULT_LABELS[field]
89
+ const hasDetails = () => Boolean(props.email || props.phone || props.address)
90
+ const isMailto = () => props.action?.startsWith('mailto:') === true
91
+
92
+ const handleSubmit = (event: SubmitEvent) => {
93
+ if (props.action !== undefined) return
94
+ event.preventDefault()
95
+ const out: ContactValues = {}
96
+ for (const field of fields()) out[field] = values()[field] ?? ''
97
+ props.onSubmit?.(out)
98
+ }
99
+
100
+ return (
101
+ <section
102
+ id={props.id}
103
+ class={cn('mx-auto flex w-full max-w-6xl flex-col gap-12 px-5 py-20', props.class)}
104
+ >
105
+ <SectionHeading title={props.title} subtitle={props.subtitle} />
106
+ <div class={cn('grid gap-8', hasDetails() && 'lg:grid-cols-[2fr_1fr]')}>
107
+ <Card glass class={cn('p-6 sm:p-8', !hasDetails() && 'mx-auto w-full max-w-2xl')}>
108
+ <form
109
+ action={props.action}
110
+ method="post"
111
+ enctype={isMailto() ? 'text/plain' : undefined}
112
+ class="grid gap-5 sm:grid-cols-2"
113
+ onSubmit={handleSubmit}
114
+ >
115
+ <For each={fields()}>
116
+ {(field) => {
117
+ const id = `${uid}-${field}`
118
+ const wide = field === 'message' || field === 'subject'
119
+ const common = {
120
+ id,
121
+ name: field,
122
+ required: REQUIRED.has(field),
123
+ onInput: (value: string) => setValues((prev) => ({ ...prev, [field]: value })),
124
+ }
125
+ return (
126
+ <div class={cn('flex flex-col gap-2', wide && 'sm:col-span-2')}>
127
+ <Label for={id} required={REQUIRED.has(field)}>
128
+ {label(field)}
129
+ </Label>
130
+ <Show
131
+ when={field !== 'message'}
132
+ fallback={<Textarea {...common} rows={5} value={values()[field] ?? ''} />}
133
+ >
134
+ <Input
135
+ {...common}
136
+ value={values()[field] ?? ''}
137
+ type={INPUT_TYPE[field as Exclude<ContactField, 'message'>]}
138
+ autocomplete={AUTOCOMPLETE[field]}
139
+ />
140
+ </Show>
141
+ </div>
142
+ )
143
+ }}
144
+ </For>
145
+ <div class="sm:col-span-2">
146
+ <Button type="submit" ripple class="w-full px-5 py-2.5 text-base sm:w-auto">
147
+ {props.submitLabel ?? 'Send message'}
148
+ </Button>
149
+ </div>
150
+ </form>
151
+ </Card>
152
+
153
+ <Show when={hasDetails()}>
154
+ <ul class="flex flex-col gap-4">
155
+ <Show when={props.email}>
156
+ {(email) => (
157
+ <Detail icon={<Mail />} label={label('email')}>
158
+ <a href={`mailto:${email()}`} class="break-all hover:text-primary">
159
+ {email()}
160
+ </a>
161
+ </Detail>
162
+ )}
163
+ </Show>
164
+ <Show when={props.phone}>
165
+ {(phone) => (
166
+ <Detail icon={<Phone />} label={label('phone')}>
167
+ <a href={`tel:${phone().replace(/[^\d+]/g, '')}`} class="hover:text-primary">
168
+ {phone()}
169
+ </a>
170
+ </Detail>
171
+ )}
172
+ </Show>
173
+ <Show when={props.address}>
174
+ {(address) => (
175
+ <Detail icon={<MapPin />} label={label('address')}>
176
+ <span class="whitespace-pre-line">{address()}</span>
177
+ </Detail>
178
+ )}
179
+ </Show>
180
+ </ul>
181
+ </Show>
182
+ </div>
183
+ </section>
184
+ )
185
+ }
186
+
187
+ function Detail(props: { icon: JSX.Element; label: string; children: JSX.Element }): JSX.Element {
188
+ return (
189
+ <li class="card flex items-start gap-4 rounded-xl p-5">
190
+ <span class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/15 text-primary [&_svg]:h-5 [&_svg]:w-5">
191
+ {props.icon}
192
+ </span>
193
+ <div class="min-w-0">
194
+ <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{props.label}</p>
195
+ <p class="mt-1 text-sm text-foreground">{props.children}</p>
196
+ </div>
197
+ </li>
198
+ )
199
+ }
@@ -0,0 +1,62 @@
1
+ // CtaBanner — a closing call-to-action band: headline, one line of support and
2
+ // one or two buttons on a tinted glass panel.
3
+ import type { JSX } from 'solid-js'
4
+ import { Show } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+ import { Button } from './Button'
8
+ import { SectionHeading, type SectionLink } from './internal/sectionKit'
9
+
10
+ export interface CtaBannerProps {
11
+ title: string
12
+ subtitle?: string
13
+ /** Main call-to-action (filled button). */
14
+ primary: SectionLink
15
+ /** Secondary call-to-action (outline button). */
16
+ secondary?: SectionLink
17
+ /** Anchor id for in-page navigation. */
18
+ id?: string
19
+ class?: string
20
+ }
21
+
22
+ /**
23
+ * Call-to-action band that closes a page or splits long ones: a headline, an
24
+ * optional line of support and one or two buttons on a glass panel tinted with
25
+ * the theme's primary color.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <CtaBanner
30
+ * title="Ready to launch?"
31
+ * subtitle="Start free, upgrade when you grow."
32
+ * primary={{ label: 'Create account', href: '/signup' }}
33
+ * />
34
+ * ```
35
+ */
36
+ export function CtaBanner(props: CtaBannerProps): JSX.Element {
37
+ return (
38
+ <section id={props.id} class={cn('mx-auto w-full max-w-6xl px-5 py-16', props.class)}>
39
+ <div class="card glow-edge relative isolate overflow-hidden rounded-2xl px-6 py-14 sm:px-12">
40
+ <div
41
+ aria-hidden="true"
42
+ class="absolute inset-0 -z-10 bg-gradient-to-br from-primary/25 via-transparent to-accent/20"
43
+ />
44
+ <div class="flex flex-col items-center gap-8 text-center">
45
+ <SectionHeading title={props.title} subtitle={props.subtitle} />
46
+ <div class="flex flex-wrap justify-center gap-3">
47
+ <Button href={props.primary.href} ripple class="px-5 py-2.5 text-base">
48
+ {props.primary.label}
49
+ </Button>
50
+ <Show when={props.secondary}>
51
+ {(link) => (
52
+ <Button href={link().href} variant="outline" class="px-5 py-2.5 text-base">
53
+ {link().label}
54
+ </Button>
55
+ )}
56
+ </Show>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </section>
61
+ )
62
+ }
@@ -9,6 +9,7 @@ import type { JSX } from 'solid-js'
9
9
  import { Show, createEffect, createMemo, createSignal, onCleanup } 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
 
@@ -67,8 +68,8 @@ interface DateFieldProps {
67
68
 
68
69
  /**
69
70
  * Compact hand-rolled month-grid date picker (no Kobalte primitive covers this yet).
70
- * Trigger button opens a portaled popover calendar; closes on outside click, Escape,
71
- * scroll, or resize. Speaks plain `YYYY-MM-DD` strings, never `Date`/`toISOString`.
71
+ * Trigger button opens a portaled popover calendar; closes on outside click or Escape,
72
+ * and follows the trigger when the page scrolls or resizes. Speaks plain `YYYY-MM-DD` strings, never `Date`/`toISOString`.
72
73
  *
73
74
  * @example
74
75
  * ```tsx
@@ -115,17 +116,25 @@ export function DateField(props: DateFieldProps): JSX.Element {
115
116
 
116
117
  // Wire/tear-down the global listeners strictly around the open state. Scroll/
117
118
  // resize close the popover (its fixed position would otherwise drift).
119
+ // Follow the trigger rather than closing. The panel is `fixed` at the
120
+ // trigger's rect, so a scroll makes that rect stale — it does not make the
121
+ // panel unwanted. Closing on any captured scroll made this unusable on a
122
+ // scrolling page: clicking a trigger below the fold scrolls it into view,
123
+ // and that scroll arrived right after the panel opened and shut it in the
124
+ // same gesture. See `createAnchorReflow`.
125
+ const reflow = createAnchorReflow({ anchor: () => btnRef, place, close })
126
+
118
127
  const toggleListeners = (isOpen: boolean) => {
119
128
  if (isOpen) {
120
129
  document.addEventListener('mousedown', onDocPointer)
121
130
  document.addEventListener('keydown', onKey)
122
- window.addEventListener('scroll', close, true)
123
- window.addEventListener('resize', close)
131
+ window.addEventListener('scroll', reflow, true)
132
+ window.addEventListener('resize', reflow)
124
133
  } else {
125
134
  document.removeEventListener('mousedown', onDocPointer)
126
135
  document.removeEventListener('keydown', onKey)
127
- window.removeEventListener('scroll', close, true)
128
- window.removeEventListener('resize', close)
136
+ window.removeEventListener('scroll', reflow, true)
137
+ window.removeEventListener('resize', reflow)
129
138
  }
130
139
  }
131
140
  // Reactively attach when open flips; always clean up on unmount.
@@ -158,8 +167,9 @@ export function DateField(props: DateFieldProps): JSX.Element {
158
167
  <Portal>
159
168
  <div
160
169
  ref={popRef}
170
+ data-a4-floating
161
171
  style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
162
- class="z-50 w-72 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg"
172
+ class="pointer-events-auto z-50 w-72 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg"
163
173
  >
164
174
  <CalendarCore
165
175
  selected={selected() ?? undefined}