@weft-js/warp 0.0.0-stage → 0.1.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/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- 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
|
+
}
|