@a4ui/core 0.31.0 → 0.32.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,89 @@
1
+ // BentoGrid + BentoCard — responsive grid of glass tiles with variable spans.
2
+ import type { JSX } from 'solid-js'
3
+ import { splitProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ export interface BentoGridProps {
8
+ children: JSX.Element
9
+ class?: string
10
+ }
11
+
12
+ export interface BentoCardProps {
13
+ children: JSX.Element
14
+ /** Number of grid columns to span at `sm`+ (2) and `lg`+ (3). Defaults to 1. */
15
+ colSpan?: 1 | 2 | 3
16
+ /** Number of grid rows to span. Defaults to 1. */
17
+ rowSpan?: 1 | 2
18
+ class?: string
19
+ }
20
+
21
+ // Static lookup so Tailwind's content scanner can see the full class names
22
+ // (dynamic `col-span-${n}` strings are invisible to it and get purged).
23
+ const COL_SPAN_CLASSES: Record<NonNullable<BentoCardProps['colSpan']>, string> = {
24
+ 1: '',
25
+ 2: 'sm:col-span-2',
26
+ 3: 'sm:col-span-2 lg:col-span-3',
27
+ }
28
+
29
+ const ROW_SPAN_CLASSES: Record<NonNullable<BentoCardProps['rowSpan']>, string> = {
30
+ 1: '',
31
+ 2: 'row-span-2',
32
+ }
33
+
34
+ /**
35
+ * Responsive CSS grid for bento-style layouts — 1 column on mobile, 2 at
36
+ * `sm`, 3 at `lg`. Compose with {@link BentoCard} tiles, using `colSpan`/
37
+ * `rowSpan` on each tile to create the varied-size bento look.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <BentoGrid>
42
+ * <BentoCard colSpan={2} rowSpan={2}>
43
+ * <h3 class="text-lg font-semibold">Overview</h3>
44
+ * </BentoCard>
45
+ * <BentoCard>
46
+ * <h3 class="text-lg font-semibold">Uptime</h3>
47
+ * </BentoCard>
48
+ * <BentoCard>
49
+ * <h3 class="text-lg font-semibold">Latency</h3>
50
+ * </BentoCard>
51
+ * <BentoCard colSpan={3}>
52
+ * <h3 class="text-lg font-semibold">Recent activity</h3>
53
+ * </BentoCard>
54
+ * </BentoGrid>
55
+ * ```
56
+ */
57
+ export function BentoGrid(props: BentoGridProps): JSX.Element {
58
+ const [local, rest] = splitProps(props, ['class', 'children'])
59
+ return (
60
+ <div
61
+ class={cn(
62
+ 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 auto-rows-[minmax(11rem,auto)]',
63
+ local.class,
64
+ )}
65
+ {...rest}
66
+ >
67
+ {local.children}
68
+ </div>
69
+ )
70
+ }
71
+
72
+ /** Glass tile for a {@link BentoGrid}, spanning columns/rows via `colSpan`/`rowSpan`. */
73
+ export function BentoCard(props: BentoCardProps): JSX.Element {
74
+ const [local, rest] = splitProps(props, ['class', 'children', 'colSpan', 'rowSpan'])
75
+ return (
76
+ <div
77
+ class={cn(
78
+ 'rounded-2xl border border-border bg-card p-5 text-card-foreground overflow-hidden',
79
+ 'transition-transform hover:-translate-y-0.5',
80
+ COL_SPAN_CLASSES[local.colSpan ?? 1],
81
+ ROW_SPAN_CLASSES[local.rowSpan ?? 1],
82
+ local.class,
83
+ )}
84
+ {...rest}
85
+ >
86
+ {local.children}
87
+ </div>
88
+ )
89
+ }
@@ -0,0 +1,96 @@
1
+ // BorderBeam — a small gradient light segment that travels continuously
2
+ // around the border of its `position: relative` parent, tracing the box's
3
+ // own edge via CSS `offset-path: rect(...)` with `offset-distance` animated
4
+ // 0% → 100%. Pure CSS: no JS animation engine, no ResizeObserver — the
5
+ // browser resolves the rect()'s `auto` edges against the element's own box,
6
+ // so the path always matches the parent's current size however it resizes.
7
+ // The traveling element is a thin bar (not a full square) so it hugs the
8
+ // edge by itself, without needing a border-only mask trick. Reduced motion
9
+ // swaps the moving beam for a static, non-animated edge glow.
10
+ import { type JSX, Show } from 'solid-js'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { motionReduced } from '../lib/motion'
14
+
15
+ export interface BorderBeamProps {
16
+ /** Length of the traveling gradient segment, in px. @default 60 */
17
+ size?: number
18
+ /** Full trip duration around the border, in seconds. @default 6 */
19
+ duration?: number
20
+ /** Animation start offset, in seconds. @default 0 */
21
+ delay?: number
22
+ class?: string
23
+ }
24
+
25
+ const KEYFRAMES_ID = 'a4ui-border-beam-keyframes'
26
+ const KEYFRAMES = '@keyframes border-beam-travel { to { offset-distance: 100%; } }'
27
+
28
+ // Injected once, lazily, the first time a <BorderBeam> renders. The repo's
29
+ // CSS doctrine keeps shared @keyframes in src/styles/tokens.css, but this
30
+ // component ships standalone (tree-shakeable, no required stylesheet import)
31
+ // so it carries its own single small <style> tag instead — de-duped by id,
32
+ // so multiple instances on a page still only inject it once.
33
+ function ensureKeyframes(): void {
34
+ if (typeof document === 'undefined' || document.getElementById(KEYFRAMES_ID)) return
35
+ const style = document.createElement('style')
36
+ style.id = KEYFRAMES_ID
37
+ style.textContent = KEYFRAMES
38
+ document.head.appendChild(style)
39
+ }
40
+
41
+ /**
42
+ * Decorative light segment that travels continuously around the border of
43
+ * its parent. The parent must be `position: relative` (or similar) — this
44
+ * renders an absolutely-positioned, `pointer-events-none` layer (`inset-0`,
45
+ * `rounded-[inherit]`) tracking the parent's own box. Purely cosmetic
46
+ * (`aria-hidden`); under reduced motion the moving beam is replaced with a
47
+ * static edge glow instead of animating.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <div class="relative overflow-hidden rounded-2xl border border-border p-6">
52
+ * <BorderBeam />
53
+ * <p>Card content</p>
54
+ * </div>
55
+ * ```
56
+ */
57
+ export function BorderBeam(props: BorderBeamProps): JSX.Element {
58
+ const size = () => props.size ?? 60
59
+ const duration = () => props.duration ?? 6
60
+ const delay = () => props.delay ?? 0
61
+
62
+ if (!motionReduced()) ensureKeyframes()
63
+
64
+ return (
65
+ <Show
66
+ when={!motionReduced()}
67
+ fallback={
68
+ <span
69
+ aria-hidden="true"
70
+ class={cn(
71
+ 'pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.35)]',
72
+ props.class,
73
+ )}
74
+ />
75
+ }
76
+ >
77
+ <span
78
+ aria-hidden="true"
79
+ class={cn('pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]', props.class)}
80
+ >
81
+ <span
82
+ class="absolute top-0 left-0 h-[3px] rounded-full"
83
+ style={{
84
+ width: `${size()}px`,
85
+ 'offset-path': `rect(0 auto auto 0 round ${size()}px)`,
86
+ 'offset-distance': '0%',
87
+ background:
88
+ 'linear-gradient(to right, transparent, hsl(var(--primary)), hsl(var(--accent)), transparent)',
89
+ animation: `border-beam-travel ${duration()}s linear infinite`,
90
+ 'animation-delay': `${delay()}s`,
91
+ }}
92
+ />
93
+ </span>
94
+ </Show>
95
+ )
96
+ }
@@ -176,21 +176,27 @@ export function CardSpread(props: CardSpreadProps): JSX.Element {
176
176
  <div
177
177
  role="group"
178
178
  aria-label={props['aria-label']}
179
- class={cn('relative inline-block h-56 w-40', props.class)}
179
+ class={cn('relative mx-auto h-96 w-full max-w-lg', props.class)}
180
180
  onPointerEnter={handleEnter}
181
181
  onPointerLeave={handleLeave}
182
182
  >
183
- <For each={props.items}>
184
- {(item, i) => (
185
- <div
186
- ref={(el) => (cardRefs[i()] = el)}
187
- class="absolute inset-x-0 top-0 h-56 w-40 rounded-xl border border-border bg-card text-card-foreground shadow-sm will-change-transform"
188
- style={{ transform: transformString(restTransform(i(), props.items.length)) }}
189
- >
190
- {item}
191
- </div>
192
- )}
193
- </For>
183
+ {/* The cards fan around this fixed, centered anchor while the wider root
184
+ above stays the (stable) hover zone: a fanned spread reaches far past
185
+ a single card's box, so anchoring the hit-area to one card made the
186
+ cards leave it and flicker enter/leave. */}
187
+ <div class="absolute left-1/2 top-1/2 h-56 w-40 -translate-x-1/2 -translate-y-1/2">
188
+ <For each={props.items}>
189
+ {(item, i) => (
190
+ <div
191
+ ref={(el) => (cardRefs[i()] = el)}
192
+ class="absolute inset-0 rounded-xl border border-border bg-card text-card-foreground shadow-sm will-change-transform"
193
+ style={{ transform: transformString(restTransform(i(), props.items.length)) }}
194
+ >
195
+ {item}
196
+ </div>
197
+ )}
198
+ </For>
199
+ </div>
194
200
  </div>
195
201
  )
196
202
  }
