@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
|
@@ -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
|
+
}
|
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
|
@@ -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
|
|
71
|
-
*
|
|
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',
|
|
123
|
-
window.addEventListener('resize',
|
|
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',
|
|
128
|
-
window.removeEventListener('resize',
|
|
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}
|