@bysivin/jumpbar 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 +21 -0
- package/README.md +183 -2
- package/package.json +47 -4
- package/src/core/frecency.js +41 -0
- package/src/core/match.js +71 -0
- package/src/core/parse.js +6 -0
- package/src/core/rank.js +70 -0
- package/src/index.js +6 -0
- package/src/vue/CommandBar.vue +173 -0
- package/src/vue/CommandBarList.vue +173 -0
- package/src/vue/useCommandBar.js +261 -0
- package/tailwind.cjs +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sivin Varghese
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,184 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jumpbar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A lightweight command palette for Vue 3. The ranking core is plain JavaScript
|
|
4
|
+
with no dependencies (about 1 KB gzipped); the shell is a native `<dialog>`
|
|
5
|
+
rendered with Tailwind utilities (about 5 KB gzipped with the core).
|
|
6
|
+
|
|
7
|
+
- Fuzzy matching with accent folding, sections, nested pages and prefixes
|
|
8
|
+
- Frecency so the things you use rise to the top
|
|
9
|
+
- Sync `commands` sources ranked on every keystroke, async `search` sources
|
|
10
|
+
with per-source debounce, minimum query length and `AbortSignal`
|
|
11
|
+
- Keyboard and pointer handling, open/close motion, skeleton rows while
|
|
12
|
+
remote results load, RTL and IME aware
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pnpm add @bysivin/jumpbar
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`vue` 3.4 or newer is a peer dependency. The package ships source, so your
|
|
21
|
+
bundler compiles the `.vue` files like your own.
|
|
22
|
+
|
|
23
|
+
## Styling
|
|
24
|
+
|
|
25
|
+
The shell is styled with Tailwind classes and expects three things from the
|
|
26
|
+
host project:
|
|
27
|
+
|
|
28
|
+
1. Add the package to `content` so its classes are generated, and add the
|
|
29
|
+
preset that defines the open/close keyframes:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
// tailwind.config.js
|
|
33
|
+
module.exports = {
|
|
34
|
+
presets: [require('@bysivin/jumpbar/tailwind')],
|
|
35
|
+
content: ['./node_modules/@bysivin/jumpbar/src/**/*.{vue,js}'],
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Define these colour tokens in your theme: `n-background`, `n-strong`,
|
|
40
|
+
`n-weak`, `n-brand`, `n-alpha-1`, `n-alpha-2`, `n-alpha-black1`,
|
|
41
|
+
`n-slate-3`, `n-slate-10`, `n-slate-11`, `n-slate-12`, `n-teal-10`,
|
|
42
|
+
`n-amber-10`.
|
|
43
|
+
|
|
44
|
+
3. Provide the icon classes `i-lucide-search`, `i-lucide-loader-circle` and
|
|
45
|
+
`i-lucide-chevron-right` (for example through `@egoist/tailwindcss-icons`
|
|
46
|
+
with the Lucide set). Items bring their own icon class.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```vue
|
|
51
|
+
<script setup>
|
|
52
|
+
import { CommandBar, createFrecency, useCommandBar } from '@bysivin/jumpbar';
|
|
53
|
+
|
|
54
|
+
const bar = useCommandBar();
|
|
55
|
+
|
|
56
|
+
bar.configure({
|
|
57
|
+
prefixes: ['@', '#'],
|
|
58
|
+
context: () => ({ scopes: ['conversation'] }),
|
|
59
|
+
frecency: createFrecency({ storage: localStorage, key: 'palette-usage' }),
|
|
60
|
+
onSelect: item => track(item),
|
|
61
|
+
onClose: () => {},
|
|
62
|
+
onError: (error, source) => report(error),
|
|
63
|
+
limit: 100,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
bar.register({
|
|
67
|
+
id: 'navigation',
|
|
68
|
+
commands: () => [
|
|
69
|
+
{
|
|
70
|
+
id: 'inbox',
|
|
71
|
+
title: 'Go to inbox',
|
|
72
|
+
section: 'Navigation',
|
|
73
|
+
icon: 'i-lucide-inbox',
|
|
74
|
+
shortcut: ['⌥', 'I'],
|
|
75
|
+
run: () => router.push('/inbox'),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
bar.register({
|
|
81
|
+
id: 'contacts',
|
|
82
|
+
minQuery: 3,
|
|
83
|
+
debounce: 300,
|
|
84
|
+
when: ({ page }) => !page,
|
|
85
|
+
search: async ({ text, signal }) => {
|
|
86
|
+
const contacts = await api.search(text, { signal });
|
|
87
|
+
return contacts.map(contact => ({
|
|
88
|
+
id: `contact-${contact.id}`,
|
|
89
|
+
title: contact.name,
|
|
90
|
+
subtitle: contact.email,
|
|
91
|
+
section: 'Contacts',
|
|
92
|
+
avatar: { name: contact.name, src: contact.thumbnail },
|
|
93
|
+
run: () => router.push(`/contacts/${contact.id}`),
|
|
94
|
+
}));
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<CommandBar
|
|
101
|
+
placeholder="Search or jump to"
|
|
102
|
+
empty-text="No matches"
|
|
103
|
+
:hints="[
|
|
104
|
+
{ keys: ['↑', '↓'], label: 'navigate' },
|
|
105
|
+
{ keys: ['↵'], label: 'select' },
|
|
106
|
+
{ keys: ['esc'], label: 'close' },
|
|
107
|
+
]"
|
|
108
|
+
/>
|
|
109
|
+
</template>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Bind the global shortcut yourself and call `bar.toggle()`. `bar.open({ page,
|
|
113
|
+
query })` opens straight into a page, for example a snooze picker.
|
|
114
|
+
|
|
115
|
+
`CommandBar` props: `placeholder`, `emptyText`, `scopeLabel` (a chip before the
|
|
116
|
+
input, for example the selected record), `hints` and `dir` (`ltr` or `rtl`).
|
|
117
|
+
|
|
118
|
+
## Items
|
|
119
|
+
|
|
120
|
+
Sources return plain objects:
|
|
121
|
+
|
|
122
|
+
| Field | Meaning |
|
|
123
|
+
| ---------------- | ------------------------------------------------------------------------ |
|
|
124
|
+
| `id` | Unique across all sources. Frecency and dedupe key on it. |
|
|
125
|
+
| `title` | Row label. Children are matched through `parentTitle title` too. |
|
|
126
|
+
| `subtitle` | Muted text after the title. Truncates before the title does. |
|
|
127
|
+
| `section` | Group header. Groups keep the order of their first item. |
|
|
128
|
+
| `icon` | CSS class rendered as the leading glyph. |
|
|
129
|
+
| `avatar` | `{ name, src, status }` instead of the icon, initials as fallback; `status` is `online`, `busy` or `offline`. |
|
|
130
|
+
| `badge` | Short trailing text. |
|
|
131
|
+
| `shortcut` | Trailing key labels, for example `['⌥', 'E']`. |
|
|
132
|
+
| `keywords` | Extra words the matcher sees (word-start matches only). |
|
|
133
|
+
| `scopes` | Ranks first when one matches `context.scopes`. |
|
|
134
|
+
| `prefix` | Typing this character first lists only items carrying it. |
|
|
135
|
+
| `weight` | Static boost between 0 and 1. |
|
|
136
|
+
| `place` | `first` or `last`: sorts around every other match, in declared order. |
|
|
137
|
+
| `searchFromRoot` | `false` keeps a child out of root searches (prefix and page only). |
|
|
138
|
+
| `page` | Selecting opens a page listing the items whose `parent` is its id. |
|
|
139
|
+
| `placeholder` | Input placeholder while that page is open. |
|
|
140
|
+
| `parent` | Id of the page this item lives under. |
|
|
141
|
+
| `run` | Called with the context as the bar starts closing; the dialog leaves the top layer about 150 ms later. Rows without it are inert. |
|
|
142
|
+
|
|
143
|
+
## Sources
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
bar.register({
|
|
147
|
+
id: 'conversations',
|
|
148
|
+
minQuery: 2,
|
|
149
|
+
debounce: 300,
|
|
150
|
+
when: ({ page }) => !page,
|
|
151
|
+
commands: request => [],
|
|
152
|
+
search: async request => [],
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`commands` runs on every keystroke and is ranked. `search` runs after the
|
|
157
|
+
debounce with an `AbortSignal`; its results keep their own order, append after
|
|
158
|
+
the ranked commands at the root, and replace the static children on a page.
|
|
159
|
+
The request carries `query`, `text`, `prefix`, `page`, `context` and, for
|
|
160
|
+
`search`, `matches` (how many commands matched locally) and `signal`.
|
|
161
|
+
`register` returns a function that removes the source again. A searcher that
|
|
162
|
+
throws has its rows dropped and `onError(error, source)` called; the default
|
|
163
|
+
rethrows. Text queries show at most `limit` ranked commands (default 100).
|
|
164
|
+
|
|
165
|
+
## Ranking
|
|
166
|
+
|
|
167
|
+
Titles match by exact, prefix, word start, every-word-starts-a-word, then
|
|
168
|
+
scattered characters. Subtitles and keywords only match on word starts, so a
|
|
169
|
+
long description never floods the list. Items in `context.scopes` get a boost,
|
|
170
|
+
recently used items another, `weight` a small static one. `place: 'first'`
|
|
171
|
+
and `place: 'last'` sit outside the scoring entirely.
|
|
172
|
+
|
|
173
|
+
## Keyboard
|
|
174
|
+
|
|
175
|
+
`↑` `↓` move, `Enter` selects, `Esc` closes, `Backspace` on an empty query goes
|
|
176
|
+
back one page. Hover only changes the selection when the pointer really moves,
|
|
177
|
+
so arrow keys keep working under a resting cursor.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
pnpm install
|
|
183
|
+
pnpm test
|
|
184
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bysivin/jumpbar",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight command palette for Vue 3: a framework-free ranking core with fuzzy matching, sections, pages, prefixes and frecency, plus a native <dialog> shell with async sources.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Sivin Varghese",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./tailwind": "./tailwind.cjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/specs",
|
|
17
|
+
"tailwind.cjs"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/iamsivin/jumpbar.git"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"command-palette",
|
|
29
|
+
"command-bar",
|
|
30
|
+
"spotlight",
|
|
31
|
+
"vue",
|
|
32
|
+
"headless",
|
|
33
|
+
"fuzzy-search"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"vue": "^3.4.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@vitejs/plugin-vue": "^5.2.4",
|
|
44
|
+
"@vue/test-utils": "^2.4.6",
|
|
45
|
+
"jsdom": "^27.2.0",
|
|
46
|
+
"vitest": "3.0.5",
|
|
47
|
+
"vue": "^3.5.12"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
const HALF_LIFE_DAYS = 7;
|
|
3
|
+
const USES_FOR_FULL_SCORE = 3;
|
|
4
|
+
const MAX_IDLE_DAYS = 30;
|
|
5
|
+
|
|
6
|
+
const load = (storage, key) => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(storage.getItem(key)) ?? {};
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const createFrecency = ({
|
|
15
|
+
storage,
|
|
16
|
+
key,
|
|
17
|
+
now = Date.now,
|
|
18
|
+
limit = 10,
|
|
19
|
+
}) => {
|
|
20
|
+
const uses = load(storage, key);
|
|
21
|
+
|
|
22
|
+
const record = id => {
|
|
23
|
+
const at = now();
|
|
24
|
+
uses[id] = [...(uses[id] ?? []), at].slice(-limit);
|
|
25
|
+
Object.keys(uses).forEach(other => {
|
|
26
|
+
if (at - uses[other].at(-1) > MAX_IDLE_DAYS * DAY_MS) delete uses[other];
|
|
27
|
+
});
|
|
28
|
+
storage.setItem(key, JSON.stringify(uses));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const score = id => {
|
|
32
|
+
const total = (uses[id] ?? []).reduce(
|
|
33
|
+
(sum, usedAt) =>
|
|
34
|
+
sum + 0.5 ** ((now() - usedAt) / DAY_MS / HALF_LIFE_DAYS),
|
|
35
|
+
0
|
|
36
|
+
);
|
|
37
|
+
return Math.min(1, total / USES_FOR_FULL_SCORE);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { record, score };
|
|
41
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const COMBINING_MARKS = /[\u0300-\u036f]/g;
|
|
2
|
+
|
|
3
|
+
const EXACT = 1;
|
|
4
|
+
const PREFIX = 0.9;
|
|
5
|
+
const WORD_START = 0.8;
|
|
6
|
+
const ALL_WORDS = 0.7;
|
|
7
|
+
const SUBSEQUENCE_MAX = 0.6;
|
|
8
|
+
const SUBSEQUENCE_MIN = 0.2;
|
|
9
|
+
|
|
10
|
+
const NORMALIZED_CACHE_LIMIT = 5000;
|
|
11
|
+
const normalized = new Map();
|
|
12
|
+
|
|
13
|
+
export const normalize = value => {
|
|
14
|
+
const raw = String(value ?? '');
|
|
15
|
+
const cached = normalized.get(raw);
|
|
16
|
+
if (cached !== undefined) return cached;
|
|
17
|
+
if (normalized.size >= NORMALIZED_CACHE_LIMIT) normalized.clear();
|
|
18
|
+
const result = raw
|
|
19
|
+
.normalize('NFD')
|
|
20
|
+
.replace(COMBINING_MARKS, '')
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.trim();
|
|
23
|
+
normalized.set(raw, result);
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const tightestSpan = (query, text) => {
|
|
28
|
+
let best = Infinity;
|
|
29
|
+
for (
|
|
30
|
+
let start = text.indexOf(query[0]);
|
|
31
|
+
start !== -1;
|
|
32
|
+
start = text.indexOf(query[0], start + 1)
|
|
33
|
+
) {
|
|
34
|
+
let cursor = start;
|
|
35
|
+
let matched = true;
|
|
36
|
+
for (let i = 1; i < query.length; i += 1) {
|
|
37
|
+
cursor = text.indexOf(query[i], cursor + 1);
|
|
38
|
+
if (cursor === -1) {
|
|
39
|
+
matched = false;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (matched) best = Math.min(best, cursor - start + 1);
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const fuzzyScore = (query, text, { subsequence = true } = {}) => {
|
|
49
|
+
const needle = normalize(query);
|
|
50
|
+
if (!needle) return EXACT;
|
|
51
|
+
const haystack = normalize(text);
|
|
52
|
+
if (haystack === needle) return EXACT;
|
|
53
|
+
if (haystack.startsWith(needle)) return PREFIX;
|
|
54
|
+
|
|
55
|
+
const words = haystack.split(/\s+/);
|
|
56
|
+
if (words.some(word => word.startsWith(needle))) return WORD_START;
|
|
57
|
+
|
|
58
|
+
const needleWords = needle.split(/\s+/);
|
|
59
|
+
if (needleWords.every(part => words.some(word => word.startsWith(part)))) {
|
|
60
|
+
return ALL_WORDS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!subsequence) return 0;
|
|
64
|
+
const compact = needle.replace(/\s+/g, '');
|
|
65
|
+
const span = tightestSpan(compact, haystack);
|
|
66
|
+
if (span === Infinity) return 0;
|
|
67
|
+
return (
|
|
68
|
+
SUBSEQUENCE_MIN +
|
|
69
|
+
(SUBSEQUENCE_MAX - SUBSEQUENCE_MIN) * (compact.length / span)
|
|
70
|
+
);
|
|
71
|
+
};
|
package/src/core/rank.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { fuzzyScore } from './match';
|
|
2
|
+
|
|
3
|
+
const SCOPE_BOOST = 0.3;
|
|
4
|
+
const FRECENCY_BOOST = 0.2;
|
|
5
|
+
const WEIGHT_BOOST = 0.1;
|
|
6
|
+
|
|
7
|
+
const NO_FRECENCY = { score: () => 0 };
|
|
8
|
+
const TIERS = { first: 0, last: 2 };
|
|
9
|
+
const tierOf = item => TIERS[item.place] ?? 1;
|
|
10
|
+
|
|
11
|
+
const visibleItems = (items, { text, prefix, page }) => {
|
|
12
|
+
if (page) return items.filter(item => item.parent === page);
|
|
13
|
+
if (prefix) return items.filter(item => item.prefix === prefix);
|
|
14
|
+
if (!text) return items.filter(item => !item.parent);
|
|
15
|
+
return items.filter(item => item.searchFromRoot !== false);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const detailText = item =>
|
|
19
|
+
[item.subtitle, ...(item.keywords ?? [])].filter(Boolean).join(' ');
|
|
20
|
+
|
|
21
|
+
const matchScore = (text, item, parentTitle) =>
|
|
22
|
+
Math.max(
|
|
23
|
+
fuzzyScore(text, item.title),
|
|
24
|
+
parentTitle ? fuzzyScore(text, `${parentTitle} ${item.title}`) : 0,
|
|
25
|
+
fuzzyScore(text, detailText(item), { subsequence: false })
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const rank = (
|
|
29
|
+
items,
|
|
30
|
+
{ text = '', prefix = null, page = null, scopes = [], frecency = NO_FRECENCY }
|
|
31
|
+
) => {
|
|
32
|
+
const titles = new Map(items.map(item => [item.id, item.title]));
|
|
33
|
+
|
|
34
|
+
return visibleItems(items, { text, prefix, page })
|
|
35
|
+
.map(item => {
|
|
36
|
+
const parentTitle =
|
|
37
|
+
item.parent && !page ? titles.get(item.parent) : undefined;
|
|
38
|
+
const match = text ? matchScore(text, item, parentTitle) : 1;
|
|
39
|
+
const inScope = item.scopes?.some(scope => scopes.includes(scope))
|
|
40
|
+
? 1
|
|
41
|
+
: 0;
|
|
42
|
+
const score =
|
|
43
|
+
match +
|
|
44
|
+
SCOPE_BOOST * inScope +
|
|
45
|
+
FRECENCY_BOOST * frecency.score(item.id) +
|
|
46
|
+
WEIGHT_BOOST * (item.weight ?? 0);
|
|
47
|
+
return {
|
|
48
|
+
item: parentTitle ? { ...item, parentTitle } : item,
|
|
49
|
+
match,
|
|
50
|
+
score,
|
|
51
|
+
};
|
|
52
|
+
})
|
|
53
|
+
.filter(entry => entry.match > 0)
|
|
54
|
+
.sort((a, b) => {
|
|
55
|
+
const tier = tierOf(a.item) - tierOf(b.item);
|
|
56
|
+
if (tier) return tier;
|
|
57
|
+
return a.item.place ? 0 : b.score - a.score;
|
|
58
|
+
})
|
|
59
|
+
.map(entry => entry.item);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const groupBySection = items => {
|
|
63
|
+
const groups = new Map();
|
|
64
|
+
items.forEach(item => {
|
|
65
|
+
const section = item.section ?? null;
|
|
66
|
+
if (!groups.has(section)) groups.set(section, []);
|
|
67
|
+
groups.get(section).push(item);
|
|
68
|
+
});
|
|
69
|
+
return [...groups].map(([section, members]) => ({ section, items: members }));
|
|
70
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { fuzzyScore, normalize } from './core/match';
|
|
2
|
+
export { parseQuery } from './core/parse';
|
|
3
|
+
export { createFrecency } from './core/frecency';
|
|
4
|
+
export { rank, groupBySection } from './core/rank';
|
|
5
|
+
export { createCommandBar, useCommandBar } from './vue/useCommandBar';
|
|
6
|
+
export { default as CommandBar } from './vue/CommandBar.vue';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
3
|
+
import { useCommandBar } from './useCommandBar';
|
|
4
|
+
import CommandBarList from './CommandBarList.vue';
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
placeholder: { type: String, default: '' },
|
|
8
|
+
emptyText: { type: String, default: '' },
|
|
9
|
+
scopeLabel: { type: String, default: '' },
|
|
10
|
+
hints: { type: Array, default: () => [] },
|
|
11
|
+
dir: { type: String, default: 'ltr' },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const CLOSE_ANIMATION_MS = 150;
|
|
15
|
+
const PLACEHOLDER_DELAY_MS = 150;
|
|
16
|
+
|
|
17
|
+
const bar = useCommandBar();
|
|
18
|
+
const dialogRef = ref(null);
|
|
19
|
+
const inputRef = ref(null);
|
|
20
|
+
const isClosing = ref(false);
|
|
21
|
+
let closeTimer = null;
|
|
22
|
+
|
|
23
|
+
const placeholder = computed(
|
|
24
|
+
() => bar.page.value?.placeholder ?? props.placeholder
|
|
25
|
+
);
|
|
26
|
+
const activeId = computed(() =>
|
|
27
|
+
bar.selected.value ? `command-bar-item-${bar.selected.value.id}` : undefined
|
|
28
|
+
);
|
|
29
|
+
const isEmpty = computed(
|
|
30
|
+
() => !bar.visible.value.length && !bar.isLoading.value
|
|
31
|
+
);
|
|
32
|
+
const showPlaceholder = ref(false);
|
|
33
|
+
let placeholderTimer = null;
|
|
34
|
+
|
|
35
|
+
watch(bar.isLoading, loading => {
|
|
36
|
+
clearTimeout(placeholderTimer);
|
|
37
|
+
if (!loading) {
|
|
38
|
+
showPlaceholder.value = false;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
placeholderTimer = setTimeout(() => {
|
|
42
|
+
showPlaceholder.value = true;
|
|
43
|
+
}, PLACEHOLDER_DELAY_MS);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const KEY_ACTIONS = {
|
|
47
|
+
ArrowDown: () => bar.moveSelection(1),
|
|
48
|
+
ArrowUp: () => bar.moveSelection(-1),
|
|
49
|
+
Enter: () => bar.select(bar.selected.value),
|
|
50
|
+
Escape: () => bar.close(),
|
|
51
|
+
Backspace: () => (bar.query.value ? false : bar.back()),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onKeydown = event => {
|
|
55
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
56
|
+
const action = KEY_ACTIONS[event.key];
|
|
57
|
+
if (!action || action() === false) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
watch(bar.isOpen, async open => {
|
|
62
|
+
clearTimeout(closeTimer);
|
|
63
|
+
if (!open) {
|
|
64
|
+
isClosing.value = true;
|
|
65
|
+
closeTimer = setTimeout(() => {
|
|
66
|
+
dialogRef.value.close();
|
|
67
|
+
isClosing.value = false;
|
|
68
|
+
}, CLOSE_ANIMATION_MS);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
isClosing.value = false;
|
|
72
|
+
if (!dialogRef.value.open) dialogRef.value.showModal();
|
|
73
|
+
await nextTick();
|
|
74
|
+
inputRef.value.focus();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
onBeforeUnmount(() => {
|
|
78
|
+
clearTimeout(closeTimer);
|
|
79
|
+
clearTimeout(placeholderTimer);
|
|
80
|
+
});
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<Teleport to="body">
|
|
85
|
+
<dialog
|
|
86
|
+
ref="dialogRef"
|
|
87
|
+
:dir="dir"
|
|
88
|
+
class="w-[calc(100vw-2rem)] max-w-2xl p-0 mx-auto mt-[clamp(2rem,10vh,6rem)] mb-auto overflow-hidden shadow-2xl rounded-2xl bg-n-background outline outline-1 outline-n-strong/50 text-n-slate-12 motion-reduce:animate-none backdrop:bg-n-alpha-black1 max-sm:w-full max-sm:max-w-full max-sm:mt-auto max-sm:mb-0 max-sm:rounded-b-none"
|
|
89
|
+
:class="
|
|
90
|
+
isClosing
|
|
91
|
+
? 'animate-command-bar-out backdrop:animate-fade-out'
|
|
92
|
+
: 'animate-pop-in backdrop:animate-fade-in'
|
|
93
|
+
"
|
|
94
|
+
@cancel.prevent="bar.close"
|
|
95
|
+
@close="bar.close"
|
|
96
|
+
@click.self="bar.close"
|
|
97
|
+
>
|
|
98
|
+
<div class="flex items-center gap-2 px-4 h-14">
|
|
99
|
+
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
|
|
100
|
+
<span
|
|
101
|
+
v-if="scopeLabel"
|
|
102
|
+
class="flex-shrink-0 px-2 py-0.5 text-xs rounded-md bg-n-alpha-2 text-n-slate-11 max-w-48 truncate"
|
|
103
|
+
>
|
|
104
|
+
{{ scopeLabel }}
|
|
105
|
+
</span>
|
|
106
|
+
<span
|
|
107
|
+
v-if="bar.page.value"
|
|
108
|
+
class="flex-shrink-0 px-2 py-0.5 text-xs font-medium rounded-md bg-n-brand/10 text-n-brand max-w-48 truncate"
|
|
109
|
+
>
|
|
110
|
+
{{ bar.page.value.title }}
|
|
111
|
+
</span>
|
|
112
|
+
<input
|
|
113
|
+
ref="inputRef"
|
|
114
|
+
role="combobox"
|
|
115
|
+
aria-autocomplete="list"
|
|
116
|
+
aria-controls="command-bar-list"
|
|
117
|
+
:aria-expanded="bar.isOpen.value"
|
|
118
|
+
:aria-activedescendant="activeId"
|
|
119
|
+
:value="bar.query.value"
|
|
120
|
+
:placeholder="placeholder"
|
|
121
|
+
autocomplete="off"
|
|
122
|
+
spellcheck="false"
|
|
123
|
+
class="flex-1 h-full min-w-0 text-base bg-transparent border-0 outline-none text-n-slate-12 placeholder:text-n-slate-10"
|
|
124
|
+
@input="bar.setQuery($event.target.value)"
|
|
125
|
+
@keydown="onKeydown"
|
|
126
|
+
/>
|
|
127
|
+
<span
|
|
128
|
+
v-if="bar.isLoading.value"
|
|
129
|
+
class="flex-shrink-0 i-lucide-loader-circle size-4 animate-spin text-n-slate-10"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="border-t border-n-weak">
|
|
133
|
+
<CommandBarList
|
|
134
|
+
v-if="bar.visible.value.length || showPlaceholder"
|
|
135
|
+
id="command-bar-list"
|
|
136
|
+
:key="bar.page.value?.id ?? 'root'"
|
|
137
|
+
class="max-h-[60vh] max-sm:max-h-[70vh] animate-fade-in-up motion-reduce:animate-none"
|
|
138
|
+
:sections="bar.sections.value"
|
|
139
|
+
:items="bar.visible.value"
|
|
140
|
+
:selected-index="bar.selectedIndex.value"
|
|
141
|
+
:pending="showPlaceholder"
|
|
142
|
+
@activate="bar.selectedIndex.value = $event"
|
|
143
|
+
@select="bar.select"
|
|
144
|
+
/>
|
|
145
|
+
<p
|
|
146
|
+
v-else-if="isEmpty"
|
|
147
|
+
class="px-4 py-10 text-sm text-center text-n-slate-11"
|
|
148
|
+
>
|
|
149
|
+
{{ emptyText }}
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
<div
|
|
153
|
+
v-if="hints.length"
|
|
154
|
+
class="flex items-center gap-4 px-4 text-xs border-t h-9 border-n-weak text-n-slate-10"
|
|
155
|
+
>
|
|
156
|
+
<span
|
|
157
|
+
v-for="hint in hints"
|
|
158
|
+
:key="hint.label"
|
|
159
|
+
class="flex items-center gap-1"
|
|
160
|
+
>
|
|
161
|
+
<kbd
|
|
162
|
+
v-for="key in hint.keys"
|
|
163
|
+
:key="key"
|
|
164
|
+
class="inline-flex items-center justify-center h-5 px-1 rounded min-w-5 text-n-slate-11 bg-n-alpha-1 outline outline-1 outline-n-weak"
|
|
165
|
+
>
|
|
166
|
+
{{ key }}
|
|
167
|
+
</kbd>
|
|
168
|
+
<span class="ms-0.5">{{ hint.label }}</span>
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
</dialog>
|
|
172
|
+
</Teleport>
|
|
173
|
+
</template>
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, reactive, ref, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
sections: { type: Array, required: true },
|
|
6
|
+
items: { type: Array, required: true },
|
|
7
|
+
selectedIndex: { type: Number, required: true },
|
|
8
|
+
pending: { type: Boolean, default: false },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits(['select', 'activate']);
|
|
12
|
+
|
|
13
|
+
const PLACEHOLDER_ROWS = 3;
|
|
14
|
+
|
|
15
|
+
const STATUS_CLASSES = {
|
|
16
|
+
online: 'bg-n-teal-10',
|
|
17
|
+
busy: 'bg-n-amber-10',
|
|
18
|
+
offline: 'bg-n-slate-10',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const listRef = ref(null);
|
|
22
|
+
const failedImages = reactive(new Set());
|
|
23
|
+
let pointer = { x: null, y: null };
|
|
24
|
+
|
|
25
|
+
const onPointerMove = (event, index) => {
|
|
26
|
+
if (event.clientX === pointer.x && event.clientY === pointer.y) return;
|
|
27
|
+
pointer = { x: event.clientX, y: event.clientY };
|
|
28
|
+
emit('activate', index);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const indexes = computed(
|
|
32
|
+
() => new Map(props.items.map((item, index) => [item.id, index]))
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const initials = name =>
|
|
36
|
+
(name ?? '')
|
|
37
|
+
.split(/\s+/)
|
|
38
|
+
.slice(0, 2)
|
|
39
|
+
.map(word => [...word][0] ?? '')
|
|
40
|
+
.join('')
|
|
41
|
+
.toUpperCase();
|
|
42
|
+
|
|
43
|
+
watch(
|
|
44
|
+
[() => props.selectedIndex, () => props.sections],
|
|
45
|
+
([index]) => {
|
|
46
|
+
const list = listRef.value;
|
|
47
|
+
if (!list) return;
|
|
48
|
+
if (index === 0) list.scrollTop = 0;
|
|
49
|
+
else {
|
|
50
|
+
list
|
|
51
|
+
.querySelector('[aria-selected="true"]')
|
|
52
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{ flush: 'post' }
|
|
56
|
+
);
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div ref="listRef" role="listbox" class="px-2 py-2 overflow-y-auto">
|
|
61
|
+
<div
|
|
62
|
+
v-for="section in sections"
|
|
63
|
+
:key="section.section ?? ''"
|
|
64
|
+
role="group"
|
|
65
|
+
:aria-label="section.section ?? undefined"
|
|
66
|
+
>
|
|
67
|
+
<p
|
|
68
|
+
v-if="section.section"
|
|
69
|
+
class="px-3 pt-3 pb-1 text-xs font-medium text-n-slate-11 first:pt-1"
|
|
70
|
+
>
|
|
71
|
+
{{ section.section }}
|
|
72
|
+
</p>
|
|
73
|
+
<div
|
|
74
|
+
v-for="item in section.items"
|
|
75
|
+
:id="`command-bar-item-${item.id}`"
|
|
76
|
+
:key="item.id"
|
|
77
|
+
role="option"
|
|
78
|
+
:aria-selected="indexes.get(item.id) === selectedIndex"
|
|
79
|
+
class="relative flex items-center h-10 gap-3 px-3 text-sm rounded-lg cursor-pointer select-none text-n-slate-12 transition-colors duration-75"
|
|
80
|
+
:class="{
|
|
81
|
+
'bg-n-alpha-2 before:absolute before:inset-y-2 before:start-0 before:w-0.5 before:rounded-full before:bg-n-brand':
|
|
82
|
+
indexes.get(item.id) === selectedIndex,
|
|
83
|
+
}"
|
|
84
|
+
@mousedown.prevent
|
|
85
|
+
@mousemove="onPointerMove($event, indexes.get(item.id))"
|
|
86
|
+
@click="emit('select', item)"
|
|
87
|
+
>
|
|
88
|
+
<span
|
|
89
|
+
v-if="item.avatar"
|
|
90
|
+
class="relative flex items-center justify-center flex-shrink-0 rounded-md size-6 bg-n-alpha-2 text-n-slate-11"
|
|
91
|
+
>
|
|
92
|
+
<img
|
|
93
|
+
v-if="item.avatar.src && !failedImages.has(item.avatar.src)"
|
|
94
|
+
:src="item.avatar.src"
|
|
95
|
+
:alt="item.avatar.name"
|
|
96
|
+
class="object-cover rounded-md size-6"
|
|
97
|
+
@error="failedImages.add(item.avatar.src)"
|
|
98
|
+
/>
|
|
99
|
+
<span v-else class="text-xs font-medium leading-none">
|
|
100
|
+
{{ initials(item.avatar.name) }}
|
|
101
|
+
</span>
|
|
102
|
+
<span
|
|
103
|
+
v-if="item.avatar.status"
|
|
104
|
+
class="absolute -bottom-px -end-px border rounded-full size-2 border-n-slate-3"
|
|
105
|
+
:class="STATUS_CLASSES[item.avatar.status]"
|
|
106
|
+
/>
|
|
107
|
+
</span>
|
|
108
|
+
<span
|
|
109
|
+
v-else
|
|
110
|
+
class="flex items-center justify-center flex-shrink-0 size-6"
|
|
111
|
+
>
|
|
112
|
+
<span
|
|
113
|
+
class="size-4"
|
|
114
|
+
:class="[
|
|
115
|
+
item.icon,
|
|
116
|
+
indexes.get(item.id) === selectedIndex
|
|
117
|
+
? 'text-n-slate-12'
|
|
118
|
+
: 'text-n-slate-11',
|
|
119
|
+
]"
|
|
120
|
+
/>
|
|
121
|
+
</span>
|
|
122
|
+
<span class="flex items-center flex-1 min-w-0 gap-1.5 overflow-hidden">
|
|
123
|
+
<template v-if="item.parentTitle">
|
|
124
|
+
<span class="truncate text-n-slate-11">{{ item.parentTitle }}</span>
|
|
125
|
+
<span
|
|
126
|
+
class="flex-shrink-0 i-lucide-chevron-right size-3 text-n-slate-10 rtl:-scale-x-100"
|
|
127
|
+
/>
|
|
128
|
+
</template>
|
|
129
|
+
<span class="flex-shrink-0 max-w-full truncate">{{
|
|
130
|
+
item.title
|
|
131
|
+
}}</span>
|
|
132
|
+
<span v-if="item.subtitle" class="truncate text-n-slate-11">
|
|
133
|
+
{{ item.subtitle }}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
<span v-if="item.badge" class="flex-shrink-0 text-xs text-n-slate-11">
|
|
137
|
+
{{ item.badge }}
|
|
138
|
+
</span>
|
|
139
|
+
<span v-if="item.shortcut" class="flex items-center gap-1">
|
|
140
|
+
<kbd
|
|
141
|
+
v-for="key in item.shortcut"
|
|
142
|
+
:key="key"
|
|
143
|
+
class="inline-flex items-center justify-center h-5 px-1 text-xs rounded min-w-5 text-n-slate-11 bg-n-alpha-1 outline outline-1 outline-n-weak"
|
|
144
|
+
>
|
|
145
|
+
{{ key }}
|
|
146
|
+
</kbd>
|
|
147
|
+
</span>
|
|
148
|
+
<span
|
|
149
|
+
v-if="item.page"
|
|
150
|
+
class="flex-shrink-0 i-lucide-chevron-right size-4 text-n-slate-10 rtl:-scale-x-100"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div
|
|
155
|
+
v-if="pending"
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
class="animate-pulse motion-reduce:animate-none"
|
|
158
|
+
>
|
|
159
|
+
<div class="px-3 pt-3 pb-1">
|
|
160
|
+
<span class="block w-20 h-3 rounded bg-n-alpha-2" />
|
|
161
|
+
</div>
|
|
162
|
+
<div
|
|
163
|
+
v-for="row in PLACEHOLDER_ROWS"
|
|
164
|
+
:key="row"
|
|
165
|
+
class="flex items-center h-10 gap-3 px-3"
|
|
166
|
+
>
|
|
167
|
+
<span class="flex-shrink-0 rounded-md size-6 bg-n-alpha-2" />
|
|
168
|
+
<span class="h-3 rounded w-36 bg-n-alpha-2" />
|
|
169
|
+
<span class="h-3 rounded w-24 bg-n-alpha-1" />
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { computed, ref, shallowRef } from 'vue';
|
|
2
|
+
import { parseQuery } from '../core/parse';
|
|
3
|
+
import { rank, groupBySection } from '../core/rank';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DEBOUNCE = 120;
|
|
6
|
+
const DEFAULT_LIMIT = 100;
|
|
7
|
+
const NO_FRECENCY = { record: () => {}, score: () => 0 };
|
|
8
|
+
|
|
9
|
+
export const createCommandBar = (initialOptions = {}) => {
|
|
10
|
+
const options = {
|
|
11
|
+
prefixes: [],
|
|
12
|
+
context: () => ({}),
|
|
13
|
+
frecency: NO_FRECENCY,
|
|
14
|
+
onSelect: () => {},
|
|
15
|
+
onClose: () => {},
|
|
16
|
+
onError: error => {
|
|
17
|
+
throw error;
|
|
18
|
+
},
|
|
19
|
+
debounce: DEFAULT_DEBOUNCE,
|
|
20
|
+
limit: DEFAULT_LIMIT,
|
|
21
|
+
...initialOptions,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let sources = [];
|
|
25
|
+
let context = {};
|
|
26
|
+
let controller = null;
|
|
27
|
+
let timers = [];
|
|
28
|
+
let sequence = 0;
|
|
29
|
+
|
|
30
|
+
const isOpen = ref(false);
|
|
31
|
+
const query = ref('');
|
|
32
|
+
const pages = shallowRef([]);
|
|
33
|
+
const items = shallowRef([]);
|
|
34
|
+
const results = shallowRef([]);
|
|
35
|
+
const selectedIndex = ref(0);
|
|
36
|
+
const pendingCount = ref(0);
|
|
37
|
+
|
|
38
|
+
const page = computed(() => pages.value.at(-1) ?? null);
|
|
39
|
+
const isLoading = computed(() => pendingCount.value > 0);
|
|
40
|
+
|
|
41
|
+
const sections = computed(() => {
|
|
42
|
+
const list =
|
|
43
|
+
page.value && results.value.length
|
|
44
|
+
? results.value
|
|
45
|
+
: [...items.value, ...results.value];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const unique = list.filter(item => {
|
|
48
|
+
if (seen.has(item.id)) return false;
|
|
49
|
+
seen.add(item.id);
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
return groupBySection(unique);
|
|
53
|
+
});
|
|
54
|
+
const visible = computed(() =>
|
|
55
|
+
sections.value.flatMap(section => section.items)
|
|
56
|
+
);
|
|
57
|
+
const selected = computed(() => visible.value[selectedIndex.value] ?? null);
|
|
58
|
+
|
|
59
|
+
const buildRequest = () => {
|
|
60
|
+
const { prefix, text } = parseQuery(query.value, options.prefixes);
|
|
61
|
+
return {
|
|
62
|
+
query: query.value,
|
|
63
|
+
text,
|
|
64
|
+
prefix,
|
|
65
|
+
page: page.value?.id ?? null,
|
|
66
|
+
context,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isActive = (source, request) =>
|
|
71
|
+
request.text.length >= (source.minQuery ?? 0) &&
|
|
72
|
+
(source.when?.(request) ?? true);
|
|
73
|
+
|
|
74
|
+
const collectCommands = request =>
|
|
75
|
+
sources.flatMap(source =>
|
|
76
|
+
source.commands && isActive(source, request)
|
|
77
|
+
? source.commands(request)
|
|
78
|
+
: []
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const delayOf = source => source.debounce ?? options.debounce;
|
|
82
|
+
|
|
83
|
+
const publish = batch => {
|
|
84
|
+
results.value = batch.searchers.flatMap(
|
|
85
|
+
searcher => batch.fresh.get(searcher.id) ?? []
|
|
86
|
+
);
|
|
87
|
+
selectedIndex.value = Math.min(
|
|
88
|
+
selectedIndex.value,
|
|
89
|
+
Math.max(visible.value.length - 1, 0)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const runSearchers = (batch, delay) => {
|
|
94
|
+
batch.searchers
|
|
95
|
+
.filter(source => delayOf(source) === delay)
|
|
96
|
+
.forEach(async source => {
|
|
97
|
+
try {
|
|
98
|
+
const list = await source.search({
|
|
99
|
+
...batch.request,
|
|
100
|
+
signal: batch.signal,
|
|
101
|
+
});
|
|
102
|
+
if (batch.sequence !== sequence) return;
|
|
103
|
+
batch.fresh.set(source.id, list);
|
|
104
|
+
publish(batch);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (batch.sequence !== sequence) return;
|
|
107
|
+
batch.fresh.set(source.id, []);
|
|
108
|
+
publish(batch);
|
|
109
|
+
options.onError(error, source);
|
|
110
|
+
} finally {
|
|
111
|
+
if (batch.sequence === sequence) pendingCount.value -= 1;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const run = () => {
|
|
117
|
+
timers.forEach(clearTimeout);
|
|
118
|
+
timers = [];
|
|
119
|
+
controller?.abort();
|
|
120
|
+
sequence += 1;
|
|
121
|
+
|
|
122
|
+
const request = buildRequest();
|
|
123
|
+
if (!request.text) results.value = [];
|
|
124
|
+
const ranked = rank(collectCommands(request), {
|
|
125
|
+
text: request.text,
|
|
126
|
+
prefix: request.prefix,
|
|
127
|
+
page: request.page,
|
|
128
|
+
scopes: context.scopes ?? [],
|
|
129
|
+
frecency: options.frecency,
|
|
130
|
+
});
|
|
131
|
+
items.value = request.text ? ranked.slice(0, options.limit) : ranked;
|
|
132
|
+
selectedIndex.value = 0;
|
|
133
|
+
|
|
134
|
+
const searchRequest = { ...request, matches: items.value.length };
|
|
135
|
+
const searchers = sources.filter(
|
|
136
|
+
source => source.search && isActive(source, searchRequest)
|
|
137
|
+
);
|
|
138
|
+
pendingCount.value = searchers.length;
|
|
139
|
+
if (!searchers.length) {
|
|
140
|
+
results.value = [];
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
controller = new AbortController();
|
|
145
|
+
const batch = {
|
|
146
|
+
sequence,
|
|
147
|
+
request: searchRequest,
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
fresh: new Map(),
|
|
150
|
+
searchers,
|
|
151
|
+
};
|
|
152
|
+
new Set(searchers.map(delayOf)).forEach(delay => {
|
|
153
|
+
timers.push(setTimeout(() => runSearchers(batch, delay), delay));
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const navigate = (nextPages, nextQuery = '') => {
|
|
158
|
+
pages.value = nextPages;
|
|
159
|
+
query.value = nextQuery;
|
|
160
|
+
results.value = [];
|
|
161
|
+
run();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const open = ({ page: pageId = null, query: initialQuery = '' } = {}) => {
|
|
165
|
+
context = options.context();
|
|
166
|
+
isOpen.value = true;
|
|
167
|
+
pages.value = [];
|
|
168
|
+
query.value = '';
|
|
169
|
+
const target = pageId
|
|
170
|
+
? (collectCommands(buildRequest()).find(item => item.id === pageId) ?? {
|
|
171
|
+
id: pageId,
|
|
172
|
+
})
|
|
173
|
+
: null;
|
|
174
|
+
navigate(target ? [target] : [], initialQuery);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const close = () => {
|
|
178
|
+
if (!isOpen.value) return;
|
|
179
|
+
timers.forEach(clearTimeout);
|
|
180
|
+
timers = [];
|
|
181
|
+
controller?.abort();
|
|
182
|
+
sequence += 1;
|
|
183
|
+
pendingCount.value = 0;
|
|
184
|
+
isOpen.value = false;
|
|
185
|
+
options.onClose();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const back = () => {
|
|
189
|
+
if (!page.value) return;
|
|
190
|
+
navigate(pages.value.slice(0, -1));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const select = item => {
|
|
194
|
+
if (!item) return;
|
|
195
|
+
if (item.page) {
|
|
196
|
+
navigate([...pages.value, item]);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!item.run) return;
|
|
200
|
+
options.frecency.record(item.id);
|
|
201
|
+
options.onSelect(item);
|
|
202
|
+
close();
|
|
203
|
+
item.run(context);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const setQuery = value => {
|
|
207
|
+
query.value = value;
|
|
208
|
+
run();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const moveSelection = delta => {
|
|
212
|
+
const count = visible.value.length;
|
|
213
|
+
if (!count) return;
|
|
214
|
+
selectedIndex.value = (selectedIndex.value + delta + count) % count;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const toggle = () => (isOpen.value ? close() : open());
|
|
218
|
+
|
|
219
|
+
const register = source => {
|
|
220
|
+
sources = [...sources, source];
|
|
221
|
+
return () => {
|
|
222
|
+
sources = sources.filter(registered => registered !== source);
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const configure = overrides => Object.assign(options, overrides);
|
|
227
|
+
|
|
228
|
+
const dispose = () => {
|
|
229
|
+
close();
|
|
230
|
+
sources = [];
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
isOpen,
|
|
235
|
+
query,
|
|
236
|
+
page,
|
|
237
|
+
pages,
|
|
238
|
+
visible,
|
|
239
|
+
sections,
|
|
240
|
+
selectedIndex,
|
|
241
|
+
selected,
|
|
242
|
+
isLoading,
|
|
243
|
+
open,
|
|
244
|
+
close,
|
|
245
|
+
toggle,
|
|
246
|
+
back,
|
|
247
|
+
select,
|
|
248
|
+
setQuery,
|
|
249
|
+
moveSelection,
|
|
250
|
+
register,
|
|
251
|
+
configure,
|
|
252
|
+
dispose,
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let shared = null;
|
|
257
|
+
|
|
258
|
+
export const useCommandBar = () => {
|
|
259
|
+
shared ??= createCommandBar();
|
|
260
|
+
return shared;
|
|
261
|
+
};
|
package/tailwind.cjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
theme: {
|
|
3
|
+
extend: {
|
|
4
|
+
keyframes: {
|
|
5
|
+
'pop-in': {
|
|
6
|
+
'0%': { opacity: 0, transform: 'scale(0.8) translateY(0.25rem)' },
|
|
7
|
+
'60%': { opacity: 1, transform: 'scale(1.04) translateY(0)' },
|
|
8
|
+
'100%': { opacity: 1, transform: 'scale(1) translateY(0)' },
|
|
9
|
+
},
|
|
10
|
+
'fade-in': {
|
|
11
|
+
'0%': { opacity: 0 },
|
|
12
|
+
},
|
|
13
|
+
'fade-in-up': {
|
|
14
|
+
'0%': { opacity: 0, transform: 'translateY(0.5rem)' },
|
|
15
|
+
'100%': { opacity: 1, transform: 'translateY(0)' },
|
|
16
|
+
},
|
|
17
|
+
'command-bar-out': {
|
|
18
|
+
from: { opacity: 1, transform: 'scale(1) translateY(0)' },
|
|
19
|
+
to: { opacity: 0, transform: 'scale(0.92) translateY(0.5rem)' },
|
|
20
|
+
},
|
|
21
|
+
'fade-out': {
|
|
22
|
+
to: { opacity: 0 },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
animation: {
|
|
26
|
+
'pop-in': 'pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) backwards',
|
|
27
|
+
'fade-in': 'fade-in 0.3s ease-out',
|
|
28
|
+
'fade-in-up': 'fade-in-up 0.3s ease-out',
|
|
29
|
+
'command-bar-out': 'command-bar-out 0.15s ease-in forwards',
|
|
30
|
+
'fade-out': 'fade-out 0.15s ease-in forwards',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|