@@ -201,19 +201,31 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
201
201
 
202
202
  // --- pointer drag: horizontal movement past a threshold advances one slide
203
203
  // per threshold crossed, so a long drag can page through several slides. --
204
- const [dragging, setDragging] = createSignal(false)
204
+ // Capture is DEFERRED until the pointer actually moves past a small
205
+ // threshold. Capturing on pointerdown (the old behaviour) retargeted the
206
+ // pointer to the stage and swallowed the `click` on the chevron buttons and
207
+ // any interactive slide content. A plain click no longer starts a drag, so
208
+ // those clicks work again.
205
209
  let startX = 0
210
+ let pointerId: number | null = null
211
+ let captured = false
212
+ const CAPTURE_THRESHOLD = 8
206
213
  const DRAG_THRESHOLD = 60
207
214
 
208
215
  const onPointerDown = (e: PointerEvent): void => {
209
216
  if (count() < 2) return
210
217
  startX = e.clientX
211
- setDragging(true)
212
- ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
218
+ pointerId = e.pointerId
219
+ captured = false
213
220
  }
214
221
  const onPointerMove = (e: PointerEvent): void => {
215
- if (!dragging()) return
222
+ if (pointerId === null) return
216
223
  const dx = e.clientX - startX
224
+ if (!captured) {
225
+ if (Math.abs(dx) < CAPTURE_THRESHOLD) return
226
+ captured = true
227
+ ;(e.currentTarget as HTMLElement).setPointerCapture(pointerId)
228
+ }
217
229
  if (dx <= -DRAG_THRESHOLD) {
218
230
  next()
219
231
  startX = e.clientX
@@ -222,8 +234,16 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
222
234
  startX = e.clientX
223
235
  }
224
236
  }
