@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.
@@ -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>
@@ -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
- };