@a4ui/core 0.16.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.
Files changed (40) hide show
  1. package/dist/{Checkbox-B5Gb3h5J.js → Checkbox-BZjX1GMG.js} +153 -146
  2. package/dist/commerce/ProductCard.d.ts +10 -0
  3. package/dist/commerce/createCart.d.ts +53 -0
  4. package/dist/commerce/index.d.ts +1 -0
  5. package/dist/commerce.js +160 -133
  6. package/dist/elements.css +244 -0
  7. package/dist/elements.iife.js +3 -3
  8. package/dist/elements.js +590 -583
  9. package/dist/full.css +244 -0
  10. package/dist/index.d.ts +5 -1
  11. package/dist/index.js +3090 -2798
  12. package/dist/layout/ActionBar.d.ts +53 -0
  13. package/dist/layout/Section.d.ts +31 -0
  14. package/dist/styles.css +66 -0
  15. package/dist/ui/Badge.d.ts +6 -0
  16. package/dist/ui/BeforeAfter.d.ts +34 -0
  17. package/dist/ui/Carousel.d.ts +6 -0
  18. package/dist/ui/FloatingActionButton.d.ts +6 -0
  19. package/dist/ui/Image.d.ts +6 -0
  20. package/dist/ui/List.d.ts +5 -0
  21. package/dist/ui/PricingTable.d.ts +60 -0
  22. package/dist/ui/Skeleton.d.ts +10 -3
  23. package/package.json +1 -1
  24. package/src/commerce/ProductCard.tsx +26 -2
  25. package/src/commerce/createCart.ts +102 -0
  26. package/src/commerce/index.ts +1 -0
  27. package/src/index.ts +5 -1
  28. package/src/layout/ActionBar.tsx +121 -0
  29. package/src/layout/Section.tsx +69 -0
  30. package/src/ui/Accordion.tsx +3 -1
  31. package/src/ui/Badge.tsx +14 -2
  32. package/src/ui/BeforeAfter.tsx +162 -0
  33. package/src/ui/Carousel.tsx +48 -3
  34. package/src/ui/Collapse.tsx +13 -4
  35. package/src/ui/FloatingActionButton.tsx +16 -1
  36. package/src/ui/Image.tsx +29 -1
  37. package/src/ui/List.tsx +10 -2
  38. package/src/ui/PricingTable.tsx +196 -0
  39. package/src/ui/Skeleton.tsx +17 -5
  40. package/src/ui/SpeedDial.tsx +3 -1
