@a4ui/core 0.33.0 → 0.34.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,22 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface AudioWaveformProps {
3
+ src?: string;
4
+ peaks?: number[];
5
+ /** Bar row height in pixels. @default 64 */
6
+ height?: number;
7
+ class?: string;
8
+ }
9
+ /**
10
+ * Waveform of vertical bars driven by an optional hidden `<audio>` element.
11
+ * Pass `peaks` (numbers in `0..1`) for a real waveform, or omit it for a
12
+ * deterministic placeholder shape. The played portion (up to
13
+ * `currentTime / duration`) is highlighted; clicking a bar seeks there. With
14
+ * no `src`, playback is simulated on a timer for demo purposes.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <AudioWaveform src="/media/track.mp3" />
19
+ * <AudioWaveform peaks={[0.2, 0.8, 0.4, 0.9, 0.3]} height={48} />
20
+ * ```
21
+ */
22
+ export declare function AudioWaveform(props: AudioWaveformProps): JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface CouponFieldProps {
3
+ /** Validate/apply a coupon code. Resolve with `{ ok: false }` for a rejected
4
+ * code — do not throw for expected rejections, only for unexpected failures. */
5
+ onApply: (code: string) => Promise<{
6
+ ok: boolean;
7
+ message?: string;
8
+ discount?: string;
9
+ }>;
10
+ placeholder?: string;
11
+ class?: string;
12
+ }
13
+ /**
14
+ * Coupon/promo code input with an "Apply" button. Tracks explicit
15
+ * idle/loading/success/error state: loading shows a spinner and disables the
16
+ * input, success shows the applied discount with a way to remove it and try
17
+ * another code, error shows the failure message inline and lets the user retry.
18
+ * Rejections are read from the resolved `{ ok: false }` result, not caught
19
+ * exceptions — an unexpected throw from `onApply` propagates uncaught.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <CouponField
24
+ * onApply={async (code) => {
25
+ * const res = await api.applyCoupon(code)
26
+ * return res.valid
27
+ * ? { ok: true, discount: '-$10' }
28
+ * : { ok: false, message: 'That code has expired.' }
29
+ * }}
30
+ * />
31
+ * ```
32
+ */
33
+ export declare function CouponField(props: CouponFieldProps): JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface FollowingPointerProps {
3
+ label?: JSX.Element;
4
+ /** Tone of the custom cursor + label chip. @default 'primary' */
5
+ color?: 'primary' | 'accent';
6
+ children: JSX.Element;
7
+ class?: string;
8
+ }
9
+ /**
10
+ * Wraps `children` in a `relative` container that hides the native cursor
11
+ * and renders a custom pointer — a small arrow glyph plus a rounded label
12
+ * chip in the given tone — that follows the mouse within the container.
13
+ * Hidden until the pointer enters, hidden again on leave. Under
14
+ * {@link motionReduced}, the native cursor is kept and nothing custom is
15
+ * rendered. Listeners are cleaned up on unmount.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <FollowingPointer label="Alex" color="accent">
20
+ * <div class="grid h-48 place-items-center rounded-lg border">Hover me</div>
21
+ * </FollowingPointer>
22
+ * ```
23
+ */
24
+ export declare function FollowingPointer(props: FollowingPointerProps): JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single bar rendered by {@link GanttChart}. */
3
+ export interface GanttTask {
4
+ id: string;
5
+ name: string;
6
+ /** ISO date, `'YYYY-MM-DD'`. */
7
+ start: string;
8
+ /** ISO date, `'YYYY-MM-DD'`. */
9
+ end: string;
10
+ /** Ids of tasks that must finish before this one starts; drawn as elbow connectors. */
11
+ dependencies?: string[];
12
+ /** Bar color. Defaults to `'primary'`. */
13
+ tone?: 'primary' | 'accent';
14
+ }
15
+ export interface GanttChartProps {
16
+ tasks: GanttTask[];
17
+ class?: string;
18
+ }
19
+ /**
20
+ * Read-only Gantt chart: a header row of date ticks over a proportional time
21
+ * axis, one row per task with a colored bar spanning `start`–`end`, elbow
22
+ * connectors for `dependencies`, and a "today" marker when today falls
23
+ * within the overall range. Horizontal scroll kicks in when the range is
24
+ * wide relative to the container.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <GanttChart
29
+ * tasks={[
30
+ * { id: 'design', name: 'Design', start: '2026-01-05', end: '2026-01-16', tone: 'accent' },
31
+ * { id: 'build', name: 'Build', start: '2026-01-19', end: '2026-02-06', dependencies: ['design'] },
32
+ * { id: 'launch', name: 'Launch', start: '2026-02-09', end: '2026-02-10', dependencies: ['build'] },
33
+ * ]}
34
+ * />
35
+ * ```
36
+ */
37
+ export declare function GanttChart(props: GanttChartProps): JSX.Element;
@@ -0,0 +1,49 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single card on a {@link Kanban} board. */
3
+ export interface KanbanCard {
4
+ /** Stable unique id (used for drag tracking + list keying). */
5
+ id: string;
6
+ /** Card body — usually a title/label. */
7
+ title: JSX.Element;
8
+ /** Optional trailing badge (e.g. priority, assignee initials). */
9
+ badge?: JSX.Element;
10
+ }
11
+ /** A column of a {@link Kanban} board. */
12
+ export interface KanbanColumn {
13
+ /** Stable unique id. */
14
+ id: string;
15
+ /** Column heading. */
16
+ title: string;
17
+ /** Cards in the column, top to bottom. */
18
+ cards: KanbanCard[];
19
+ /** Optional WIP limit — the count badge switches to a warning tone past it. */
20
+ limit?: number;
21
+ }
22
+ export interface KanbanProps {
23
+ /** Columns, in display order left to right. */
24
+ columns: KanbanColumn[];
25
+ /** Called with the full new columns array after any drag (reorder or move). */
26
+ onChange?: (columns: KanbanColumn[]) => void;
27
+ class?: string;
28
+ }
29
+ /**
30
+ * Horizontal Kanban board: glass column panels holding vertical lists of
31
+ * draggable cards. Drag a card by its grip handle to reorder it within a
32
+ * column or drop it into another column — a dashed placeholder marks the
33
+ * drop slot and a floating clone follows the pointer, the same pointer-events
34
+ * idiom as {@link Sortable} extended to track which column's list the
35
+ * pointer is currently over. Works controlled (pass `columns` sourced from
36
+ * your own state + `onChange` to write it back) or uncontrolled (pass
37
+ * `columns` once and just read the final layout from `onChange`).
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const [columns, setColumns] = createSignal<KanbanColumn[]>([
42
+ * { id: 'todo', title: 'To do', cards: [{ id: '1', title: 'Write spec' }] },
43
+ * { id: 'doing', title: 'Doing', cards: [], limit: 2 },
44
+ * { id: 'done', title: 'Done', cards: [] },
45
+ * ])
46
+ * <Kanban columns={columns()} onChange={setColumns} />
47
+ * ```
48
+ */
49
+ export declare function Kanban(props: KanbanProps): JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface LampProps {
3
+ children?: JSX.Element;
4
+ class?: string;
5
+ }
6
+ /**
7
+ * Hero/section backdrop: a spotlight beam fanning down from the top center —
8
+ * two symmetric blurred gradient cones (`--primary` / `--accent`) plus a
9
+ * bright thin source line — over a dark base, with `children` centered in
10
+ * the illuminated area below. Grows/brightens in on mount via Motion
11
+ * (opacity + scale); static under {@link motionReduced}. Self-contained
12
+ * (`relative overflow-hidden`); size it with `class` (e.g. `min-h-[28rem]`).
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <Lamp class="min-h-[28rem]">
17
+ * <h1 class="text-4xl font-semibold text-foreground">Build in the light</h1>
18
+ * <p class="mt-4 text-muted-foreground">A hero backdrop with a spotlight glow.</p>
19
+ * </Lamp>
20
+ * ```
21
+ */
22
+ export declare function Lamp(props: LampProps): JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { JSX } from 'solid-js';
2
+ /** One image in a {@link Lightbox}. */
3
+ export interface LightboxImage {
4
+ src: string;
5
+ alt?: string;
6
+ /** Smaller image used in the thumbnail grid/strip; falls back to `src`. */
7
+ thumb?: string;
8
+ }
9
+ export interface LightboxProps {
10
+ images: LightboxImage[];
11
+ /** Controlled overlay open state. When set, wins over internal state — pair with `onOpenChange`. */
12
+ open?: boolean;
13
+ /** Controlled active image index (clamped to the images array). When set, wins over internal state — pair with `onIndexChange`. */
14
+ index?: number;
15
+ onOpenChange?: (open: boolean) => void;
16
+ onIndexChange?: (index: number) => void;
17
+ /** Show the thumbnail strip at the bottom of the overlay. @default true */
18
+ showThumbnails?: boolean;
19
+ class?: string;
20
+ }
21
+ /**
22
+ * Responsive thumbnail grid (`thumb || src`) that opens into a full-screen
23
+ * viewer on click: the active image centered, prev/next chevrons, a close
24
+ * button, an alt-text caption, a zoom toggle, and (by default) a thumbnail
25
+ * strip with the active image highlighted. Navigate with the arrow keys or
26
+ * Escape, click the backdrop to close. Works controlled
27
+ * (`open`/`index` + `onOpenChange`/`onIndexChange`) or uncontrolled. Body
28
+ * scroll locks while open, like `Modal`; the zoom transition is skipped
29
+ * under `prefers-reduced-motion` but stays fully functional.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <Lightbox
34
+ * images={[
35
+ * { src: '/photos/1-full.jpg', thumb: '/photos/1-thumb.jpg', alt: 'Sunset over the bay' },
36
+ * { src: '/photos/2-full.jpg', thumb: '/photos/2-thumb.jpg', alt: 'Mountain trail' },
37
+ * ]}
38
+ * />
39
+ * ```
40
+ */
41
+ export declare function Lightbox(props: LightboxProps): JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single step in an {@link OnboardingChecklist}. */
3
+ export interface OnboardingStep {
4
+ /** Stable identifier, used as the toggle key and `For` item key. */
5
+ id: string;
6
+ title: JSX.Element;
7
+ /** Optional detail shown when the step is expanded. */
8
+ description?: JSX.Element;
9
+ /** Whether the step is complete. */
10
+ done?: boolean;
11
+ /** Optional call-to-action rendered alongside the description. */
12
+ action?: {
13
+ label: string;
14
+ onClick: () => void;
15
+ };
16
+ }
17
+ export interface OnboardingChecklistProps {
18
+ steps: OnboardingStep[];
19
+ /** Called with the step id and its next `done` state when the indicator is toggled. */
20
+ onToggle?: (id: string, done: boolean) => void;
21
+ /** Heading shown above the progress summary. Defaults to `'Getting started'`. */
22
+ title?: JSX.Element;
23
+ class?: string;
24
+ }
25
+ /**
26
+ * Card-shaped onboarding/setup checklist: a header with a completion ring and
27
+ * "{done} of {total} complete" summary, followed by expandable steps. Each
28
+ * step has a clickable check indicator (toggles `done` via `onToggle`), a
29
+ * title, and an optional description + CTA revealed on expand. Completion is
30
+ * controlled by the caller; expansion is local UI state (one step open at a time).
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * const [steps, setSteps] = createSignal<OnboardingStep[]>([
35
+ * { id: 'profile', title: 'Complete your profile', done: true },
36
+ * {
37
+ * id: 'invite',
38
+ * title: 'Invite a teammate',
39
+ * description: 'Collaborate faster by adding at least one teammate.',
40
+ * action: { label: 'Invite', onClick: () => openInviteDialog() },
41
+ * },
42
+ * ])
43
+ * <OnboardingChecklist
44
+ * steps={steps()}
45
+ * onToggle={(id, done) =>
46
+ * setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, done } : s)))
47
+ * }
48
+ * />
49
+ * ```
50
+ */
51
+ export declare function OnboardingChecklist(props: OnboardingChecklistProps): JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single record fed into {@link PivotTable}. */
3
+ export interface PivotDatum {
4
+ [key: string]: string | number;
5
+ }
6
+ export interface PivotTableProps {
7
+ data: PivotDatum[];
8
+ /** Field whose unique values become row headers. */
9
+ rowField: string;
10
+ /** Field whose unique values become column headers. */
11
+ columnField: string;
12
+ /** Numeric field aggregated into each cell. */
13
+ valueField: string;
14
+ /** Aggregation applied per (row, column) bucket. Defaults to 'sum'. */
15
+ aggregate?: 'sum' | 'count' | 'avg';
16
+ class?: string;
17
+ }
18
+ /**
19
+ * Cross-tab table: crosses the unique values of `rowField` and `columnField`,
20
+ * aggregating `valueField` into each cell (default `'sum'`), plus row/column
21
+ * totals.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <PivotTable
26
+ * data={[
27
+ * { region: 'West', product: 'Widget', revenue: 100 },
28
+ * { region: 'West', product: 'Gadget', revenue: 50 },
29
+ * { region: 'East', product: 'Widget', revenue: 75 },
30
+ * ]}
31
+ * rowField="region"
32
+ * columnField="product"
33
+ * valueField="revenue"
34
+ * />
35
+ * ```
36
+ */
37
+ export declare function PivotTable(props: PivotTableProps): JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface SheetSnapProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ /** Fractions of viewport height, ascending, e.g. `[0.4, 0.9]`. @default [0.5, 0.9] */
6
+ snapPoints?: number[];
7
+ /** Index into `snapPoints` to open at. @default 0 */
8
+ defaultSnap?: number;
9
+ children: JSX.Element;
10
+ class?: string;
11
+ }
12
+ /**
13
+ * A draggable bottom sheet that rests at one of several height "snap points"
14
+ * (fractions of viewport height) instead of just open/closed. Drag the handle
15
+ * to resize with the finger; releasing snaps to the nearest point, biased by
16
+ * flick velocity (a fast swipe jumps a level), and a hard downward flick/drag
17
+ * past the lowest point dismisses. Falls back to an instant show/hide at
18
+ * `defaultSnap` — no drag physics — under reduced motion.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * const [open, setOpen] = createSignal(false)
23
+ * <SheetSnap open={open()} onOpenChange={setOpen} snapPoints={[0.4, 0.9]}>
24
+ * <p>Sheet content…</p>
25
+ * </SheetSnap>
26
+ * ```
27
+ */
28
+ export declare function SheetSnap(props: SheetSnapProps): JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A column definition for {@link TreeTable}. */
3
+ export interface TreeTableColumn<T> {
4
+ /** Row data property this column reads when `cell` is omitted. */
5
+ key: string;
6
+ /** Column heading content. */
7
+ header: JSX.Element;
8
+ /** Custom cell renderer; falls back to `String(row[key])` when omitted. */
9
+ cell?: (row: T) => JSX.Element;
10
+ /** Right-align the column (numbers, totals). */
11
+ align?: 'left' | 'right';
12
+ }
13
+ /** A single node in a {@link TreeTable}; nodes may nest via `children`. */
14
+ export interface TreeTableRow<T> {
15
+ /** Unique identifier; used to track expanded state. */
16
+ id: string;
17
+ /** Row payload passed to each column's `cell` renderer. */
18
+ data: T;
19
+ /** Child rows revealed when this row is expanded. */
20
+ children?: TreeTableRow<T>[];
21
+ }
22
+ export interface TreeTableProps<T> {
23
+ columns: TreeTableColumn<T>[];
24
+ rows: TreeTableRow<T>[];
25
+ /** Expand every row with children on first render. Defaults to false. */
26
+ defaultExpanded?: boolean;
27
+ class?: string;
28
+ }
29
+ /**
30
+ * Hierarchical table: rows may carry `children`, revealed by clicking the
31
+ * chevron in the first column. Depth is shown via indentation; expanded state
32
+ * is tracked internally, seeded fully expanded when `defaultExpanded` is set.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <TreeTable
37
+ * defaultExpanded
38
+ * columns={[
39
+ * { key: 'name', header: 'Name' },
40
+ * { key: 'size', header: 'Size', align: 'right', cell: (row) => `${row.size} KB` },
41
+ * ]}
42
+ * rows={[
43
+ * {
44
+ * id: 'src',
45
+ * data: { name: 'src', size: 0 },
46
+ * children: [{ id: 'index', data: { name: 'index.ts', size: 2 } }],
47
+ * },
48
+ * ]}
49
+ * />
50
+ * ```
51
+ */
52
+ export declare function TreeTable<T>(props: TreeTableProps<T>): JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface VideoPlayerShellProps {
3
+ src: string;
4
+ poster?: string;
5
+ class?: string;
6
+ }
7
+ /**
8
+ * Native `<video>` wrapped in a glass shell with fully custom controls: a big
9
+ * center play/pause, a bottom bar (play/pause, elapsed/duration, a scrub
10
+ * range, mute toggle, fullscreen, Picture-in-Picture), and a keyboard map
11
+ * (Space = play/pause, ArrowLeft/Right = seek ±5s). Controls auto-hide while
12
+ * playing and reappear on pointer movement.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <VideoPlayerShell src="/media/demo.mp4" poster="/media/demo-poster.jpg" />
17
+ * ```
18
+ */
19
+ export declare function VideoPlayerShell(props: VideoPlayerShellProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.33.0",
3
+ "version": "0.34.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.33.0'
11
+ export const A4UI_VERSION = '0.34.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -209,6 +209,30 @@ export { Globe, type GlobeMarker, type GlobeArc, type GlobeProps } from './ui/Gl
209
209
  export { WorldMap, type WorldMapPoint, type WorldMapConnection, type WorldMapProps } from './ui/WorldMap'
210
210
  export { Confetti, fireConfetti, type ConfettiProps } from './ui/Confetti'
211
211
  export { CursorTrail, type CursorTrailProps } from './ui/CursorTrail'
212
+
213
+ // Productivity data views.
214
+ export { Kanban, type KanbanCard, type KanbanColumn, type KanbanProps } from './ui/Kanban'
215
+ export { GanttChart, type GanttTask, type GanttChartProps } from './ui/GanttChart'
216
+ export { TreeTable, type TreeTableColumn, type TreeTableRow, type TreeTableProps } from './ui/TreeTable'
217
+ export { PivotTable, type PivotDatum, type PivotTableProps } from './ui/PivotTable'
218
+
219
+ // Onboarding + commerce.
220
+ export {
221
+ OnboardingChecklist,
222
+ type OnboardingStep,
223
+ type OnboardingChecklistProps,
224
+ } from './ui/OnboardingChecklist'
225
+ export { CouponField, type CouponFieldProps } from './ui/CouponField'
226
+
227
+ // Media.
228
+ export { Lightbox, type LightboxImage, type LightboxProps } from './ui/Lightbox'
229
+ export { VideoPlayerShell, type VideoPlayerShellProps } from './ui/VideoPlayerShell'
230
+ export { AudioWaveform, type AudioWaveformProps } from './ui/AudioWaveform'
231
+
232
+ // Spatial + interaction.
233
+ export { Lamp, type LampProps } from './ui/Lamp'
234
+ export { FollowingPointer, type FollowingPointerProps } from './ui/FollowingPointer'
235
+ export { SheetSnap, type SheetSnapProps } from './ui/SheetSnap'
212
236
  export {
213
237
  CopyButton,
214
238
  type CopyButtonProps,
@@ -0,0 +1,190 @@
1
+ // Bar-style audio waveform over a hidden native <audio> element — no media
2
+ // library. When no real `peaks` are supplied, a deterministic placeholder
3
+ // waveform is derived from the bar index (abs(sin(...)) blend) so the shape
4
+ // is stable across renders instead of reshuffling on every re-render like
5
+ // Math.random() would.
6
+ import { Pause, Play } from 'lucide-solid'
7
+ import type { JSX } from 'solid-js'
8
+ import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { motionReduced } from '../lib/motion'
12
+
13
+ export interface AudioWaveformProps {
14
+ src?: string
15
+ peaks?: number[]
16
+ /** Bar row height in pixels. @default 64 */
17
+ height?: number
18
+ class?: string
19
+ }
20
+
21
+ const PLACEHOLDER_BAR_COUNT = 48
22
+ // Demo progress rate (fraction of the fake "duration" per animation frame
23
+ // tick) used only when there's no `src` to drive real audio playback.
24
+ const DEMO_TICK_MS = 100
25
+ const DEMO_STEP = 0.01
26
+
27
+ /** Deterministic placeholder bar heights (0..1), no Math.random. */
28
+ function placeholderPeaks(count: number): number[] {
29
+ return Array.from({ length: count }, (_, i) => {
30
+ const a = Math.abs(Math.sin(i * 0.35))
31
+ const b = Math.abs(Math.sin(i * 0.09 + 1.2))
32
+ return 0.15 + 0.85 * (0.65 * a + 0.35 * b)
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Waveform of vertical bars driven by an optional hidden `<audio>` element.
38
+ * Pass `peaks` (numbers in `0..1`) for a real waveform, or omit it for a
39
+ * deterministic placeholder shape. The played portion (up to
40
+ * `currentTime / duration`) is highlighted; clicking a bar seeks there. With
41
+ * no `src`, playback is simulated on a timer for demo purposes.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <AudioWaveform src="/media/track.mp3" />
46
+ * <AudioWaveform peaks={[0.2, 0.8, 0.4, 0.9, 0.3]} height={48} />
47
+ * ```
48
+ */
49
+ export function AudioWaveform(props: AudioWaveformProps): JSX.Element {
50
+ let audioRef: HTMLAudioElement | undefined
51
+
52
+ const [playing, setPlaying] = createSignal(false)
53
+ const [currentTime, setCurrentTime] = createSignal(0)
54
+ const [duration, setDuration] = createSignal(0)
55
+
56
+ const bars = createMemo(() => props.peaks ?? placeholderPeaks(PLACEHOLDER_BAR_COUNT))
57
+ const progress = createMemo(() => (duration() > 0 ? currentTime() / duration() : 0))
58
+ const barHeight = () => props.height ?? 64
59
+
60
+ const togglePlay = (): void => {
61
+ if (props.src) {
62
+ const audio = audioRef
63
+ if (!audio) return
64
+ if (audio.paused) void audio.play()
65
+ else audio.pause()
66
+ return
67
+ }
68
+ // No source: drive a fake demo progress instead of real playback.
69
+ setPlaying((p) => !p)
70
+ }
71
+
72
+ const seekToRatio = (ratio: number): void => {
73
+ const clamped = Math.min(Math.max(ratio, 0), 1)
74
+ if (props.src) {
75
+ const audio = audioRef
76
+ if (!audio || !duration()) return
77
+ audio.currentTime = clamped * duration()
78
+ return
79
+ }
80
+ setCurrentTime(clamped * (duration() || 1))
81
+ }
82
+
83
+ const handleBarClick = (index: number): void => {
84
+ seekToRatio((index + 0.5) / bars().length)
85
+ }
86
+
87
+ onMount(() => {
88
+ if (props.src) {
89
+ const audio = audioRef
90
+ if (!audio) return
91
+
92
+ const onPlay = (): void => {
93
+ setPlaying(true)
94
+ }
95
+ const onPause = (): void => {
96
+ setPlaying(false)
97
+ }
98
+ const onTimeUpdate = (): void => {
99
+ setCurrentTime(audio.currentTime)
100
+ }
101
+ const onLoadedMetadata = (): void => {
102
+ setDuration(audio.duration || 0)
103
+ }
104
+
105
+ audio.addEventListener('play', onPlay)
106
+ audio.addEventListener('pause', onPause)
107
+ audio.addEventListener('timeupdate', onTimeUpdate)
108
+ audio.addEventListener('loadedmetadata', onLoadedMetadata)
109
+
110
+ onCleanup(() => {
111
+ audio.removeEventListener('play', onPlay)
112
+ audio.removeEventListener('pause', onPause)
113
+ audio.removeEventListener('timeupdate', onTimeUpdate)
114
+ audio.removeEventListener('loadedmetadata', onLoadedMetadata)
115
+ })
116
+ return
117
+ }
118
+
119
+ // Demo mode: fake a duration and advance currentTime on an interval while playing.
120
+ setDuration(30)
121
+ const interval = setInterval(() => {
122
+ if (!playing() || motionReduced()) return
123
+ setCurrentTime((t) => {
124
+ const next = t + DEMO_STEP * duration()
125
+ if (next >= duration()) {
126
+ setPlaying(false)
127
+ return 0
128
+ }
129
+ return next
130
+ })
131
+ }, DEMO_TICK_MS)
132
+ onCleanup(() => clearInterval(interval))
133
+ })
134
+
135
+ return (
136
+ <div class={cn('card flex items-center gap-3 p-3', props.class)}>
137
+ <Show when={props.src}>
138
+ <audio ref={audioRef} src={props.src} />
139
+ </Show>
140
+
141
+ <button
142
+ type="button"
143
+ aria-label={playing() ? 'Pause' : 'Play'}
144
+ onClick={togglePlay}
145
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform duration-150 hover:scale-105 active:scale-95"
146
+ >
147
+ <Show when={playing()} fallback={<Play class="h-4 w-4 translate-x-0.5" fill="currentColor" />}>
148
+ <Pause class="h-4 w-4" fill="currentColor" />
149
+ </Show>
150
+ </button>
151
+
152
+ <div
153
+ role="slider"
154
+ aria-label="Waveform position"
155
+ aria-valuemin={0}
156
+ aria-valuemax={100}
157
+ aria-valuenow={Math.round(progress() * 100)}
158
+ tabIndex={0}
159
+ class="flex flex-1 cursor-pointer items-center gap-[2px]"
160
+ style={{ height: `${barHeight()}px` }}
161
+ onClick={(event) => {
162
+ const rect = event.currentTarget.getBoundingClientRect()
163
+ seekToRatio((event.clientX - rect.left) / rect.width)
164
+ }}
165
+ >
166
+ <For each={bars()}>
167
+ {(peak, index) => {
168
+ const played = createMemo(() => index() / bars().length <= progress())
169
+ return (
170
+ <button
171
+ type="button"
172
+ aria-hidden="true"
173
+ tabIndex={-1}
174
+ onClick={(event) => {
175
+ event.stopPropagation()
176
+ handleBarClick(index())
177
+ }}
178
+ class={cn(
179
+ 'w-full min-w-[2px] flex-1 rounded-full transition-colors duration-150',
180
+ played() ? 'bg-primary' : 'bg-muted',
181
+ )}
182
+ style={{ height: `${Math.max(peak, 0.06) * 100}%` }}
183
+ />
184
+ )
185
+ }}
186
+ </For>
187
+ </div>
188
+ </div>
189
+ )
190
+ }