@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.
- package/README.md +11 -11
- package/dist/elements.css +66 -0
- package/dist/full.css +66 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +3033 -2517
- package/dist/ui/BalanceCard.d.ts +24 -0
- package/dist/ui/BlockEditor.d.ts +42 -0
- package/dist/ui/KpiBlock.d.ts +22 -0
- package/dist/ui/MoneyActionButton.d.ts +22 -0
- package/dist/ui/ScrollScene.d.ts +25 -0
- package/dist/ui/SideRail.d.ts +39 -0
- package/dist/ui/Spaces.d.ts +31 -0
- package/dist/ui/StickyReveal.d.ts +24 -0
- package/dist/ui/TransactionFeed.d.ts +40 -0
- package/package.json +1 -1
- package/src/index.ts +12 -1
- package/src/ui/BalanceCard.tsx +68 -0
- package/src/ui/BlockEditor.tsx +139 -0
- package/src/ui/KpiBlock.tsx +58 -0
- package/src/ui/MoneyActionButton.tsx +54 -0
- package/src/ui/ScrollScene.tsx +70 -0
- package/src/ui/SideRail.tsx +120 -0
- package/src/ui/Spaces.tsx +139 -0
- package/src/ui/StickyReveal.tsx +103 -0
- package/src/ui/TransactionFeed.tsx +130 -0
|
@@ -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
|
+
}
|