@a4ui/core 0.17.0 → 0.18.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/dist/{Checkbox-DMx0y81E.js → Checkbox-BZjX1GMG.js} +3 -3
- package/dist/commerce/createCart.d.ts +53 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce.js +154 -133
- package/dist/elements.css +133 -0
- package/dist/full.css +133 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3002 -2735
- package/dist/layout/ActionBar.d.ts +53 -0
- package/dist/layout/Section.d.ts +31 -0
- package/dist/ui/BeforeAfter.d.ts +34 -0
- package/dist/ui/PricingTable.d.ts +60 -0
- package/package.json +1 -1
- package/src/commerce/ProductCard.tsx +9 -1
- package/src/commerce/createCart.ts +102 -0
- package/src/commerce/index.ts +1 -0
- package/src/index.ts +5 -1
- package/src/layout/ActionBar.tsx +121 -0
- package/src/layout/Section.tsx +69 -0
- package/src/ui/BeforeAfter.tsx +162 -0
- package/src/ui/PricingTable.tsx +196 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// BeforeAfter — an image comparison slider: two images stacked in the same
|
|
2
|
+
// box, the "after" image clipped to a draggable split so dragging the handle
|
|
3
|
+
// reveals more of one and hides the other. Pure signal + CSS `clip-path`
|
|
4
|
+
// (engine-free — no `motion` dependency), same spirit as TiltCard/Spotlight.
|
|
5
|
+
// Dragging is instant (no transition to fight the pointer); clicking the
|
|
6
|
+
// track to jump the split animates smoothly unless reduced motion is on.
|
|
7
|
+
import { createSignal, Show, type JSX } from 'solid-js'
|
|
8
|
+
import { ChevronsLeftRight } from 'lucide-solid'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
12
|
+
|
|
13
|
+
export interface BeforeAfterProps {
|
|
14
|
+
/** Image URL shown fully, underneath. */
|
|
15
|
+
before: string
|
|
16
|
+
/** Image URL revealed by the handle, on top, clipped to the split. */
|
|
17
|
+
after: string
|
|
18
|
+
/** Accessible description of the comparison, used as the slider's label. */
|
|
19
|
+
alt: string
|
|
20
|
+
/** Corner labels as `[beforeLabel, afterLabel]`, e.g. `['Antes', 'Después']`. */
|
|
21
|
+
labels?: [string, string]
|
|
22
|
+
/** Initial split position, 0..100 (percent of width revealing `after`). @default 50 */
|
|
23
|
+
start?: number
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const clamp = (value: number): number => Math.min(100, Math.max(0, value))
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A before/after image comparison slider: drag the handle (pointer or
|
|
31
|
+
* Left/Right arrow keys once focused) to reveal more of `after` versus
|
|
32
|
+
* `before`. The `after` image is clipped with `clip-path: inset(...)` driven
|
|
33
|
+
* by a `0..100` split signal — no canvas, no animation engine. Clicking
|
|
34
|
+
* anywhere on the track jumps the split there with a short transition
|
|
35
|
+
* (skipped under `prefers-reduced-motion`); dragging itself is always instant.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <BeforeAfter
|
|
40
|
+
* before="/room-before.jpg"
|
|
41
|
+
* after="/room-after.jpg"
|
|
42
|
+
* alt="Living room before and after renovation"
|
|
43
|
+
* labels={['Antes', 'Después']}
|
|
44
|
+
* class="aspect-video rounded-2xl border border-border"
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function BeforeAfter(props: BeforeAfterProps): JSX.Element {
|
|
49
|
+
const [x, setX] = createSignal(clamp(props.start ?? 50))
|
|
50
|
+
const [dragging, setDragging] = createSignal(false)
|
|
51
|
+
|
|
52
|
+
let container: HTMLDivElement | undefined
|
|
53
|
+
|
|
54
|
+
const updateFromClientX = (clientX: number): void => {
|
|
55
|
+
if (!container) return
|
|
56
|
+
const rect = container.getBoundingClientRect()
|
|
57
|
+
setX(clamp(((clientX - rect.left) / rect.width) * 100))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const onHandlePointerDown = (event: PointerEvent): void => {
|
|
61
|
+
const handle = event.currentTarget as HTMLElement
|
|
62
|
+
handle.setPointerCapture(event.pointerId)
|
|
63
|
+
handle.focus()
|
|
64
|
+
setDragging(true)
|
|
65
|
+
updateFromClientX(event.clientX)
|
|
66
|
+
|
|
67
|
+
const move = (moveEvent: PointerEvent): void => updateFromClientX(moveEvent.clientX)
|
|
68
|
+
const release = (): void => {
|
|
69
|
+
setDragging(false)
|
|
70
|
+
handle.removeEventListener('pointermove', move)
|
|
71
|
+
handle.removeEventListener('pointerup', release)
|
|
72
|
+
handle.removeEventListener('lostpointercapture', release)
|
|
73
|
+
}
|
|
74
|
+
handle.addEventListener('pointermove', move)
|
|
75
|
+
handle.addEventListener('pointerup', release)
|
|
76
|
+
handle.addEventListener('lostpointercapture', release)
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const onTrackClick = (event: MouseEvent): void => {
|
|
81
|
+
updateFromClientX(event.clientX)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
85
|
+
const step = event.shiftKey ? 10 : 2
|
|
86
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
setX((value) => clamp(value - step))
|
|
89
|
+
} else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
setX((value) => clamp(value + step))
|
|
92
|
+
} else if (event.key === 'Home') {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
setX(0)
|
|
95
|
+
} else if (event.key === 'End') {
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
setX(100)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const transitionClass = () =>
|
|
102
|
+
!dragging() && !motionReduced() ? 'transition-[clip-path] duration-200 ease-out' : ''
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
ref={container}
|
|
107
|
+
class={cn('relative aspect-video w-full select-none overflow-hidden', props.class)}
|
|
108
|
+
onClick={onTrackClick}
|
|
109
|
+
>
|
|
110
|
+
<img
|
|
111
|
+
src={props.before}
|
|
112
|
+
alt=""
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
draggable={false}
|
|
115
|
+
class="absolute inset-0 h-full w-full object-cover"
|
|
116
|
+
/>
|
|
117
|
+
<img
|
|
118
|
+
src={props.after}
|
|
119
|
+
alt={props.alt}
|
|
120
|
+
draggable={false}
|
|
121
|
+
class={cn('absolute inset-0 h-full w-full object-cover', transitionClass())}
|
|
122
|
+
style={{ 'clip-path': `inset(0 ${100 - x()}% 0 0)` }}
|
|
123
|
+
/>
|
|
124
|
+
|
|
125
|
+
<Show when={props.labels}>
|
|
126
|
+
{(labels) => (
|
|
127
|
+
<>
|
|
128
|
+
<span class="pointer-events-none absolute bottom-3 left-3 rounded-md border border-border bg-background/70 px-2 py-1 text-xs font-medium text-foreground backdrop-blur-sm">
|
|
129
|
+
{labels()[0]}
|
|
130
|
+
</span>
|
|
131
|
+
<span class="pointer-events-none absolute bottom-3 right-3 rounded-md border border-border bg-background/70 px-2 py-1 text-xs font-medium text-foreground backdrop-blur-sm">
|
|
132
|
+
{labels()[1]}
|
|
133
|
+
</span>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</Show>
|
|
137
|
+
|
|
138
|
+
<div
|
|
139
|
+
role="slider"
|
|
140
|
+
tabindex={0}
|
|
141
|
+
aria-label={props.alt}
|
|
142
|
+
aria-orientation="horizontal"
|
|
143
|
+
aria-valuemin={0}
|
|
144
|
+
aria-valuemax={100}
|
|
145
|
+
aria-valuenow={Math.round(x())}
|
|
146
|
+
onPointerDown={onHandlePointerDown}
|
|
147
|
+
onKeyDown={onKeyDown}
|
|
148
|
+
onClick={(event) => event.stopPropagation()}
|
|
149
|
+
class={cn(
|
|
150
|
+
'group absolute inset-y-0 flex w-8 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center',
|
|
151
|
+
"before:pointer-events-none before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-background before:content-['']",
|
|
152
|
+
'focus-visible:outline-none',
|
|
153
|
+
)}
|
|
154
|
+
style={{ left: `${x()}%` }}
|
|
155
|
+
>
|
|
156
|
+
<span class="grid h-8 w-8 place-items-center rounded-full border border-border bg-background text-foreground shadow-md group-focus-visible:ring-2 group-focus-visible:ring-primary group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background">
|
|
157
|
+
<ChevronsLeftRight class="h-4 w-4" />
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// PricingTable — a responsive grid of pricing tiers, each a Card with a
|
|
2
|
+
// feature checklist and a CTA Button. When any tier carries an annual price,
|
|
3
|
+
// a monthly/annual toggle renders above the grid (controlled or uncontrolled).
|
|
4
|
+
import { Check } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { For, Show, createSignal, splitProps } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { Badge } from './Badge'
|
|
10
|
+
import { Button } from './Button'
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle } from './Card'
|
|
12
|
+
import { spawnRipple } from './Ripple'
|
|
13
|
+
|
|
14
|
+
// Mirrors Button's `primary`/`outline` classes for the `href` case, where the
|
|
15
|
+
// CTA must render as an `<a>` — Button itself only renders a `<button>`.
|
|
16
|
+
const CTA_LINK_BASE =
|
|
17
|
+
'relative inline-flex w-full items-center justify-center overflow-hidden rounded-md px-3 py-2 text-sm font-medium transition-[color,background-color,transform] duration-150 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring'
|
|
18
|
+
const CTA_LINK_VARIANT = {
|
|
19
|
+
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
20
|
+
outline: 'border border-border bg-transparent text-foreground hover:bg-muted',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Billing period a {@link PricingTable} can show. */
|
|
24
|
+
export type PricingPeriod = 'monthly' | 'annual'
|
|
25
|
+
|
|
26
|
+
/** One plan/tier rendered by {@link PricingTable}. */
|
|
27
|
+
export interface PricingTier {
|
|
28
|
+
/** Plan name, e.g. `"Pro"`. */
|
|
29
|
+
name: string
|
|
30
|
+
/** Pre-formatted price for the monthly period, e.g. `"$0"` or `"$99/mes"`. */
|
|
31
|
+
price: string
|
|
32
|
+
/** Pre-formatted price for the annual period. When ANY tier sets this, the toggle renders. */
|
|
33
|
+
priceAnnual?: string
|
|
34
|
+
/** Short one-line description under the plan name. */
|
|
35
|
+
description?: string
|
|
36
|
+
/** Bullet list of included features, each rendered with a check icon. */
|
|
37
|
+
features: string[]
|
|
38
|
+
/** Renders this tier emphasized: `glass` surface, a ring, and a "Popular" {@link Badge}. */
|
|
39
|
+
highlighted?: boolean
|
|
40
|
+
/** Call-to-action button. Rendered as an `<a>` when `href` is set, otherwise a `<button>`. */
|
|
41
|
+
cta?: { label: string; href?: string; onClick?: () => void }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PricingTableProps {
|
|
45
|
+
/** Tiers to render, in order, as a responsive grid. */
|
|
46
|
+
tiers: PricingTier[]
|
|
47
|
+
/** Controlled billing period. Omit to let the toggle manage its own state. */
|
|
48
|
+
period?: PricingPeriod
|
|
49
|
+
/** Fired when the toggle changes period, controlled or uncontrolled. */
|
|
50
|
+
onPeriodChange?: (period: PricingPeriod) => void
|
|
51
|
+
class?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Responsive pricing grid: one {@link Card} per tier, a feature checklist, and
|
|
56
|
+
* a CTA {@link Button}. The highlighted tier gets a frosted glass surface, a
|
|
57
|
+
* ring, and a "Popular" {@link Badge}. When any tier sets `priceAnnual`, a
|
|
58
|
+
* monthly/annual toggle renders above the grid — pass `period` +
|
|
59
|
+
* `onPeriodChange` to control it, or omit both to let it manage its own state.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* <PricingTable
|
|
64
|
+
* tiers={[
|
|
65
|
+
* { name: 'Free', price: '$0', features: ['1 project', 'Community support'], cta: { label: 'Start' } },
|
|
66
|
+
* {
|
|
67
|
+
* name: 'Pro',
|
|
68
|
+
* price: '$19/mo',
|
|
69
|
+
* priceAnnual: '$190/yr',
|
|
70
|
+
* description: 'For growing teams',
|
|
71
|
+
* features: ['Unlimited projects', 'Priority support'],
|
|
72
|
+
* highlighted: true,
|
|
73
|
+
* cta: { label: 'Upgrade', href: '/upgrade' },
|
|
74
|
+
* },
|
|
75
|
+
* ]}
|
|
76
|
+
* />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function PricingTable(props: PricingTableProps): JSX.Element {
|
|
80
|
+
const [local] = splitProps(props, ['tiers', 'period', 'onPeriodChange', 'class'])
|
|
81
|
+
|
|
82
|
+
const [internalPeriod, setInternalPeriod] = createSignal<PricingPeriod>('monthly')
|
|
83
|
+
const period = () => local.period ?? internalPeriod()
|
|
84
|
+
const setPeriod = (next: PricingPeriod) => {
|
|
85
|
+
if (local.period === undefined) setInternalPeriod(next)
|
|
86
|
+
local.onPeriodChange?.(next)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const showToggle = () => local.tiers.some((tier) => tier.priceAnnual !== undefined)
|
|
90
|
+
const priceFor = (tier: PricingTier) =>
|
|
91
|
+
period() === 'annual' ? (tier.priceAnnual ?? tier.price) : tier.price
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div class={cn('flex flex-col items-center gap-8', local.class)}>
|
|
95
|
+
<Show when={showToggle()}>
|
|
96
|
+
<div class="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 p-1 text-sm">
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class={cn(
|
|
100
|
+
'rounded-full px-3 py-1.5 font-medium transition-colors',
|
|
101
|
+
period() === 'monthly'
|
|
102
|
+
? 'bg-primary text-primary-foreground'
|
|
103
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
104
|
+
)}
|
|
105
|
+
aria-pressed={period() === 'monthly'}
|
|
106
|
+
onClick={() => setPeriod('monthly')}
|
|
107
|
+
>
|
|
108
|
+
Monthly
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
class={cn(
|
|
113
|
+
'rounded-full px-3 py-1.5 font-medium transition-colors',
|
|
114
|
+
period() === 'annual'
|
|
115
|
+
? 'bg-primary text-primary-foreground'
|
|
116
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
117
|
+
)}
|
|
118
|
+
aria-pressed={period() === 'annual'}
|
|
119
|
+
onClick={() => setPeriod('annual')}
|
|
120
|
+
>
|
|
121
|
+
Annual
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</Show>
|
|
125
|
+
|
|
126
|
+
<div class="grid w-full gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
127
|
+
<For each={local.tiers}>
|
|
128
|
+
{(tier) => (
|
|
129
|
+
<Card
|
|
130
|
+
glass={tier.highlighted}
|
|
131
|
+
class={cn('relative flex flex-col', tier.highlighted && 'ring-2 ring-primary')}
|
|
132
|
+
>
|
|
133
|
+
<Show when={tier.highlighted}>
|
|
134
|
+
<Badge tone="info" class="absolute -top-3 right-6">
|
|
135
|
+
Popular
|
|
136
|
+
</Badge>
|
|
137
|
+
</Show>
|
|
138
|
+
<CardHeader>
|
|
139
|
+
<CardTitle>{tier.name}</CardTitle>
|
|
140
|
+
<Show when={tier.description}>
|
|
141
|
+
<p class="text-sm text-muted-foreground">{tier.description}</p>
|
|
142
|
+
</Show>
|
|
143
|
+
<p class="pt-2 text-3xl font-bold text-foreground">{priceFor(tier)}</p>
|
|
144
|
+
</CardHeader>
|
|
145
|
+
<CardContent class="flex flex-1 flex-col gap-6">
|
|
146
|
+
<ul class="flex flex-1 flex-col gap-2">
|
|
147
|
+
<For each={tier.features}>
|
|
148
|
+
{(feature) => (
|
|
149
|
+
<li class="flex items-start gap-2 text-sm text-foreground">
|
|
150
|
+
<Check class="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
|
151
|
+
{feature}
|
|
152
|
+
</li>
|
|
153
|
+
)}
|
|
154
|
+
</For>
|
|
155
|
+
</ul>
|
|
156
|
+
<Show when={tier.cta}>
|
|
157
|
+
{(cta) => (
|
|
158
|
+
<Show
|
|
159
|
+
when={cta().href}
|
|
160
|
+
fallback={
|
|
161
|
+
<Button
|
|
162
|
+
ripple
|
|
163
|
+
onClick={cta().onClick}
|
|
164
|
+
variant={tier.highlighted ? 'primary' : 'outline'}
|
|
165
|
+
class="w-full"
|
|
166
|
+
>
|
|
167
|
+
{cta().label}
|
|
168
|
+
</Button>
|
|
169
|
+
}
|
|
170
|
+
>
|
|
171
|
+
{(href) => (
|
|
172
|
+
<a
|
|
173
|
+
href={href()}
|
|
174
|
+
onClick={cta().onClick}
|
|
175
|
+
onPointerDown={(event) =>
|
|
176
|
+
spawnRipple(event.currentTarget, event, { opacity: 0.35 })
|
|
177
|
+
}
|
|
178
|
+
class={cn(
|
|
179
|
+
CTA_LINK_BASE,
|
|
180
|
+
CTA_LINK_VARIANT[tier.highlighted ? 'primary' : 'outline'],
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{cta().label}
|
|
184
|
+
</a>
|
|
185
|
+
)}
|
|
186
|
+
</Show>
|
|
187
|
+
)}
|
|
188
|
+
</Show>
|
|
189
|
+
</CardContent>
|
|
190
|
+
</Card>
|
|
191
|
+
)}
|
|
192
|
+
</For>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|