@@ -0,0 +1,53 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Edge an {@link ActionBar} pins to when `sticky`. Defaults to `'top'`. */
3
+ export type ActionBarPosition = 'top' | 'bottom';
4
+ export interface ActionBarProps {
5
+ /**
6
+ * The prominent CTA. Rendered as `<a href>` when `href` is set (e.g.
7
+ * `tel:+15551234567`), otherwise as a `<button type="button">` driven by
8
+ * `onClick`.
9
+ */
10
+ action: {
11
+ label: string;
12
+ href?: string;
13
+ onClick?: () => void;
14
+ };
15
+ /** Optional left slot, e.g. a `<Badge pulse>24/7</Badge>`. */
16
+ status?: JSX.Element;
17
+ /** Optional short text between `status` and the action. Hidden below `sm` so the action stays reachable. */
18
+ message?: JSX.Element;
19
+ /** Pins the bar to the viewport edge (`position`) instead of scrolling with the page. Defaults to `true`. */
20
+ sticky?: boolean;
21
+ /** Edge to pin to when `sticky`. Defaults to `'top'`. */
22
+ position?: ActionBarPosition;
23
+ class?: string;
24
+ }
25
+ /**
26
+ * Sticky action / emergency bar: a full-width strip that pins a single
27
+ * prominent CTA to the viewport edge, with an optional status badge and
28
+ * short message beside it. Built for urgent-service verticals (towing, HVAC,
29
+ * locksmith, …) where the CTA — usually a `tel:` link — must stay reachable
30
+ * through the whole scroll. The action gets a click ripple like `<Button
31
+ * ripple>`; the message drops out on narrow viewports so the badge + action
32
+ * never wrap.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <ActionBar
37
+ * status={<Badge pulse>24/7</Badge>}
38
+ * message="Stuck on the road? We're on our way."
39
+ * action={{ label: 'Call now', href: 'tel:+15551234567' }}
40
+ * />
41
+ * ```
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * // Bottom-pinned, plain onClick action instead of a tel: link.
46
+ * <ActionBar
47
+ * position="bottom"
48
+ * message="Free quote in 2 minutes"
49
+ * action={{ label: 'Get a quote', onClick: openQuoteForm }}
50
+ * />
51
+ * ```
52
+ */
53
+ export declare function ActionBar(props: ActionBarProps): JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { JSX, ParentProps } from 'solid-js';
2
+ /** Max content width of a {@link Section}. Defaults to `'6xl'`. */
3
+ export type SectionWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '6xl' | '7xl' | 'full';
4
+ /** Vertical padding of a {@link Section}. Defaults to `'lg'`. */
5
+ export type SectionPad = 'none' | 'sm' | 'md' | 'lg';
6
+ interface SectionProps extends ParentProps, JSX.HTMLAttributes<HTMLElement> {
7
+ /** Max content width. Defaults to `'6xl'`. */
8
+ size?: SectionWidth;
9
+ /** Vertical padding rhythm. Defaults to `'lg'`. */
10
+ py?: SectionPad;
11
+ class?: string;
12
+ }
13
+ /**
14
+ * Centered content container with a max width and consistent vertical rhythm —
15
+ * the layout wrapper repeated on every marketing/company page. Pass an `id` for
16
+ * anchor-based in-page navigation. For a full-bleed colored band, wrap Section
17
+ * in a parent that carries the background.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <Section id="services">
22
+ * <h2>Servicios</h2>
23
+ * </Section>
24
+ *
25
+ * <div class="bg-muted/30">
26
+ * <Section size="4xl" py="md">…</Section>
27
+ * </div>
28
+ * ```
29
+ */
30
+ export declare function Section(props: SectionProps): JSX.Element;
31
+ export {};
package/dist/styles.css CHANGED
@@ -162,6 +162,72 @@ body {
162
162
  animation: toast-out 0.18s ease-in;
163
163
  }
164
164
 
165
+ /* Skeleton shimmer — a light band sweeping across the placeholder (opt-in
166
+ alternative to the default pulse). The band color is token-driven so it reads
167
+ on every theme; the sweep is a pure CSS animation (no engine). */
168
+ @keyframes skeleton-shimmer {
169
+ 100% {
170
+ transform: translateX(100%);
171
+ }
172
+ }
173
+ .skeleton-shimmer {
174
+ position: relative;
175
+ overflow: hidden;
176
+ }
177
+ .skeleton-shimmer::after {
178
+ content: '';
179
+ position: absolute;
180
+ inset: 0;
181
+ transform: translateX(-100%);
182
+ background: linear-gradient(90deg, transparent, hsl(var(--foreground) / 0.08), transparent);
183
+ animation: skeleton-shimmer 1.5s ease-in-out infinite;
184
+ }
185
+
186
+ /* Accordion open/close — animate the panel height using Kobalte's measured
187
+ `--kb-accordion-content-height` var (presence keyed off data-expanded/closed,
188
+ animationend-driven like Modal). Engine-free. */
189
+ @keyframes accordion-down {
190
+ from {
191
+ height: 0;
192
+ }
193
+ to {
194
+ height: var(--kb-accordion-content-height);
195
+ }
196
+ }
197
+ @keyframes accordion-up {
198
+ from {
199
+ height: var(--kb-accordion-content-height);
200
+ }
201
+ to {
202
+ height: 0;
203
+ }
204
+ }
205
+ .accordion-content {
206
+ overflow: hidden;
207
+ }
208
+ .accordion-content[data-expanded] {
209
+ animation: accordion-down 0.2s ease-out;
210
+ }
211
+ .accordion-content[data-closed] {
212
+ animation: accordion-up 0.2s ease-out;
213
+ }
214
+
215
+ /* List stagger — rows fade/slide in on mount, delayed per index (set inline as
216
+ `animation-delay`). Opt-in via <List stagger>. */
217
+ @keyframes list-row-in {
218
+ from {
219
+ opacity: 0;
220
+ transform: translateY(6px);
221
+ }
222
+ to {
223
+ opacity: 1;
224
+ transform: none;
225
+ }
226
+ }
227
+ .list-row-stagger {
228
+ animation: list-row-in 0.35s both ease-out;
229
+ }
230
+
165
231
  /* Table row enter/exit fade. */
