use-command-palette 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.
- package/README.md +517 -0
- package/dist/chunk-6DZX6EAA.mjs +37 -0
- package/dist/index.d.mts +132 -0
- package/dist/index.d.ts +132 -0
- package/dist/index.js +690 -0
- package/dist/index.mjs +661 -0
- package/dist/testing.d.mts +22 -0
- package/dist/testing.d.ts +22 -0
- package/dist/testing.js +42987 -0
- package/dist/testing.mjs +42944 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# use-command-palette
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/use-command-palette)
|
|
4
|
+
[](https://www.npmjs.com/package/use-command-palette)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
**Headless React hook for command palettes. You bring the UI, we bring the logic.**
|
|
9
|
+
|
|
10
|
+
No component library. No Provider. No opinions about your CSS. Just a hook that returns state, actions, and ARIA-ready prop getters — and gets out of your way.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Why use-command-palette?
|
|
15
|
+
|
|
16
|
+
| | cmdk | kbar | **use-command-palette** |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| Zero dependencies | ✗ | ✗ | **✓** |
|
|
19
|
+
| No Provider required | ✗ | ✗ | **✓** |
|
|
20
|
+
| No UI shipped | ✗ | ✗ | **✓** |
|
|
21
|
+
| Async filter support | ✗ | ✗ | **✓** |
|
|
22
|
+
| Nested commands (pages) | ✗ | ✗ | **✓** |
|
|
23
|
+
| Recent items built-in | ✗ | ✗ | **✓** |
|
|
24
|
+
| Animation state machine | ✗ | ✗ | **✓** |
|
|
25
|
+
| Testing utilities | ✗ | ✗ | **✓** |
|
|
26
|
+
| TypeScript strict | ✓ | partial | **✓** |
|
|
27
|
+
| SSR safe | partial | partial | **✓** |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Live Demos
|
|
32
|
+
|
|
33
|
+
| Style | Description |
|
|
34
|
+
|-------|-------------|
|
|
35
|
+
| [Plain CSS demo](#) | CSS custom properties, dark mode, animations |
|
|
36
|
+
| [Tailwind demo](#) | Utility-first, dark/light, nested pages |
|
|
37
|
+
| [MUI demo](#) | Material-UI primitives, no custom CSS |
|
|
38
|
+
| [Minimal demo](#) | Inline styles only — shows the raw API |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install use-command-palette
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
React 16.8+ is the only peer dependency.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { useCommandPalette } from 'use-command-palette'
|
|
56
|
+
|
|
57
|
+
const commands = [
|
|
58
|
+
{ id: 'open', label: 'Open File', keywords: ['open'] },
|
|
59
|
+
{ id: 'save', label: 'Save File', keywords: ['save'] },
|
|
60
|
+
{ id: 'theme', label: 'Change Theme' },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export default function App() {
|
|
64
|
+
const {
|
|
65
|
+
isOpen, isMounted, open, close, announcement,
|
|
66
|
+
filteredItems, highlightedIndex,
|
|
67
|
+
getContainerProps, getInputProps, getListProps, getItemProps, getAnnouncerProps,
|
|
68
|
+
} = useCommandPalette({
|
|
69
|
+
items: commands,
|
|
70
|
+
onSelect: (item) => console.log('selected:', item.label),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{/* Aria live announcer — visually hidden, required for screen readers */}
|
|
76
|
+
<div {...getAnnouncerProps()}>{announcement}</div>
|
|
77
|
+
|
|
78
|
+
<button onClick={open}>Open palette (Ctrl+K)</button>
|
|
79
|
+
|
|
80
|
+
{isMounted && (
|
|
81
|
+
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }} onClick={close}>
|
|
82
|
+
<div {...getContainerProps()} onClick={(e) => e.stopPropagation()}>
|
|
83
|
+
<input {...getInputProps({ placeholder: 'Search commands…' })} />
|
|
84
|
+
<ul {...getListProps()}>
|
|
85
|
+
{filteredItems.map((item, index) => (
|
|
86
|
+
<li
|
|
87
|
+
key={item.id}
|
|
88
|
+
{...getItemProps({ index, item })}
|
|
89
|
+
style={{ background: index === highlightedIndex ? '#e0e7ff' : 'transparent' }}
|
|
90
|
+
>
|
|
91
|
+
{item.label}
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Full API Reference
|
|
106
|
+
|
|
107
|
+
### `useCommandPalette(options)`
|
|
108
|
+
|
|
109
|
+
#### Options
|
|
110
|
+
|
|
111
|
+
| Option | Type | Default | Description |
|
|
112
|
+
|--------|------|---------|-------------|
|
|
113
|
+
| `items` | `CommandItem[]` | **required** | The full list of commands |
|
|
114
|
+
| `onSelect` | `(item: CommandItem) => void` | **required** | Called when the user picks an item |
|
|
115
|
+
| `filterFn` | `(items, query) => CommandItem[] \| Promise<CommandItem[]>` | built-in fuzzy | Override the default filter; sync or async |
|
|
116
|
+
| `defaultOpen` | `boolean` | `false` | Uncontrolled initial open state |
|
|
117
|
+
| `isOpen` | `boolean` | — | Controlled open state (omit for uncontrolled) |
|
|
118
|
+
| `onOpenChange` | `(open: boolean) => void` | — | Called in controlled mode when open state should change |
|
|
119
|
+
| `hotkey` | `string \| string[]` | `'mod+k'` | Global keyboard shortcut(s). `mod` = Cmd on Mac, Ctrl elsewhere |
|
|
120
|
+
| `closeOnSelect` | `boolean` | `true` | Whether selecting an item closes the palette |
|
|
121
|
+
| `animationDuration` | `number` | `0` | Duration in ms for enter/exit animation; 0 = instant |
|
|
122
|
+
| `recent` | `{ enabled: boolean; max?: number; storageKey?: string }` | — | Persist recently used items to localStorage |
|
|
123
|
+
|
|
124
|
+
#### Return value — State
|
|
125
|
+
|
|
126
|
+
| Property | Type | Description |
|
|
127
|
+
|----------|------|-------------|
|
|
128
|
+
| `isOpen` | `boolean` | Whether the palette is open |
|
|
129
|
+
| `query` | `string` | Current search query |
|
|
130
|
+
| `highlightedIndex` | `number` | Index of the highlighted item |
|
|
131
|
+
| `filteredItems` | `CommandItem[]` | Items matching the current query |
|
|
132
|
+
| `groupedItems` | `GroupedItems[]` | `filteredItems` grouped by their `group` field |
|
|
133
|
+
| `isLoading` | `boolean` | `true` while an async `filterFn` is in-flight |
|
|
134
|
+
| `isMounted` | `boolean` | `true` while the palette should be in the DOM (use instead of `isOpen` when `animationDuration > 0`) |
|
|
135
|
+
| `animationState` | `'entering' \| 'entered' \| 'exiting' \| 'exited'` | Current animation phase |
|
|
136
|
+
| `recentItems` | `CommandItem[]` | Recently selected items (requires `recent.enabled`) |
|
|
137
|
+
| `currentPage` | `CommandItem \| null` | Active nested page; null at root |
|
|
138
|
+
| `breadcrumb` | `CommandItem[]` | Ancestors of the current page |
|
|
139
|
+
| `canGoBack` | `boolean` | Whether the user can navigate back |
|
|
140
|
+
| `announcement` | `string` | Current screen-reader announcement text |
|
|
141
|
+
|
|
142
|
+
#### Return value — Actions
|
|
143
|
+
|
|
144
|
+
| Property | Type | Description |
|
|
145
|
+
|----------|------|-------------|
|
|
146
|
+
| `open` | `() => void` | Open the palette (saves focus) |
|
|
147
|
+
| `close` | `() => void` | Close the palette (restores focus, clears query) |
|
|
148
|
+
| `toggle` | `() => void` | Toggle open/closed |
|
|
149
|
+
| `setQuery` | `(q: string) => void` | Update the search query |
|
|
150
|
+
| `selectItem` | `(item: CommandItem) => void` | Programmatically select an item |
|
|
151
|
+
| `highlightIndex` | `(i: number) => void` | Move highlight to a specific index |
|
|
152
|
+
| `goToPage` | `(item: CommandItem) => void` | Navigate into an item's children |
|
|
153
|
+
| `goBack` | `() => void` | Navigate back to the parent page |
|
|
154
|
+
|
|
155
|
+
#### Return value — Prop getters
|
|
156
|
+
|
|
157
|
+
| Property | Description |
|
|
158
|
+
|----------|-------------|
|
|
159
|
+
| `getContainerProps()` | Spread onto your modal/dialog wrapper |
|
|
160
|
+
| `getInputProps(overrides?)` | Spread onto the search `<input>` |
|
|
161
|
+
| `getListProps()` | Spread onto the results `<ul>` |
|
|
162
|
+
| `getItemProps({ index, item })` | Spread onto each result `<li>` |
|
|
163
|
+
| `getAnnouncerProps()` | Spread onto a visually-hidden `<div>` for `aria-live` announcements |
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Features in depth
|
|
168
|
+
|
|
169
|
+
### Async filtering
|
|
170
|
+
|
|
171
|
+
`filterFn` can return a `Promise`. The hook sets `isLoading: true` while the promise is in-flight, cancels stale results when a new query arrives, and resolves to `isLoading: false` when done. Sync functions work exactly as before — no loading flash.
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
useCommandPalette({
|
|
175
|
+
items,
|
|
176
|
+
onSelect,
|
|
177
|
+
filterFn: async (items, query) => {
|
|
178
|
+
const results = await searchAPI(query)
|
|
179
|
+
return results
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### Animation state
|
|
187
|
+
|
|
188
|
+
Set `animationDuration` (in ms) to get a four-state machine: `entering → entered → exiting → exited`. Use `isMounted` to decide when to add the element to the DOM; use `animationState` to drive your CSS class names or inline styles.
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
const { isMounted, animationState } = useCommandPalette({
|
|
192
|
+
items, onSelect,
|
|
193
|
+
animationDuration: 200,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Render while mounted (includes exit phase)
|
|
197
|
+
{isMounted && (
|
|
198
|
+
<div className={`palette ${animationState}`}>
|
|
199
|
+
{/* CSS: .palette.entering { opacity: 0 } .palette.entered { opacity: 1 } */}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
When `animationDuration` is 0 (the default), `isMounted === isOpen` and `animationState` is always `'entered'` or `'exited'` — identical to the old behaviour.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
### Recent items
|
|
209
|
+
|
|
210
|
+
Pass `recent: { enabled: true }` to persist recently selected items to `localStorage`. They are surfaced in `recentItems` and updated automatically on each selection.
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
const { recentItems } = useCommandPalette({
|
|
214
|
+
items, onSelect,
|
|
215
|
+
recent: { enabled: true, max: 5, storageKey: 'my-app-recent' },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Show recentItems at the top of an empty palette, or merge them into items
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
### Controlled isOpen
|
|
224
|
+
|
|
225
|
+
Pass `isOpen` and `onOpenChange` to take control of the open state — useful for URL-driven palettes or when the open state lives in a state management store.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
const [open, setOpen] = useState(false)
|
|
229
|
+
|
|
230
|
+
useCommandPalette({
|
|
231
|
+
items, onSelect,
|
|
232
|
+
isOpen: open,
|
|
233
|
+
onOpenChange: setOpen,
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Omit both props for the existing uncontrolled behaviour.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
### Nested commands (pages)
|
|
242
|
+
|
|
243
|
+
Add a `children` array to any `CommandItem`. When a user selects a parent item the hook navigates into the children instead of calling `onSelect`. Use `currentPage`, `breadcrumb`, `canGoBack`, and `goBack` to render navigation chrome.
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
const commands = [
|
|
247
|
+
{
|
|
248
|
+
id: 'theme',
|
|
249
|
+
label: 'Change Theme',
|
|
250
|
+
children: [
|
|
251
|
+
{ id: 'theme-dark', label: 'Dark' },
|
|
252
|
+
{ id: 'theme-light', label: 'Light' },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
const { currentPage, breadcrumb, canGoBack, goBack } = useCommandPalette({ items: commands, onSelect })
|
|
258
|
+
|
|
259
|
+
// Render a breadcrumb:
|
|
260
|
+
{canGoBack && (
|
|
261
|
+
<div>
|
|
262
|
+
<button onClick={goBack}>← back</button>
|
|
263
|
+
{breadcrumb.map((crumb) => <span key={crumb.id}> / {crumb.label}</span>)}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
### Multiple hotkeys
|
|
271
|
+
|
|
272
|
+
`hotkey` accepts a string or array of strings. Any match opens/closes the palette.
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
useCommandPalette({ items, onSelect, hotkey: ['mod+k', 'mod+p'] })
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
### useRegisterCommands (no Provider)
|
|
281
|
+
|
|
282
|
+
Register commands from anywhere in your component tree without a Provider. The module-level registry re-renders all subscribers when commands change.
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
// In a deep component:
|
|
286
|
+
import { useRegisterCommands } from 'use-command-palette'
|
|
287
|
+
|
|
288
|
+
function SettingsPanel() {
|
|
289
|
+
useRegisterCommands([
|
|
290
|
+
{ id: 'reset', label: 'Reset Settings' },
|
|
291
|
+
], []) // deps array, like useEffect
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// In the palette component:
|
|
295
|
+
import { useRegisteredCommands } from 'use-command-palette'
|
|
296
|
+
|
|
297
|
+
function CommandPalette() {
|
|
298
|
+
const { commands: registeredCommands } = useRegisteredCommands()
|
|
299
|
+
|
|
300
|
+
const { filteredItems } = useCommandPalette({
|
|
301
|
+
items: [...myStaticCommands, ...registeredCommands],
|
|
302
|
+
onSelect,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
### Aria-live announcements
|
|
310
|
+
|
|
311
|
+
`getAnnouncerProps()` returns props for a visually-hidden `div` with `aria-live="polite"`. The `announcement` string updates automatically:
|
|
312
|
+
|
|
313
|
+
- Palette opens → `"Command palette open"`
|
|
314
|
+
- Results change → `"N results"`
|
|
315
|
+
- Item selected → `"[label] selected"`
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
<div {...getAnnouncerProps()}>{announcement}</div>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
### Testing utilities
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
import {
|
|
327
|
+
openPaletteWithHotkey,
|
|
328
|
+
typeInPalette,
|
|
329
|
+
pressArrowDown,
|
|
330
|
+
pressEnter,
|
|
331
|
+
pressEscape,
|
|
332
|
+
getAllPaletteItems,
|
|
333
|
+
getHighlightedItem,
|
|
334
|
+
getPaletteInput,
|
|
335
|
+
waitForPaletteResults,
|
|
336
|
+
} from 'use-command-palette/testing'
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```tsx
|
|
340
|
+
it('filters and selects', async () => {
|
|
341
|
+
render(<MyApp />)
|
|
342
|
+
openPaletteWithHotkey()
|
|
343
|
+
typeInPalette('save')
|
|
344
|
+
await waitForPaletteResults()
|
|
345
|
+
expect(getAllPaletteItems()).toHaveLength(1)
|
|
346
|
+
pressEnter()
|
|
347
|
+
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ label: 'Save File' }))
|
|
348
|
+
})
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Prop Getters
|
|
354
|
+
|
|
355
|
+
Prop getters are the headless pattern: we return the attributes, you decide where they go.
|
|
356
|
+
|
|
357
|
+
### `getContainerProps()`
|
|
358
|
+
|
|
359
|
+
```tsx
|
|
360
|
+
<div {...getContainerProps()}>
|
|
361
|
+
{/* role="dialog" aria-modal={true} aria-label="Command palette" */}
|
|
362
|
+
</div>
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### `getInputProps(overrides?)`
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
<input {...getInputProps({ placeholder: 'Search…', className: 'my-input' })} />
|
|
369
|
+
// value, onChange, onKeyDown, role="combobox", aria-expanded,
|
|
370
|
+
// aria-controls, aria-activedescendant, autoComplete="off", ref
|
|
371
|
+
// Your onChange/onKeyDown are merged, not replaced.
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### `getListProps()`
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
<ul {...getListProps()}>
|
|
378
|
+
{/* role="listbox" id="cmd-palette-list" */}
|
|
379
|
+
</ul>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### `getItemProps({ index, item })`
|
|
383
|
+
|
|
384
|
+
```tsx
|
|
385
|
+
{filteredItems.map((item, index) => (
|
|
386
|
+
<li key={item.id} {...getItemProps({ index, item })}>
|
|
387
|
+
{/* role="option" aria-selected aria-disabled onClick onMouseEnter id */}
|
|
388
|
+
{item.label}
|
|
389
|
+
</li>
|
|
390
|
+
))}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### `getAnnouncerProps()`
|
|
394
|
+
|
|
395
|
+
```tsx
|
|
396
|
+
<div {...getAnnouncerProps()}>{announcement}</div>
|
|
397
|
+
{/* aria-live="polite" aria-atomic={true} + visually-hidden style */}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Keyboard Shortcuts
|
|
403
|
+
|
|
404
|
+
| Key | Behaviour |
|
|
405
|
+
|-----|-----------|
|
|
406
|
+
| `Mod+K` | Toggle the palette open/closed from anywhere |
|
|
407
|
+
| `ArrowDown` / `Tab` | Move highlight down; wraps |
|
|
408
|
+
| `ArrowUp` / `Shift+Tab` | Move highlight up; wraps |
|
|
409
|
+
| `Enter` | Select the highlighted item |
|
|
410
|
+
| `Escape` | Close palette (or go back one page if nested) |
|
|
411
|
+
|
|
412
|
+
Focus moves to the input on open; restores to the previously focused element on close.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Filtering
|
|
417
|
+
|
|
418
|
+
The built-in filter uses fuzzy scoring with three tiers:
|
|
419
|
+
|
|
420
|
+
1. **Exact substring at word boundary** — highest score
|
|
421
|
+
2. **Exact substring elsewhere** — high score, slight position penalty
|
|
422
|
+
3. **Fuzzy match** (characters in order, not adjacent) — lower score, bonuses for consecutive runs and word-start positions
|
|
423
|
+
|
|
424
|
+
Override with your own `filterFn` for server search, `match-sorter`, etc.:
|
|
425
|
+
|
|
426
|
+
```tsx
|
|
427
|
+
import { matchSorter } from 'match-sorter'
|
|
428
|
+
|
|
429
|
+
useCommandPalette({
|
|
430
|
+
items, onSelect,
|
|
431
|
+
filterFn: (items, query) =>
|
|
432
|
+
query ? matchSorter(items, query, { keys: ['label', 'keywords'] }) : items,
|
|
433
|
+
})
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Or go async for remote search:
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
filterFn: async (items, query) => {
|
|
440
|
+
if (!query) return items
|
|
441
|
+
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
|
442
|
+
return res.json()
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Grouping
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
const { groupedItems, filteredItems, highlightedIndex, getItemProps } = useCommandPalette({ items, onSelect })
|
|
452
|
+
|
|
453
|
+
<ul {...getListProps()}>
|
|
454
|
+
{groupedItems.map(({ group, items }) => (
|
|
455
|
+
<li key={group ?? '__ungrouped__'}>
|
|
456
|
+
{group && <div className="group-label">{group}</div>}
|
|
457
|
+
<ul>
|
|
458
|
+
{items.map((item) => {
|
|
459
|
+
const index = filteredItems.indexOf(item)
|
|
460
|
+
return (
|
|
461
|
+
<li key={item.id} {...getItemProps({ index, item })}>
|
|
462
|
+
{item.label}
|
|
463
|
+
</li>
|
|
464
|
+
)
|
|
465
|
+
})}
|
|
466
|
+
</ul>
|
|
467
|
+
</li>
|
|
468
|
+
))}
|
|
469
|
+
</ul>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## TypeScript
|
|
475
|
+
|
|
476
|
+
### `CommandItem`
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
interface CommandItem {
|
|
480
|
+
id: string
|
|
481
|
+
label: string
|
|
482
|
+
keywords?: string[]
|
|
483
|
+
group?: string
|
|
484
|
+
disabled?: boolean
|
|
485
|
+
children?: CommandItem[] // nested pages
|
|
486
|
+
[key: string]: unknown // attach any extra data
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Extending `CommandItem`
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
type AppCommand = CommandItem & {
|
|
494
|
+
icon: React.ReactNode
|
|
495
|
+
shortcut?: string
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const commands: AppCommand[] = [
|
|
499
|
+
{ id: 'save', label: 'Save', icon: <SaveIcon />, shortcut: 'Ctrl+S' },
|
|
500
|
+
]
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Contributing
|
|
506
|
+
|
|
507
|
+
1. Fork and create a branch
|
|
508
|
+
2. `npm install`
|
|
509
|
+
3. `npm test` — run the test suite (114 tests)
|
|
510
|
+
4. `npm run lint` — TypeScript strict check
|
|
511
|
+
5. Submit a PR with a clear description
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## License
|
|
516
|
+
|
|
517
|
+
MIT © use-command-palette contributors
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
8
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
9
|
+
}) : x)(function(x) {
|
|
10
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
11
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
12
|
+
});
|
|
13
|
+
var __commonJS = (cb, mod) => function __require2() {
|
|
14
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
25
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
26
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
27
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
28
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
29
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
30
|
+
mod
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
__require,
|
|
35
|
+
__commonJS,
|
|
36
|
+
__toESM
|
|
37
|
+
};
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { InputHTMLAttributes, RefCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface CommandItem {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
keywords?: string[];
|
|
7
|
+
group?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
children?: CommandItem[];
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
interface GroupedItems {
|
|
13
|
+
group: string | undefined;
|
|
14
|
+
items: CommandItem[];
|
|
15
|
+
}
|
|
16
|
+
interface UseCommandPaletteOptions {
|
|
17
|
+
items: CommandItem[];
|
|
18
|
+
onSelect: (item: CommandItem) => void;
|
|
19
|
+
filterFn?: (items: CommandItem[], query: string) => CommandItem[] | Promise<CommandItem[]>;
|
|
20
|
+
defaultOpen?: boolean;
|
|
21
|
+
/** Pass a value to enable controlled mode; omit for uncontrolled */
|
|
22
|
+
isOpen?: boolean;
|
|
23
|
+
onOpenChange?: (open: boolean) => void;
|
|
24
|
+
hotkey?: string | string[];
|
|
25
|
+
closeOnSelect?: boolean;
|
|
26
|
+
animationDuration?: number;
|
|
27
|
+
recent?: {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
max?: number;
|
|
30
|
+
storageKey?: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
interface ContainerProps {
|
|
34
|
+
role: 'dialog';
|
|
35
|
+
'aria-modal': true;
|
|
36
|
+
'aria-label': string;
|
|
37
|
+
}
|
|
38
|
+
interface ListProps {
|
|
39
|
+
role: 'listbox';
|
|
40
|
+
id: string;
|
|
41
|
+
}
|
|
42
|
+
interface ItemProps {
|
|
43
|
+
role: 'option';
|
|
44
|
+
'aria-selected': boolean;
|
|
45
|
+
'aria-disabled': boolean;
|
|
46
|
+
onClick: () => void;
|
|
47
|
+
onMouseEnter: () => void;
|
|
48
|
+
id: string;
|
|
49
|
+
}
|
|
50
|
+
type InputPropsOverrides = Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'onKeyDown' | 'role' | 'autoComplete' | 'aria-expanded' | 'aria-controls' | 'aria-activedescendant'>;
|
|
51
|
+
interface InputProps extends InputPropsOverrides {
|
|
52
|
+
ref: RefCallback<HTMLInputElement>;
|
|
53
|
+
value: string;
|
|
54
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
55
|
+
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
|
56
|
+
role: 'combobox';
|
|
57
|
+
'aria-expanded': boolean;
|
|
58
|
+
'aria-controls': string;
|
|
59
|
+
'aria-activedescendant': string | undefined;
|
|
60
|
+
autoComplete: 'off';
|
|
61
|
+
}
|
|
62
|
+
type AnimationState = 'entering' | 'entered' | 'exiting' | 'exited';
|
|
63
|
+
interface AnnouncerProps {
|
|
64
|
+
'aria-live': 'polite';
|
|
65
|
+
'aria-atomic': true;
|
|
66
|
+
style: React.CSSProperties;
|
|
67
|
+
}
|
|
68
|
+
interface UseCommandPaletteReturn {
|
|
69
|
+
isOpen: boolean;
|
|
70
|
+
query: string;
|
|
71
|
+
highlightedIndex: number;
|
|
72
|
+
filteredItems: CommandItem[];
|
|
73
|
+
groupedItems: GroupedItems[];
|
|
74
|
+
isLoading: boolean;
|
|
75
|
+
isMounted: boolean;
|
|
76
|
+
animationState: AnimationState;
|
|
77
|
+
recentItems: CommandItem[];
|
|
78
|
+
currentPage: CommandItem | null;
|
|
79
|
+
breadcrumb: CommandItem[];
|
|
80
|
+
canGoBack: boolean;
|
|
81
|
+
announcement: string;
|
|
82
|
+
open: () => void;
|
|
83
|
+
close: () => void;
|
|
84
|
+
toggle: () => void;
|
|
85
|
+
setQuery: (query: string) => void;
|
|
86
|
+
selectItem: (item: CommandItem) => void;
|
|
87
|
+
highlightIndex: (index: number) => void;
|
|
88
|
+
goToPage: (item: CommandItem) => void;
|
|
89
|
+
goBack: () => void;
|
|
90
|
+
getContainerProps: () => ContainerProps;
|
|
91
|
+
getListProps: () => ListProps;
|
|
92
|
+
getItemProps: (args: {
|
|
93
|
+
index: number;
|
|
94
|
+
item: CommandItem;
|
|
95
|
+
}) => ItemProps;
|
|
96
|
+
getInputProps: (overrides?: InputPropsOverrides) => InputProps;
|
|
97
|
+
getAnnouncerProps: () => AnnouncerProps;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
declare function useCommandPalette({ items, onSelect, filterFn, defaultOpen, isOpen: controlledIsOpen, onOpenChange, hotkey, closeOnSelect, animationDuration, recent, }: UseCommandPaletteOptions): UseCommandPaletteReturn;
|
|
101
|
+
|
|
102
|
+
declare function useRegisterCommands(commands: CommandItem[], deps: unknown[]): void;
|
|
103
|
+
|
|
104
|
+
declare function useRegisteredCommands(): {
|
|
105
|
+
commands: CommandItem[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type Listener = () => void;
|
|
109
|
+
declare class CommandRegistry {
|
|
110
|
+
private store;
|
|
111
|
+
private listeners;
|
|
112
|
+
register(key: string, commands: CommandItem[]): void;
|
|
113
|
+
unregister(key: string): void;
|
|
114
|
+
getAll(): CommandItem[];
|
|
115
|
+
subscribe(listener: Listener): () => void;
|
|
116
|
+
private notify;
|
|
117
|
+
}
|
|
118
|
+
declare const registry: CommandRegistry;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Scores how well `query` matches `text` using fuzzy matching.
|
|
122
|
+
*
|
|
123
|
+
* Scoring tiers:
|
|
124
|
+
* - Exact substring at a word boundary → highest (150)
|
|
125
|
+
* - Exact substring elsewhere → high (100 - small position penalty)
|
|
126
|
+
* - Fuzzy (chars in order, not adjacent) → lower (accumulates per-char points)
|
|
127
|
+
*
|
|
128
|
+
* Returns `null` when there is no match at all.
|
|
129
|
+
*/
|
|
130
|
+
declare function fuzzyScore(text: string, query: string): number | null;
|
|
131
|
+
|
|
132
|
+
export { type AnimationState, type AnnouncerProps, type CommandItem, type ContainerProps, type GroupedItems, type InputProps, type InputPropsOverrides, type ItemProps, type ListProps, type UseCommandPaletteOptions, type UseCommandPaletteReturn, fuzzyScore, registry, useCommandPalette, useRegisterCommands, useRegisteredCommands };
|