@weft-js/warp 0.0.0-stage → 0.1.1

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.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,348 @@
1
+ import { SearchIcon } from '@weft-js/warp-icons'
2
+ import { dialog, listKey, moveShuttle, paletteHotkey, rankCommands, uid } from '@weft-js/warp-core'
3
+ import { derived, effect, flush, state, untrack, watch } from '@weft-js/core'
4
+ import { bindable, type Openable } from './bind'
5
+ import { Icon, type IconRef } from './Icon'
6
+ import { Kbd } from './Kbd'
7
+ import { use } from '@weft-js/warp-core/weft'
8
+ import './styles'
9
+ import '@weft-js/warp-core/styles/components/dialog.css'
10
+ import '@weft-js/warp-core/styles/components/menu.css'
11
+ import '@weft-js/warp-core/styles/components/command-palette.css'
12
+ import '@weft-js/warp-core/styles/components/palette-motion.css'
13
+
14
+ /** One entry the palette can run or drill into. */
15
+ export interface Command {
16
+ /** Stable key for the row; the label is used when it is left out. */
17
+ id?: string
18
+ /** The row's words, searched by fuzzy letters. */
19
+ label: string
20
+ /** Rows gather under their group's heading. */
21
+ group?: string
22
+ /** A glyph before the label. */
23
+ icon?: IconRef
24
+ /** Keys shown at the row's end, like "⌘N". Shown only: binding the key is the app's. */
25
+ shortcut?: string
26
+ /** Quiet text at the row's end: where it lives, or the words that matched. */
27
+ hint?: string
28
+ /** More words it answers to. */
29
+ keywords?: string[]
30
+ /** Letters of `label` to mark, from `rankCommands`. A source's rows mark what the query matched without it. */
31
+ hits?: number[]
32
+ /** Where it goes. ⌘Enter opens this in a new tab. */
33
+ href?: string
34
+ /** → on the row, or picking a row with no `run`, shows these in the same list. */
35
+ children?: () => Command[]
36
+ /** What picking the row does. It runs after the palette has closed, so what it opens takes focus. */
37
+ run?: () => void
38
+ }
39
+
40
+ /**
41
+ * Results for a query. Return an array to answer in the same frame; return a promise and its rows land below what's
42
+ * showing, without moving the active row. The signal aborts when the query has changed.
43
+ */
44
+ export type CommandSource = (query: string, signal: AbortSignal) => Command[] | Promise<Command[]>
45
+
46
+ /** What a first character changes to: `>` for commands, `@` for one kind of thing. */
47
+ export interface CommandMode {
48
+ /** The first characters that switch to this mode, like ">" or "@". */
49
+ prefix: string
50
+ /** The mode's name, shown in the input row while it is on. */
51
+ label: string
52
+ /** The input's placeholder while the mode is on. */
53
+ placeholder?: string
54
+ /** The rows searched in this mode, in place of the palette's `commands`. */
55
+ commands?: Command[]
56
+ /** Searched as you type in this mode, after its `commands`. */
57
+ sources?: CommandSource[]
58
+ }
59
+
60
+ /** Props for CommandPalette. */
61
+ export type CommandPaletteProps = Openable & {
62
+ /** The rows searched as you type, grouped by their `group`. */
63
+ commands?: Command[]
64
+ /** Searched as you type, after `commands`. */
65
+ sources?: CommandSource[]
66
+ /** Prefixes that switch what is searched: `>` for commands, `@` for people. */
67
+ modes?: CommandMode[]
68
+ /** The order groups show in, whatever order rows arrive. Groups not named follow. */
69
+ groupOrder?: string[]
70
+ /** Hears every command run, however it was picked. */
71
+ onRun?: (command: Command, reason: 'pointer' | 'keyboard') => void
72
+ /** The dialog's accessible name, and the input's label. */
73
+ label?: string
74
+ /** The input's placeholder when no mode is on. */
75
+ placeholder?: string
76
+ /** The key that opens it with ⌘ or Ctrl. `false` leaves opening to the app. */
77
+ hotkey?: string | false
78
+ /** `/` opens it too, outside a field. */
79
+ slash?: boolean | undefined
80
+ }
81
+
82
+ type Row = { head: string; item?: undefined; at?: undefined } | { head?: undefined; item: Command; at: number }
83
+
84
+ const HIT = 'warp-palette-hit'
85
+
86
+ /** Rows for groups in `order`, numbered in the order the arrows walk them. */
87
+ function arrange(commands: Command[], order: readonly string[] = []) {
88
+ const groups = new Map<string, Command[]>()
89
+ for (const c of commands) {
90
+ const name = c.group ?? ''
91
+ if (!groups.has(name)) groups.set(name, [])
92
+ groups.get(name)!.push(c)
93
+ }
94
+ const rank = (n: string) => { const i = order.indexOf(n); return i < 0 ? order.length : i }
95
+ const rows: Row[] = []
96
+ let at = 0
97
+ for (const [name, list] of [...groups].sort((a, b) => rank(a[0]) - rank(b[0]))) {
98
+ if (name) rows.push({ head: name })
99
+ for (const item of list) rows.push({ item, at: at++ })
100
+ }
101
+ return rows
102
+ }
103
+
104
+ /** Marks where `words` start in the rows' text, through the CSS Custom Highlight API: no markup, no row is rebuilt. */
105
+ function paint(list: HTMLElement | undefined, query: string, rows: readonly Row[]) {
106
+ const win = list?.ownerDocument.defaultView
107
+ if (!win?.CSS?.highlights || typeof win.Highlight !== 'function') return
108
+ const ranges: Range[] = []
109
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
110
+ const mark = (node: Text | null | undefined, indices: number[]) => {
111
+ for (const i of indices) {
112
+ if (!node || i >= node.length) continue
113
+ const r = new win.Range()
114
+ r.setStart(node, i)
115
+ r.setEnd(node, i + 1)
116
+ ranges.push(r)
117
+ }
118
+ }
119
+ rows.forEach((row, n) => {
120
+ if (!row.item) return
121
+ const el = list!.querySelectorAll(':scope > [data-part="row"]')[n]
122
+ const label = el?.querySelector('[data-part="text"]')?.firstChild as Text | null
123
+ const hint = el?.querySelector('[data-part="hint"]')?.firstChild as Text | null
124
+ if (row.item.hits) return mark(label, row.item.hits)
125
+ for (const node of [label, hint]) {
126
+ const text = node?.data.toLowerCase() ?? ''
127
+ for (const w of words) {
128
+ const i = text.indexOf(w)
129
+ if (i >= 0) mark(node, Array.from({ length: w.length }, (_, k) => i + k))
130
+ }
131
+ }
132
+ })
133
+ win.CSS.highlights.set(HIT, new win.Highlight(...ranges))
134
+ }
135
+
136
+ /** Writes `value` only if it differs, and hides the node when there is nothing to show. */
137
+ const put = (el: HTMLElement, value: string | undefined) => {
138
+ el.hidden = !value
139
+ if (value && el.textContent !== value) el.textContent = value
140
+ }
141
+
142
+ /** One row of the pool. Everything but the active mark is one write pass per change of row, so a list costs little to open. */
143
+ function Slot({ row, id, active, onPick, onHover }: { row: () => Row | undefined; id: string; active: () => number; onPick: (at: number) => void; onHover: (at: number) => void }) {
144
+ const r = derived(() => row())
145
+ const at = () => untrack(() => r.value?.at ?? -1)
146
+ let box!: HTMLElement, head!: HTMLElement, option!: HTMLElement, text!: HTMLElement, hint!: HTMLElement, keys!: HTMLElement
147
+ const view = (
148
+ <div ref={n => (box = n)} data-part="row" role="presentation" hidden="">
149
+ <div ref={n => (head = n)} data-part="group-label" role="presentation" hidden=""></div>
150
+ <div ref={n => (option = n)} data-part="item" hidden="" aria-selected={r.value?.item ? (r.value.at === active() ? 'true' : 'false') : undefined} onPointerMove={() => { if (at() !== active()) onHover(at()) }} onClick={() => onPick(at())}>
151
+ {r.value?.item?.icon && <Icon name={r.value?.item?.icon} />}
152
+ <span ref={n => (text = n)} data-part="text"></span>
153
+ <span ref={n => (hint = n)} data-part="hint" hidden=""></span>
154
+ <span ref={n => (keys = n)} data-part="shortcut" hidden=""></span>
155
+ </div>
156
+ </div>
157
+ )
158
+ effect(() => r.value, v => {
159
+ const item = v?.item
160
+ box.hidden = !v
161
+ put(head, v?.head)
162
+ option.hidden = !item
163
+ if (item) { option.setAttribute('role', 'option'); option.id = `${id}-${v.at}` } else { option.removeAttribute('role'); option.removeAttribute('id') }
164
+ text.textContent = item?.label ?? ''
165
+ put(hint, item?.hint)
166
+ put(keys, item?.shortcut)
167
+ })
168
+ return view
169
+ }
170
+
171
+ /**
172
+ * The command palette (05 §3; components.html:1109-1113, 1704-1754): an APG combobox in a modal <dialog>. Focus stays in
173
+ * the input. ↑/↓, Home/End and PageUp/PageDown move the active row, Tab goes to the next group, → opens a row's `children`,
174
+ * Enter runs it, ⌘Enter opens its `href` in a new tab. Esc clears the query, then closes and returns focus. A nested level
175
+ * opens and closes (← at the query's start, or Esc once it is empty) with an empty query, and typing there filters its rows.
176
+ *
177
+ * Rows are a pool that only grows: a key press writes text into nodes that already exist, so a query never builds a row
178
+ * and nothing shifts. Matches are painted with the Custom Highlight API. Rows appear only while it's open, so a closed
179
+ * palette costs a page one empty dialog.
180
+ */
181
+ export function CommandPalette({ open, defaultOpen, onOpenChange, commands, sources, modes, groupOrder, onRun, label = 'Command palette', placeholder = 'Search or run a command…', hotkey = 'k', slash }: CommandPaletteProps) {
182
+ const id = uid('pal')
183
+ const flag = bindable<boolean>(() => open, () => defaultOpen, false, () => o => onOpenChange?.(o))
184
+ const typed = state('')
185
+ const active = state(0)
186
+ let face: HTMLElement | undefined
187
+ const rows = state<Row[]>([])
188
+ const size = state(0)
189
+ const drill = state<Command[] | undefined>(undefined)
190
+ const mode = derived(() => modes?.find(m => typed.value.startsWith(m.prefix)))
191
+ const items = derived(() => rows.value.flatMap(r => (r.item ? [r.item] : [])))
192
+ const pending = state(false)
193
+ const result = derived(() => { const n = items.value.length; return !flag.value ? '' : n ? `${n} ${n === 1 ? 'command' : 'commands'}` : pending.value ? '' : 'No results' })
194
+ let el: HTMLDialogElement | undefined
195
+ let input: HTMLInputElement | undefined
196
+ let list: HTMLDivElement | undefined
197
+ let query: AbortController | undefined
198
+ let shown: Command[] = []
199
+ // Esc steps back before it closes: a query clears first, then an empty nested level goes up one, then the palette
200
+ // closes. The dialog's layer asks this first, so the step holds whether the key reached the input or the dialog.
201
+ const back = () => {
202
+ if (input?.value) { clear(); answer(''); return true }
203
+ if (untrack(() => drill.value)) { up(); return true }
204
+ return false
205
+ }
206
+ use(dialog, () => el, () => ({ open: flag.value, onClose: () => flag.set(false), onEscape: back }))
207
+ if (untrack(() => hotkey) !== false) use(paletteHotkey, () => el, () => ({ key: String(hotkey), slash, onPress: () => flag.set(!untrack(() => flag.value)) }))
208
+
209
+ const show = (next: Command[], q: string) => {
210
+ shown = next
211
+ const arranged = arrange(next, groupOrder)
212
+ if (arranged.length > untrack(() => size.value)) size.value = arranged.length
213
+ rows.value = arranged
214
+ flush()
215
+ paint(list, q, arranged)
216
+ }
217
+ const rank = (cmds: Command[], q: string) => rankCommands(cmds, q).flatMap(g => g.rows.map(r => (r.hits.length ? { ...r.item, hits: r.hits } : r.item)))
218
+ const answer = (value: string) => {
219
+ query?.abort()
220
+ const kids = untrack(() => drill.value)
221
+ if (kids) { active.value = 0; pending.value = false; return show(rank(kids, value), value) } // a nested level filters its own rows
222
+ const ctrl = (query = new AbortController())
223
+ const m = modes?.find(x => value.startsWith(x.prefix))
224
+ const q = m ? value.slice(m.prefix.length) : value
225
+ const statics = m ? m.commands ?? [] : commands ?? []
226
+ let now = rank(statics, q)
227
+ const late: Promise<Command[]>[] = []
228
+ for (const source of (m ? m.sources : sources) ?? []) {
229
+ const got = source(q, ctrl.signal)
230
+ if (Array.isArray(got)) now = now.concat(got)
231
+ else late.push(got)
232
+ }
233
+ active.value = 0
234
+ pending.value = late.length > 0
235
+ show(now, q)
236
+ let left = late.length
237
+ for (const p of late) void p.then(more => {
238
+ if (ctrl.signal.aborted) return
239
+ if (more.length) show(shown.concat(more.filter(c => !shown.some(s => s.href && s.href === c.href))), q)
240
+ if (!--left) pending.value = false
241
+ }, () => { if (!ctrl.signal.aborted && !--left) pending.value = false })
242
+ }
243
+ watch(() => flag.value, on => {
244
+ if (!on) return
245
+ typed.value = ''
246
+ drill.value = undefined
247
+ active.value = 0
248
+ if (input) input.value = ''
249
+ answer('')
250
+ })
251
+ // entering or leaving a level starts its search afresh: what was typed found the row, it isn't a search of its rows
252
+ const clear = () => {
253
+ if (input) input.value = ''
254
+ typed.value = ''
255
+ }
256
+ const down = (kids: Command[]) => {
257
+ clear()
258
+ drill.value = kids
259
+ answer('')
260
+ }
261
+ const up = () => {
262
+ clear()
263
+ drill.value = undefined
264
+ answer('')
265
+ }
266
+ const run = (at: number, reason: 'pointer' | 'keyboard') => {
267
+ const c = items.value[at]
268
+ if (!c) return
269
+ if (!c.run && c.children) return down(c.children())
270
+ flag.set(false)
271
+ flush()
272
+ // after the dialog has gone, so what the command opens takes focus from the opener
273
+ queueMicrotask(() => { c.run?.(); onRun?.(c, reason) })
274
+ }
275
+ const move = (to: number) => {
276
+ active.value = to
277
+ flush()
278
+ el?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' })
279
+ }
280
+ const onKey = (e: KeyboardEvent) => {
281
+ if (e.isComposing) return
282
+ if (e.key === 'Escape') { if (!e.defaultPrevented && back()) e.preventDefault(); return } // the layer took it first
283
+ const count = items.value.length
284
+ if (!count) return
285
+ const at = active.value
286
+ if (e.key === 'Home' || e.key === 'End') {
287
+ e.preventDefault()
288
+ return move(e.key === 'Home' ? 0 : count - 1)
289
+ }
290
+ if (e.key === 'Tab') {
291
+ const heads = rows.value.flatMap((r, i) => (r.head === undefined ? [] : [(rows.value[i + 1])?.at ?? 0]))
292
+ if (heads.length < 2) return e.preventDefault()
293
+ e.preventDefault()
294
+ const next = e.shiftKey ? [...heads].reverse().find(h => h < at) ?? heads.at(-1)! : heads.find(h => h > at) ?? heads[0]
295
+ return move(next!)
296
+ }
297
+ if (e.key === 'ArrowRight' && input && input.selectionStart === input.value.length && items.value[at]?.children) {
298
+ e.preventDefault()
299
+ return down(items.value[at].children())
300
+ }
301
+ if (e.key === 'ArrowLeft' && drill.value && !input?.selectionStart) {
302
+ e.preventDefault()
303
+ return up()
304
+ }
305
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
306
+ const href = items.value[at]?.href
307
+ if (href) { e.preventDefault(); window.open(href, '_blank', 'noopener') }
308
+ return
309
+ }
310
+ const m = listKey(e.key, at, count, { wrap: true, typing: true, page: 5 })
311
+ if (!m.handled) return
312
+ e.preventDefault()
313
+ if (m.pick) return run(at, 'keyboard')
314
+ move(m.to)
315
+ }
316
+ const onInput = (e: Event & { currentTarget: HTMLInputElement }) => {
317
+ typed.value = e.currentTarget.value
318
+ answer(e.currentTarget.value)
319
+ }
320
+ const slots = derived(() => Array.from({ length: size.value }, (_, i) => i))
321
+ // one highlight that travels (redesign §3): it reads the row after the render that moved it
322
+ watch(() => [active.value, rows.value, flag.value] as const, ([, , on]) => { requestAnimationFrame(() => {
323
+ if (!face) return
324
+ if (!on) { face.setAttribute('data-instant', ''); face.style.removeProperty('--warp-a'); return }
325
+ // the row, not its option: while a row's entrance runs, its translate makes it the option's offsetParent
326
+ const row = list?.querySelector<HTMLElement>('[aria-selected="true"]')?.parentElement
327
+ if (row) moveShuttle(face, row.offsetTop, row.offsetTop + row.offsetHeight)
328
+ requestAnimationFrame(() => face?.removeAttribute('data-instant'))
329
+ }) })
330
+ return (
331
+ <dialog ref={n => (el = n)} data-scope="palette" aria-label={label}>
332
+ <div data-part="input-row" data-sweep="">
333
+ <span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
334
+ <SearchIcon />
335
+ <label for={`${id}-in`} data-part="sr-only">{label}</label>
336
+ <input ref={n => (input = n)} data-initial="" data-part="input" id={`${id}-in`} role="combobox" aria-expanded="true" aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={items.value.length ? `${id}-${active.value}` : undefined} placeholder={mode.value?.placeholder ?? placeholder} autocomplete="off" spellcheck={false} onInput={onInput} onKeyDown={onKey} />
337
+ {mode.value ? <span data-part="mode">{mode.value.label}</span> : <Kbd>esc</Kbd>}
338
+ </div>
339
+ <div ref={n => (list = n)} data-part="list" role="listbox" id={`${id}-list`} aria-label="Commands">
340
+ <span ref={n => (face = n)} data-part="shuttle" aria-hidden="true"></span>
341
+ {slots.value.map(i => <Slot key={i} id={id} row={() => rows.value[i]} active={() => active.value} onPick={at => run(at, 'pointer')} onHover={at => (active.value = at)} />)}
342
+ </div>
343
+ {flag.value && !items.value.length && !pending.value && <div data-part="empty">{`Nothing matches “${typed.value}”.`}</div>}
344
+ <span data-part="sr-only" role="status">{result.value}</span>
345
+ <div data-part="footer" aria-hidden="true"><span><Kbd>↑</Kbd><Kbd>↓</Kbd> move</span><span><Kbd>tab</Kbd> group</span><span><Kbd>↵</Kbd> open</span><span><Kbd>esc</Kbd> close</span></div>
346
+ </dialog>
347
+ )
348
+ }