@a4ui/core 0.25.0 → 0.27.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 +5 -5
- package/dist/elements.css +62 -0
- package/dist/full.css +62 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +3362 -2862
- package/dist/layout/Aurora.d.ts +6 -0
- package/dist/lib/createLinkedSelection.d.ts +30 -0
- package/dist/lib/easing.d.ts +42 -0
- package/dist/ui/CategoryStrip.d.ts +24 -0
- package/dist/ui/CodeTabs.d.ts +27 -0
- package/dist/ui/DataView.d.ts +51 -0
- package/dist/ui/MasterDetail.d.ts +27 -0
- package/dist/ui/PillSearch.d.ts +38 -0
- package/dist/ui/SlashMenu.d.ts +43 -0
- package/package.json +1 -1
- package/preset.js +44 -0
- package/src/index.ts +11 -1
- package/src/layout/Aurora.tsx +41 -11
- package/src/lib/createLinkedSelection.ts +60 -0
- package/src/lib/easing.ts +43 -0
- package/src/ui/CategoryStrip.tsx +80 -0
- package/src/ui/CodeTabs.tsx +100 -0
- package/src/ui/DataView.tsx +198 -0
- package/src/ui/MasterDetail.tsx +117 -0
- package/src/ui/PillSearch.tsx +86 -0
- package/src/ui/SlashMenu.tsx +141 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tabbed code blocks with a per-tab copy button, for embedding several code
|
|
2
|
+
// samples/languages (docs, marketing) in a single card. Plain monospace —
|
|
3
|
+
// no syntax highlighting.
|
|
4
|
+
import { Check, Copy } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
/** A single tab's content: label, source code, and optional language tag. */
|
|
11
|
+
export interface CodeTab {
|
|
12
|
+
label: string
|
|
13
|
+
code: string
|
|
14
|
+
/** Language tag, e.g. `'tsx'`. Not used for highlighting; informational only. */
|
|
15
|
+
lang?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CodeTabsProps {
|
|
19
|
+
tabs: CodeTab[]
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const COPIED_TIMEOUT_MS = 1500
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Card of tabbed code blocks with a copy button for the active tab, for
|
|
27
|
+
* showing several code samples or language variants side by side.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <CodeTabs
|
|
32
|
+
* tabs={[
|
|
33
|
+
* { label: 'npm', code: 'npm install @a4ui/core' },
|
|
34
|
+
* { label: 'pnpm', code: 'pnpm add @a4ui/core' },
|
|
35
|
+
* ]}
|
|
36
|
+
* />
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function CodeTabs(props: CodeTabsProps): JSX.Element {
|
|
40
|
+
const [activeIndex, setActiveIndex] = createSignal(0)
|
|
41
|
+
const [copied, setCopied] = createSignal(false)
|
|
42
|
+
|
|
43
|
+
const active = () => props.tabs[activeIndex()]
|
|
44
|
+
|
|
45
|
+
const copy = () => {
|
|
46
|
+
if (!navigator.clipboard) return
|
|
47
|
+
navigator.clipboard.writeText(active().code).then(() => {
|
|
48
|
+
setCopied(true)
|
|
49
|
+
setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}>
|
|
55
|
+
<div class="flex items-center justify-between border-b border-border pr-2">
|
|
56
|
+
<div class="flex" role="tablist">
|
|
57
|
+
<For each={props.tabs}>
|
|
58
|
+
{(tab, index) => (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="tab"
|
|
62
|
+
aria-selected={activeIndex() === index()}
|
|
63
|
+
class={cn(
|
|
64
|
+
'border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
|
65
|
+
activeIndex() === index()
|
|
66
|
+
? 'border-foreground text-foreground'
|
|
67
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
68
|
+
)}
|
|
69
|
+
onClick={() => setActiveIndex(index())}
|
|
70
|
+
>
|
|
71
|
+
{tab.label}
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</For>
|
|
75
|
+
</div>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
79
|
+
onClick={copy}
|
|
80
|
+
>
|
|
81
|
+
<Show
|
|
82
|
+
when={copied()}
|
|
83
|
+
fallback={
|
|
84
|
+
<>
|
|
85
|
+
<Copy class="h-3.5 w-3.5" />
|
|
86
|
+
Copy
|
|
87
|
+
</>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
<Check class="h-3.5 w-3.5" />
|
|
91
|
+
Copied
|
|
92
|
+
</Show>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
<pre class="overflow-x-auto p-4 text-sm font-mono text-foreground">
|
|
96
|
+
<code>{active().code}</code>
|
|
97
|
+
</pre>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -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,117 @@
|
|
|
1
|
+
// List + detail-pane layout with keyboard navigation, e.g. an inbox or a
|
|
2
|
+
// command-result view. The list is a `listbox`; Up/Down move the selection,
|
|
3
|
+
// Home/End jump to the first/last item. Controlled (`selectedId`+`onSelect`)
|
|
4
|
+
// or uncontrolled (internal signal, defaults to the first item).
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface MasterDetailItem {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
meta?: JSX.Element
|
|
14
|
+
detail: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MasterDetailProps {
|
|
18
|
+
items: MasterDetailItem[]
|
|
19
|
+
/** Controlled selection. Uncontrolled (internal signal, defaults to the first item) when omitted. */
|
|
20
|
+
selectedId?: string
|
|
21
|
+
onSelect?: (id: string) => void
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List + detail-pane layout: a scrollable list of items on the left, the
|
|
27
|
+
* selected item's detail content on the right. Arrow keys (and Home/End)
|
|
28
|
+
* move the selection within the list.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <MasterDetail
|
|
33
|
+
* items={messages.map((m) => ({ id: m.id, label: m.subject, meta: <Badge>{m.from}</Badge>, detail: <MessageBody message={m} /> }))}
|
|
34
|
+
* />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function MasterDetail(props: MasterDetailProps): JSX.Element {
|
|
38
|
+
const [internalId, setInternalId] = createSignal<string | undefined>(props.items[0]?.id)
|
|
39
|
+
|
|
40
|
+
const selectedId = createMemo(() => props.selectedId ?? internalId())
|
|
41
|
+
|
|
42
|
+
const select = (id: string) => {
|
|
43
|
+
if (props.selectedId === undefined) setInternalId(id)
|
|
44
|
+
props.onSelect?.(id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const selectedItem = createMemo(() => props.items.find((item) => item.id === selectedId()))
|
|
48
|
+
|
|
49
|
+
let listEl: HTMLDivElement | undefined
|
|
50
|
+
|
|
51
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
52
|
+
const items = props.items
|
|
53
|
+
if (items.length === 0) return
|
|
54
|
+
const currentIndex = items.findIndex((item) => item.id === selectedId())
|
|
55
|
+
|
|
56
|
+
let nextIndex: number
|
|
57
|
+
if (event.key === 'ArrowDown')
|
|
58
|
+
nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, items.length - 1)
|
|
59
|
+
else if (event.key === 'ArrowUp') nextIndex = currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0)
|
|
60
|
+
else if (event.key === 'Home') nextIndex = 0
|
|
61
|
+
else if (event.key === 'End') nextIndex = items.length - 1
|
|
62
|
+
else return
|
|
63
|
+
|
|
64
|
+
event.preventDefault()
|
|
65
|
+
const next = items[nextIndex]
|
|
66
|
+
if (!next) return
|
|
67
|
+
select(next.id)
|
|
68
|
+
listEl?.querySelector<HTMLElement>(`[data-item-id="${next.id}"]`)?.focus()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div class={cn('grid grid-cols-1 gap-4 sm:grid-cols-[16rem_1fr]', props.class)}>
|
|
73
|
+
<div
|
|
74
|
+
ref={listEl}
|
|
75
|
+
role="listbox"
|
|
76
|
+
aria-label="Items"
|
|
77
|
+
tabindex={0}
|
|
78
|
+
onKeyDown={onKeyDown}
|
|
79
|
+
class="flex max-h-96 flex-col gap-1 overflow-y-auto rounded-xl border border-border bg-card p-1 text-card-foreground sm:max-h-[28rem]"
|
|
80
|
+
>
|
|
81
|
+
<For each={props.items}>
|
|
82
|
+
{(item) => {
|
|
83
|
+
const isSelected = () => item.id === selectedId()
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
role="option"
|
|
88
|
+
aria-selected={isSelected()}
|
|
89
|
+
data-item-id={item.id}
|
|
90
|
+
tabindex={-1}
|
|
91
|
+
onClick={() => select(item.id)}
|
|
92
|
+
class={cn(
|
|
93
|
+
'flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
|
94
|
+
isSelected() ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<span class="truncate">{item.label}</span>
|
|
98
|
+
<Show when={item.meta}>{item.meta}</Show>
|
|
99
|
+
</button>
|
|
100
|
+
)
|
|
101
|
+
}}
|
|
102
|
+
</For>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground">
|
|
105
|
+
{/* keyed: re-render the detail when the selected item changes (a plain
|
|
106
|
+
Show wouldn't re-run its child when `when` stays truthy across items). */}
|
|
107
|
+
<Show
|
|
108
|
+
when={selectedItem()}
|
|
109
|
+
keyed
|
|
110
|
+
fallback={<p class="text-sm text-muted-foreground">Select an item to see its details.</p>}
|
|
111
|
+
>
|
|
112
|
+
{(item) => item.detail}
|
|
113
|
+
</Show>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Compact multi-parameter search entry: a single rounded-full bar that
|
|
2
|
+
// compresses several fields (e.g. Where / When / Who) into clickable
|
|
3
|
+
// segments, ending in a round primary search button.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For } from 'solid-js'
|
|
6
|
+
import { Search } from 'lucide-solid'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface PillField {
|
|
11
|
+
/** Stable identifier passed back via {@link PillSearchProps.onFieldClick}. */
|
|
12
|
+
key: string
|
|
13
|
+
/** Small label shown above the value, e.g. `"Where"`. */
|
|
14
|
+
label: string
|
|
15
|
+
/** Current value shown below the label. Falls back to `placeholder` when unset. */
|
|
16
|
+
value?: string
|
|
17
|
+
/** Muted hint shown when `value` is unset. */
|
|
18
|
+
placeholder?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PillSearchProps {
|
|
22
|
+
fields: PillField[]
|
|
23
|
+
/** Called with the clicked field's `key`. */
|
|
24
|
+
onFieldClick?: (key: string) => void
|
|
25
|
+
/** Called when the round search button is pressed. */
|
|
26
|
+
onSearch?: () => void
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rounded-full search bar that packs several fields into segments with a
|
|
32
|
+
* round primary search button — the classic "Where / When / Who" pattern
|
|
33
|
+
* compressed into one compact control.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <PillSearch
|
|
38
|
+
* fields={[
|
|
39
|
+
* { key: 'where', label: 'Where', value: 'Lisbon' },
|
|
40
|
+
* { key: 'when', label: 'When', placeholder: 'Add dates' },
|
|
41
|
+
* { key: 'who', label: 'Who', placeholder: 'Add guests' },
|
|
42
|
+
* ]}
|
|
43
|
+
* onFieldClick={(key) => console.log('open', key)}
|
|
44
|
+
* onSearch={() => console.log('search')}
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function PillSearch(props: PillSearchProps): JSX.Element {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
class={cn(
|
|
52
|
+
'inline-flex max-w-full items-center overflow-x-auto rounded-full border border-border bg-card shadow-sm',
|
|
53
|
+
props.class,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<For each={props.fields}>
|
|
57
|
+
{(field, index) => (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
aria-label={field.label}
|
|
61
|
+
onClick={() => props.onFieldClick?.(field.key)}
|
|
62
|
+
class={cn(
|
|
63
|
+
'flex min-w-[6.5rem] shrink-0 flex-col items-start px-4 py-2 text-left transition-colors duration-150 hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring',
|
|
64
|
+
index() === 0 ? 'rounded-l-full' : 'border-l border-border',
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span class="text-xs font-medium text-muted-foreground">{field.label}</span>
|
|
68
|
+
<span class={cn('text-sm', field.value ? 'text-foreground' : 'text-muted-foreground')}>
|
|
69
|
+
{field.value ?? field.placeholder}
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
</For>
|
|
74
|
+
<div class="shrink-0 py-1.5 pl-1 pr-1.5">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-label="Search"
|
|
78
|
+
onClick={() => props.onSearch?.()}
|
|
79
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[color,background-color,transform] duration-150 hover:bg-primary/90 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
80
|
+
>
|
|
81
|
+
<Search class="h-4 w-4" aria-hidden="true" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|