@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 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
- # Temporary Holding Version
1
+ # jumpbar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ };
@@ -0,0 +1,6 @@
1
+ export const parseQuery = (input, prefixes = []) => {
2
+ const trimmed = String(input ?? '').trim();
3
+ const prefix = prefixes.includes(trimmed[0]) ? trimmed[0] : null;
4
+ const text = prefix ? trimmed.slice(1).trim() : trimmed;
5
+ return { prefix, text };
6
+ };
@@ -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
+ };