@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/package.json CHANGED
@@ -1,22 +1,37 @@
1
1
  {
2
2
  "name": "@bysivin/jumpbar",
3
- "version": "0.1.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": "./src/index.js",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./types/index.d.ts",
9
11
  "exports": {
10
- ".": "./src/index.js",
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
- "src",
16
- "!src/**/specs",
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
+ }
@@ -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>[];
@@ -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>;
@@ -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
@@ -1,6 +0,0 @@
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
- };
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';
@@ -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>