@a4ui/core 0.26.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.
- package/README.md +11 -11
- package/dist/elements.css +70 -0
- package/dist/full.css +70 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +4127 -3375
- package/dist/layout/Aurora.d.ts +6 -0
- package/dist/lib/createLinkedSelection.d.ts +30 -0
- package/dist/ui/BalanceCard.d.ts +24 -0
- package/dist/ui/DataView.d.ts +51 -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/SlashMenu.d.ts +43 -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/preset.js +24 -0
- package/src/index.ts +14 -1
- package/src/layout/Aurora.tsx +41 -11
- package/src/lib/createLinkedSelection.ts +60 -0
- package/src/ui/BalanceCard.tsx +68 -0
- package/src/ui/DataView.tsx +198 -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/SlashMenu.tsx +141 -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,198 @@
|
|
|
1
|
+
// One dataset, three switchable presentations: table (reuses Table), board
|
|
2
|
+
// (rows grouped into columns, Kanban-style), and gallery (a card grid). The
|
|
3
|
+
// view switcher reuses SegmentedControl; board/gallery cards fall back to a
|
|
4
|
+
// simple key/value list when no `card` renderer is given.
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { Badge } from './Badge'
|
|
10
|
+
import type { SegmentedOption } from './SegmentedControl'
|
|
11
|
+
import { SegmentedControl } from './SegmentedControl'
|
|
12
|
+
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
|
|
13
|
+
|
|
14
|
+
/** A single table column for {@link DataView}'s table presentation. */
|
|
15
|
+
export interface DataViewColumn<T> {
|
|
16
|
+
/** Row property this column reads. */
|
|
17
|
+
key: string
|
|
18
|
+
/** Column heading text. */
|
|
19
|
+
header: string
|
|
20
|
+
/** Custom cell renderer; falls back to `String(row[key])` when omitted. */
|
|
21
|
+
cell?: (row: T) => JSX.Element
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Presentation mode offered by {@link DataView}. */
|
|
25
|
+
export type DataViewMode = 'table' | 'board' | 'gallery'
|
|
26
|
+
|
|
27
|
+
/** Props for {@link DataView}. */
|
|
28
|
+
export interface DataViewProps<T> {
|
|
29
|
+
data: T[]
|
|
30
|
+
/** Table columns; also used as the source of truth in board/gallery fallbacks. */
|
|
31
|
+
columns: DataViewColumn<T>[]
|
|
32
|
+
/** Stable row identifier, used as a DOM hook for tests/a11y. */
|
|
33
|
+
getId: (row: T) => string
|
|
34
|
+
/** For board view: group rows into columns by this key. If omitted, board mode is not offered. */
|
|
35
|
+
groupBy?: (row: T) => string
|
|
36
|
+
/** Card renderer for board + gallery. Falls back to a simple key/value list. */
|
|
37
|
+
card?: (row: T) => JSX.Element
|
|
38
|
+
/** Which views to offer (in order). Default: `['table', 'gallery']` plus `'board'` when `groupBy` is set. */
|
|
39
|
+
views?: DataViewMode[]
|
|
40
|
+
/** Controlled current view. Uncontrolled (internal signal) when omitted. */
|
|
41
|
+
view?: DataViewMode
|
|
42
|
+
onViewChange?: (view: DataViewMode) => void
|
|
43
|
+
class?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MODE_LABELS: Record<DataViewMode, string> = {
|
|
47
|
+
table: 'Table',
|
|
48
|
+
board: 'Board',
|
|
49
|
+
gallery: 'Gallery',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultModes<T>(props: DataViewProps<T>): DataViewMode[] {
|
|
53
|
+
if (props.views) return props.views
|
|
54
|
+
return props.groupBy ? ['table', 'board', 'gallery'] : ['table', 'gallery']
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Key/value fallback used for board + gallery cards when no `card` renderer is given. */
|
|
58
|
+
function FallbackCard<T>(props: { row: T }): JSX.Element {
|
|
59
|
+
const entries = () => Object.entries(props.row as Record<string, unknown>)
|
|
60
|
+
return (
|
|
61
|
+
<dl class="flex flex-col gap-1 text-sm">
|
|
62
|
+
<For each={entries()}>
|
|
63
|
+
{([key, value]) => (
|
|
64
|
+
<div class="flex items-center justify-between gap-3">
|
|
65
|
+
<dt class="text-muted-foreground">{key}</dt>
|
|
66
|
+
<dd class="truncate font-medium text-foreground">{String(value)}</dd>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</For>
|
|
70
|
+
</dl>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One dataset, switchable between table, board (grouped columns), and
|
|
76
|
+
* gallery (card grid) presentations. Generic over the row type — pass
|
|
77
|
+
* `columns` for the table, an optional `groupBy` to enable the board view,
|
|
78
|
+
* and an optional `card` renderer reused by board + gallery.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```tsx
|
|
82
|
+
* <DataView
|
|
83
|
+
* data={tasks}
|
|
84
|
+
* getId={(t) => t.id}
|
|
85
|
+
* columns={[
|
|
86
|
+
* { key: 'title', header: 'Title' },
|
|
87
|
+
* { key: 'status', header: 'Status' },
|
|
88
|
+
* ]}
|
|
89
|
+
* groupBy={(t) => t.status}
|
|
90
|
+
* card={(t) => <span class="font-medium">{t.title}</span>}
|
|
91
|
+
* />
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function DataView<T>(props: DataViewProps<T>): JSX.Element {
|
|
95
|
+
const modes = createMemo<DataViewMode[]>(() => defaultModes(props))
|
|
96
|
+
|
|
97
|
+
const [internalView, setInternalView] = createSignal<DataViewMode>(props.view ?? modes()[0] ?? 'table')
|
|
98
|
+
|
|
99
|
+
const view = createMemo(() => props.view ?? internalView())
|
|
100
|
+
|
|
101
|
+
const setView = (next: DataViewMode) => {
|
|
102
|
+
if (props.view === undefined) setInternalView(next)
|
|
103
|
+
props.onViewChange?.(next)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const options = createMemo<SegmentedOption[]>(() =>
|
|
107
|
+
modes().map((mode) => ({ value: mode, label: MODE_LABELS[mode] })),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const groups = createMemo(() => {
|
|
111
|
+
const groupBy = props.groupBy
|
|
112
|
+
if (!groupBy) return []
|
|
113
|
+
const byKey = new Map<string, T[]>()
|
|
114
|
+
for (const row of props.data) {
|
|
115
|
+
const key = groupBy(row)
|
|
116
|
+
const bucket = byKey.get(key)
|
|
117
|
+
if (bucket) bucket.push(row)
|
|
118
|
+
else byKey.set(key, [row])
|
|
119
|
+
}
|
|
120
|
+
return Array.from(byKey.entries()).map(([key, rows]) => ({ key, rows }))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div class={cn('flex w-full flex-col gap-4', props.class)}>
|
|
125
|
+
<div class="flex items-center justify-end">
|
|
126
|
+
<SegmentedControl
|
|
127
|
+
value={view()}
|
|
128
|
+
onChange={(next) => setView(next as DataViewMode)}
|
|
129
|
+
options={options()}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<Show when={view() === 'table'}>
|
|
134
|
+
<Table>
|
|
135
|
+
<TableHead>
|
|
136
|
+
<TableRow>
|
|
137
|
+
<For each={props.columns}>{(column) => <TableHeadCell>{column.header}</TableHeadCell>}</For>
|
|
138
|
+
</TableRow>
|
|
139
|
+
</TableHead>
|
|
140
|
+
<TableBody>
|
|
141
|
+
<For each={props.data}>
|
|
142
|
+
{(row) => (
|
|
143
|
+
<TableRow data-row-id={props.getId(row)}>
|
|
144
|
+
<For each={props.columns}>
|
|
145
|
+
{(column) => (
|
|
146
|
+
<TableCell>
|
|
147
|
+
{column.cell?.(row) ?? String((row as Record<string, unknown>)[column.key])}
|
|
148
|
+
</TableCell>
|
|
149
|
+
)}
|
|
150
|
+
</For>
|
|
151
|
+
</TableRow>
|
|
152
|
+
)}
|
|
153
|
+
</For>
|
|
154
|
+
</TableBody>
|
|
155
|
+
</Table>
|
|
156
|
+
</Show>
|
|
157
|
+
|
|
158
|
+
<Show when={view() === 'board' && props.groupBy}>
|
|
159
|
+
<div class="flex gap-4 overflow-x-auto">
|
|
160
|
+
<For each={groups()}>
|
|
161
|
+
{(group) => (
|
|
162
|
+
<div class="flex min-w-64 flex-col gap-3 rounded-xl border border-border bg-card p-3 text-card-foreground">
|
|
163
|
+
<div class="flex items-center justify-between gap-2 text-sm font-semibold">
|
|
164
|
+
<span class="truncate">{group.key}</span>
|
|
165
|
+
<Badge tone="neutral">{group.rows.length}</Badge>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="flex flex-col gap-2">
|
|
168
|
+
<For each={group.rows}>
|
|
169
|
+
{(row) => (
|
|
170
|
+
<div
|
|
171
|
+
data-row-id={props.getId(row)}
|
|
172
|
+
class="rounded-lg border border-border bg-background p-3"
|
|
173
|
+
>
|
|
174
|
+
{props.card?.(row) ?? <FallbackCard row={row} />}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</For>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</For>
|
|
182
|
+
</div>
|
|
183
|
+
</Show>
|
|
184
|
+
|
|
185
|
+
<Show when={view() === 'gallery'}>
|
|
186
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
187
|
+
<For each={props.data}>
|
|
188
|
+
{(row) => (
|
|
189
|
+
<div data-row-id={props.getId(row)} class="card rounded-xl bg-card p-4 text-card-foreground">
|
|
190
|
+
{props.card?.(row) ?? <FallbackCard row={row} />}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</For>
|
|
194
|
+
</div>
|
|
195
|
+
</Show>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SlashMenu — filterable "/" insert/command menu (the "type / to insert
|
|
2
|
+
// anything" pattern). Presentational and keyboard-driven; the caller wires
|
|
3
|
+
// the '/' trigger and owns `open` + `query` (usually whatever the user typed
|
|
4
|
+
// after '/').
|
|
5
|
+
import { createEffect, createMemo, createSignal, For, Show, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** A single insertable item in a {@link SlashMenu}. */
|
|
10
|
+
export interface SlashItem {
|
|
11
|
+
/** Value passed to `onSelect` when this item is picked. */
|
|
12
|
+
value: string
|
|
13
|
+
/** Primary text shown on the row. */
|
|
14
|
+
label: string
|
|
15
|
+
/** Optional secondary text shown muted below the label. */
|
|
16
|
+
description?: string
|
|
17
|
+
/** Optional leading icon. */
|
|
18
|
+
icon?: JSX.Element
|
|
19
|
+
/** Extra terms matched against the query, in addition to label + description. */
|
|
20
|
+
keywords?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SlashMenuProps {
|
|
24
|
+
items: SlashItem[]
|
|
25
|
+
/** Filter text — typically whatever the user typed after '/'. */
|
|
26
|
+
query: string
|
|
27
|
+
/** Invoked with the picked item's `value` (click or Enter). */
|
|
28
|
+
onSelect: (value: string) => void
|
|
29
|
+
/** Renders nothing when `false`. @default true */
|
|
30
|
+
open?: boolean
|
|
31
|
+
class?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Floating, keyboard-navigable menu for a "/"-triggered insert/command flow.
|
|
36
|
+
* Filters `items` against `query` (case-insensitive match over label +
|
|
37
|
+
* description + keywords), highlights an active row (↑/↓, wraps), and picks
|
|
38
|
+
* it on Enter or click. Purely presentational: the caller owns the '/'
|
|
39
|
+
* trigger, `open`, and `query` — this component only renders the filtered
|
|
40
|
+
* list and reports the pick.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* const [query, setQuery] = createSignal('')
|
|
45
|
+
* <SlashMenu
|
|
46
|
+
* items={[{ value: 'heading', label: 'Heading', icon: <Heading size={16} /> }]}
|
|
47
|
+
* query={query()}
|
|
48
|
+
* onSelect={(value) => insertBlock(value)}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function SlashMenu(props: SlashMenuProps): JSX.Element {
|
|
53
|
+
const [active, setActive] = createSignal(0)
|
|
54
|
+
let rootRef: HTMLDivElement | undefined
|
|
55
|
+
|
|
56
|
+
const isOpen = () => props.open ?? true
|
|
57
|
+
|
|
58
|
+
const results = createMemo<SlashItem[]>(() => {
|
|
59
|
+
const q = props.query.trim().toLowerCase()
|
|
60
|
+
if (!q) return props.items
|
|
61
|
+
return props.items.filter((item) =>
|
|
62
|
+
`${item.label} ${item.description ?? ''} ${(item.keywords ?? []).join(' ')}`.toLowerCase().includes(q),
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Keep the highlighted row in range whenever the filtered set changes.
|
|
67
|
+
createEffect(() => {
|
|
68
|
+
results()
|
|
69
|
+
setActive(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Focus the menu whenever it opens so arrow keys work immediately.
|
|
73
|
+
createEffect(() => {
|
|
74
|
+
if (isOpen()) rootRef?.focus()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const pick = (item: SlashItem) => props.onSelect(item.value)
|
|
78
|
+
|
|
79
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
80
|
+
const list = results()
|
|
81
|
+
if (list.length === 0) return
|
|
82
|
+
if (e.key === 'ArrowDown') {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
setActive((i) => (i + 1) % list.length)
|
|
85
|
+
} else if (e.key === 'ArrowUp') {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
setActive((i) => (i - 1 + list.length) % list.length)
|
|
88
|
+
} else if (e.key === 'Enter') {
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
const item = list[active()]
|
|
91
|
+
if (item) pick(item)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Show when={isOpen()}>
|
|
97
|
+
<div
|
|
98
|
+
ref={rootRef}
|
|
99
|
+
role="listbox"
|
|
100
|
+
tabindex={0}
|
|
101
|
+
onKeyDown={onKeyDown}
|
|
102
|
+
class={cn(
|
|
103
|
+
'card max-h-72 overflow-y-auto rounded-lg border border-border bg-card p-1 shadow-lg outline-none',
|
|
104
|
+
props.class,
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<Show
|
|
108
|
+
when={results().length}
|
|
109
|
+
fallback={<div class="px-3 py-6 text-center text-sm text-muted-foreground">No matches</div>}
|
|
110
|
+
>
|
|
111
|
+
<For each={results()}>
|
|
112
|
+
{(item, i) => (
|
|
113
|
+
<div
|
|
114
|
+
role="option"
|
|
115
|
+
aria-selected={active() === i()}
|
|
116
|
+
onMouseEnter={() => setActive(i())}
|
|
117
|
+
onClick={() => pick(item)}
|
|
118
|
+
class={cn(
|
|
119
|
+
'flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-left transition-colors',
|
|
120
|
+
active() === i() ? 'bg-muted' : undefined,
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
<Show when={item.icon}>
|
|
124
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
125
|
+
{item.icon}
|
|
126
|
+
</span>
|
|
127
|
+
</Show>
|
|
128
|
+
<div class="min-w-0 flex-1">
|
|
129
|
+
<div class="truncate text-sm text-foreground">{item.label}</div>
|
|
130
|
+
<Show when={item.description}>
|
|
131
|
+
<div class="truncate text-xs text-muted-foreground">{item.description}</div>
|
|
132
|
+
</Show>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</For>
|
|
137
|
+
</Show>
|
|
138
|
+
</div>
|
|
139
|
+
</Show>
|
|
140
|
+
)
|
|
141
|
+
}
|