@a4ui/core 0.22.0 → 0.24.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,124 @@
1
+ // Full-bleed promo bar. Optionally dismissible (local signal, closes for the
2
+ // session only — no persistence) and optionally carries a copyable coupon
3
+ // code pill. Kept dependency-free: clipboard access is feature-detected, not
4
+ // polyfilled, so it degrades to "show the code, copy button is a no-op" on
5
+ // browsers/contexts without `navigator.clipboard`.
6
+ import type { JSX } from 'solid-js'
7
+ import { createSignal, Show } from 'solid-js'
8
+ import { Check, Copy, X } from 'lucide-solid'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ /** Background tone of an {@link AnnouncementBar}. */
13
+ export type AnnouncementTone = 'primary' | 'accent' | 'neutral'
14
+
15
+ const TONE: Record<AnnouncementTone, string> = {
16
+ primary: 'bg-primary text-primary-foreground',
17
+ accent: 'bg-accent text-accent-foreground',
18
+ neutral: 'bg-muted text-foreground',
19
+ }
20
+
21
+ export interface AnnouncementBarProps {
22
+ /** Message content. */
23
+ children: JSX.Element
24
+ /** Background tone. Defaults to `'primary'`. */
25
+ tone?: AnnouncementTone
26
+ /** Show a dismiss (X) button. Defaults to `false`. */
27
+ dismissible?: boolean
28
+ /** Optional coupon code rendered as a copyable pill. */
29
+ couponCode?: string
30
+ /** Make the whole message a link. */
31
+ href?: string
32
+ /** Called after the bar is dismissed. */
33
+ onDismiss?: () => void
34
+ class?: string
35
+ }
36
+
37
+ /**
38
+ * A full-width promo/notice bar, meant to sit above or below the page header.
39
+ * Supports an optional dismiss button, an optional link wrapping the message,
40
+ * and an optional copyable coupon-code pill.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * <AnnouncementBar
45
+ * tone="accent"
46
+ * dismissible
47
+ * couponCode="SAVE20"
48
+ * href="/sale"
49
+ * >
50
+ * Summer sale is live — 20% off everything
51
+ * </AnnouncementBar>
52
+ * ```
53
+ */
54
+ export function AnnouncementBar(props: AnnouncementBarProps): JSX.Element {
55
+ const [dismissed, setDismissed] = createSignal(false)
56
+ const [copied, setCopied] = createSignal(false)
57
+
58
+ const copyCode = async () => {
59
+ const code = props.couponCode
60
+ if (!code || !navigator.clipboard) return
61
+ await navigator.clipboard.writeText(code)
62
+ setCopied(true)
63
+ setTimeout(() => setCopied(false), 1500)
64
+ }
65
+
66
+ const dismiss = () => {
67
+ setDismissed(true)
68
+ props.onDismiss?.()
69
+ }
70
+
71
+ return (
72
+ <Show when={!dismissed()}>
73
+ <div
74
+ role="region"
75
+ aria-label="Announcement"
76
+ class={cn(
77
+ 'relative w-full rounded-none px-4 py-2 text-center text-sm',
78
+ TONE[props.tone ?? 'primary'],
79
+ props.dismissible && 'pr-10',
80
+ props.class,
81
+ )}
82
+ >
83
+ <Show when={props.href} fallback={<span>{props.children}</span>}>
84
+ <a
85
+ href={props.href}
86
+ class="underline-offset-2 transition-colors hover:underline motion-reduce:transition-none"
87
+ >
88
+ {props.children}
89
+ </a>
90
+ </Show>
91
+
92
+ <Show when={props.couponCode}>
93
+ {(code) => (
94
+ <span class="ml-2 inline-flex items-center gap-1 rounded-md bg-black/10 px-2 py-0.5 font-mono">
95
+ {code()}
96
+ <button
97
+ type="button"
98
+ aria-label={copied() ? 'Coupon code copied' : 'Copy coupon code'}
99
+ onClick={copyCode}
100
+ class="inline-flex items-center gap-1 transition-opacity hover:opacity-80 motion-reduce:transition-none"
101
+ >
102
+ <Show when={copied()} fallback={<Copy class="h-3 w-3" />}>
103
+ <Check class="h-3 w-3" />
104
+ Copied
105
+ </Show>
106
+ </button>
107
+ </span>
108
+ )}
109
+ </Show>
110
+
111
+ <Show when={props.dismissible}>
112
+ <button
113
+ type="button"
114
+ aria-label="Dismiss announcement"
115
+ onClick={dismiss}
116
+ class="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 transition-opacity hover:opacity-80 motion-reduce:transition-none"
117
+ >
118
+ <X class="h-4 w-4" />
119
+ </button>
120
+ </Show>
121
+ </div>
122
+ </Show>
123
+ )
124
+ }
@@ -0,0 +1,94 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { For, Show } from 'solid-js'
3
+
4
+ import { cn } from '../lib/cn'
5
+
6
+ export interface LogoItem {
7
+ src: string
8
+ alt: string
9
+ href?: string
10
+ }
11
+
12
+ export interface LogoWallProps {
13
+ logos: LogoItem[]
14
+ /** Grayscale until hover. Defaults to true. */
15
+ grayscale?: boolean
16
+ /** Columns on desktop. Defaults to a responsive auto-fit. */
17
+ columns?: number
18
+ class?: string
19
+ }
20
+
21
+ // Static Tailwind class lookups — dynamic `grid-cols-${n}` strings aren't
22
+ // picked up by the JIT scanner, so the desktop/mobile column counts must be
23
+ // literal class names known at build time.
24
+ const DESKTOP_COLS: Record<number, string> = {
25
+ 2: 'md:grid-cols-2',
26
+ 3: 'md:grid-cols-3',
27
+ 4: 'md:grid-cols-4',
28
+ 5: 'md:grid-cols-5',
29
+ 6: 'md:grid-cols-6',
30
+ 7: 'md:grid-cols-7',
31
+ 8: 'md:grid-cols-8',
32
+ }
33
+
34
+ const MOBILE_COLS: Record<number, string> = {
35
+ 2: 'grid-cols-2',
36
+ 3: 'grid-cols-3',
37
+ 4: 'grid-cols-3',
38
+ 5: 'grid-cols-3',
39
+ 6: 'grid-cols-3',
40
+ 7: 'grid-cols-3',
41
+ 8: 'grid-cols-3',
42
+ }
43
+
44
+ /**
45
+ * "As seen in" grid of press/partner logos, grayscale by default with a
46
+ * color-on-hover reveal. Each logo can optionally link out; sits neutral
47
+ * on any background.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <LogoWall
52
+ * logos={[
53
+ * { src: 'https://picsum.photos/seed/techdaily/160/48', alt: 'TechDaily' },
54
+ * { src: 'https://picsum.photos/seed/stylemag/160/48', alt: 'StyleMag', href: 'https://stylemag.example' },
55
+ * { src: 'https://picsum.photos/seed/marketwire/160/48', alt: 'MarketWire' },
56
+ * ]}
57
+ * />
58
+ * ```
59
+ */
60
+ export function LogoWall(props: LogoWallProps): JSX.Element {
61
+ const grayscale = () => props.grayscale !== false
62
+ const gridCols = () =>
63
+ props.columns !== undefined
64
+ ? cn(MOBILE_COLS[props.columns] ?? 'grid-cols-2', DESKTOP_COLS[props.columns] ?? 'md:grid-cols-6')
65
+ : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6'
66
+
67
+ return (
68
+ <div class={cn('grid items-center gap-6 md:gap-8', gridCols(), props.class)}>
69
+ <For each={props.logos}>
70
+ {(logo) => {
71
+ const img = (
72
+ <img
73
+ src={logo.src}
74
+ alt={logo.alt}
75
+ loading="lazy"
76
+ class={cn(
77
+ 'mx-auto max-h-10 w-auto object-contain opacity-70 transition duration-300',
78
+ grayscale() && 'grayscale hover:grayscale-0 hover:opacity-100',
79
+ !grayscale() && 'hover:opacity-100',
80
+ )}
81
+ />
82
+ )
83
+ return (
84
+ <Show when={logo.href} fallback={img}>
85
+ <a href={logo.href} target="_blank" rel="noreferrer" aria-label={logo.alt}>
86
+ {img}
87
+ </a>
88
+ </Show>
89
+ )
90
+ }}
91
+ </For>
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,128 @@
1
+ // RatingsSummary — a compact trust-band grid of aggregate ratings pulled
2
+ // from multiple review sources, each rendered as a small glass-neutral tile.
3
+ import { Star } from 'lucide-solid'
4
+ import type { JSX } from 'solid-js'
5
+ import { For, Show } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ /** One aggregate rating rendered as a tile by {@link RatingsSummary}. */
10
+ export interface RatingSource {
11
+ /** Platform / source name, e.g. `"Google"`. */
12
+ name: string
13
+ /** Headline score, e.g. `4.9` or `"A+"`. */
14
+ score: string | number
15
+ /** Denominator, e.g. `5`. Shown as `score/outOf` when provided and `score` is numeric. */
16
+ outOf?: string | number
17
+ /** Number of reviews/feedback backing the score. */
18
+ count?: number
19
+ /** Optional logo/icon node; falls back to `name` when omitted. */
20
+ logo?: JSX.Element
21
+ /** When set, the tile renders as a link to the source. */
22
+ href?: string
23
+ }
24
+
25
+ export interface RatingsSummaryProps {
26
+ /** Rating sources to render, in order, as a responsive grid. */
27
+ sources: RatingSource[]
28
+ /** Columns on desktop. Defaults to a responsive 4-column grid. */
29
+ columns?: number
30
+ class?: string
31
+ }
32
+
33
+ const DESKTOP_COLS: Record<number, string> = {
34
+ 2: 'md:grid-cols-2',
35
+ 3: 'md:grid-cols-3',
36
+ 4: 'md:grid-cols-4',
37
+ 5: 'md:grid-cols-5',
38
+ 6: 'md:grid-cols-6',
39
+ }
40
+
41
+ /**
42
+ * Compact social-proof grid of aggregate ratings across multiple sources —
43
+ * a trust band for landing pages and checkout flows. Each source renders as
44
+ * a small glass-neutral tile with its logo/name, headline score (optionally
45
+ * `score/outOf`), a review count, and — when the score is numeric out of 5 —
46
+ * a row of filled stars. Tiles with an `href` render as links that open in
47
+ * a new tab.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <RatingsSummary
52
+ * sources={[
53
+ * { name: 'Google', score: 4.8, outOf: 5, count: 2140, href: 'https://example.com/reviews' },
54
+ * { name: 'Trustpilot', score: 4.6, outOf: 5, count: 980 },
55
+ * { name: 'Verified Buyers', score: 4.9, outOf: 5, count: 512 },
56
+ * { name: 'App Store', score: 'A+', count: 310 },
57
+ * ]}
58
+ * />
59
+ * ```
60
+ */
61
+ export function RatingsSummary(props: RatingsSummaryProps): JSX.Element {
62
+ const desktopCols = () => (props.columns !== undefined ? DESKTOP_COLS[props.columns] : undefined)
63
+
64
+ return (
65
+ <div class={cn('grid grid-cols-2 gap-3', desktopCols() ?? 'md:grid-cols-4', props.class)}>
66
+ <For each={props.sources}>
67
+ {(source) => {
68
+ const numericScore = () => (typeof source.score === 'number' ? source.score : undefined)
69
+ const tile = (
70
+ <>
71
+ <Show
72
+ when={source.logo}
73
+ fallback={<p class="text-sm font-medium text-muted-foreground">{source.name}</p>}
74
+ >
75
+ {(logo) => <div class="flex items-center justify-center text-muted-foreground">{logo()}</div>}
76
+ </Show>
77
+ <p class="mt-1 text-2xl font-bold text-foreground">
78
+ <Show
79
+ when={numericScore() !== undefined && source.outOf !== undefined}
80
+ fallback={source.score}
81
+ >
82
+ {source.score}/{source.outOf}
83
+ </Show>
84
+ </p>
85
+ <Show when={numericScore() !== undefined && (numericScore() as number) <= 5}>
86
+ <div class="mt-1 flex items-center justify-center gap-0.5" aria-hidden="true">
87
+ <For each={[0, 1, 2, 3, 4]}>
88
+ {(i) => (
89
+ <Star
90
+ class={cn(
91
+ 'h-3.5 w-3.5',
92
+ i < Math.round(numericScore() as number)
93
+ ? 'fill-primary text-primary'
94
+ : 'fill-muted text-muted',
95
+ )}
96
+ />
97
+ )}
98
+ </For>
99
+ </div>
100
+ </Show>
101
+ <Show when={source.count !== undefined}>
102
+ <p class="mt-1 text-xs text-muted-foreground">{source.count?.toLocaleString()} reviews</p>
103
+ </Show>
104
+ </>
105
+ )
106
+
107
+ return (
108
+ <Show
109
+ when={source.href}
110
+ fallback={<div class="rounded-xl border border-border bg-card/60 p-4 text-center">{tile}</div>}
111
+ >
112
+ {(href) => (
113
+ <a
114
+ href={href()}
115
+ target="_blank"
116
+ rel="noreferrer"
117
+ class="rounded-xl border border-border bg-card/60 p-4 text-center transition hover:border-primary/50"
118
+ >
119
+ {tile}
120
+ </a>
121
+ )}
122
+ </Show>
123
+ )
124
+ }}
125
+ </For>
126
+ </div>
127
+ )
128
+ }
@@ -0,0 +1,89 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { For, Show } from 'solid-js'
3
+ import { cn } from '../lib/cn'
4
+
5
+ export interface SpecRow {
6
+ label: string
7
+ value: JSX.Element | string
8
+ }
9
+
10
+ export interface SpecGroup {
11
+ title?: string
12
+ rows: SpecRow[]
13
+ }
14
+
15
+ export interface SpecSheetProps {
16
+ groups: SpecGroup[]
17
+ /** 1 or 2 columns of rows per group on wider screens. Defaults to 1. */
18
+ columns?: 1 | 2
19
+ class?: string
20
+ }
21
+
22
+ /**
23
+ * A grouped key/value specification table, for product specs, tech sheets,
24
+ * or any other structured attribute list. Rows are label/value pairs with a
25
+ * subtle divider; groups can carry an optional uppercase title and rows can
26
+ * flow in one or two columns on wider screens. Designed to sit inside a
27
+ * `Card` with a clean, glassy-neutral look.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <SpecSheet
32
+ * columns={2}
33
+ * groups={[
34
+ * {
35
+ * title: 'Display',
36
+ * rows: [
37
+ * { label: 'Panel', value: 'AMOLED, 6.4"' },
38
+ * { label: 'Resolution', value: '2340 x 1080' },
39
+ * { label: 'Refresh rate', value: '120 Hz' },
40
+ * ],
41
+ * },
42
+ * {
43
+ * title: 'Battery',
44
+ * rows: [
45
+ * { label: 'Capacity', value: '4500 mAh' },
46
+ * { label: 'Charging', value: '65 W wired' },
47
+ * ],
48
+ * },
49
+ * ]}
50
+ * />
51
+ * ```
52
+ */
53
+ export function SpecSheet(props: SpecSheetProps): JSX.Element {
54
+ return (
55
+ <div class={cn('flex flex-col gap-6', props.class)}>
56
+ <For each={props.groups}>
57
+ {(group) => (
58
+ <div class="flex flex-col gap-2">
59
+ <Show when={group.title}>
60
+ <div class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
61
+ {group.title}
62
+ </div>
63
+ </Show>
64
+ <dl
65
+ class={cn(
66
+ 'divide-y divide-border/60',
67
+ props.columns === 2 && 'grid grid-cols-1 gap-x-8 sm:grid-cols-2 sm:divide-y-0',
68
+ )}
69
+ >
70
+ <For each={group.rows}>
71
+ {(row) => (
72
+ <div
73
+ class={cn(
74
+ 'flex items-baseline justify-between gap-4 py-2 text-sm',
75
+ props.columns === 2 && 'border-b border-border/60 sm:border-b',
76
+ )}
77
+ >
78
+ <dt class="text-muted-foreground">{row.label}</dt>
79
+ <dd class="text-right font-medium text-foreground">{row.value}</dd>
80
+ </div>
81
+ )}
82
+ </For>
83
+ </dl>
84
+ </div>
85
+ )}
86
+ </For>
87
+ </div>
88
+ )
89
+ }