225
- const endDrag = (): void => {
226
- setDragging(false)
237
+ const endDrag = (e: PointerEvent): void => {
238
+ if (pointerId !== null && captured) {
239
+ try {
240
+ ;(e.currentTarget as HTMLElement).releasePointerCapture(pointerId)
241
+ } catch {
242
+ /* already released */
243
+ }
244
+ }
245
+ pointerId = null
246
+ captured = false
227
247
  }
228
248
 
229
249
  return (
@@ -266,6 +286,7 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
266
286
  type="button"
267
287
  aria-label="Previous slide"
268
288
  disabled={count() === 0 || active() === 0}
289
+ onPointerDown={(e) => e.stopPropagation()}
269
290
  onClick={prev}
270
291
  class="absolute left-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-40"
271
292
  >
@@ -275,6 +296,7 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
275
296
  type="button"
276
297
  aria-label="Next slide"
277
298
  disabled={count() === 0 || active() === count() - 1}
299
+ onPointerDown={(e) => e.stopPropagation()}
278
300
  onClick={next}
279
301
  class="absolute right-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-40"
280
302
  >
@@ -0,0 +1,124 @@
1
+ // Dock — macOS-style app dock. A horizontal glass pill of icon buttons that
2
+ // magnify toward the cursor (closest item biggest, tapering over a fixed
3
+ // radius), driven by direct transform + CSS transition (scale only, so it
4
+ // stays on the compositor). No-op magnify under reduced motion — icons stay
5
+ // static but remain clickable.
6
+ import { For, Show, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced } from '../lib/motion'
10
+
11
+ /** Px radius from the cursor within which items magnify; beyond it, scale is 1×. */
12
+ const FALLOFF_PX = 120
13
+ /** Peak scale applied to the item directly under the cursor. */
14
+ const PEAK_SCALE = 1.6
15
+
16
+ export interface DockItem {
17
+ icon: JSX.Element
18
+ label?: string
19
+ onClick?: (e: MouseEvent) => void
20
+ href?: string
21
+ }
22
+
23
+ export interface DockProps {
24
+ items: DockItem[]
25
+ class?: string
26
+ }
27
+
28
+ /**
29
+ * macOS-style dock: a glass pill of icon buttons that magnify based on
30
+ * horizontal distance to the cursor. Scale-only transform (`transform-origin:
31
+ * bottom`), reset to 1× on pointerleave. Respects `prefers-reduced-motion`
32
+ * (renders static, still fully clickable/keyboard-focusable).
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * import { Home, Search, Settings } from 'lucide-solid'
37
+ *
38
+ * <Dock
39
+ * items={[
40
+ * { icon: <Home size={22} />, label: 'Home', onClick: () => {} },
41
+ * { icon: <Search size={22} />, label: 'Search', onClick: () => {} },
42
+ * { icon: <Settings size={22} />, label: 'Settings', href: '/settings' },
43
+ * ]}
44
+ * />
45
+ * ```
46
+ */
47
+ export function Dock(props: DockProps): JSX.Element {
48
+ const itemEls: (HTMLElement | undefined)[] = []
49
+
50
+ const handlePointerMove = (event: PointerEvent): void => {
51
+ if (motionReduced()) return
52
+
53
+ for (const el of itemEls) {
54
+ if (!el) continue
55
+ const rect = el.getBoundingClientRect()
56
+ const center = rect.left + rect.width / 2
57
+ const distance = Math.abs(event.clientX - center)
58
+ const scale = 1 + (PEAK_SCALE - 1) * Math.max(0, 1 - distance / FALLOFF_PX)
59
+ el.style.transform = `scale(${scale})`
60
+ }
61
+ }
62
+
63
+ const handlePointerLeave = (): void => {
64
+ for (const el of itemEls) {
65
+ if (el) el.style.transform = 'scale(1)'
66
+ }
67
+ }
68
+
69
+ return (
70
+ <div
71
+ class={cn(
72
+ 'inline-flex items-end gap-2 rounded-2xl border border-border bg-card/70 px-3 py-2 backdrop-blur',
73
+ props.class,
74
+ )}
75
+ onPointerMove={handlePointerMove}
76
+ onPointerLeave={handlePointerLeave}
77
+ >
78
+ <For each={props.items}>
79
+ {(item, i) => {
80
+ const shared = {
81
+ ref: (el: HTMLElement) => {
82
+ itemEls[i()] = el
83
+ },
84
+ class:
85
+ 'group relative inline-flex h-11 w-11 shrink-0 origin-bottom items-center justify-center rounded-xl text-foreground transition-transform duration-150 ease-out will-change-transform hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
86
+ 'aria-label': item.label,
87
+ onClick: item.onClick,
88
+ }
89
+
90
+ return (
91
+ <Show
92
+ when={item.href}
93
+ fallback={
94
+ <button type="button" {...shared}>
95
+ {item.icon}
96
+ <Show when={item.label}>
97
+ <span
98
+ aria-hidden="true"
99
+ class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md border border-border bg-card px-2 py-1 text-xs whitespace-nowrap text-card-foreground opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
100
+ >
101
+ {item.label}
102
+ </span>
103
+ </Show>
104
+ </button>
105
+ }
106
+ >
107
+ <a href={item.href} {...shared}>
108
+ {item.icon}
109
+ <Show when={item.label}>
110
+ <span
111
+ aria-hidden="true"
112
+ class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md border border-border bg-card px-2 py-1 text-xs whitespace-nowrap text-card-foreground opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
113
+ >
114
+ {item.label}
115
+ </span>
116
+ </Show>
117
+ </a>
118
+ </Show>
119
+ )
120
+ }}
121
+ </For>
122
+ </div>
123
+ )
124
+ }
@@ -0,0 +1,109 @@
1
+ // Meteors — Aceternity-style decorative meteor shower: thin diagonal streaks
2
+ // that fall down-left and fade, looping with per-meteor stagger. Pure CSS
3
+ // keyframes (injected once into the document head, like LoadingDots) so the
4
+ // motion costs nothing in JS; each meteor's position/delay/duration is derived
5
+ // deterministically from its index (no Math.random, so the layout is
6
+ // reproducible across renders and safe under SSR).
7
+ import { For, type JSX, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { motionReduced } from '../lib/motion'
11
+
12
+ export interface MeteorsProps {
13
+ /** Number of meteor streaks to render. @default 20 */
14
+ count?: number
15
+ class?: string
16
+ }
17
+
18
+ const STYLE_ID = 'a4ui-meteors-style'
19
+
20
+ function ensureStyleInjected(): void {
21
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
22
+ const style = document.createElement('style')
23
+ style.id = STYLE_ID
24
+ style.textContent = `
25
+ @keyframes a4-meteor-fall {
26
+ 0% { transform: rotate(215deg) translateX(0); opacity: 1; }
27
+ 70% { opacity: 1; }
28
+ 100% { transform: rotate(215deg) translateX(-500px); opacity: 0; }
29
+ }
30
+ `
31
+ document.head.appendChild(style)
32
+ }
33
+
34
+ /**
35
+ * Deterministic pseudo-random value in [0, 1) derived from an index via a
36
+ * sine-based hash. Used to vary each meteor's position/timing without
37
+ * `Math.random`, so the layout is identical across renders (and SSR-safe).
38
+ */
39
+ function pseudoRandom(seed: number): number {
40
+ const x = Math.sin(seed * 12.9898) * 43758.5453
41
+ return x - Math.floor(x)
42
+ }
43
+
44
+ /**
45
+ * Decorative full-cover meteor shower: thin diagonal streaks that fall
46
+ * down-left and fade, looping with staggered delays and varied start
47
+ * positions/durations. Purely visual — absolute, `pointer-events-none`,
48
+ * `aria-hidden` — so it layers behind/inside a `relative overflow-hidden`
49
+ * container without affecting layout or a11y. Under {@link motionReduced} it
50
+ * swaps the falling streaks for a handful of static, faint dots.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * <div class="relative overflow-hidden rounded-2xl border border-border p-8">
55
+ * <Meteors count={20} />
56
+ * <p class="relative">Content sits above the meteors.</p>
57
+ * </div>
58
+ * ```
59
+ */
60
+ export function Meteors(props: MeteorsProps): JSX.Element {
61
+ ensureStyleInjected()
62
+
63
+ const count = () => props.count ?? 20
64
+ const meteors = () => Array.from({ length: count() })
65
+ const staticDots = () => Array.from({ length: Math.min(6, count()) })
66
+
67
+ return (
68
+ <div class={cn('pointer-events-none absolute inset-0 overflow-hidden', props.class)} aria-hidden="true">
69
+ <Show
70
+ when={!motionReduced()}
71
+ fallback={
72
+ <For each={staticDots()}>
73
+ {(_, i) => (
74
+ <span
75
+ class="absolute h-0.5 w-0.5 rounded-full bg-foreground/30"
76
+ style={{
77
+ top: `${pseudoRandom(i() * 2 + 1) * 100}%`,
78
+ left: `${pseudoRandom(i() * 2 + 2) * 100}%`,
79
+ }}
80
+ />
81
+ )}
82
+ </For>
83
+ }
84
+ >
85
+ <For each={meteors()}>
86
+ {(_, i) => {
87
+ const left = pseudoRandom(i() * 2 + 1) * 100
88
+ const delay = pseudoRandom(i() * 2 + 2) * 8
89
+ const duration = 2 + pseudoRandom(i() * 3 + 5) * 5
90
+
91
+ return (
92
+ <span
93
+ class={cn(
94
+ 'absolute top-0 h-0.5 w-0.5 rotate-[215deg] rounded-full bg-foreground',
95
+ "before:absolute before:top-1/2 before:h-px before:w-12 before:-translate-y-1/2 before:bg-gradient-to-r before:from-foreground/60 before:to-transparent before:content-['']",
96
+ )}
97
+ style={{
98
+ left: `${left}%`,
99
+ animation: `a4-meteor-fall ${duration}s linear infinite`,
100
+ 'animation-delay': `${delay}s`,
101
+ }}
102
+ />
103
+ )
104
+ }}
105
+ </For>
106
+ </Show>
107
+ </div>
108
+ )
109
+ }