@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
|
@@ -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
|
+
}
|
package/src/ui/ChatThread.tsx
CHANGED
|
@@ -1,59 +1,151 @@
|
|
|
1
1
|
// ChatThread — layout shell for AI/conversation UIs: a centered, scrollable
|
|
2
|
-
// reading column that stacks chat messages.
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
-
/**
|
|
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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* <
|
|
28
|
-
* <
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ui/DateField.tsx
CHANGED
|
@@ -167,8 +167,9 @@ export function DateField(props: DateFieldProps): JSX.Element {
|
|
|
167
167
|
<Portal>
|
|
168
168
|
<div
|
|
169
169
|
ref={popRef}
|
|
170
|
+
data-a4-floating
|
|
170
171
|
style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
|
|
171
|
-
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"
|
|
172
173
|
>
|
|
173
174
|
<CalendarCore
|
|
174
175
|
selected={selected() ?? undefined}
|
|
@@ -193,8 +193,9 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|
|
193
193
|
<Portal>
|
|
194
194
|
<div
|
|
195
195
|
ref={popRef}
|
|
196
|
+
data-a4-floating
|
|
196
197
|
style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
|
|
197
|
-
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"
|
|
198
199
|
>
|
|
199
200
|
<CalendarCore
|
|
200
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
|
+
}
|