@a4ui/core 0.27.0 → 0.28.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,24 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface BalanceCardProps {
3
+ label: string;
4
+ amount: number;
5
+ /** ISO 4217 currency code. Defaults to `'USD'`. */
6
+ currency?: string;
7
+ /** BCP 47 locale used for formatting. Defaults to `'en-US'`. */
8
+ locale?: string;
9
+ /** Period-over-period change as a fraction (0.042 = +4.2%). Shows an up/down chip. */
10
+ delta?: number;
11
+ /** Small line under the balance (e.g. "Available" / account number). */
12
+ sub?: JSX.Element;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * Balance-first summary card: label, large formatted amount, and an optional
17
+ * signed-percent delta chip (up/down colored via {@link Badge} tones).
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <BalanceCard label="Total balance" amount={128430.52} delta={0.042} />
22
+ * ```
23
+ */
24
+ export declare function BalanceCard(props: BalanceCardProps): JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface KpiBlockProps {
3
+ label: string;
4
+ value: string | number;
5
+ /** Change as a fraction (0.128 = +12.8%). Shows a signed up/down chip. */
6
+ delta?: number;
7
+ /** Optional chart node (e.g. a <Sparkline/> from @a4ui/core/charts) shown at the bottom. */
8
+ chart?: JSX.Element;
9
+ class?: string;
10
+ }
11
+ /**
12
+ * Labelled metric tile for dashboards: a large value with a signed
13
+ * up/down delta chip and an optional chart slot at the bottom. The chart is
14
+ * consumer-supplied (e.g. a `<Sparkline/>` from `@a4ui/core/charts`) — this
15
+ * component takes it as a prop rather than importing the charts package.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <KpiBlock label="Monthly revenue" value="$48,204" delta={0.128} />
20
+ * ```
21
+ */
22
+ export declare function KpiBlock(props: KpiBlockProps): JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Money-movement action a {@link MoneyActionButton} represents; picks the leading icon. */
3
+ export type MoneyActionKind = 'send' | 'request' | 'pay' | 'withdraw';
4
+ export interface MoneyActionButtonProps {
5
+ children: JSX.Element;
6
+ /** Which money-movement action this triggers. Defaults to `'send'`. */
7
+ kind?: MoneyActionKind;
8
+ onClick?: () => void;
9
+ disabled?: boolean;
10
+ class?: string;
11
+ }
12
+ /**
13
+ * Bold, full-height pill CTA reserved for value-moving actions — send,
14
+ * request, pay, withdraw. Uses the app's single accent (`bg-primary`) so it
15
+ * stands out from ordinary buttons, with a leading icon chosen by `kind`.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <MoneyActionButton kind="send" onClick={() => sendMoney()}>Send</MoneyActionButton>
20
+ * ```
21
+ */
22
+ export declare function MoneyActionButton(props: MoneyActionButtonProps): JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface ScrollSceneProps {
3
+ /** Render-prop receiving a reactive progress accessor (0..1). */
4
+ children: (progress: () => number) => JSX.Element;
5
+ class?: string;
6
+ }
7
+ /**
8
+ * Wraps `children` in an element that tracks its own scroll progress: 0 when
9
+ * its top edge reaches the bottom of the viewport, 1 when its bottom edge
10
+ * passes the top of the viewport. Progress is exposed as a reactive accessor
11
+ * via a render prop, so the caller drives whatever transform/animation it
12
+ * wants — this component only measures. Scroll position isn't itself motion,
13
+ * so it keeps measuring under `prefers-reduced-motion`; it just never
14
+ * animates anything on its own.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <ScrollScene>
19
+ * {(progress) => (
20
+ * <div style={{ opacity: progress() }}>Reveal on scroll</div>
21
+ * )}
22
+ * </ScrollScene>
23
+ * ```
24
+ */
25
+ export declare function ScrollScene(props: ScrollSceneProps): JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single destination within a {@link SideRail}. */
3
+ export interface SideRailItem {
4
+ value: string;
5
+ label: string;
6
+ icon?: JSX.Element;
7
+ /** Optional badge (e.g. an unread count) rendered on the item. */
8
+ badge?: JSX.Element;
9
+ }
10
+ export interface SideRailProps {
11
+ items: SideRailItem[];
12
+ value: string;
13
+ onChange: (value: string) => void;
14
+ /** Show text labels under icons. When false, labels go in a tooltip. @default true */
15
+ labels?: boolean;
16
+ class?: string;
17
+ }
18
+ /**
19
+ * Narrow vertical column of stacked icon+label destinations — the vertical
20
+ * counterpart to {@link BottomNavigation} for side placement. The active item
21
+ * is highlighted with a left indicator bar and `aria-selected`. ArrowUp/
22
+ * ArrowDown move the selection between items (roving activation, matching the
23
+ * WAI-ARIA tabs pattern). Renders inline where placed — the consumer is
24
+ * responsible for positioning (e.g. `fixed inset-y-0 left-0`).
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <SideRail
29
+ * value={section()}
30
+ * onChange={setSection}
31
+ * items={[
32
+ * { value: 'home', label: 'Home', icon: <HomeIcon /> },
33
+ * { value: 'inbox', label: 'Inbox', icon: <InboxIcon />, badge: <Badge tone="info">3</Badge> },
34
+ * { value: 'settings', label: 'Settings', icon: <SettingsIcon /> },
35
+ * ]}
36
+ * />
37
+ * ```
38
+ */
39
+ export declare function SideRail(props: SideRailProps): JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface Space {
3
+ id: string;
4
+ label: string;
5
+ icon?: JSX.Element;
6
+ content: JSX.Element;
7
+ }
8
+ export interface SpacesProps {
9
+ spaces: Space[];
10
+ /** Controlled active space. Uncontrolled (internal signal, defaults to the first space) when omitted. */
11
+ activeId?: string;
12
+ onChange?: (id: string) => void;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * Swipeable, switchable "context" containers — one space visible at a time,
17
+ * sliding horizontally between spaces, with a dot/pill indicator rail to jump
18
+ * directly to one. Drag/touch to swipe (the track follows the pointer and
19
+ * snaps on release past a threshold), same engine-free approach as Carousel.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <Spaces
24
+ * spaces={[
25
+ * { id: 'personal', label: 'Personal', icon: <User class="h-4 w-4" />, content: <PersonalPane /> },
26
+ * { id: 'work', label: 'Work', icon: <Briefcase class="h-4 w-4" />, content: <WorkPane /> },
27
+ * ]}
28
+ * />
29
+ * ```
30
+ */
31
+ export declare function Spaces(props: SpacesProps): JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface StickyRevealProps {
3
+ children: JSX.Element;
4
+ /** Total scroll distance of the section (the taller it is, the longer the pin). CSS length, e.g. '200vh'. @default '180vh' */
5
+ height?: string;
6
+ class?: string;
7
+ }
8
+ /**
9
+ * A pinned hero/feature section: the outer element reserves `height` of
10
+ * scroll distance while the inner content sticks to the viewport and fades +
11
+ * rises in as the user scrolls through it. Needs real scroll room above/below
12
+ * to see the effect — drop it inside a page with enough surrounding content
13
+ * (or bump `height`) rather than in a short isolated container.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <StickyReveal height="200vh">
18
+ * <Card class="p-10 text-center">
19
+ * <h2 class="text-3xl font-bold">Pinned moment</h2>
20
+ * </Card>
21
+ * </StickyReveal>
22
+ * ```
23
+ */
24
+ export declare function StickyReveal(props: StickyRevealProps): JSX.Element;
@@ -0,0 +1,40 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single row in a {@link TransactionFeed}. */
3
+ export interface Transaction {
4
+ id: string;
5
+ title: string;
6
+ subtitle?: string;
7
+ /** Negative = outgoing, positive (or zero) = incoming. */
8
+ amount: number;
9
+ /** ISO or display string. Required to group rows when `groupByDate` is set. */
10
+ date?: string;
11
+ icon?: JSX.Element;
12
+ }
13
+ export interface TransactionFeedProps {
14
+ transactions: Transaction[];
15
+ /** ISO 4217 currency code. Defaults to `'USD'`. */
16
+ currency?: string;
17
+ /** BCP 47 locale used for formatting. Defaults to `'en-US'`. */
18
+ locale?: string;
19
+ /** Group rows under date headers (uses each tx.date). @default false */
20
+ groupByDate?: boolean;
21
+ class?: string;
22
+ }
23
+ /**
24
+ * Dense vertical list of transactions/activity for finance dashboards. Each
25
+ * row shows an optional leading icon, a title + subtitle, and the amount
26
+ * formatted as currency — incoming (`amount >= 0`) in a success text tone,
27
+ * outgoing as `-$X` in the default foreground. Optionally groups rows under
28
+ * `text-xs uppercase` date headers via `groupByDate`.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <TransactionFeed
33
+ * transactions={[
34
+ * { id: '1', title: 'Acme Corp', subtitle: 'Invoice #1042', amount: 1200, date: '2026-07-18' },
35
+ * { id: '2', title: 'Cloud Hosting', subtitle: 'Monthly plan', amount: -49.99, date: '2026-07-17' },
36
+ * ]}
37
+ * />
38
+ * ```
39
+ */
40
+ export declare function TransactionFeed(props: TransactionFeedProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.27.0",
3
+ "version": "0.28.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.27.0'
11
+ export const A4UI_VERSION = '0.28.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -168,6 +168,16 @@ export { MasterDetail, type MasterDetailProps, type MasterDetailItem } from './u
168
168
  export { SlashMenu, type SlashMenuProps, type SlashItem } from './ui/SlashMenu'
169
169
  export { DataView, type DataViewProps, type DataViewColumn, type DataViewMode } from './ui/DataView'
170
170
 
171
+ // Phase 3 — scroll storytelling, finance, spaces/rail.
172
+ export { ScrollScene, type ScrollSceneProps } from './ui/ScrollScene'
173
+ export { StickyReveal, type StickyRevealProps } from './ui/StickyReveal'
174
+ export { BalanceCard, type BalanceCardProps } from './ui/BalanceCard'
175
+ export { TransactionFeed, type TransactionFeedProps, type Transaction } from './ui/TransactionFeed'
176
+ export { KpiBlock, type KpiBlockProps } from './ui/KpiBlock'
177
+ export { MoneyActionButton, type MoneyActionButtonProps, type MoneyActionKind } from './ui/MoneyActionButton'
178
+ export { Spaces, type SpacesProps, type Space } from './ui/Spaces'
179
+ export { SideRail, type SideRailProps, type SideRailItem } from './ui/SideRail'
180
+
171
181
  // Motion components (src/ui) — animation primitives built on the `motion` engine
172
182
  // (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
173
183
  // `motion` is external, so importing one of these is the only thing that pulls it.
@@ -0,0 +1,68 @@
1
+ // Balance-first summary card for finance dashboards: a label, a large
2
+ // formatted balance, and an optional up/down delta chip.
3
+ import type { JSX } from 'solid-js'
4
+ import { Show } from 'solid-js'
5
+ import { TrendingDown, TrendingUp } from 'lucide-solid'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { Badge } from './Badge'
9
+ import { Card, CardContent } from './Card'
10
+
11
+ export interface BalanceCardProps {
12
+ label: string
13
+ amount: number
14
+ /** ISO 4217 currency code. Defaults to `'USD'`. */
15
+ currency?: string
16
+ /** BCP 47 locale used for formatting. Defaults to `'en-US'`. */
17
+ locale?: string
18
+ /** Period-over-period change as a fraction (0.042 = +4.2%). Shows an up/down chip. */
19
+ delta?: number
20
+ /** Small line under the balance (e.g. "Available" / account number). */
21
+ sub?: JSX.Element
22
+ class?: string
23
+ }
24
+
25
+ /**
26
+ * Balance-first summary card: label, large formatted amount, and an optional
27
+ * signed-percent delta chip (up/down colored via {@link Badge} tones).
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <BalanceCard label="Total balance" amount={128430.52} delta={0.042} />
32
+ * ```
33
+ */
34
+ export function BalanceCard(props: BalanceCardProps): JSX.Element {
35
+ const format = () =>
36
+ new Intl.NumberFormat(props.locale ?? 'en-US', {
37
+ style: 'currency',
38
+ currency: props.currency ?? 'USD',
39
+ }).format(props.amount)
40
+
41
+ const isPositive = () => (props.delta ?? 0) >= 0
42
+
43
+ const deltaLabel = () => {
44
+ const pct = Math.abs(props.delta as number) * 100
45
+ const rounded = Math.round(pct * 10) / 10
46
+ return `${isPositive() ? '+' : '-'}${rounded}%`
47
+ }
48
+
49
+ return (
50
+ <Card glass class={cn('inline-block', props.class)}>
51
+ <CardContent class="flex flex-col gap-2">
52
+ <span class="text-sm text-muted-foreground">{props.label}</span>
53
+ <div class="flex items-center gap-3">
54
+ <span class="text-3xl font-bold text-foreground">{format()}</span>
55
+ <Show when={props.delta !== undefined}>
56
+ <Badge tone={isPositive() ? 'success' : 'danger'}>
57
+ <Show when={isPositive()} fallback={<TrendingDown class="h-3 w-3" aria-hidden="true" />}>
58
+ <TrendingUp class="h-3 w-3" aria-hidden="true" />
59
+ </Show>
60
+ {deltaLabel()}
61
+ </Badge>
62
+ </Show>
63
+ </div>
64
+ <Show when={props.sub}>{props.sub}</Show>
65
+ </CardContent>
66
+ </Card>
67
+ )
68
+ }
@@ -0,0 +1,58 @@
1
+ // Labelled metric block for dashboards: value + a signed delta chip, with an
2
+ // optional inline chart slot (e.g. a Sparkline from @a4ui/core/charts) passed
3
+ // in by the consumer — this component never imports the charts package.
4
+ import { ArrowDown, ArrowUp } from 'lucide-solid'
5
+ import { Show, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { Badge } from './Badge'
9
+ import { Card, CardContent } from './Card'
10
+
11
+ export interface KpiBlockProps {
12
+ label: string
13
+ value: string | number
14
+ /** Change as a fraction (0.128 = +12.8%). Shows a signed up/down chip. */
15
+ delta?: number
16
+ /** Optional chart node (e.g. a <Sparkline/> from @a4ui/core/charts) shown at the bottom. */
17
+ chart?: JSX.Element
18
+ class?: string
19
+ }
20
+
21
+ const formatDelta = (delta: number) => `${delta >= 0 ? '+' : ''}${(delta * 100).toFixed(1)}%`
22
+
23
+ /**
24
+ * Labelled metric tile for dashboards: a large value with a signed
25
+ * up/down delta chip and an optional chart slot at the bottom. The chart is
26
+ * consumer-supplied (e.g. a `<Sparkline/>` from `@a4ui/core/charts`) — this
27
+ * component takes it as a prop rather than importing the charts package.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <KpiBlock label="Monthly revenue" value="$48,204" delta={0.128} />
32
+ * ```
33
+ */
34
+ export function KpiBlock(props: KpiBlockProps): JSX.Element {
35
+ return (
36
+ <Card glass class={props.class}>
37
+ <CardContent class="p-5">
38
+ <p class="text-sm text-muted-foreground">{props.label}</p>
39
+ <div class="mt-1 flex items-center gap-2">
40
+ <span class="text-2xl font-semibold text-foreground">{props.value}</span>
41
+ <Show when={props.delta !== undefined}>
42
+ <Badge tone={(props.delta as number) >= 0 ? 'success' : 'danger'}>
43
+ {(props.delta as number) >= 0 ? (
44
+ <ArrowUp class="h-3 w-3" aria-hidden="true" />
45
+ ) : (
46
+ <ArrowDown class="h-3 w-3" aria-hidden="true" />
47
+ )}
48
+ {formatDelta(props.delta as number)}
49
+ </Badge>
50
+ </Show>
51
+ </div>
52
+ <Show when={props.chart}>
53
+ <div class={cn('mt-3')}>{props.chart}</div>
54
+ </Show>
55
+ </CardContent>
56
+ </Card>
57
+ )
58
+ }
@@ -0,0 +1,54 @@
1
+ // Prominent CTA reserved for value-moving actions (send / request / pay /
2
+ // withdraw). One accent, tightly scoped to money movement — deliberately
3
+ // bolder than the generic Button so it reads as "this moves money" at a
4
+ // glance, distinct from ordinary actions elsewhere in the UI.
5
+ import { ArrowDownLeft, ArrowUpRight, Banknote, CreditCard } from 'lucide-solid'
6
+ import type { Component, JSX } from 'solid-js'
7
+ import { splitProps } from 'solid-js'
8
+ import { Dynamic } from 'solid-js/web'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ /** Money-movement action a {@link MoneyActionButton} represents; picks the leading icon. */
13
+ export type MoneyActionKind = 'send' | 'request' | 'pay' | 'withdraw'
14
+
15
+ const KIND_ICON: Record<MoneyActionKind, Component<{ class?: string }>> = {
16
+ send: ArrowUpRight,
17
+ request: ArrowDownLeft,
18
+ pay: CreditCard,
19
+ withdraw: Banknote,
20
+ }
21
+
22
+ export interface MoneyActionButtonProps {
23
+ children: JSX.Element
24
+ /** Which money-movement action this triggers. Defaults to `'send'`. */
25
+ kind?: MoneyActionKind
26
+ onClick?: () => void
27
+ disabled?: boolean
28
+ class?: string
29
+ }
30
+
31
+ const BASE =
32
+ 'inline-flex h-full items-center justify-center gap-2 rounded-xl px-5 py-3 text-sm font-semibold bg-primary text-primary-foreground transition-[background-color,transform] duration-150 motion-reduce:transition-none hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50'
33
+
34
+ /**
35
+ * Bold, full-height pill CTA reserved for value-moving actions — send,
36
+ * request, pay, withdraw. Uses the app's single accent (`bg-primary`) so it
37
+ * stands out from ordinary buttons, with a leading icon chosen by `kind`.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <MoneyActionButton kind="send" onClick={() => sendMoney()}>Send</MoneyActionButton>
42
+ * ```
43
+ */
44
+ export function MoneyActionButton(props: MoneyActionButtonProps): JSX.Element {
45
+ const [local, rest] = splitProps(props, ['children', 'kind', 'class'])
46
+ const Icon = () => KIND_ICON[local.kind ?? 'send']
47
+
48
+ return (
49
+ <button type="button" class={cn(BASE, local.class)} {...rest}>
50
+ <Dynamic component={Icon()} class="h-4 w-4 shrink-0" aria-hidden="true" />
51
+ {local.children}
52
+ </button>
53
+ )
54
+ }
@@ -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
+ }