@kurajs/ctrlk 0.0.1
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 +80 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.js +147 -0
- package/dist/dom.d.ts +46 -0
- package/dist/dom.js +325 -0
- package/dist/highlight.d.ts +15 -0
- package/dist/highlight.js +54 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/styles.d.ts +4 -0
- package/dist/styles.js +98 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +3 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @kurajs/ctrlk
|
|
2
|
+
|
|
3
|
+
A headless, **zero-dependency** ⌘K command palette with a built-in default renderer.
|
|
4
|
+
Vanilla DOM — no React, no Preact, nothing. It mirrors [cmdk](https://cmdk.paco.me)'s
|
|
5
|
+
model and accessibility (dialog + combobox/listbox/option, `aria-activedescendant`,
|
|
6
|
+
full keyboard) so it drops into any stack — including server-rendered sites with no
|
|
7
|
+
client framework — and ships zero runtime bytes beyond itself.
|
|
8
|
+
|
|
9
|
+
## Batteries-included
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createCtrlk, mountCtrlk } from "@kurajs/ctrlk";
|
|
13
|
+
|
|
14
|
+
const ctrl = createCtrlk({
|
|
15
|
+
// Async source: called (debounced, abortable) on every keystroke.
|
|
16
|
+
search: async (query, signal) => {
|
|
17
|
+
const res = await fetch(`/search.json?q=${encodeURIComponent(query)}`, { signal });
|
|
18
|
+
const { hits } = await res.json();
|
|
19
|
+
return hits.map((h) => ({
|
|
20
|
+
id: h.slug + "#" + h.headingId,
|
|
21
|
+
title: h.heading ?? h.title,
|
|
22
|
+
description: `${h.section} › ${h.title}`, // breadcrumb path
|
|
23
|
+
excerpt: h.text,
|
|
24
|
+
group: h.section,
|
|
25
|
+
icon: h.headingId ? "hash" : "page",
|
|
26
|
+
href: `/docs/${h.slug}` + (h.headingId ? `#${h.headingId}` : ""),
|
|
27
|
+
}));
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
mountCtrlk(ctrl, {
|
|
32
|
+
trigger: ".search-box", // clicking this opens the palette
|
|
33
|
+
tokensOf: (s) => s.query.split(/\s+/), // or the engine's matched terms, for exact highlight
|
|
34
|
+
});
|
|
35
|
+
// ⌘K / Ctrl+K and "/" now open it; ↑/↓ navigate, Enter opens, Esc closes.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Static command list
|
|
39
|
+
|
|
40
|
+
Omit `search` and pass `items` to get cmdk-style local fuzzy filtering:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const ctrl = createCtrlk({
|
|
44
|
+
items: [
|
|
45
|
+
{ id: "new", title: "New file", group: "Actions", keywords: ["create"] },
|
|
46
|
+
{ id: "theme", title: "Toggle theme", group: "Actions" },
|
|
47
|
+
],
|
|
48
|
+
onSelect: (item) => run(item.id),
|
|
49
|
+
});
|
|
50
|
+
mountCtrlk(ctrl);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Headless
|
|
54
|
+
|
|
55
|
+
`createCtrlk` is the whole state machine (no DOM): `open/close/toggle`, `setQuery`,
|
|
56
|
+
`move`/`setActive`, `select`, and `subscribe`. Drive your own renderer from `getState()`,
|
|
57
|
+
or render with your framework of choice — `mountCtrlk` is just the default one.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const ctrl = createCtrlk({ items });
|
|
61
|
+
const stop = ctrl.subscribe((state) => paint(state)); // { open, query, loading, items, groups, activeIndex, error }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Theming
|
|
65
|
+
|
|
66
|
+
The default renderer is styled entirely through `--ctrlk-*` CSS variables (light/dark
|
|
67
|
+
out of the box). Theme it by setting them on `.ctrlk-overlay` — e.g. to inherit a host's
|
|
68
|
+
tokens: `.ctrlk-overlay { --ctrlk-accent: var(--accent); --ctrlk-bg: var(--surface); }`.
|
|
69
|
+
Pass `renderItem` to fully control row markup, or `injectStyles: false` to ship your own.
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
- `createCtrlk(options) → Ctrlk` — headless controller. Options: `search` | `items` + `filter`,
|
|
74
|
+
`debounce`, `empty`, `onSelect`.
|
|
75
|
+
- `mountCtrlk(ctrl, opts) → { destroy }` — default DOM. Opts: `trigger`, `hotkey`, `labels`,
|
|
76
|
+
`tokensOf`, `renderItem`, `injectStyles`, `platform`, `ariaLabel`, `target`.
|
|
77
|
+
- `platformHotkeyLabel()` → `"⌘K"` on macOS, `"Ctrl K"` elsewhere (for a trigger hint).
|
|
78
|
+
- `highlight(text, tokens)` → segments for custom renderers.
|
|
79
|
+
|
|
80
|
+
Zero dependencies. Runs on Node, Bun, Deno, Cloudflare Workers (core), and every browser.
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Ctrlk, CtrlkItem, CtrlkOptions } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Default static-mode scorer: case-insensitive match over `title` + `keywords`. A contiguous
|
|
4
|
+
* substring beats a scattered subsequence, and among substring hits an earlier, shorter match
|
|
5
|
+
* ranks higher (more specific). Returns 0 for no match. Replace via {@link CtrlkOptions.filter}.
|
|
6
|
+
*/
|
|
7
|
+
export declare function defaultFilter(item: CtrlkItem, query: string): number;
|
|
8
|
+
/**
|
|
9
|
+
* Create a headless ⌘K controller. Provide `search` for an async source (debounced, abortable)
|
|
10
|
+
* or `items` + `filter` for a static, locally-filtered command list. Subscribe to drive a UI.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createCtrlk<D = unknown>(options?: CtrlkOptions<D>): Ctrlk<D>;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const DEFAULT_DEBOUNCE = 120;
|
|
2
|
+
/**
|
|
3
|
+
* Default static-mode scorer: case-insensitive match over `title` + `keywords`. A contiguous
|
|
4
|
+
* substring beats a scattered subsequence, and among substring hits an earlier, shorter match
|
|
5
|
+
* ranks higher (more specific). Returns 0 for no match. Replace via {@link CtrlkOptions.filter}.
|
|
6
|
+
*/
|
|
7
|
+
export function defaultFilter(item, query) {
|
|
8
|
+
const q = query.trim().toLowerCase();
|
|
9
|
+
if (!q)
|
|
10
|
+
return 1;
|
|
11
|
+
const hay = (item.title + " " + (item.keywords?.join(" ") ?? "")).toLowerCase();
|
|
12
|
+
const idx = hay.indexOf(q);
|
|
13
|
+
if (idx >= 0)
|
|
14
|
+
return 1000 - idx - hay.length * 0.01; // contiguous substring: strongest signal
|
|
15
|
+
// Subsequence fallback: every query char appears in order somewhere in the haystack.
|
|
16
|
+
let at = 0;
|
|
17
|
+
for (const ch of q) {
|
|
18
|
+
at = hay.indexOf(ch, at);
|
|
19
|
+
if (at < 0)
|
|
20
|
+
return 0;
|
|
21
|
+
at++;
|
|
22
|
+
}
|
|
23
|
+
return 1 - hay.length * 0.001; // weak match, deprioritized vs any substring hit
|
|
24
|
+
}
|
|
25
|
+
/** Bucket items by `group`, preserving first-seen group order; ungrouped items fall under "". */
|
|
26
|
+
function bucket(items) {
|
|
27
|
+
const order = [];
|
|
28
|
+
const map = new Map();
|
|
29
|
+
for (const it of items) {
|
|
30
|
+
const g = it.group ?? "";
|
|
31
|
+
let arr = map.get(g);
|
|
32
|
+
if (!arr) {
|
|
33
|
+
arr = [];
|
|
34
|
+
map.set(g, arr);
|
|
35
|
+
order.push(g);
|
|
36
|
+
}
|
|
37
|
+
arr.push(it);
|
|
38
|
+
}
|
|
39
|
+
return order.map((label) => ({ label, items: map.get(label) }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create a headless ⌘K controller. Provide `search` for an async source (debounced, abortable)
|
|
43
|
+
* or `items` + `filter` for a static, locally-filtered command list. Subscribe to drive a UI.
|
|
44
|
+
*/
|
|
45
|
+
export function createCtrlk(options = {}) {
|
|
46
|
+
const debounceMs = options.debounce ?? DEFAULT_DEBOUNCE;
|
|
47
|
+
const subs = new Set();
|
|
48
|
+
let state = { open: false, query: "", loading: false, items: [], groups: [], activeIndex: -1, error: null };
|
|
49
|
+
let timer = null;
|
|
50
|
+
let ac = null;
|
|
51
|
+
let runSeq = 0; // monotonic guard: only the most recent async run may apply its result
|
|
52
|
+
const emit = () => { const snap = state; for (const fn of [...subs])
|
|
53
|
+
fn(snap); };
|
|
54
|
+
const set = (patch) => { state = { ...state, ...patch }; emit(); };
|
|
55
|
+
const clearTimer = () => { if (timer) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timer = null;
|
|
58
|
+
} };
|
|
59
|
+
const applyItems = (items) => set({ items, groups: bucket(items), activeIndex: items.length ? 0 : -1, loading: false, error: null });
|
|
60
|
+
const runStatic = (q) => {
|
|
61
|
+
const src = options.items ?? [];
|
|
62
|
+
if (!q.trim()) {
|
|
63
|
+
applyItems((options.empty ?? src).slice());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const filter = options.filter ?? defaultFilter;
|
|
67
|
+
applyItems(src.map((it) => ({ it, s: filter(it, q) }))
|
|
68
|
+
.filter((x) => x.s > 0)
|
|
69
|
+
.sort((a, b) => b.s - a.s)
|
|
70
|
+
.map((x) => x.it));
|
|
71
|
+
};
|
|
72
|
+
const runAsync = async (q) => {
|
|
73
|
+
// Empty query never hits the network — show the configured suggestions (or nothing).
|
|
74
|
+
if (!q.trim()) {
|
|
75
|
+
ac?.abort();
|
|
76
|
+
applyItems((options.empty ?? []).slice());
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const seq = ++runSeq;
|
|
80
|
+
ac?.abort();
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
ac = controller;
|
|
83
|
+
set({ loading: true });
|
|
84
|
+
try {
|
|
85
|
+
const items = await options.search(q, controller.signal);
|
|
86
|
+
if (seq === runSeq)
|
|
87
|
+
applyItems(items); // ignore out-of-order/stale resolves
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (seq === runSeq && !controller.signal.aborted)
|
|
91
|
+
set({ loading: false, error: err });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const schedule = (q) => {
|
|
95
|
+
clearTimer();
|
|
96
|
+
if (!options.search) {
|
|
97
|
+
runStatic(q);
|
|
98
|
+
return;
|
|
99
|
+
} // local filter is cheap — run synchronously
|
|
100
|
+
if (!q.trim()) {
|
|
101
|
+
runAsync(q);
|
|
102
|
+
return;
|
|
103
|
+
} // empty → suggestions, no need to debounce
|
|
104
|
+
timer = setTimeout(() => runAsync(q), debounceMs);
|
|
105
|
+
};
|
|
106
|
+
const open = () => { if (!state.open) {
|
|
107
|
+
set({ open: true });
|
|
108
|
+
schedule(state.query);
|
|
109
|
+
} };
|
|
110
|
+
// Closing invalidates any in-flight async run (bump the seq) so a late resolve/reject can't
|
|
111
|
+
// apply stale results — or set an error — onto a palette the user already dismissed.
|
|
112
|
+
const close = () => { if (state.open) {
|
|
113
|
+
clearTimer();
|
|
114
|
+
runSeq++;
|
|
115
|
+
ac?.abort();
|
|
116
|
+
set({ open: false, loading: false });
|
|
117
|
+
} };
|
|
118
|
+
return {
|
|
119
|
+
getState: () => state,
|
|
120
|
+
subscribe(fn) { subs.add(fn); fn(state); return () => { subs.delete(fn); }; },
|
|
121
|
+
open,
|
|
122
|
+
close,
|
|
123
|
+
toggle() { state.open ? close() : open(); },
|
|
124
|
+
setQuery(q) { if (q !== state.query) {
|
|
125
|
+
set({ query: q });
|
|
126
|
+
schedule(q);
|
|
127
|
+
} },
|
|
128
|
+
move(delta) {
|
|
129
|
+
const n = state.items.length;
|
|
130
|
+
if (!n)
|
|
131
|
+
return;
|
|
132
|
+
const i = state.activeIndex < 0 ? (delta > 0 ? 0 : n - 1) : (state.activeIndex + delta + n) % n;
|
|
133
|
+
set({ activeIndex: i });
|
|
134
|
+
},
|
|
135
|
+
setActive(index) {
|
|
136
|
+
const n = state.items.length;
|
|
137
|
+
if (n)
|
|
138
|
+
set({ activeIndex: Math.max(0, Math.min(n - 1, index)) });
|
|
139
|
+
},
|
|
140
|
+
select(item, ev) {
|
|
141
|
+
const it = item ?? state.items[state.activeIndex];
|
|
142
|
+
if (it)
|
|
143
|
+
options.onSelect?.(it, ev);
|
|
144
|
+
},
|
|
145
|
+
destroy() { clearTimer(); ac?.abort(); subs.clear(); },
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Ctrlk, CtrlkItem, CtrlkState } from "./types.ts";
|
|
2
|
+
export interface MountLabels {
|
|
3
|
+
placeholder?: string;
|
|
4
|
+
/** Shown when a non-empty query returns nothing. */
|
|
5
|
+
empty?: string;
|
|
6
|
+
loading?: string;
|
|
7
|
+
/** Shown when the query is empty and there are no suggestions. */
|
|
8
|
+
initial?: string;
|
|
9
|
+
selectHint?: string;
|
|
10
|
+
openHint?: string;
|
|
11
|
+
closeHint?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface MountOptions<D = unknown> {
|
|
14
|
+
/** Where to append the dialog. Default `document.body`. */
|
|
15
|
+
target?: HTMLElement;
|
|
16
|
+
/** Open on ⌘K / Ctrl+K and `/`. Default true. A predicate fully customizes the chord. */
|
|
17
|
+
hotkey?: boolean | ((e: KeyboardEvent) => boolean);
|
|
18
|
+
/** Element(s) or a selector whose click opens the palette (e.g. the nav search box). */
|
|
19
|
+
trigger?: Element | Iterable<Element> | string | null;
|
|
20
|
+
labels?: MountLabels;
|
|
21
|
+
/** Highlight tokens for the current state (e.g. the search engine's matched terms).
|
|
22
|
+
* Default: the query split on whitespace. */
|
|
23
|
+
tokensOf?: (state: CtrlkState<D>) => string[];
|
|
24
|
+
/** Override a row's markup. Required ARIA/handlers are still applied to the returned element. */
|
|
25
|
+
renderItem?: (item: CtrlkItem<D>, ctx: {
|
|
26
|
+
active: boolean;
|
|
27
|
+
tokens: string[];
|
|
28
|
+
}) => HTMLElement;
|
|
29
|
+
/** Inject the default stylesheet. Default true. */
|
|
30
|
+
injectStyles?: boolean;
|
|
31
|
+
/** Force the hotkey-hint platform; default auto-detect from the UA. */
|
|
32
|
+
platform?: "mac" | "other";
|
|
33
|
+
/** ARIA label for the dialog. Default "Search". */
|
|
34
|
+
ariaLabel?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface MountHandle {
|
|
37
|
+
/** Remove listeners + DOM and tear down the controller subscription. */
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
/** The closed-state hotkey hint to render on a trigger ("⌘K" on macOS, "Ctrl K" elsewhere). */
|
|
41
|
+
export declare function platformHotkeyLabel(platform?: "mac" | "other"): string;
|
|
42
|
+
/**
|
|
43
|
+
* Mount the default ⌘K UI for a controller. Returns a handle to tear it down. No-op (returns
|
|
44
|
+
* an inert handle) when there is no document, so it's safe to import in SSR bundles.
|
|
45
|
+
*/
|
|
46
|
+
export declare function mountCtrlk<D = unknown>(ctrl: Ctrlk<D>, opts?: MountOptions<D>): MountHandle;
|
package/dist/dom.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { highlight } from "./highlight.js";
|
|
2
|
+
import { injectStyles } from "./styles.js";
|
|
3
|
+
const DEFAULT_LABELS = {
|
|
4
|
+
placeholder: "Search…",
|
|
5
|
+
empty: "No results",
|
|
6
|
+
loading: "Searching…",
|
|
7
|
+
initial: "Type to search",
|
|
8
|
+
selectHint: "Select",
|
|
9
|
+
openHint: "Open",
|
|
10
|
+
closeHint: "Close",
|
|
11
|
+
};
|
|
12
|
+
const SEARCH_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>`;
|
|
13
|
+
const PAGE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>`;
|
|
14
|
+
function detectMac(force) {
|
|
15
|
+
if (force)
|
|
16
|
+
return force === "mac";
|
|
17
|
+
if (typeof navigator === "undefined")
|
|
18
|
+
return false;
|
|
19
|
+
const n = `${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`;
|
|
20
|
+
return /mac|iphone|ipad|ipod/i.test(n);
|
|
21
|
+
}
|
|
22
|
+
/** The closed-state hotkey hint to render on a trigger ("⌘K" on macOS, "Ctrl K" elsewhere). */
|
|
23
|
+
export function platformHotkeyLabel(platform) {
|
|
24
|
+
return detectMac(platform) ? "⌘K" : "Ctrl K";
|
|
25
|
+
}
|
|
26
|
+
const isTypingTarget = (el) => {
|
|
27
|
+
const n = el;
|
|
28
|
+
if (!n)
|
|
29
|
+
return false;
|
|
30
|
+
const tag = n.tagName;
|
|
31
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || n.isContentEditable;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Mount the default ⌘K UI for a controller. Returns a handle to tear it down. No-op (returns
|
|
35
|
+
* an inert handle) when there is no document, so it's safe to import in SSR bundles.
|
|
36
|
+
*/
|
|
37
|
+
export function mountCtrlk(ctrl, opts = {}) {
|
|
38
|
+
if (typeof document === "undefined")
|
|
39
|
+
return { destroy() { } };
|
|
40
|
+
const doc = document;
|
|
41
|
+
const labels = { ...DEFAULT_LABELS, ...opts.labels };
|
|
42
|
+
const tokensOf = opts.tokensOf ?? ((s) => s.query.toLowerCase().split(/\s+/).filter(Boolean));
|
|
43
|
+
if (opts.injectStyles !== false)
|
|
44
|
+
injectStyles(doc);
|
|
45
|
+
// --- build the static shell once ---
|
|
46
|
+
const overlay = doc.createElement("div");
|
|
47
|
+
overlay.className = "ctrlk-overlay";
|
|
48
|
+
overlay.hidden = true;
|
|
49
|
+
overlay.setAttribute("role", "presentation");
|
|
50
|
+
const dialog = doc.createElement("div");
|
|
51
|
+
dialog.className = "ctrlk-dialog";
|
|
52
|
+
dialog.setAttribute("role", "dialog");
|
|
53
|
+
dialog.setAttribute("aria-modal", "true");
|
|
54
|
+
dialog.setAttribute("aria-label", opts.ariaLabel ?? "Search");
|
|
55
|
+
const header = doc.createElement("div");
|
|
56
|
+
header.className = "ctrlk-header";
|
|
57
|
+
const searchIcon = doc.createElement("span");
|
|
58
|
+
searchIcon.innerHTML = SEARCH_SVG; // static, trusted markup
|
|
59
|
+
const input = doc.createElement("input");
|
|
60
|
+
input.className = "ctrlk-input";
|
|
61
|
+
input.type = "text";
|
|
62
|
+
input.placeholder = labels.placeholder;
|
|
63
|
+
input.setAttribute("role", "combobox");
|
|
64
|
+
input.setAttribute("aria-autocomplete", "list");
|
|
65
|
+
input.setAttribute("aria-expanded", "false");
|
|
66
|
+
input.setAttribute("autocomplete", "off");
|
|
67
|
+
input.setAttribute("autocapitalize", "off");
|
|
68
|
+
input.setAttribute("spellcheck", "false");
|
|
69
|
+
const listId = "ctrlk-list";
|
|
70
|
+
input.setAttribute("aria-controls", listId);
|
|
71
|
+
const escKbd = doc.createElement("kbd");
|
|
72
|
+
escKbd.className = "ctrlk-esc";
|
|
73
|
+
escKbd.textContent = "ESC";
|
|
74
|
+
header.append(searchIcon.firstChild, input, escKbd);
|
|
75
|
+
const list = doc.createElement("div");
|
|
76
|
+
list.className = "ctrlk-list";
|
|
77
|
+
list.id = listId;
|
|
78
|
+
list.setAttribute("role", "listbox");
|
|
79
|
+
const footer = doc.createElement("div");
|
|
80
|
+
footer.className = "ctrlk-footer";
|
|
81
|
+
footer.append(hint(["↑", "↓"], labels.selectHint), hint(["↵"], labels.openHint), spacer(hint(["esc"], labels.closeHint)));
|
|
82
|
+
dialog.append(header, list, footer);
|
|
83
|
+
overlay.append(dialog);
|
|
84
|
+
(opts.target ?? doc.body).append(overlay);
|
|
85
|
+
function hint(keys, text) {
|
|
86
|
+
const span = doc.createElement("span");
|
|
87
|
+
span.className = "ctrlk-hint";
|
|
88
|
+
for (const k of keys) {
|
|
89
|
+
const kbd = doc.createElement("kbd");
|
|
90
|
+
kbd.textContent = k;
|
|
91
|
+
span.append(kbd);
|
|
92
|
+
}
|
|
93
|
+
span.append(doc.createTextNode(text));
|
|
94
|
+
return span;
|
|
95
|
+
}
|
|
96
|
+
function spacer(el) { el.classList.add("ctrlk-spacer"); return el; }
|
|
97
|
+
// --- rendering ---
|
|
98
|
+
let optionEls = [];
|
|
99
|
+
let lastItems = null;
|
|
100
|
+
let wasOpen = false;
|
|
101
|
+
let returnFocus = null;
|
|
102
|
+
function fillHighlighted(el, text, tokens) {
|
|
103
|
+
el.textContent = "";
|
|
104
|
+
for (const seg of highlight(text, tokens)) {
|
|
105
|
+
if (seg.match) {
|
|
106
|
+
const m = doc.createElement("mark");
|
|
107
|
+
m.textContent = seg.text;
|
|
108
|
+
el.append(m);
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
el.append(doc.createTextNode(seg.text));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function defaultRow(item, tokens) {
|
|
115
|
+
const row = doc.createElement(item.href ? "a" : "div");
|
|
116
|
+
if (item.href)
|
|
117
|
+
row.href = item.href;
|
|
118
|
+
const icon = doc.createElement("span");
|
|
119
|
+
icon.className = "ctrlk-option-icon";
|
|
120
|
+
if (item.icon === "hash")
|
|
121
|
+
icon.textContent = "#";
|
|
122
|
+
else if (item.icon && item.icon !== "page")
|
|
123
|
+
icon.textContent = item.icon;
|
|
124
|
+
else
|
|
125
|
+
icon.innerHTML = PAGE_SVG; // static, trusted
|
|
126
|
+
const body = doc.createElement("div");
|
|
127
|
+
body.className = "ctrlk-option-body";
|
|
128
|
+
const title = doc.createElement("div");
|
|
129
|
+
title.className = "ctrlk-option-title";
|
|
130
|
+
fillHighlighted(title, item.title, tokens);
|
|
131
|
+
body.append(title);
|
|
132
|
+
if (item.description) {
|
|
133
|
+
const path = doc.createElement("div");
|
|
134
|
+
path.className = "ctrlk-option-path";
|
|
135
|
+
fillHighlighted(path, item.description, tokens);
|
|
136
|
+
body.append(path);
|
|
137
|
+
}
|
|
138
|
+
if (item.excerpt) {
|
|
139
|
+
const ex = doc.createElement("div");
|
|
140
|
+
ex.className = "ctrlk-option-excerpt";
|
|
141
|
+
fillHighlighted(ex, item.excerpt, tokens);
|
|
142
|
+
body.append(ex);
|
|
143
|
+
}
|
|
144
|
+
row.append(icon, body);
|
|
145
|
+
return row;
|
|
146
|
+
}
|
|
147
|
+
function activate(item, ev) {
|
|
148
|
+
ctrl.select(item, { metaKey: ev.metaKey, ctrlKey: ev.ctrlKey, shiftKey: ev.shiftKey, altKey: ev.altKey });
|
|
149
|
+
ctrl.close();
|
|
150
|
+
if (item.href)
|
|
151
|
+
location.assign(item.href);
|
|
152
|
+
}
|
|
153
|
+
function rebuildList(state) {
|
|
154
|
+
const tokens = tokensOf(state);
|
|
155
|
+
list.textContent = "";
|
|
156
|
+
optionEls = [];
|
|
157
|
+
if (!state.items.length) {
|
|
158
|
+
const div = doc.createElement("div");
|
|
159
|
+
div.className = "ctrlk-state";
|
|
160
|
+
div.textContent = state.loading ? labels.loading : state.query.trim() ? labels.empty : labels.initial;
|
|
161
|
+
list.append(div);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let flat = 0;
|
|
165
|
+
for (const group of state.groups) {
|
|
166
|
+
if (group.label) {
|
|
167
|
+
const gl = doc.createElement("div");
|
|
168
|
+
gl.className = "ctrlk-group-label";
|
|
169
|
+
gl.setAttribute("role", "presentation");
|
|
170
|
+
gl.textContent = group.label;
|
|
171
|
+
list.append(gl);
|
|
172
|
+
}
|
|
173
|
+
for (const item of group.items) {
|
|
174
|
+
const index = flat++;
|
|
175
|
+
const base = opts.renderItem ? opts.renderItem(item, { active: false, tokens }) : defaultRow(item, tokens);
|
|
176
|
+
base.classList.add("ctrlk-option");
|
|
177
|
+
base.id = `ctrlk-opt-${index}`;
|
|
178
|
+
base.setAttribute("role", "option");
|
|
179
|
+
base.setAttribute("aria-selected", "false");
|
|
180
|
+
base.dataset.index = String(index);
|
|
181
|
+
base.addEventListener("click", (e) => {
|
|
182
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
183
|
+
return; // let the browser open links natively
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
activate(item, e);
|
|
186
|
+
});
|
|
187
|
+
base.addEventListener("mousemove", () => { if (state.activeIndex !== index)
|
|
188
|
+
ctrl.setActive(index); });
|
|
189
|
+
list.append(base);
|
|
190
|
+
optionEls.push(base);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function syncActive(state) {
|
|
195
|
+
optionEls.forEach((el, i) => el.setAttribute("aria-selected", i === state.activeIndex ? "true" : "false"));
|
|
196
|
+
const active = optionEls[state.activeIndex];
|
|
197
|
+
if (active) {
|
|
198
|
+
input.setAttribute("aria-activedescendant", active.id);
|
|
199
|
+
active.scrollIntoView({ block: "nearest" });
|
|
200
|
+
}
|
|
201
|
+
else
|
|
202
|
+
input.removeAttribute("aria-activedescendant");
|
|
203
|
+
}
|
|
204
|
+
function render(state) {
|
|
205
|
+
overlay.hidden = !state.open;
|
|
206
|
+
input.setAttribute("aria-expanded", String(state.open && state.items.length > 0));
|
|
207
|
+
if (state.open && !wasOpen) {
|
|
208
|
+
returnFocus = doc.activeElement;
|
|
209
|
+
doc.documentElement.classList.add("ctrlk-open");
|
|
210
|
+
if (input.value !== state.query)
|
|
211
|
+
input.value = state.query;
|
|
212
|
+
input.focus();
|
|
213
|
+
}
|
|
214
|
+
else if (!state.open && wasOpen) {
|
|
215
|
+
doc.documentElement.classList.remove("ctrlk-open");
|
|
216
|
+
lastItems = null; // force a fresh list next open
|
|
217
|
+
if (returnFocus && doc.contains(returnFocus))
|
|
218
|
+
returnFocus.focus();
|
|
219
|
+
}
|
|
220
|
+
wasOpen = state.open;
|
|
221
|
+
if (!state.open)
|
|
222
|
+
return;
|
|
223
|
+
// Keep the input text in sync when the query changed programmatically (not from typing).
|
|
224
|
+
if (input.value !== state.query)
|
|
225
|
+
input.value = state.query;
|
|
226
|
+
if (state.items !== lastItems) {
|
|
227
|
+
rebuildList(state);
|
|
228
|
+
lastItems = state.items;
|
|
229
|
+
}
|
|
230
|
+
syncActive(state);
|
|
231
|
+
}
|
|
232
|
+
// --- wiring ---
|
|
233
|
+
const unsubscribe = ctrl.subscribe(render);
|
|
234
|
+
const onInput = () => ctrl.setQuery(input.value);
|
|
235
|
+
input.addEventListener("input", onInput);
|
|
236
|
+
const onDialogKeydown = (e) => {
|
|
237
|
+
switch (e.key) {
|
|
238
|
+
case "ArrowDown":
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
ctrl.move(1);
|
|
241
|
+
break;
|
|
242
|
+
case "ArrowUp":
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
ctrl.move(-1);
|
|
245
|
+
break;
|
|
246
|
+
case "Home":
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
ctrl.setActive(0);
|
|
249
|
+
break;
|
|
250
|
+
case "End":
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
ctrl.setActive(ctrl.getState().items.length - 1);
|
|
253
|
+
break;
|
|
254
|
+
case "Enter": {
|
|
255
|
+
const st = ctrl.getState();
|
|
256
|
+
const item = st.items[st.activeIndex];
|
|
257
|
+
if (item) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
activate(item, e);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "Escape":
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
ctrl.close();
|
|
266
|
+
break;
|
|
267
|
+
case "Tab":
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
break; // trap focus on the input (combobox pattern)
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
dialog.addEventListener("keydown", onDialogKeydown);
|
|
273
|
+
// Close on a click in the backdrop (outside the dialog).
|
|
274
|
+
const onOverlayMousedown = (e) => { if (e.target === overlay)
|
|
275
|
+
ctrl.close(); };
|
|
276
|
+
overlay.addEventListener("mousedown", onOverlayMousedown);
|
|
277
|
+
// Global hotkey: ⌘K / Ctrl+K toggles; "/" opens when not already typing.
|
|
278
|
+
const hotkeyEnabled = opts.hotkey ?? true;
|
|
279
|
+
const onGlobalKeydown = (e) => {
|
|
280
|
+
if (typeof hotkeyEnabled === "function") {
|
|
281
|
+
if (hotkeyEnabled(e)) {
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
ctrl.toggle();
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!hotkeyEnabled)
|
|
288
|
+
return;
|
|
289
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
ctrl.toggle();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (e.key === "/" && !ctrl.getState().open && !isTypingTarget(e.target)) {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
ctrl.open();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
doc.addEventListener("keydown", onGlobalKeydown);
|
|
300
|
+
// Triggers: clicking opens the palette (and never submits a wrapping form).
|
|
301
|
+
const triggers = [];
|
|
302
|
+
if (opts.trigger) {
|
|
303
|
+
const els = typeof opts.trigger === "string"
|
|
304
|
+
? Array.from(doc.querySelectorAll(opts.trigger))
|
|
305
|
+
: opts.trigger instanceof Element ? [opts.trigger] : Array.from(opts.trigger);
|
|
306
|
+
for (const el of els) {
|
|
307
|
+
triggers.push(el);
|
|
308
|
+
el.addEventListener("click", onTriggerClick);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function onTriggerClick(e) { e.preventDefault(); ctrl.open(); }
|
|
312
|
+
return {
|
|
313
|
+
destroy() {
|
|
314
|
+
unsubscribe();
|
|
315
|
+
input.removeEventListener("input", onInput);
|
|
316
|
+
dialog.removeEventListener("keydown", onDialogKeydown);
|
|
317
|
+
overlay.removeEventListener("mousedown", onOverlayMousedown);
|
|
318
|
+
doc.removeEventListener("keydown", onGlobalKeydown);
|
|
319
|
+
for (const el of triggers)
|
|
320
|
+
el.removeEventListener("click", onTriggerClick);
|
|
321
|
+
doc.documentElement.classList.remove("ctrlk-open");
|
|
322
|
+
overlay.remove();
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface HighlightSegment {
|
|
2
|
+
text: string;
|
|
3
|
+
/** True when this segment matched one of the query tokens. */
|
|
4
|
+
match: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Split `text` into consecutive segments, marking the spans that match any `token`
|
|
8
|
+
* (case-insensitive). Overlapping/adjacent matches are merged. Tokens should be the terms the
|
|
9
|
+
* search engine actually matched on (e.g. BM25's per-locale tokens) so CJK/accented matches
|
|
10
|
+
* line up with scoring rather than a naive whitespace re-split.
|
|
11
|
+
*
|
|
12
|
+
* Returns a single unmatched segment when there are no tokens or no hits — so a renderer can
|
|
13
|
+
* always map over the result uniformly.
|
|
14
|
+
*/
|
|
15
|
+
export declare function highlight(text: string, tokens: readonly string[]): HighlightSegment[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Query-term highlighting, as a pure function so it's testable without a DOM and reusable
|
|
2
|
+
// by any renderer. The default DOM renderer turns matched segments into <mark> elements
|
|
3
|
+
// (built via textContent, never innerHTML — so user/content text is never parsed as markup).
|
|
4
|
+
/**
|
|
5
|
+
* Split `text` into consecutive segments, marking the spans that match any `token`
|
|
6
|
+
* (case-insensitive). Overlapping/adjacent matches are merged. Tokens should be the terms the
|
|
7
|
+
* search engine actually matched on (e.g. BM25's per-locale tokens) so CJK/accented matches
|
|
8
|
+
* line up with scoring rather than a naive whitespace re-split.
|
|
9
|
+
*
|
|
10
|
+
* Returns a single unmatched segment when there are no tokens or no hits — so a renderer can
|
|
11
|
+
* always map over the result uniformly.
|
|
12
|
+
*/
|
|
13
|
+
export function highlight(text, tokens) {
|
|
14
|
+
const terms = tokens.map((t) => t.toLowerCase()).filter(Boolean);
|
|
15
|
+
if (!text || terms.length === 0)
|
|
16
|
+
return [{ text, match: false }];
|
|
17
|
+
const hay = text.toLowerCase();
|
|
18
|
+
// Collect every [start, end) match range across all tokens.
|
|
19
|
+
const ranges = [];
|
|
20
|
+
for (const term of terms) {
|
|
21
|
+
let from = 0;
|
|
22
|
+
for (;;) {
|
|
23
|
+
const at = hay.indexOf(term, from);
|
|
24
|
+
if (at < 0)
|
|
25
|
+
break;
|
|
26
|
+
ranges.push([at, at + term.length]);
|
|
27
|
+
from = at + term.length;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (ranges.length === 0)
|
|
31
|
+
return [{ text, match: false }];
|
|
32
|
+
// Merge overlapping/touching ranges so we never emit empty or nested marks.
|
|
33
|
+
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
34
|
+
const merged = [];
|
|
35
|
+
for (const [s, e] of ranges) {
|
|
36
|
+
const last = merged[merged.length - 1];
|
|
37
|
+
if (last && s <= last[1])
|
|
38
|
+
last[1] = Math.max(last[1], e);
|
|
39
|
+
else
|
|
40
|
+
merged.push([s, e]);
|
|
41
|
+
}
|
|
42
|
+
// Walk the merged ranges, interleaving the unmatched gaps. Slice the ORIGINAL text to keep case.
|
|
43
|
+
const out = [];
|
|
44
|
+
let cursor = 0;
|
|
45
|
+
for (const [s, e] of merged) {
|
|
46
|
+
if (s > cursor)
|
|
47
|
+
out.push({ text: text.slice(cursor, s), match: false });
|
|
48
|
+
out.push({ text: text.slice(s, e), match: true });
|
|
49
|
+
cursor = e;
|
|
50
|
+
}
|
|
51
|
+
if (cursor < text.length)
|
|
52
|
+
out.push({ text: text.slice(cursor), match: false });
|
|
53
|
+
return out;
|
|
54
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createCtrlk, defaultFilter } from "./core.ts";
|
|
2
|
+
export { mountCtrlk, platformHotkeyLabel } from "./dom.ts";
|
|
3
|
+
export type { MountHandle, MountLabels, MountOptions } from "./dom.ts";
|
|
4
|
+
export { highlight } from "./highlight.ts";
|
|
5
|
+
export type { HighlightSegment } from "./highlight.ts";
|
|
6
|
+
export { CSS as ctrlkCss, STYLE_ID, injectStyles } from "./styles.ts";
|
|
7
|
+
export type { Ctrlk, CtrlkGroup, CtrlkItem, CtrlkOptions, CtrlkSelectEvent, CtrlkState, } from "./types.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @kurajs/ctrlk — a headless, zero-dependency ⌘K command palette with a built-in default
|
|
2
|
+
// renderer. Use `createCtrlk` for the headless state machine and `mountCtrlk` for the
|
|
3
|
+
// batteries-included DOM, or drive the controller from your own renderer.
|
|
4
|
+
export { createCtrlk, defaultFilter } from "./core.js";
|
|
5
|
+
export { mountCtrlk, platformHotkeyLabel } from "./dom.js";
|
|
6
|
+
export { highlight } from "./highlight.js";
|
|
7
|
+
export { CSS as ctrlkCss, STYLE_ID, injectStyles } from "./styles.js";
|
package/dist/styles.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const STYLE_ID = "ctrlk-styles";
|
|
2
|
+
export declare const CSS = "\n.ctrlk-overlay {\n --ctrlk-bg: #fff;\n --ctrlk-fg: #1a1a1a;\n --ctrlk-muted: #6b7280;\n --ctrlk-border: #e5e7eb;\n --ctrlk-active: #f3f4f6;\n --ctrlk-accent: #2563eb;\n --ctrlk-mark: #facc15;\n --ctrlk-mark-fg: #1a1a1a;\n --ctrlk-shadow: 0 16px 48px rgba(0,0,0,.18);\n --ctrlk-radius: 14px;\n position: fixed; inset: 0; z-index: 9999;\n display: flex; align-items: flex-start; justify-content: center;\n padding: 12vh 16px 16px;\n background: rgba(15,17,21,.45);\n backdrop-filter: blur(2px);\n}\n.ctrlk-overlay[hidden] { display: none; }\n@media (prefers-color-scheme: dark) {\n .ctrlk-overlay {\n --ctrlk-bg: #1c1f26; --ctrlk-fg: #f3f4f6; --ctrlk-muted: #9aa1ad;\n --ctrlk-border: #2d323c; --ctrlk-active: #272b34; --ctrlk-accent: #6ea8fe;\n --ctrlk-mark-fg: #1a1a1a; --ctrlk-shadow: 0 16px 48px rgba(0,0,0,.5);\n }\n}\n.ctrlk-dialog {\n width: 100%; max-width: 640px; max-height: 76vh;\n display: flex; flex-direction: column; overflow: hidden;\n background: var(--ctrlk-bg); color: var(--ctrlk-fg);\n border: 1px solid var(--ctrlk-border); border-radius: var(--ctrlk-radius);\n box-shadow: var(--ctrlk-shadow);\n animation: ctrlk-in .12s ease-out;\n}\n@keyframes ctrlk-in { from { opacity: 0; transform: translateY(-6px) scale(.99); } to { opacity: 1; transform: none; } }\n@media (prefers-reduced-motion: reduce) { .ctrlk-dialog { animation: none; } }\n\n.ctrlk-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--ctrlk-border); }\n.ctrlk-header svg { width: 18px; height: 18px; flex: none; color: var(--ctrlk-muted); }\n.ctrlk-input {\n flex: 1; min-width: 0; border: 0; outline: 0; background: transparent;\n font: inherit; font-size: 1rem; color: var(--ctrlk-fg);\n}\n.ctrlk-input::placeholder { color: var(--ctrlk-muted); }\n.ctrlk-esc {\n flex: none; font-size: .7rem; line-height: 1; padding: 4px 7px; color: var(--ctrlk-muted);\n border: 1px solid var(--ctrlk-border); border-radius: 6px; background: var(--ctrlk-active);\n}\n\n.ctrlk-list { overflow-y: auto; overscroll-behavior: contain; padding: 6px; flex: 1; }\n.ctrlk-group-label {\n padding: 12px 10px 6px; font-size: .72rem; font-weight: 600; letter-spacing: .04em;\n text-transform: uppercase; color: var(--ctrlk-muted);\n}\n.ctrlk-option {\n display: flex; gap: 11px; align-items: flex-start; padding: 9px 10px; border-radius: 9px;\n cursor: pointer; color: inherit; text-decoration: none; scroll-margin: 8px;\n}\n.ctrlk-option[aria-selected=\"true\"] { background: var(--ctrlk-active); }\n.ctrlk-option-icon { flex: none; width: 18px; height: 18px; margin-top: 2px; color: var(--ctrlk-muted); display: flex; align-items: center; justify-content: center; font-size: .95rem; }\n.ctrlk-option-body { min-width: 0; flex: 1; }\n.ctrlk-option-title { font-size: .92rem; font-weight: 500; color: var(--ctrlk-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.ctrlk-option-path { font-size: .76rem; color: var(--ctrlk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.ctrlk-option-excerpt { font-size: .8rem; color: var(--ctrlk-muted); margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }\n.ctrlk-option mark { background: var(--ctrlk-mark); color: var(--ctrlk-mark-fg); border-radius: 2px; padding: 0 1px; }\n\n.ctrlk-state { padding: 36px 16px; text-align: center; color: var(--ctrlk-muted); font-size: .9rem; }\n\n.ctrlk-footer {\n display: flex; gap: 16px; align-items: center; padding: 9px 14px;\n border-top: 1px solid var(--ctrlk-border); color: var(--ctrlk-muted); font-size: .76rem;\n}\n.ctrlk-footer .ctrlk-hint { display: inline-flex; gap: 5px; align-items: center; }\n.ctrlk-footer kbd {\n font: inherit; font-size: .72rem; min-width: 18px; height: 18px; padding: 0 4px;\n display: inline-flex; align-items: center; justify-content: center;\n border: 1px solid var(--ctrlk-border); border-radius: 5px; background: var(--ctrlk-active);\n}\n.ctrlk-footer .ctrlk-spacer { margin-left: auto; }\n\nhtml.ctrlk-open, body.ctrlk-open { overflow: hidden !important; }\n";
|
|
3
|
+
/** Inject the default stylesheet once (no-op on the server or if already present). */
|
|
4
|
+
export declare function injectStyles(doc?: Document): void;
|
package/dist/styles.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Default renderer styles, as a string injected once into a <style id="ctrlk-styles">.
|
|
2
|
+
// Self-contained: everything is driven by `--ctrlk-*` custom properties with sensible
|
|
3
|
+
// light/dark defaults, so a host (e.g. Kura docs) can theme it by setting those vars —
|
|
4
|
+
// `.ctrlk-overlay { --ctrlk-accent: var(--accent); }` — without touching this sheet.
|
|
5
|
+
export const STYLE_ID = "ctrlk-styles";
|
|
6
|
+
export const CSS = `
|
|
7
|
+
.ctrlk-overlay {
|
|
8
|
+
--ctrlk-bg: #fff;
|
|
9
|
+
--ctrlk-fg: #1a1a1a;
|
|
10
|
+
--ctrlk-muted: #6b7280;
|
|
11
|
+
--ctrlk-border: #e5e7eb;
|
|
12
|
+
--ctrlk-active: #f3f4f6;
|
|
13
|
+
--ctrlk-accent: #2563eb;
|
|
14
|
+
--ctrlk-mark: #facc15;
|
|
15
|
+
--ctrlk-mark-fg: #1a1a1a;
|
|
16
|
+
--ctrlk-shadow: 0 16px 48px rgba(0,0,0,.18);
|
|
17
|
+
--ctrlk-radius: 14px;
|
|
18
|
+
position: fixed; inset: 0; z-index: 9999;
|
|
19
|
+
display: flex; align-items: flex-start; justify-content: center;
|
|
20
|
+
padding: 12vh 16px 16px;
|
|
21
|
+
background: rgba(15,17,21,.45);
|
|
22
|
+
backdrop-filter: blur(2px);
|
|
23
|
+
}
|
|
24
|
+
.ctrlk-overlay[hidden] { display: none; }
|
|
25
|
+
@media (prefers-color-scheme: dark) {
|
|
26
|
+
.ctrlk-overlay {
|
|
27
|
+
--ctrlk-bg: #1c1f26; --ctrlk-fg: #f3f4f6; --ctrlk-muted: #9aa1ad;
|
|
28
|
+
--ctrlk-border: #2d323c; --ctrlk-active: #272b34; --ctrlk-accent: #6ea8fe;
|
|
29
|
+
--ctrlk-mark-fg: #1a1a1a; --ctrlk-shadow: 0 16px 48px rgba(0,0,0,.5);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
.ctrlk-dialog {
|
|
33
|
+
width: 100%; max-width: 640px; max-height: 76vh;
|
|
34
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
35
|
+
background: var(--ctrlk-bg); color: var(--ctrlk-fg);
|
|
36
|
+
border: 1px solid var(--ctrlk-border); border-radius: var(--ctrlk-radius);
|
|
37
|
+
box-shadow: var(--ctrlk-shadow);
|
|
38
|
+
animation: ctrlk-in .12s ease-out;
|
|
39
|
+
}
|
|
40
|
+
@keyframes ctrlk-in { from { opacity: 0; transform: translateY(-6px) scale(.99); } to { opacity: 1; transform: none; } }
|
|
41
|
+
@media (prefers-reduced-motion: reduce) { .ctrlk-dialog { animation: none; } }
|
|
42
|
+
|
|
43
|
+
.ctrlk-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--ctrlk-border); }
|
|
44
|
+
.ctrlk-header svg { width: 18px; height: 18px; flex: none; color: var(--ctrlk-muted); }
|
|
45
|
+
.ctrlk-input {
|
|
46
|
+
flex: 1; min-width: 0; border: 0; outline: 0; background: transparent;
|
|
47
|
+
font: inherit; font-size: 1rem; color: var(--ctrlk-fg);
|
|
48
|
+
}
|
|
49
|
+
.ctrlk-input::placeholder { color: var(--ctrlk-muted); }
|
|
50
|
+
.ctrlk-esc {
|
|
51
|
+
flex: none; font-size: .7rem; line-height: 1; padding: 4px 7px; color: var(--ctrlk-muted);
|
|
52
|
+
border: 1px solid var(--ctrlk-border); border-radius: 6px; background: var(--ctrlk-active);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ctrlk-list { overflow-y: auto; overscroll-behavior: contain; padding: 6px; flex: 1; }
|
|
56
|
+
.ctrlk-group-label {
|
|
57
|
+
padding: 12px 10px 6px; font-size: .72rem; font-weight: 600; letter-spacing: .04em;
|
|
58
|
+
text-transform: uppercase; color: var(--ctrlk-muted);
|
|
59
|
+
}
|
|
60
|
+
.ctrlk-option {
|
|
61
|
+
display: flex; gap: 11px; align-items: flex-start; padding: 9px 10px; border-radius: 9px;
|
|
62
|
+
cursor: pointer; color: inherit; text-decoration: none; scroll-margin: 8px;
|
|
63
|
+
}
|
|
64
|
+
.ctrlk-option[aria-selected="true"] { background: var(--ctrlk-active); }
|
|
65
|
+
.ctrlk-option-icon { flex: none; width: 18px; height: 18px; margin-top: 2px; color: var(--ctrlk-muted); display: flex; align-items: center; justify-content: center; font-size: .95rem; }
|
|
66
|
+
.ctrlk-option-body { min-width: 0; flex: 1; }
|
|
67
|
+
.ctrlk-option-title { font-size: .92rem; font-weight: 500; color: var(--ctrlk-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
.ctrlk-option-path { font-size: .76rem; color: var(--ctrlk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
|
+
.ctrlk-option-excerpt { font-size: .8rem; color: var(--ctrlk-muted); margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
70
|
+
.ctrlk-option mark { background: var(--ctrlk-mark); color: var(--ctrlk-mark-fg); border-radius: 2px; padding: 0 1px; }
|
|
71
|
+
|
|
72
|
+
.ctrlk-state { padding: 36px 16px; text-align: center; color: var(--ctrlk-muted); font-size: .9rem; }
|
|
73
|
+
|
|
74
|
+
.ctrlk-footer {
|
|
75
|
+
display: flex; gap: 16px; align-items: center; padding: 9px 14px;
|
|
76
|
+
border-top: 1px solid var(--ctrlk-border); color: var(--ctrlk-muted); font-size: .76rem;
|
|
77
|
+
}
|
|
78
|
+
.ctrlk-footer .ctrlk-hint { display: inline-flex; gap: 5px; align-items: center; }
|
|
79
|
+
.ctrlk-footer kbd {
|
|
80
|
+
font: inherit; font-size: .72rem; min-width: 18px; height: 18px; padding: 0 4px;
|
|
81
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
82
|
+
border: 1px solid var(--ctrlk-border); border-radius: 5px; background: var(--ctrlk-active);
|
|
83
|
+
}
|
|
84
|
+
.ctrlk-footer .ctrlk-spacer { margin-left: auto; }
|
|
85
|
+
|
|
86
|
+
html.ctrlk-open, body.ctrlk-open { overflow: hidden !important; }
|
|
87
|
+
`;
|
|
88
|
+
/** Inject the default stylesheet once (no-op on the server or if already present). */
|
|
89
|
+
export function injectStyles(doc = document) {
|
|
90
|
+
if (typeof document === "undefined" && !doc)
|
|
91
|
+
return;
|
|
92
|
+
if (doc.getElementById(STYLE_ID))
|
|
93
|
+
return;
|
|
94
|
+
const el = doc.createElement("style");
|
|
95
|
+
el.id = STYLE_ID;
|
|
96
|
+
el.textContent = CSS;
|
|
97
|
+
doc.head.appendChild(el);
|
|
98
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** A selectable row. The shape is intentionally open — `data` carries whatever the
|
|
2
|
+
* consumer needs back in `onSelect`. `id` must be stable and unique (ARIA ids,
|
|
3
|
+
* selection, dedup all key on it). */
|
|
4
|
+
export interface CtrlkItem<D = unknown> {
|
|
5
|
+
/** Stable unique id. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Primary label. In static-filter mode this (plus `keywords`) is what the query matches. */
|
|
8
|
+
title: string;
|
|
9
|
+
/** Secondary line — e.g. a breadcrumb path ("Guides › Install"). */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Snippet / matched body text shown under the title. */
|
|
12
|
+
excerpt?: string;
|
|
13
|
+
/** Group label. Items sharing a group render under one heading, in first-seen order. */
|
|
14
|
+
group?: string;
|
|
15
|
+
/** Leading glyph/icon hint for the default renderer ("page" | "hash" | custom string). */
|
|
16
|
+
icon?: string;
|
|
17
|
+
/** Extra terms matched in static-filter mode (never shown). */
|
|
18
|
+
keywords?: string[];
|
|
19
|
+
/** Optional destination. The default renderer makes the row a link and Enter navigates here. */
|
|
20
|
+
href?: string;
|
|
21
|
+
/** Arbitrary payload handed back to `onSelect`. */
|
|
22
|
+
data?: D;
|
|
23
|
+
}
|
|
24
|
+
/** A click/keypress context passed to `onSelect`, so a consumer can honor modifier-clicks
|
|
25
|
+
* (open in new tab, etc.) without the renderer owning that policy. */
|
|
26
|
+
export interface CtrlkSelectEvent {
|
|
27
|
+
metaKey?: boolean;
|
|
28
|
+
ctrlKey?: boolean;
|
|
29
|
+
shiftKey?: boolean;
|
|
30
|
+
altKey?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface CtrlkOptions<D = unknown> {
|
|
33
|
+
/** Async data source, called (debounced) on every query change; return ranked items.
|
|
34
|
+
* Receives an AbortSignal so stale in-flight requests can be dropped. When set, it takes
|
|
35
|
+
* precedence over `items`/`filter`. */
|
|
36
|
+
search?: (query: string, signal: AbortSignal) => CtrlkItem<D>[] | Promise<CtrlkItem<D>[]>;
|
|
37
|
+
/** Static item pool, filtered locally by `filter` (cmdk-style). Used when `search` is absent. */
|
|
38
|
+
items?: CtrlkItem<D>[];
|
|
39
|
+
/** Static-mode scorer: returns >0 to keep an item (higher = ranked earlier), 0 to drop it.
|
|
40
|
+
* Defaults to a case-insensitive substring/subsequence match over title + keywords. */
|
|
41
|
+
filter?: (item: CtrlkItem<D>, query: string) => number;
|
|
42
|
+
/** Milliseconds to wait after the last keystroke before calling `search`. Default 120. */
|
|
43
|
+
debounce?: number;
|
|
44
|
+
/** Rows to show when the query is empty (recent searches, suggestions). Default: none in
|
|
45
|
+
* async mode, the full pool in static mode. */
|
|
46
|
+
empty?: CtrlkItem<D>[];
|
|
47
|
+
/** Invoked when a row is chosen (Enter or click). The default renderer additionally
|
|
48
|
+
* navigates `item.href` when present and the event has no opening modifier. */
|
|
49
|
+
onSelect?: (item: CtrlkItem<D>, ev?: CtrlkSelectEvent) => void;
|
|
50
|
+
}
|
|
51
|
+
/** A group of rows under one heading. The ungrouped bucket has `label === ""`. */
|
|
52
|
+
export interface CtrlkGroup<D = unknown> {
|
|
53
|
+
label: string;
|
|
54
|
+
items: CtrlkItem<D>[];
|
|
55
|
+
}
|
|
56
|
+
/** The full observable state. The renderer is a pure function of this. */
|
|
57
|
+
export interface CtrlkState<D = unknown> {
|
|
58
|
+
open: boolean;
|
|
59
|
+
query: string;
|
|
60
|
+
loading: boolean;
|
|
61
|
+
/** Resolved, ordered rows for the current query (post-filter in static mode). */
|
|
62
|
+
items: CtrlkItem<D>[];
|
|
63
|
+
/** `items` bucketed by group, in first-seen order. */
|
|
64
|
+
groups: CtrlkGroup<D>[];
|
|
65
|
+
/** Index into `items` of the highlighted row, or -1 when there are none. */
|
|
66
|
+
activeIndex: number;
|
|
67
|
+
/** The last async-source error, if any (cleared on the next successful resolve). */
|
|
68
|
+
error: Error | null;
|
|
69
|
+
}
|
|
70
|
+
/** The headless controller. Drive it from any renderer (or none). */
|
|
71
|
+
export interface Ctrlk<D = unknown> {
|
|
72
|
+
getState(): Readonly<CtrlkState<D>>;
|
|
73
|
+
/** Subscribe to state changes. Fires once immediately with the current state. Returns an unsubscribe. */
|
|
74
|
+
subscribe(fn: (state: Readonly<CtrlkState<D>>) => void): () => void;
|
|
75
|
+
open(): void;
|
|
76
|
+
close(): void;
|
|
77
|
+
toggle(): void;
|
|
78
|
+
setQuery(query: string): void;
|
|
79
|
+
/** Move the active row by `delta`, wrapping around the ends. */
|
|
80
|
+
move(delta: number): void;
|
|
81
|
+
/** Set the active row to an absolute index (clamped to range). */
|
|
82
|
+
setActive(index: number): void;
|
|
83
|
+
/** Choose a row — the given `item`, or the active one — firing `onSelect`. */
|
|
84
|
+
select(item?: CtrlkItem<D>, ev?: CtrlkSelectEvent): void;
|
|
85
|
+
/** Cancel timers/in-flight requests and drop all subscribers. */
|
|
86
|
+
destroy(): void;
|
|
87
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kurajs/ctrlk",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Kura — ⌘K command palette: a headless, zero-dependency command/search menu with a built-in default renderer. Framework-agnostic (vanilla DOM); mirrors cmdk's model & accessibility without React.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"test": "node --experimental-strip-types --test test/*.test.ts"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"kura",
|
|
21
|
+
"june",
|
|
22
|
+
"command-palette",
|
|
23
|
+
"cmdk",
|
|
24
|
+
"ctrl-k",
|
|
25
|
+
"command-menu",
|
|
26
|
+
"search",
|
|
27
|
+
"headless",
|
|
28
|
+
"a11y"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://kura.build",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.7.0"
|
|
34
|
+
},
|
|
35
|
+
"author": "Lawrence Lin",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/kurajs/kura.git",
|
|
42
|
+
"directory": "packages/ctrlk"
|
|
43
|
+
}
|
|
44
|
+
}
|