@bysivin/jumpbar 0.1.0 → 0.2.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/README.md +69 -11
- package/dist/core.js +148 -0
- package/dist/core.js.map +1 -0
- package/dist/index.js +583 -0
- package/dist/index.js.map +1 -0
- package/package.json +31 -8
- package/tailwind.cjs +22 -0
- package/theme.css +33 -0
- package/types/core.d.ts +76 -0
- package/types/index.d.ts +77 -0
- package/src/core/frecency.js +0 -41
- package/src/core/match.js +0 -71
- package/src/core/parse.js +0 -6
- package/src/core/rank.js +0 -70
- package/src/index.js +0 -6
- package/src/vue/CommandBar.vue +0 -173
- package/src/vue/CommandBarList.vue +0 -173
- package/src/vue/useCommandBar.js +0 -261
|
@@ -1,173 +0,0 @@
|
|
|
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>
|
package/src/vue/useCommandBar.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
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
|
-
};
|