@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
package/package.json
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bysivin/jumpbar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sivin Varghese",
|
|
7
7
|
"type": "module",
|
|
8
|
-
"main": "./
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
9
11
|
"exports": {
|
|
10
|
-
".":
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./types/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./core": {
|
|
18
|
+
"types": "./types/core.d.ts",
|
|
19
|
+
"import": "./dist/core.js",
|
|
20
|
+
"default": "./dist/core.js"
|
|
21
|
+
},
|
|
11
22
|
"./tailwind": "./tailwind.cjs",
|
|
23
|
+
"./theme.css": "./theme.css",
|
|
12
24
|
"./package.json": "./package.json"
|
|
13
25
|
},
|
|
14
26
|
"files": [
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"tailwind.cjs"
|
|
27
|
+
"dist",
|
|
28
|
+
"types",
|
|
29
|
+
"tailwind.cjs",
|
|
30
|
+
"theme.css"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": [
|
|
33
|
+
"*.css"
|
|
18
34
|
],
|
|
19
|
-
"sideEffects": false,
|
|
20
35
|
"publishConfig": {
|
|
21
36
|
"access": "public"
|
|
22
37
|
},
|
|
@@ -33,16 +48,24 @@
|
|
|
33
48
|
"fuzzy-search"
|
|
34
49
|
],
|
|
35
50
|
"scripts": {
|
|
51
|
+
"build": "vite build",
|
|
36
52
|
"test": "vitest run",
|
|
37
|
-
"test:watch": "vitest"
|
|
53
|
+
"test:watch": "vitest",
|
|
54
|
+
"prepublishOnly": "pnpm build && pnpm test"
|
|
38
55
|
},
|
|
39
56
|
"peerDependencies": {
|
|
40
57
|
"vue": "^3.4.0"
|
|
41
58
|
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"vue": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
42
64
|
"devDependencies": {
|
|
43
65
|
"@vitejs/plugin-vue": "^5.2.4",
|
|
44
66
|
"@vue/test-utils": "^2.4.6",
|
|
45
67
|
"jsdom": "^27.2.0",
|
|
68
|
+
"vite": "^6.4.4",
|
|
46
69
|
"vitest": "3.0.5",
|
|
47
70
|
"vue": "^3.5.12"
|
|
48
71
|
}
|
package/tailwind.cjs
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
|
+
const TOKENS = [
|
|
2
|
+
'background',
|
|
3
|
+
'foreground',
|
|
4
|
+
'muted-foreground',
|
|
5
|
+
'subtle-foreground',
|
|
6
|
+
'muted',
|
|
7
|
+
'subtle',
|
|
8
|
+
'border',
|
|
9
|
+
'ring',
|
|
10
|
+
'primary',
|
|
11
|
+
'primary-soft',
|
|
12
|
+
'overlay',
|
|
13
|
+
'online',
|
|
14
|
+
'busy',
|
|
15
|
+
'offline',
|
|
16
|
+
];
|
|
17
|
+
|
|
1
18
|
module.exports = {
|
|
2
19
|
theme: {
|
|
3
20
|
extend: {
|
|
21
|
+
colors: {
|
|
22
|
+
jumpbar: Object.fromEntries(
|
|
23
|
+
TOKENS.map(name => [name, `var(--jumpbar-${name})`])
|
|
24
|
+
),
|
|
25
|
+
},
|
|
4
26
|
keyframes: {
|
|
5
27
|
'pop-in': {
|
|
6
28
|
'0%': { opacity: 0, transform: 'scale(0.8) translateY(0.25rem)' },
|
package/theme.css
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--jumpbar-background: #ffffff;
|
|
3
|
+
--jumpbar-foreground: #1c2024;
|
|
4
|
+
--jumpbar-muted-foreground: #60646c;
|
|
5
|
+
--jumpbar-subtle-foreground: #80838d;
|
|
6
|
+
--jumpbar-muted: rgb(0 0 0 / 0.05);
|
|
7
|
+
--jumpbar-subtle: rgb(0 0 0 / 0.03);
|
|
8
|
+
--jumpbar-border: rgb(0 0 0 / 0.08);
|
|
9
|
+
--jumpbar-ring: rgb(0 0 0 / 0.14);
|
|
10
|
+
--jumpbar-primary: #0090ff;
|
|
11
|
+
--jumpbar-primary-soft: rgb(0 144 255 / 0.1);
|
|
12
|
+
--jumpbar-overlay: rgb(0 0 0 / 0.45);
|
|
13
|
+
--jumpbar-online: #12a594;
|
|
14
|
+
--jumpbar-busy: #ffba18;
|
|
15
|
+
--jumpbar-offline: #80838d;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.dark {
|
|
19
|
+
--jumpbar-background: #18191b;
|
|
20
|
+
--jumpbar-foreground: #edeef0;
|
|
21
|
+
--jumpbar-muted-foreground: #b0b4ba;
|
|
22
|
+
--jumpbar-subtle-foreground: #777b84;
|
|
23
|
+
--jumpbar-muted: rgb(255 255 255 / 0.07);
|
|
24
|
+
--jumpbar-subtle: rgb(255 255 255 / 0.04);
|
|
25
|
+
--jumpbar-border: rgb(255 255 255 / 0.1);
|
|
26
|
+
--jumpbar-ring: rgb(255 255 255 / 0.16);
|
|
27
|
+
--jumpbar-primary: #0090ff;
|
|
28
|
+
--jumpbar-primary-soft: rgb(0 144 255 / 0.15);
|
|
29
|
+
--jumpbar-overlay: rgb(0 0 0 / 0.6);
|
|
30
|
+
--jumpbar-online: #12a594;
|
|
31
|
+
--jumpbar-busy: #ffba18;
|
|
32
|
+
--jumpbar-offline: #777b84;
|
|
33
|
+
}
|
package/types/core.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type Context = { scopes?: string[] } & Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface Avatar {
|
|
4
|
+
name: string;
|
|
5
|
+
src?: string | null;
|
|
6
|
+
status?: 'online' | 'busy' | 'offline';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Item {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
section?: string;
|
|
14
|
+
icon?: string;
|
|
15
|
+
avatar?: Avatar;
|
|
16
|
+
badge?: string;
|
|
17
|
+
shortcut?: string[];
|
|
18
|
+
keywords?: string[];
|
|
19
|
+
scopes?: string[];
|
|
20
|
+
prefix?: string;
|
|
21
|
+
weight?: number;
|
|
22
|
+
place?: 'first' | 'last';
|
|
23
|
+
searchFromRoot?: boolean;
|
|
24
|
+
page?: boolean;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
parent?: string;
|
|
27
|
+
run?: (context: Context) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RankedItem extends Item {
|
|
31
|
+
parentTitle?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Section<T extends Item = RankedItem> {
|
|
35
|
+
section: string | null;
|
|
36
|
+
items: T[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Frecency {
|
|
40
|
+
record(id: string): void;
|
|
41
|
+
score(id: string): number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FrecencyStorage {
|
|
45
|
+
getItem(key: string): string | null;
|
|
46
|
+
setItem(key: string, value: string): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FrecencyOptions {
|
|
50
|
+
storage: FrecencyStorage;
|
|
51
|
+
key: string;
|
|
52
|
+
now?: () => number;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RankOptions {
|
|
57
|
+
text?: string;
|
|
58
|
+
prefix?: string | null;
|
|
59
|
+
page?: string | null;
|
|
60
|
+
scopes?: string[];
|
|
61
|
+
frecency?: Pick<Frecency, 'score'>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalize(value: unknown): string;
|
|
65
|
+
export function fuzzyScore(
|
|
66
|
+
query: string,
|
|
67
|
+
text: string,
|
|
68
|
+
options?: { subsequence?: boolean }
|
|
69
|
+
): number;
|
|
70
|
+
export function parseQuery(
|
|
71
|
+
input: string,
|
|
72
|
+
prefixes?: string[]
|
|
73
|
+
): { prefix: string | null; text: string };
|
|
74
|
+
export function createFrecency(options: FrecencyOptions): Frecency;
|
|
75
|
+
export function rank(items: Item[], options: RankOptions): RankedItem[];
|
|
76
|
+
export function groupBySection<T extends Item>(items: T[]): Section<T>[];
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComputedRef, DefineComponent, Ref } from 'vue';
|
|
2
|
+
import type { Context, Frecency, Item, RankedItem, Section } from './core';
|
|
3
|
+
|
|
4
|
+
export * from './core';
|
|
5
|
+
|
|
6
|
+
export interface Request {
|
|
7
|
+
query: string;
|
|
8
|
+
text: string;
|
|
9
|
+
prefix: string | null;
|
|
10
|
+
page: string | null;
|
|
11
|
+
context: Context;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SearchRequest extends Request {
|
|
15
|
+
matches: number;
|
|
16
|
+
signal: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Source {
|
|
20
|
+
id: string;
|
|
21
|
+
minQuery?: number;
|
|
22
|
+
debounce?: number;
|
|
23
|
+
when?: (request: Request | SearchRequest) => boolean;
|
|
24
|
+
commands?: (request: Request) => Item[];
|
|
25
|
+
search?: (request: SearchRequest) => Promise<Item[]> | Item[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommandBarOptions {
|
|
29
|
+
prefixes?: string[];
|
|
30
|
+
context?: () => Context;
|
|
31
|
+
frecency?: Frecency;
|
|
32
|
+
onSelect?: (item: Item) => void;
|
|
33
|
+
onClose?: () => void;
|
|
34
|
+
onError?: (error: unknown, source: Source) => void;
|
|
35
|
+
debounce?: number;
|
|
36
|
+
limit?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CommandBarInstance {
|
|
40
|
+
isOpen: Ref<boolean>;
|
|
41
|
+
query: Ref<string>;
|
|
42
|
+
page: ComputedRef<Item | null>;
|
|
43
|
+
pages: Ref<Item[]>;
|
|
44
|
+
visible: ComputedRef<RankedItem[]>;
|
|
45
|
+
sections: ComputedRef<Section[]>;
|
|
46
|
+
selectedIndex: Ref<number>;
|
|
47
|
+
selected: ComputedRef<RankedItem | null>;
|
|
48
|
+
isLoading: ComputedRef<boolean>;
|
|
49
|
+
open(options?: { page?: string | null; query?: string }): void;
|
|
50
|
+
close(): void;
|
|
51
|
+
toggle(): void;
|
|
52
|
+
back(): void;
|
|
53
|
+
select(item: Item | null | undefined): void;
|
|
54
|
+
setQuery(value: string): void;
|
|
55
|
+
moveSelection(delta: number): void;
|
|
56
|
+
register(source: Source): () => void;
|
|
57
|
+
configure(overrides: CommandBarOptions): void;
|
|
58
|
+
dispose(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createCommandBar(options?: CommandBarOptions): CommandBarInstance;
|
|
62
|
+
export function useCommandBar(): CommandBarInstance;
|
|
63
|
+
|
|
64
|
+
export interface Hint {
|
|
65
|
+
keys: string[];
|
|
66
|
+
label: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CommandBarProps {
|
|
70
|
+
placeholder?: string;
|
|
71
|
+
emptyText?: string;
|
|
72
|
+
scopeLabel?: string;
|
|
73
|
+
hints?: Hint[];
|
|
74
|
+
dir?: 'ltr' | 'rtl';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const CommandBar: DefineComponent<CommandBarProps>;
|
package/src/core/frecency.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
};
|
package/src/core/match.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
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/parse.js
DELETED
package/src/core/rank.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
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';
|
package/src/vue/CommandBar.vue
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
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>
|