@a4ui/core 0.27.0 → 0.29.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,70 @@
1
+ // ScrollScene — binds a render prop to the element's own scroll progress (0
2
+ // as it enters the viewport from the bottom, 1 as it leaves past the top),
3
+ // for scroll-driven storytelling. Unlike Parallax (which applies a fixed
4
+ // transform via Motion's `scroll`), this exposes the raw progress signal so
5
+ // the caller decides what to do with it.
6
+ import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface ScrollSceneProps {
11
+ /** Render-prop receiving a reactive progress accessor (0..1). */
12
+ children: (progress: () => number) => JSX.Element
13
+ class?: string
14
+ }
15
+
16
+ /**
17
+ * Wraps `children` in an element that tracks its own scroll progress: 0 when
18
+ * its top edge reaches the bottom of the viewport, 1 when its bottom edge
19
+ * passes the top of the viewport. Progress is exposed as a reactive accessor
20
+ * via a render prop, so the caller drives whatever transform/animation it
21
+ * wants — this component only measures. Scroll position isn't itself motion,
22
+ * so it keeps measuring under `prefers-reduced-motion`; it just never
23
+ * animates anything on its own.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <ScrollScene>
28
+ * {(progress) => (
29
+ * <div style={{ opacity: progress() }}>Reveal on scroll</div>
30
+ * )}
31
+ * </ScrollScene>
32
+ * ```
33
+ */
34
+ export function ScrollScene(props: ScrollSceneProps): JSX.Element {
35
+ let root!: HTMLDivElement
36
+ const [progress, setProgress] = createSignal(0)
37
+
38
+ onMount(() => {
39
+ let ticking = false
40
+
41
+ const measure = () => {
42
+ ticking = false
43
+ const rect = root.getBoundingClientRect()
44
+ const viewportH = window.innerHeight
45
+ const raw = (viewportH - rect.top) / (viewportH + rect.height)
46
+ setProgress(Math.min(1, Math.max(0, raw)))
47
+ }
48
+
49
+ const onScrollOrResize = () => {
50
+ if (ticking) return
51
+ ticking = true
52
+ requestAnimationFrame(measure)
53
+ }
54
+
55
+ measure()
56
+ window.addEventListener('scroll', onScrollOrResize, { passive: true })
57
+ window.addEventListener('resize', onScrollOrResize, { passive: true })
58
+
59
+ onCleanup(() => {
60
+ window.removeEventListener('scroll', onScrollOrResize)
61
+ window.removeEventListener('resize', onScrollOrResize)
62
+ })
63
+ })
64
+
65
+ return (
66
+ <div ref={root} class={cn(props.class)}>
67
+ {props.children(progress)}
68
+ </div>
69
+ )
70
+ }
@@ -0,0 +1,120 @@
1
+ // Vertical navigation rail — the vertical alternative to BottomNavigation / a
2
+ // compact sidebar. Icon-first, optional labels under each icon; when labels
3
+ // are hidden, the icon is wrapped in a Tooltip instead.
4
+ import type { JSX } from 'solid-js'
5
+ import { For, Show } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { Tooltip } from './Tooltip'
9
+
10
+ /** A single destination within a {@link SideRail}. */
11
+ export interface SideRailItem {
12
+ value: string
13
+ label: string
14
+ icon?: JSX.Element
15
+ /** Optional badge (e.g. an unread count) rendered on the item. */
16
+ badge?: JSX.Element
17
+ }
18
+
19
+ export interface SideRailProps {
20
+ items: SideRailItem[]
21
+ value: string
22
+ onChange: (value: string) => void
23
+ /** Show text labels under icons. When false, labels go in a tooltip. @default true */
24
+ labels?: boolean
25
+ class?: string
26
+ }
27
+
28
+ /**
29
+ * Narrow vertical column of stacked icon+label destinations — the vertical
30
+ * counterpart to {@link BottomNavigation} for side placement. The active item
31
+ * is highlighted with a left indicator bar and `aria-selected`. ArrowUp/
32
+ * ArrowDown move the selection between items (roving activation, matching the
33
+ * WAI-ARIA tabs pattern). Renders inline where placed — the consumer is
34
+ * responsible for positioning (e.g. `fixed inset-y-0 left-0`).
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * <SideRail
39
+ * value={section()}
40
+ * onChange={setSection}
41
+ * items={[
42
+ * { value: 'home', label: 'Home', icon: <HomeIcon /> },
43
+ * { value: 'inbox', label: 'Inbox', icon: <InboxIcon />, badge: <Badge tone="info">3</Badge> },
44
+ * { value: 'settings', label: 'Settings', icon: <SettingsIcon /> },
45
+ * ]}
46
+ * />
47
+ * ```
48
+ */
49
+ export function SideRail(props: SideRailProps): JSX.Element {
50
+ const showLabels = () => props.labels ?? true
51
+
52
+ const move = (delta: 1 | -1, currentIndex: number) => {
53
+ const count = props.items.length
54
+ const nextIndex = (currentIndex + delta + count) % count
55
+ const next = props.items[nextIndex]
56
+ if (next) props.onChange(next.value)
57
+ }
58
+
59
+ return (
60
+ <div
61
+ role="tablist"
62
+ aria-orientation="vertical"
63
+ class={cn('flex w-20 flex-col items-stretch gap-1 border-r border-border bg-glass py-2', props.class)}
64
+ >
65
+ <For each={props.items}>
66
+ {(item, index) => {
67
+ const active = () => item.value === props.value
68
+
69
+ const onKeyDown = (event: KeyboardEvent) => {
70
+ if (event.key === 'ArrowDown') {
71
+ event.preventDefault()
72
+ move(1, index())
73
+ } else if (event.key === 'ArrowUp') {
74
+ event.preventDefault()
75
+ move(-1, index())
76
+ }
77
+ }
78
+
79
+ const button = (
80
+ <button
81
+ type="button"
82
+ role="tab"
83
+ aria-selected={active()}
84
+ tabIndex={active() ? 0 : -1}
85
+ onClick={() => props.onChange(item.value)}
86
+ onKeyDown={onKeyDown}
87
+ class={cn(
88
+ 'relative flex flex-col items-center gap-1 px-2 py-2.5 text-[11px] transition-colors',
89
+ active() ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
90
+ )}
91
+ >
92
+ <span
93
+ aria-hidden="true"
94
+ class={cn(
95
+ 'absolute inset-y-1 left-0 w-0.5 rounded-full bg-primary transition-opacity',
96
+ active() ? 'opacity-100' : 'opacity-0',
97
+ )}
98
+ />
99
+ <span class="relative inline-flex">
100
+ {item.icon}
101
+ <Show when={item.badge}>
102
+ <span class="absolute -right-2 -top-2">{item.badge}</span>
103
+ </Show>
104
+ </span>
105
+ <Show when={showLabels()}>
106
+ <span class="truncate">{item.label}</span>
107
+ </Show>
108
+ </button>
109
+ )
110
+
111
+ return (
112
+ <Show when={showLabels()} fallback={<Tooltip content={item.label}>{button}</Tooltip>}>
113
+ {button}
114
+ </Show>
115
+ )
116
+ }}
117
+ </For>
118
+ </div>
119
+ )
120
+ }
@@ -0,0 +1,139 @@
1
+ // Switchable "context" containers (like browser Spaces / workspaces): one
2
+ // space's content is visible at a time, sliding horizontally between spaces
3
+ // on a translateX track (mirrors Carousel's pointer-drag swipe), with a
4
+ // dot/pill indicator rail below. Controlled (`activeId`+`onChange`) or
5
+ // uncontrolled (internal signal, defaults to the first space).
6
+ import { createMemo, createSignal, For, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced } from '../lib/motion'
10
+
11
+ export interface Space {
12
+ id: string
13
+ label: string
14
+ icon?: JSX.Element
15
+ content: JSX.Element
16
+ }
17
+
18
+ export interface SpacesProps {
19
+ spaces: Space[]
20
+ /** Controlled active space. Uncontrolled (internal signal, defaults to the first space) when omitted. */
21
+ activeId?: string
22
+ onChange?: (id: string) => void
23
+ class?: string
24
+ }
25
+
26
+ /**
27
+ * Swipeable, switchable "context" containers — one space visible at a time,
28
+ * sliding horizontally between spaces, with a dot/pill indicator rail to jump
29
+ * directly to one. Drag/touch to swipe (the track follows the pointer and
30
+ * snaps on release past a threshold), same engine-free approach as Carousel.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <Spaces
35
+ * spaces={[
36
+ * { id: 'personal', label: 'Personal', icon: <User class="h-4 w-4" />, content: <PersonalPane /> },
37
+ * { id: 'work', label: 'Work', icon: <Briefcase class="h-4 w-4" />, content: <WorkPane /> },
38
+ * ]}
39
+ * />
40
+ * ```
41
+ */
42
+ export function Spaces(props: SpacesProps): JSX.Element {
43
+ const [internalId, setInternalId] = createSignal<string | undefined>(props.spaces[0]?.id)
44
+
45
+ const activeId = createMemo(() => props.activeId ?? internalId())
46
+ const count = (): number => props.spaces.length
47
+ const activeIndex = createMemo(() => {
48
+ const i = props.spaces.findIndex((s) => s.id === activeId())
49
+ return i < 0 ? 0 : i
50
+ })
51
+
52
+ const select = (id: string): void => {
53
+ if (props.activeId === undefined) setInternalId(id)
54
+ props.onChange?.(id)
55
+ }
56
+ const selectIndex = (i: number): void => {
57
+ const n = count()
58
+ if (n === 0) return
59
+ const space = props.spaces[((i % n) + n) % n]
60
+ if (space) select(space.id)
61
+ }
62
+
63
+ // --- drag/touch swipe (engine-free; the track follows the pointer, then the
64
+ // CSS transition snaps to the resolved space on release) -------------------
65
+ let viewport: HTMLDivElement | undefined
66
+ const [dragging, setDragging] = createSignal(false)
67
+ const [dragPx, setDragPx] = createSignal(0)
68
+ let startX = 0
69
+
70
+ const onPointerDown = (e: PointerEvent): void => {
71
+ if (count() < 2) return
72
+ startX = e.clientX
73
+ setDragging(true)
74
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
75
+ }
76
+ const onPointerMove = (e: PointerEvent): void => {
77
+ if (dragging()) setDragPx(e.clientX - startX)
78
+ }
79
+ const endDrag = (): void => {
80
+ if (!dragging()) return
81
+ const width = viewport?.clientWidth ?? 1
82
+ const dx = dragPx()
83
+ const threshold = Math.max(48, width * 0.2)
84
+ if (dx <= -threshold) selectIndex(activeIndex() + 1)
85
+ else if (dx >= threshold) selectIndex(activeIndex() - 1)
86
+ setDragPx(0)
87
+ setDragging(false)
88
+ }
89
+
90
+ return (
91
+ <div class={cn('flex flex-col gap-3', props.class)}>
92
+ <div
93
+ ref={viewport}
94
+ class="relative overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
95
+ >
96
+ <div
97
+ class={cn(
98
+ 'flex',
99
+ count() > 1 && 'touch-pan-y cursor-grab active:cursor-grabbing',
100
+ !dragging() && !motionReduced() && 'transition-transform duration-500 ease-out',
101
+ )}
102
+ style={{ transform: `translateX(calc(-${activeIndex() * 100}% + ${dragPx()}px))` }}
103
+ onPointerDown={onPointerDown}
104
+ onPointerMove={onPointerMove}
105
+ onPointerUp={endDrag}
106
+ onPointerCancel={endDrag}
107
+ >
108
+ <For each={props.spaces}>{(space) => <div class="w-full shrink-0">{space.content}</div>}</For>
109
+ </div>
110
+ </div>
111
+
112
+ <div role="tablist" aria-label="Spaces" class="flex items-center justify-center gap-1">
113
+ <For each={props.spaces}>
114
+ {(space) => {
115
+ const isActive = () => space.id === activeId()
116
+ return (
117
+ <button
118
+ type="button"
119
+ role="tab"
120
+ aria-selected={isActive()}
121
+ aria-label={space.label}
122
+ onClick={() => select(space.id)}
123
+ class={cn(
124
+ 'flex h-7 items-center gap-1.5 rounded-full px-2 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
125
+ isActive()
126
+ ? 'bg-primary text-primary-foreground'
127
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
128
+ )}
129
+ >
130
+ {space.icon}
131
+ <span class={cn(!isActive() && 'sr-only sm:not-sr-only')}>{space.label}</span>
132
+ </button>
133
+ )
134
+ }}
135
+ </For>
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,103 @@
1
+ // StickyReveal — a tall scroll region whose inner content pins in place
2
+ // (`position: sticky`) while a subtle fade/rise reveal plays as the section
3
+ // scrolls through the viewport. Built for a pinned hero/feature moment, not
4
+ // for scrollytelling with multiple discrete steps (see ScrollProgress-style
5
+ // primitives for that). Listener is bound in onMount (client-only, SSR-safe)
6
+ // and rAF-throttled; skipped entirely under reduced motion.
7
+ import type { JSX } from 'solid-js'
8
+ import { createSignal, onCleanup, onMount } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { motionReduced } from '../lib/motion'
12
+
13
+ export interface StickyRevealProps {
14
+ children: JSX.Element
15
+ /** Total scroll distance of the section (the taller it is, the longer the pin). CSS length, e.g. '200vh'. @default '180vh' */
16
+ height?: string
17
+ class?: string
18
+ }
19
+
20
+ // Reveal ramps in over the first 40% of scroll progress through the section,
21
+ // then holds fully visible, then fades back out over the last 15% so the
22
+ // content doesn't just vanish when the pin releases.
23
+ const REVEAL_END = 0.4
24
+ const FADE_OUT_START = 0.85
25
+
26
+ /**
27
+ * A pinned hero/feature section: the outer element reserves `height` of
28
+ * scroll distance while the inner content sticks to the viewport and fades +
29
+ * rises in as the user scrolls through it. Needs real scroll room above/below
30
+ * to see the effect — drop it inside a page with enough surrounding content
31
+ * (or bump `height`) rather than in a short isolated container.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * <StickyReveal height="200vh">
36
+ * <Card class="p-10 text-center">
37
+ * <h2 class="text-3xl font-bold">Pinned moment</h2>
38
+ * </Card>
39
+ * </StickyReveal>
40
+ * ```
41
+ */
42
+ export function StickyReveal(props: StickyRevealProps): JSX.Element {
43
+ const reduced = motionReduced()
44
+ const [progress, setProgress] = createSignal(reduced ? 1 : 0)
45
+
46
+ let outerEl: HTMLDivElement | undefined
47
+ let rafId: number | null = null
48
+
49
+ const measure = () => {
50
+ rafId = null
51
+ if (!outerEl) return
52
+ const rect = outerEl.getBoundingClientRect()
53
+ const viewportH = window.innerHeight || 1
54
+ const total = rect.height - viewportH
55
+ if (total <= 0) {
56
+ setProgress(1)
57
+ return
58
+ }
59
+ const raw = -rect.top / total
60
+ setProgress(Math.min(1, Math.max(0, raw)))
61
+ }
62
+
63
+ const onScroll = () => {
64
+ if (rafId !== null) return
65
+ rafId = requestAnimationFrame(measure)
66
+ }
67
+
68
+ onMount(() => {
69
+ if (reduced) return
70
+ measure()
71
+ window.addEventListener('scroll', onScroll, { passive: true })
72
+ window.addEventListener('resize', onScroll, { passive: true })
73
+ onCleanup(() => {
74
+ window.removeEventListener('scroll', onScroll)
75
+ window.removeEventListener('resize', onScroll)
76
+ if (rafId !== null) cancelAnimationFrame(rafId)
77
+ })
78
+ })
79
+
80
+ const visibility = () => {
81
+ const p = progress()
82
+ const rampIn = Math.min(1, p / REVEAL_END)
83
+ const rampOut = p <= FADE_OUT_START ? 1 : Math.max(0, 1 - (p - FADE_OUT_START) / (1 - FADE_OUT_START))
84
+ return Math.min(rampIn, rampOut)
85
+ }
86
+
87
+ const innerStyle = (): JSX.CSSProperties => {
88
+ if (reduced) return { opacity: 1, transform: 'none' }
89
+ const v = visibility()
90
+ return {
91
+ opacity: v,
92
+ transform: `translateY(${(1 - v) * 24}px)`,
93
+ }
94
+ }
95
+
96
+ return (
97
+ <div ref={outerEl} class={cn('relative', props.class)} style={{ height: props.height ?? '180vh' }}>
98
+ <div class="sticky top-0 flex h-screen items-center justify-center">
99
+ <div style={innerStyle()}>{props.children}</div>
100
+ </div>
101
+ </div>
102
+ )
103
+ }
@@ -0,0 +1,130 @@
1
+ // Dense transaction/activity list for finance dashboards: icon, title +
2
+ // subtitle on the left, currency amount (+ optional date) on the right.
3
+ // Outgoing amounts render in the default foreground; incoming amounts use a
4
+ // success text tone (never a hardcoded green) so it recolors with the theme.
5
+ import { For, Show, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ /** A single row in a {@link TransactionFeed}. */
10
+ export interface Transaction {
11
+ id: string
12
+ title: string
13
+ subtitle?: string
14
+ /** Negative = outgoing, positive (or zero) = incoming. */
15
+ amount: number
16
+ /** ISO or display string. Required to group rows when `groupByDate` is set. */
17
+ date?: string
18
+ icon?: JSX.Element
19
+ }
20
+
21
+ export interface TransactionFeedProps {
22
+ transactions: Transaction[]
23
+ /** ISO 4217 currency code. Defaults to `'USD'`. */
24
+ currency?: string
25
+ /** BCP 47 locale used for formatting. Defaults to `'en-US'`. */
26
+ locale?: string
27
+ /** Group rows under date headers (uses each tx.date). @default false */
28
+ groupByDate?: boolean
29
+ class?: string
30
+ }
31
+
32
+ interface TransactionGroup {
33
+ date: string
34
+ items: Transaction[]
35
+ }
36
+
37
+ /** Groups transactions by `date`, preserving first-seen order of each group. */
38
+ function groupTransactions(transactions: Transaction[]): TransactionGroup[] {
39
+ const groups: TransactionGroup[] = []
40
+ const byDate = new Map<string, TransactionGroup>()
41
+ for (const tx of transactions) {
42
+ const key = tx.date ?? ''
43
+ let group = byDate.get(key)
44
+ if (!group) {
45
+ group = { date: key, items: [] }
46
+ byDate.set(key, group)
47
+ groups.push(group)
48
+ }
49
+ group.items.push(tx)
50
+ }
51
+ return groups
52
+ }
53
+
54
+ /**
55
+ * Dense vertical list of transactions/activity for finance dashboards. Each
56
+ * row shows an optional leading icon, a title + subtitle, and the amount
57
+ * formatted as currency — incoming (`amount >= 0`) in a success text tone,
58
+ * outgoing as `-$X` in the default foreground. Optionally groups rows under
59
+ * `text-xs uppercase` date headers via `groupByDate`.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <TransactionFeed
64
+ * transactions={[
65
+ * { id: '1', title: 'Acme Corp', subtitle: 'Invoice #1042', amount: 1200, date: '2026-07-18' },
66
+ * { id: '2', title: 'Cloud Hosting', subtitle: 'Monthly plan', amount: -49.99, date: '2026-07-17' },
67
+ * ]}
68
+ * />
69
+ * ```
70
+ */
71
+ export function TransactionFeed(props: TransactionFeedProps): JSX.Element {
72
+ const format = (value: number) =>
73
+ new Intl.NumberFormat(props.locale ?? 'en-US', {
74
+ style: 'currency',
75
+ currency: props.currency ?? 'USD',
76
+ }).format(Math.abs(value))
77
+
78
+ const row = (tx: Transaction, showDate: boolean) => (
79
+ <li class="flex items-center gap-3 py-2.5">
80
+ <Show when={tx.icon}>
81
+ <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted">{tx.icon}</div>
82
+ </Show>
83
+ <div class="min-w-0 flex-1">
84
+ <p class="truncate text-sm font-medium text-foreground">{tx.title}</p>
85
+ <Show when={tx.subtitle}>
86
+ <p class="truncate text-xs text-muted-foreground">{tx.subtitle}</p>
87
+ </Show>
88
+ </div>
89
+ <div class="shrink-0 text-right">
90
+ <p
91
+ class={cn(
92
+ 'text-sm font-medium tabular-nums',
93
+ tx.amount >= 0 ? 'text-emerald-400 light:text-emerald-700' : 'text-foreground',
94
+ )}
95
+ >
96
+ {tx.amount >= 0 ? format(tx.amount) : `-${format(tx.amount)}`}
97
+ </p>
98
+ <Show when={showDate && tx.date}>
99
+ <p class="text-xs text-muted-foreground">{tx.date}</p>
100
+ </Show>
101
+ </div>
102
+ </li>
103
+ )
104
+
105
+ return (
106
+ <Show
107
+ when={props.groupByDate}
108
+ fallback={
109
+ <ul class={cn('divide-y divide-border/60', props.class)}>
110
+ <For each={props.transactions}>{(tx) => row(tx, true)}</For>
111
+ </ul>
112
+ }
113
+ >
114
+ <div class={cn('flex flex-col gap-4', props.class)}>
115
+ <For each={groupTransactions(props.transactions)}>
116
+ {(group) => (
117
+ <div>
118
+ <Show when={group.date}>
119
+ <p class="mb-1 text-xs uppercase text-muted-foreground">{group.date}</p>
120
+ </Show>
121
+ <ul class="divide-y divide-border/60">
122
+ <For each={group.items}>{(tx) => row(tx, false)}</For>
123
+ </ul>
124
+ </div>
125
+ )}
126
+ </For>
127
+ </div>
128
+ </Show>
129
+ )
130
+ }