@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.
@@ -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
+ }