@a4ui/core 0.26.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,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
+ }