166
232
  .row-enter-active,
167
233
  .row-exit-active {
@@ -4,6 +4,12 @@ export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
4
4
  interface BadgeProps extends ParentProps {
5
5
  /** Visual/semantic tone. Defaults to `'neutral'`. */
6
6
  tone?: BadgeTone;
7
+ /**
8
+ * Show a pinging dot before the label for "live"/"recording"/"online"
9
+ * states. Tinted with the tone's color (`currentColor`); pure CSS
10
+ * (`animate-ping`), reduced-motion aware.
11
+ */
12
+ pulse?: boolean;
7
13
  class?: string;
8
14
  }
9
15
  /**
@@ -0,0 +1,34 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface BeforeAfterProps {
3
+ /** Image URL shown fully, underneath. */
4
+ before: string;
5
+ /** Image URL revealed by the handle, on top, clipped to the split. */
6
+ after: string;
7
+ /** Accessible description of the comparison, used as the slider's label. */
8
+ alt: string;
9
+ /** Corner labels as `[beforeLabel, afterLabel]`, e.g. `['Antes', 'Después']`. */
10
+ labels?: [string, string];
11
+ /** Initial split position, 0..100 (percent of width revealing `after`). @default 50 */
12
+ start?: number;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * A before/after image comparison slider: drag the handle (pointer or
17
+ * Left/Right arrow keys once focused) to reveal more of `after` versus
18
+ * `before`. The `after` image is clipped with `clip-path: inset(...)` driven
19
+ * by a `0..100` split signal — no canvas, no animation engine. Clicking
20
+ * anywhere on the track jumps the split there with a short transition
21
+ * (skipped under `prefers-reduced-motion`); dragging itself is always instant.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <BeforeAfter
26
+ * before="/room-before.jpg"
27
+ * after="/room-after.jpg"
28
+ * alt="Living room before and after renovation"
29
+ * labels={['Antes', 'Después']}
30
+ * class="aspect-video rounded-2xl border border-border"
31
+ * />
32
+ * ```
33
+ */
34
+ export declare function BeforeAfter(props: BeforeAfterProps): JSX.Element;
@@ -4,6 +4,12 @@ export interface CarouselProps {
4
4
  slides: JSX.Element[];
5
5
  /** Auto-advance interval in ms. Omit to disable autoplay (also skipped under reduced motion / hover). */
6
6
  autoplayMs?: number;
7
+ /**
8
+ * Drag/touch to swipe between slides (in addition to arrows, dots, and arrow
9
+ * keys). The track follows the pointer and snaps on release — pure CSS, no
10
+ * engine. @default true
11
+ */
12
+ swipe?: boolean;
7
13
  class?: string;
8
14
  }
9
15
  /**
@@ -9,6 +9,12 @@ export interface FloatingActionButtonProps {
9
9
  onClick?: () => void;
10
10
  /** Screen corner to anchor to. Defaults to `'bottom-right'`. */
11
11
  position?: FloatingActionButtonPosition;
12
+ /**
13
+ * Material-style click ripple from the press position. Engine-free (Web
14
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
15
+ * costs nothing when off.
16
+ */
17
+ ripple?: boolean;
12
18
  class?: string;
13
19
  }
14
20
  /**
@@ -5,6 +5,12 @@ export interface ImageProps {
5
5
  class?: string;
6
6
  /** When true, clicking the image opens a zoomable lightbox. @default true */
7
7
  preview?: boolean;
8
+ /**
9
+ * Reveal the thumbnail with a blur-up: it starts blurred and slightly scaled,
10
+ * then eases to sharp once the image finishes loading. Pure CSS transition,
11
+ * no-op under reduced motion. @default false
12
+ */
13
+ blurUp?: boolean;
8
14
  }
9
15
  /**
10
16
  * Lazy-loaded content image. Unless `preview` is disabled, the thumbnail is a
package/dist/ui/List.d.ts CHANGED
@@ -9,6 +9,11 @@ export interface ListItem {
9
9
  }
10
10
  interface ListProps {
11
11
  items: ListItem[];
12
+ /**
13
+ * Cascade the rows in on mount — each row fades/slides up staggered by its
14
+ * index. Pure CSS (`list-row-stagger`), reduced-motion aware. @default false
15
+ */
16
+ stagger?: boolean;
12
17
  class?: string;
13
18
  }
14
19
  export type { ListProps };
@@ -0,0 +1,60 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Billing period a {@link PricingTable} can show. */
3
+ export type PricingPeriod = 'monthly' | 'annual';
4
+ /** One plan/tier rendered by {@link PricingTable}. */
5
+ export interface PricingTier {
6
+ /** Plan name, e.g. `"Pro"`. */
7
+ name: string;
8
+ /** Pre-formatted price for the monthly period, e.g. `"$0"` or `"$99/mes"`. */
9
+ price: string;
10
+ /** Pre-formatted price for the annual period. When ANY tier sets this, the toggle renders. */
11
+ priceAnnual?: string;
12
+ /** Short one-line description under the plan name. */
13
+ description?: string;
14
+ /** Bullet list of included features, each rendered with a check icon. */
15
+ features: string[];
16
+ /** Renders this tier emphasized: `glass` surface, a ring, and a "Popular" {@link Badge}. */
17
+ highlighted?: boolean;
18
+ /** Call-to-action button. Rendered as an `<a>` when `href` is set, otherwise a `<button>`. */
19
+ cta?: {
20
+ label: string;
21
+ href?: string;
22
+ onClick?: () => void;
23
+ };
24
+ }
25
+ interface PricingTableProps {
26
+ /** Tiers to render, in order, as a responsive grid. */
27
+ tiers: PricingTier[];
28
+ /** Controlled billing period. Omit to let the toggle manage its own state. */
29
+ period?: PricingPeriod;
30
+ /** Fired when the toggle changes period, controlled or uncontrolled. */
31
+ onPeriodChange?: (period: PricingPeriod) => void;
32
+ class?: string;
33
+ }
34
+ /**
35
+ * Responsive pricing grid: one {@link Card} per tier, a feature checklist, and
36
+ * a CTA {@link Button}. The highlighted tier gets a frosted glass surface, a
37
+ * ring, and a "Popular" {@link Badge}. When any tier sets `priceAnnual`, a
38
+ * monthly/annual toggle renders above the grid — pass `period` +
39
+ * `onPeriodChange` to control it, or omit both to let it manage its own state.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * <PricingTable
44
+ * tiers={[
45
+ * { name: 'Free', price: '$0', features: ['1 project', 'Community support'], cta: { label: 'Start' } },
46
+ * {
47
+ * name: 'Pro',
48
+ * price: '$19/mo',
49
+ * priceAnnual: '$190/yr',
50
+ * description: 'For growing teams',
51
+ * features: ['Unlimited projects', 'Priority support'],
52
+ * highlighted: true,
53
+ * cta: { label: 'Upgrade', href: '/upgrade' },
54
+ * },
55
+ * ]}
56
+ * />
57
+ * ```
58
+ */
59
+ export declare function PricingTable(props: PricingTableProps): JSX.Element;
60
+ export {};
@@ -2,15 +2,22 @@ import { JSX } from 'solid-js';
2
2
  interface SkeletonProps {
3
3
  /** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
4
4
  class?: string;
5
+ /**
6
+ * Use a light band sweeping across the placeholder instead of the default
7
+ * pulse. Pure CSS (`.skeleton-shimmer`), token-tinted, reduced-motion aware.
8
+ */
9
+ shimmer?: boolean;
5
10
  }
6
11
  /**
7
- * Pulsing placeholder block for content that is still loading. Plain `div`,
8
- * no Kobalte primitive — size it via `class` to match the shape of the real
9
- * content (text line, avatar, card, etc.).
12
+ * Placeholder block for content that is still loading. Plain `div`, no Kobalte
13
+ * primitive — size it via `class` to match the shape of the real content (text
14
+ * line, avatar, card, etc.). Pulses by default; pass `shimmer` for a sweeping
15
+ * light band instead.
10
16
  *
11
17
  * @example
12
18
  * ```tsx
13
19
  * <Skeleton class="h-4 w-48" />
20
+ * <Skeleton shimmer class="h-4 w-48" /> // sweeping band instead of pulse
14
21
  * ```
15
22
  */
16
23
  export declare function Skeleton(props: SkeletonProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,16 @@ export interface ProductCardProps {
37
37
  currency?: string
38
38
  locale?: string
39
39
  onAddToCart?: () => void
40
+ /**
41
+ * 3D hover tilt toward the cursor — forwarded to the underlying {@link Card}
42
+ * ({@link attachTilt}). Engine-free and reduced-motion aware.
43
+ */
44
+ tilt?: boolean
45
+ /**
46
+ * Cursor-following radial glow inside the card — forwarded to the underlying
47
+ * {@link Card} ({@link attachSpotlight}). Engine-free and reduced-motion aware.
48
+ */
49
+ spotlight?: boolean
40
50
  class?: string
41
51
  }
42
52
 
@@ -72,7 +82,13 @@ function inferTone(badge: string | undefined, tone: ProductBadgeTone | undefined
72
82
  */
73
83
  export function ProductCard(props: ProductCardProps): JSX.Element {
74
84
  return (
75
- <Card glass glow class={cn('flex h-full flex-col overflow-hidden p-4', props.class)}>
85
+ <Card
86
+ glass
87
+ glow
88
+ tilt={props.tilt}
89
+ spotlight={props.spotlight}
90
+ class={cn('flex h-full flex-col overflow-hidden p-4', props.class)}
91
+ >
76
92
  <div class="relative aspect-square overflow-hidden rounded-lg bg-muted">
77
93
  <img src={props.image} alt={props.title} loading="lazy" class="h-full w-full object-cover" />
78
94
  <Show when={props.badge}>
@@ -111,7 +127,15 @@ export function ProductCard(props: ProductCardProps): JSX.Element {
111
127
  locale={props.locale}
112
128
  />
113
129
  <Show when={props.onAddToCart}>
114
- <Button class="mt-auto w-full" onClick={() => props.onAddToCart?.()}>
130
+ {/* stopPropagation so a card wrapped in a link/router-<A> adds to cart
131
+ without also navigating. */}
132
+ <Button
133
+ class="mt-auto w-full"
134
+ onClick={(e) => {
135
+ e.stopPropagation()
136
+ props.onAddToCart?.()
137
+ }}
138
+ >
115
139
  Add to cart
116
140
  </Button>
117
141
  </Show>
@@ -0,0 +1,102 @@
1
+ // A minimal signal-backed shopping cart store: add/remove/setQty/clear plus
2
+ // derived count() and subtotal(). Generic over the product shape so it can
3
+ // back CartLine/CartSummary (or any custom card) without re-implementing the
4
+ // same bookkeeping in every commerce template.
5
+ import type { Accessor } from 'solid-js'
6
+ import { createMemo, createSignal } from 'solid-js'
7
+
8
+ export interface CartProduct {
9
+ id: string | number
10
+ price: number
11
+ }
12
+
13
+ export interface CartEntry<T extends CartProduct> {
14
+ product: T
15
+ qty: number
16
+ }
17
+
18
+ export interface Cart<T extends CartProduct> {
19
+ /** Reactive list of cart entries. */
20
+ items: Accessor<CartEntry<T>[]>
21
+ /** Add a product, or increment its `qty` if it's already in the cart. */
22
+ add: (product: T, qty?: number) => void
23
+ /** Remove a product from the cart by id. */
24
+ remove: (id: T['id']) => void
25
+ /** Set a product's quantity. `qty <= 0` removes the entry. */
26
+ setQty: (id: T['id'], qty: number) => void
27
+ /** Empty the cart. */
28
+ clear: () => void
29
+ /** Total number of units across all entries. */
30
+ count: () => number
31
+ /** Sum of `price * qty` across all entries. */
32
+ subtotal: () => number
33
+ /** Whether a product with this id is currently in the cart. */
34
+ has: (id: T['id']) => boolean
35
+ }
36
+
37
+ /**
38
+ * Create a signal-backed shopping cart store, generic over the product type.
39
+ * `count()` and `subtotal()` are memoized and stay in sync with `items()`.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * interface Product extends CartProduct {
44
+ * id: string
45
+ * price: number
46
+ * title: string
47
+ * }
48
+ *
49
+ * const cart = createCart<Product>()
50
+ *
51
+ * cart.add({ id: 'sku-1', price: 29.99, title: 'Wireless Mouse' })
52
+ * cart.add({ id: 'sku-1', price: 29.99, title: 'Wireless Mouse' }) // qty -> 2
53
+ * cart.setQty('sku-1', 3)
54
+ *
55
+ * cart.count() // 3
56
+ * cart.subtotal() // 89.97
57
+ *
58
+ * cart.remove('sku-1')
59
+ * cart.has('sku-1') // false
60
+ * ```
61
+ */
62
+ export function createCart<T extends CartProduct>(initial: CartEntry<T>[] = []): Cart<T> {
63
+ const [items, setItems] = createSignal<CartEntry<T>[]>(initial)
64
+
65
+ const add = (product: T, qty = 1): void => {
66
+ setItems((current) => {
67
+ const existing = current.find((entry) => entry.product.id === product.id)
68
+ if (existing) {
69
+ return current.map((entry) =>
70
+ entry.product.id === product.id ? { ...entry, qty: entry.qty + qty } : entry,
71
+ )
72
+ }
73
+ return [...current, { product, qty }]
74
+ })
75
+ }
76
+
77
+ const remove = (id: T['id']): void => {
78
+ setItems((current) => current.filter((entry) => entry.product.id !== id))
79
+ }
80
+
81
+ const setQty = (id: T['id'], qty: number): void => {
82
+ if (qty <= 0) {
83
+ remove(id)
84
+ return
85
+ }
86
+ setItems((current) => current.map((entry) => (entry.product.id === id ? { ...entry, qty } : entry)))
87
+ }
88
+
89
+ const clear = (): void => {
90
+ setItems([])
91
+ }
92
+
93
+ const count = createMemo(() => items().reduce((total, entry) => total + entry.qty, 0))
94
+
95
+ const subtotal = createMemo(() =>
96
+ items().reduce((total, entry) => total + entry.product.price * entry.qty, 0),
97
+ )
98
+
99
+ const has = (id: T['id']): boolean => items().some((entry) => entry.product.id === id)
100
+
101
+ return { items, add, remove, setQty, clear, count, subtotal, has }
102
+ }
@@ -8,3 +8,4 @@ export { ProductGrid, type ProductGridProps } from './ProductGrid'
8
8
  export { CartLine, type CartLineProps } from './CartLine'
9
9
  export { CartSummary, type CartSummaryProps, type CartSummaryItem } from './CartSummary'
10
10
  export { FilterGroup, type FilterGroupProps, type FacetOption } from './FilterGroup'
11
+ export { createCart, type Cart, type CartProduct, type CartEntry } from './createCart'
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // import '@a4ui/core/styles.css'
9
9
  // import { Button, Card, Modal } from '@a4ui/core'
10
10
 
11
- export const A4UI_VERSION = '0.16.0'
11
+ export const A4UI_VERSION = '0.18.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -86,6 +86,8 @@ export { Carousel, type CarouselProps } from './ui/Carousel'
86
86
  export { Stepper, type StepItem, type StepperProps } from './ui/Stepper'
87
87
  export { Timeline, type TimelineItem, type TimelineTone, type TimelineProps } from './ui/Timeline'
88
88
  export { Rating, type RatingProps } from './ui/Rating'
89
+ export { PricingTable, type PricingTier, type PricingPeriod } from './ui/PricingTable'
90
+ export { BeforeAfter, type BeforeAfterProps } from './ui/BeforeAfter'
89
91
  export { Empty, type EmptyProps } from './ui/Empty'
90
92
  export { Calendar, type CalendarProps } from './ui/Calendar'
91
93
  export { Tree, type TreeNode, type TreeProps } from './ui/Tree'
@@ -168,6 +170,8 @@ export { ChristmasBackground } from './layout/ChristmasBackground'
168
170
  export { ThemeToggle } from './layout/ThemeToggle'
169
171
  export { EffectsToggle } from './layout/EffectsToggle'
170
172
  export { NavGroup } from './layout/NavGroup'
173
+ export { Section, type SectionWidth, type SectionPad } from './layout/Section'
174
+ export { ActionBar, type ActionBarProps, type ActionBarPosition } from './layout/ActionBar'
171
175
 
172
176
  // Themes (src/themes) — swappable color palettes (Space is the default). Pick one
173
177
  // with `selectTheme('dino')`, restore the saved one with `initTheme()`, or build
@@ -0,0 +1,121 @@
1
+ // ActionBar — sticky action / emergency bar: a full-width strip that pins one
2
+ // prominent CTA to the viewport edge, with an optional status badge + short
3
+ // message beside it. Built for urgent-service verticals (towing, HVAC,
4
+ // locksmith, …) where the call-to-action — usually a `tel:` link — has to
5
+ // stay reachable through the whole page scroll. On narrow viewports the
6
+ // message drops out first so the badge + action never wrap or get pushed
7
+ // off-screen.
8
+ import { Show, splitProps, type JSX } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { spawnRipple } from '../ui/Ripple'
12
+
13
+ /** Edge an {@link ActionBar} pins to when `sticky`. Defaults to `'top'`. */
14
+ export type ActionBarPosition = 'top' | 'bottom'
15
+
16
+ export interface ActionBarProps {
17
+ /**
18
+ * The prominent CTA. Rendered as `<a href>` when `href` is set (e.g.
19
+ * `tel:+15551234567`), otherwise as a `<button type="button">` driven by
20
+ * `onClick`.
21
+ */
22
+ action: { label: string; href?: string; onClick?: () => void }
23
+ /** Optional left slot, e.g. a `<Badge pulse>24/7</Badge>`. */
24
+ status?: JSX.Element
25
+ /** Optional short text between `status` and the action. Hidden below `sm` so the action stays reachable. */
26
+ message?: JSX.Element
27
+ /** Pins the bar to the viewport edge (`position`) instead of scrolling with the page. Defaults to `true`. */
28
+ sticky?: boolean
29
+ /** Edge to pin to when `sticky`. Defaults to `'top'`. */
30
+ position?: ActionBarPosition
31
+ class?: string
32
+ }
33
+
34
+ const POSITION_CLASSES: Record<ActionBarPosition, string> = {
35
+ top: 'top-0',
36
+ bottom: 'bottom-0',
37
+ }
38
+
39
+ // Deliberately not the Button primitive: Button always renders a <button>,
40
+ // and this CTA needs to be a real <a href="tel:…"> for click-to-call. The
41
+ // classes mirror Button's shape/motion but use accent (not primary) so the
42
+ // CTA still pops against the bar's own bg-primary surface.
43
+ const ACTION_CLASSES =
44
+ 'relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-accent px-4 py-2 text-sm font-semibold whitespace-nowrap text-accent-foreground transition-[background-color,transform] duration-150 hover:bg-accent/90 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring'
45
+
46
+ /**
47
+ * Sticky action / emergency bar: a full-width strip that pins a single
48
+ * prominent CTA to the viewport edge, with an optional status badge and
49
+ * short message beside it. Built for urgent-service verticals (towing, HVAC,
50
+ * locksmith, …) where the CTA — usually a `tel:` link — must stay reachable
51
+ * through the whole scroll. The action gets a click ripple like `<Button
52
+ * ripple>`; the message drops out on narrow viewports so the badge + action
53
+ * never wrap.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * <ActionBar
58
+ * status={<Badge pulse>24/7</Badge>}
59
+ * message="Stuck on the road? We're on our way."
60
+ * action={{ label: 'Call now', href: 'tel:+15551234567' }}
61
+ * />
62
+ * ```
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // Bottom-pinned, plain onClick action instead of a tel: link.
67
+ * <ActionBar
68
+ * position="bottom"
69
+ * message="Free quote in 2 minutes"
70
+ * action={{ label: 'Get a quote', onClick: openQuoteForm }}
71
+ * />
72
+ * ```
73
+ */
74
+ export function ActionBar(props: ActionBarProps): JSX.Element {
75
+ const [local] = splitProps(props, ['action', 'status', 'message', 'sticky', 'position', 'class'])
76
+
77
+ const handlePointerDown: JSX.EventHandler<HTMLElement, PointerEvent> = (event) => {
78
+ spawnRipple(event.currentTarget, event, { opacity: 0.35 })
79
+ }
80
+
81
+ return (
82
+ <div
83
+ class={cn(
84
+ 'z-40 flex w-full items-center justify-between gap-3 bg-primary px-4 py-3 text-primary-foreground',
85
+ (local.sticky ?? true) && cn('sticky', POSITION_CLASSES[local.position ?? 'top']),
86
+ local.class,
87
+ )}
88
+ >
89
+ <div class="flex min-w-0 items-center gap-2 sm:gap-3">
90
+ <Show when={local.status}>{local.status}</Show>
91
+ <Show when={local.message}>
92
+ <p class="hidden min-w-0 truncate text-sm sm:block sm:text-base">{local.message}</p>
93
+ </Show>
94
+ </div>
95
+ <Show
96
+ when={local.action.href}
97
+ fallback={
98
+ <button
99
+ type="button"
100
+ class={ACTION_CLASSES}
101
+ onPointerDown={handlePointerDown}
102
+ onClick={local.action.onClick}
103
+ >
104
+ {local.action.label}
105
+ </button>
106
+ }
107
+ >
108
+ {(href) => (
109
+ <a
110
+ href={href()}
111
+ class={ACTION_CLASSES}
112
+ onPointerDown={handlePointerDown}
113
+ onClick={local.action.onClick}
114
+ >
115
+ {local.action.label}
116
+ </a>
117
+ )}
118
+ </Show>
119
+ </div>
120
+ )
121
+ }