@a4ui/core 0.18.0 → 0.20.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.
@@ -13,6 +13,16 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
13
13
  * costs nothing when off.
14
14
  */
15
15
  ripple?: boolean;
16
+ /**
17
+ * When set, the button renders as an `<a href>` (a link) instead of a
18
+ * `<button>`, keeping the variant look, focus ring, and ripple. Use for
19
+ * navigation / router links and `tel:`/`mailto:` CTAs.
20
+ */
21
+ href?: string;
22
+ /** Anchor `target` — only used when `href` is set. */
23
+ target?: string;
24
+ /** Anchor `rel` — only used when `href` is set. */
25
+ rel?: string;
16
26
  }
17
27
  /**
18
28
  * Base button primitive: a plain `<button>` with A4ui's variants, focus ring,
@@ -0,0 +1,76 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A selectable option in a `'select'` group. */
3
+ export interface ConfiguratorOption {
4
+ value: string;
5
+ label: string;
6
+ /** Added to the running total when selected. */
7
+ price?: number;
8
+ }
9
+ /** A countable item in a `'counter'` group (each with a +/- stepper). */
10
+ export interface ConfiguratorItem {
11
+ id: string;
12
+ label: string;
13
+ /** Added to the total as `price * qty`. */
14
+ price?: number;
15
+ }
16
+ /** One group of controls. `select` = single choice, `counter` = quantities, `text` = free text. */
17
+ export type ConfiguratorGroup = {
18
+ type: 'select';
19
+ name: string;
20
+ label: string;
21
+ options: ConfiguratorOption[];
22
+ } | {
23
+ type: 'counter';
24
+ name: string;
25
+ label: string;
26
+ items: ConfiguratorItem[];
27
+ } | {
28
+ type: 'text';
29
+ name: string;
30
+ label: string;
31
+ placeholder?: string;
32
+ maxLength?: number;
33
+ };
34
+ /** The configurator's state: keyed by group `name`. select/text -> string, counter -> `{ [id]: qty }`. */
35
+ export type ConfiguratorState = Record<string, string | Record<string, number>>;
36
+ export interface ConfiguratorProps {
37
+ groups: ConfiguratorGroup[];
38
+ value: ConfiguratorState;
39
+ onChange: (next: ConfiguratorState) => void;
40
+ /** Format the running total. @default `n => $${n}` */
41
+ formatTotal?: (total: number) => string;
42
+ /** Live preview, rendered beside the controls; receives the state and running total. */
43
+ preview?: (state: ConfiguratorState, total: number) => JSX.Element;
44
+ /** CTA under the summary (e.g. "add to cart" / "request quote"). */
45
+ action?: {
46
+ label: string;
47
+ onClick?: () => void;
48
+ href?: string;
49
+ };
50
+ class?: string;
51
+ }
52
+ /**
53
+ * Data-driven product configurator: declare `groups` (select / counter / text),
54
+ * hold the `value` state in the parent, and get option pickers, quantity
55
+ * steppers, a live summary with a running total, and an optional `preview`.
56
+ * Engine-free, fully typed.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * const [cfg, setCfg] = createSignal<ConfiguratorState>({ size: 'm', extras: {} })
61
+ * <Configurator
62
+ * value={cfg()}
63
+ * onChange={setCfg}
64
+ * groups={[
65
+ * { type: 'select', name: 'size', label: 'Tamaño', options: [
66
+ * { value: 's', label: 'Chico', price: 320 }, { value: 'm', label: 'Mediano', price: 620 } ] },
67
+ * { type: 'counter', name: 'extras', label: 'Extras', items: [
68
+ * { id: 'jamon', label: 'Jamón serrano', price: 90 } ] },
69
+ * { type: 'text', name: 'nota', label: 'Nota', placeholder: 'Para grabar…' },
70
+ * ]}
71
+ * preview={(state, total) => <MyPreview state={state} total={total} />}
72
+ * action={{ label: 'Solicitar', onClick: submit }}
73
+ * />
74
+ * ```
75
+ */
76
+ export declare function Configurator(props: ConfiguratorProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",
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.18.0'
11
+ export const A4UI_VERSION = '0.20.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -88,6 +88,14 @@ export { Timeline, type TimelineItem, type TimelineTone, type TimelineProps } fr
88
88
  export { Rating, type RatingProps } from './ui/Rating'
89
89
  export { PricingTable, type PricingTier, type PricingPeriod } from './ui/PricingTable'
90
90
  export { BeforeAfter, type BeforeAfterProps } from './ui/BeforeAfter'
91
+ export {
92
+ Configurator,
93
+ type ConfiguratorProps,
94
+ type ConfiguratorGroup,
95
+ type ConfiguratorState,
96
+ type ConfiguratorOption,
97
+ type ConfiguratorItem,
98
+ } from './ui/Configurator'
91
99
  export { Empty, type EmptyProps } from './ui/Empty'
92
100
  export { Calendar, type CalendarProps } from './ui/Calendar'
93
101
  export { Tree, type TreeNode, type TreeProps } from './ui/Tree'
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,215 @@
1
+ // Configurator — a data-driven "pick options -> live preview + running total"
2
+ // builder. Distilled from two templates that hand-rolled the same shape: a
3
+ // laser-engraving customizer (text + product/style pickers) and a charcuterie
4
+ // board builder (size + per-item counters). Controlled: the parent owns the
5
+ // state object; the Configurator renders the group controls, a live summary
6
+ // with a running total, and an optional preview render-prop.
7
+ import type { JSX } from 'solid-js'
8
+ import { createMemo, For, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { Button } from './Button'
12
+
13
+ /** A selectable option in a `'select'` group. */
14
+ export interface ConfiguratorOption {
15
+ value: string
16
+ label: string
17
+ /** Added to the running total when selected. */
18
+ price?: number
19
+ }
20
+ /** A countable item in a `'counter'` group (each with a +/- stepper). */
21
+ export interface ConfiguratorItem {
22
+ id: string
23
+ label: string
24
+ /** Added to the total as `price * qty`. */
25
+ price?: number
26
+ }
27
+
28
+ /** One group of controls. `select` = single choice, `counter` = quantities, `text` = free text. */
29
+ export type ConfiguratorGroup =
30
+ | { type: 'select'; name: string; label: string; options: ConfiguratorOption[] }
31
+ | { type: 'counter'; name: string; label: string; items: ConfiguratorItem[] }
32
+ | { type: 'text'; name: string; label: string; placeholder?: string; maxLength?: number }
33
+
34
+ /** The configurator's state: keyed by group `name`. select/text -> string, counter -> `{ [id]: qty }`. */
35
+ export type ConfiguratorState = Record<string, string | Record<string, number>>
36
+
37
+ export interface ConfiguratorProps {
38
+ groups: ConfiguratorGroup[]
39
+ value: ConfiguratorState
40
+ onChange: (next: ConfiguratorState) => void
41
+ /** Format the running total. @default `n => $${n}` */
42
+ formatTotal?: (total: number) => string
43
+ /** Live preview, rendered beside the controls; receives the state and running total. */
44
+ preview?: (state: ConfiguratorState, total: number) => JSX.Element
45
+ /** CTA under the summary (e.g. "add to cart" / "request quote"). */
46
+ action?: { label: string; onClick?: () => void; href?: string }
47
+ class?: string
48
+ }
49
+
50
+ const asString = (v: string | Record<string, number> | undefined): string => (typeof v === 'string' ? v : '')
51
+ const asCounts = (v: string | Record<string, number> | undefined): Record<string, number> =>
52
+ v && typeof v === 'object' ? v : {}
53
+
54
+ /**
55
+ * Data-driven product configurator: declare `groups` (select / counter / text),
56
+ * hold the `value` state in the parent, and get option pickers, quantity
57
+ * steppers, a live summary with a running total, and an optional `preview`.
58
+ * Engine-free, fully typed.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const [cfg, setCfg] = createSignal<ConfiguratorState>({ size: 'm', extras: {} })
63
+ * <Configurator
64
+ * value={cfg()}
65
+ * onChange={setCfg}
66
+ * groups={[
67
+ * { type: 'select', name: 'size', label: 'Tamaño', options: [
68
+ * { value: 's', label: 'Chico', price: 320 }, { value: 'm', label: 'Mediano', price: 620 } ] },
69
+ * { type: 'counter', name: 'extras', label: 'Extras', items: [
70
+ * { id: 'jamon', label: 'Jamón serrano', price: 90 } ] },
71
+ * { type: 'text', name: 'nota', label: 'Nota', placeholder: 'Para grabar…' },
72
+ * ]}
73
+ * preview={(state, total) => <MyPreview state={state} total={total} />}
74
+ * action={{ label: 'Solicitar', onClick: submit }}
75
+ * />
76
+ * ```
77
+ */
78
+ export function Configurator(props: ConfiguratorProps): JSX.Element {
79
+ const fmt = (n: number): string => (props.formatTotal ?? ((x: number) => `$${x}`))(n)
80
+
81
+ const total = createMemo(() => {
82
+ let sum = 0
83
+ for (const g of props.groups) {
84
+ if (g.type === 'select') {
85
+ const chosen = asString(props.value[g.name])
86
+ sum += g.options.find((o) => o.value === chosen)?.price ?? 0
87
+ } else if (g.type === 'counter') {
88
+ const counts = asCounts(props.value[g.name])
89
+ for (const it of g.items) sum += (it.price ?? 0) * (counts[it.id] ?? 0)
90
+ }
91
+ }
92
+ return sum
93
+ })
94
+
95
+ const setSelect = (name: string, value: string): void => props.onChange({ ...props.value, [name]: value })
96
+ const setCount = (name: string, id: string, qty: number): void => {
97
+ const counts = { ...asCounts(props.value[name]) }
98
+ if (qty <= 0) delete counts[id]
99
+ else counts[id] = qty
100
+ props.onChange({ ...props.value, [name]: counts })
101
+ }
102
+ const setText = (name: string, value: string): void => props.onChange({ ...props.value, [name]: value })
103
+
104
+ const controls = (
105
+ <div class="space-y-6">
106
+ <For each={props.groups}>
107
+ {(g) => (
108
+ <div>
109
+ <p class="mb-2 text-sm font-medium text-foreground">{g.label}</p>
110
+ <Show when={g.type === 'select' && g}>
111
+ {(sel) => (
112
+ <div class="flex flex-wrap gap-2">
113
+ <For each={sel().options}>
114
+ {(o) => (
115
+ <button
116
+ type="button"
117
+ aria-pressed={asString(props.value[sel().name]) === o.value}
118
+ onClick={() => setSelect(sel().name, o.value)}
119
+ class={cn(
120
+ 'rounded-lg border px-3 py-1.5 text-sm transition-colors',
121
+ asString(props.value[sel().name]) === o.value
122
+ ? 'border-primary bg-primary/10 text-primary'
123
+ : 'border-border text-muted-foreground hover:text-foreground',
124
+ )}
125
+ >
126
+ {o.label}
127
+ <Show when={o.price}>
128
+ <span class="ml-1 opacity-70">· {fmt(o.price as number)}</span>
129
+ </Show>
130
+ </button>
131
+ )}
132
+ </For>
133
+ </div>
134
+ )}
135
+ </Show>
136
+ <Show when={g.type === 'counter' && g}>
137
+ {(cnt) => (
138
+ <div class="space-y-2">
139
+ <For each={cnt().items}>
140
+ {(it) => {
141
+ const qty = (): number => asCounts(props.value[cnt().name])[it.id] ?? 0
142
+ return (
143
+ <div class="flex items-center justify-between gap-3">
144
+ <span class="text-sm text-foreground">
145
+ {it.label}
146
+ <Show when={it.price}>
147
+ <span class="ml-1 text-muted-foreground">· {fmt(it.price as number)}</span>
148
+ </Show>
149
+ </span>
150
+ <div class="flex items-center gap-2">
151
+ <Button
152
+ variant="outline"
153
+ aria-label={`Quitar ${it.label}`}
154
+ onClick={() => setCount(cnt().name, it.id, qty() - 1)}
155
+ class="h-8 w-8 p-0"
156
+ >
157
+
158
+ </Button>
159
+ <span class="w-6 text-center text-sm tabular-nums">{qty()}</span>
160
+ <Button
161
+ variant="outline"
162
+ aria-label={`Agregar ${it.label}`}
163
+ onClick={() => setCount(cnt().name, it.id, qty() + 1)}
164
+ class="h-8 w-8 p-0"
165
+ >
166
+ +
167
+ </Button>
168
+ </div>
169
+ </div>
170
+ )
171
+ }}
172
+ </For>
173
+ </div>
174
+ )}
175
+ </Show>
176
+ <Show when={g.type === 'text' && g}>
177
+ {(txt) => (
178
+ <input
179
+ type="text"
180
+ value={asString(props.value[txt().name])}
181
+ maxLength={txt().maxLength}
182
+ placeholder={txt().placeholder}
183
+ aria-label={txt().label}
184
+ onInput={(e) => setText(txt().name, e.currentTarget.value)}
185
+ class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
186
+ />
187
+ )}
188
+ </Show>
189
+ </div>
190
+ )}
191
+ </For>
192
+
193
+ <div class="flex items-center justify-between border-t border-border pt-4">
194
+ <span class="text-sm text-muted-foreground">Total</span>
195
+ <span class="text-lg font-semibold text-foreground tabular-nums">{fmt(total())}</span>
196
+ </div>
197
+ <Show when={props.action}>
198
+ {(a) => (
199
+ <Button ripple href={a().href} onClick={() => a().onClick?.()} class="w-full justify-center">
200
+ {a().label}
201
+ </Button>
202
+ )}
203
+ </Show>
204
+ </div>
205
+ )
206
+
207
+ return (
208
+ <div class={cn('grid gap-6', props.preview ? 'md:grid-cols-2' : '', props.class)}>
209
+ {controls}
210
+ <Show when={props.preview}>
211
+ <div class="min-w-0">{props.preview?.(props.value, total())}</div>
212
+ </Show>
213
+ </div>
214
+ )
215
+ }