@a4ui/core 0.17.0 → 0.19.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.
@@ -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
+ }
package/src/ui/Button.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { JSX, ParentProps } from 'solid-js'
2
- import { splitProps } from 'solid-js'
2
+ import { Show, splitProps } from 'solid-js'
3
3
 
4
4
  import { cn } from '../lib/cn'
5
5
  import { spawnRipple } from './Ripple'
@@ -31,6 +31,16 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
31
31
  * costs nothing when off.
32
32
  */
33
33
  ripple?: boolean
34
+ /**
35
+ * When set, the button renders as an `<a href>` (a link) instead of a
36
+ * `<button>`, keeping the variant look, focus ring, and ripple. Use for
37
+ * navigation / router links and `tel:`/`mailto:` CTAs.
38
+ */
39
+ href?: string
40
+ /** Anchor `target` — only used when `href` is set. */
41
+ target?: string
42
+ /** Anchor `rel` — only used when `href` is set. */
43
+ rel?: string
34
44
  }
35
45
 
36
46
  /**
@@ -44,28 +54,52 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
44
54
  * ```
45
55
  */
46
56
  export function Button(props: ButtonProps): JSX.Element {
47
- const [local, rest] = splitProps(props, ['variant', 'class', 'type', 'children', 'ripple', 'onPointerDown'])
57
+ const [local, rest] = splitProps(props, [
58
+ 'variant',
59
+ 'class',
60
+ 'type',
61
+ 'children',
62
+ 'ripple',
63
+ 'onPointerDown',
64
+ 'href',
65
+ 'target',
66
+ 'rel',
67
+ ])
68
+
69
+ const classes = (): string =>
70
+ cn(
71
+ BUTTON_BASE,
72
+ VARIANT_CLASSES[local.variant ?? 'primary'],
73
+ local.ripple && 'relative overflow-hidden',
74
+ local.class,
75
+ )
48
76
 
49
- const handlePointerDown: JSX.EventHandler<HTMLButtonElement, PointerEvent> = (event) => {
77
+ const handlePointerDown = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
50
78
  if (local.ripple) spawnRipple(event.currentTarget, event, { opacity: 0.35 })
51
79
  const user = local.onPointerDown
52
- if (typeof user === 'function') user(event)
53
- else if (user) user[0](user[1], event)
80
+ if (typeof user === 'function') user(event as never)
81
+ else if (user) user[0](user[1], event as never)
54
82
  }
55
83
 
56
84
  return (
57
- <button
58
- type={local.type ?? 'button'}
59
- class={cn(
60
- BUTTON_BASE,
61
- VARIANT_CLASSES[local.variant ?? 'primary'],
62
- local.ripple && 'relative overflow-hidden',
63
- local.class,
64
- )}
65
- onPointerDown={handlePointerDown}
66
- {...rest}
85
+ <Show
86
+ when={local.href !== undefined}
87
+ fallback={
88
+ <button type={local.type ?? 'button'} class={classes()} onPointerDown={handlePointerDown} {...rest}>
89
+ {local.children}
90
+ </button>
91
+ }
67
92
  >
68
- {local.children}
69
- </button>
93
+ <a
94
+ href={local.href}
95
+ target={local.target}
96
+ rel={local.rel}
97
+ class={classes()}
98
+ onPointerDown={handlePointerDown}
99
+ {...(rest as JSX.AnchorHTMLAttributes<HTMLAnchorElement>)}
100
+ >
101
+ {local.children}
102
+ </a>
103
+ </Show>
70
104
  )
71
105
  }
@@ -146,18 +146,24 @@ export function Carousel(props: CarouselProps): JSX.Element {
146
146
  </button>
147
147
  </div>
148
148
 
149
- <div class="flex items-center justify-center gap-2">
149
+ <div class="flex items-center justify-center gap-1">
150
150
  <For each={props.slides}>
151
151
  {(_, i) => (
152
+ // 24×24 hit target (a11y target-size) around a small visual dot.
152
153
  <button
153
154
  type="button"
154
155
  aria-label={`Go to slide ${i() + 1}`}
156
+ aria-current={index() === i() ? 'true' : undefined}
155
157
  onClick={() => go(i())}
156
- class={cn(
157
- 'h-2 w-2 rounded-full transition-colors',
158
- index() === i() ? 'bg-primary' : 'bg-muted',
159
- )}
160
- />
158
+ class="grid h-6 w-6 place-items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
159
+ >
160
+ <span
161
+ class={cn(
162
+ 'h-2 w-2 rounded-full transition-colors',
163
+ index() === i() ? 'bg-primary' : 'bg-muted',
164
+ )}
165
+ />
166
+ </button>
161
167
  )}
162
168
  </For>
163
169
  </div>
@@ -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
+ }