Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ jobs:
- name: Check
run: pnpm check

- name: Size
run: node scripts/size.mjs

- name: Build site
run: pnpm site:build
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ dist
coverage
*.log
.vitest/

# Claude Code per-user settings (may contain approved commands with secrets)
.claude/settings.local.json
.impeccable/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
A tiny, fast, type-safe keybinding library, inspired by
[tinykeys](https://github.com/jamiebuilds/tinykeys).

- **~2 KB gzipped**, zero dependencies, ESM + CJS
- **~3 KB gzipped**, zero dependencies, ESM + CJS
- **Type-checked bindings**: `"Hyper+k"` or `"Shift+"` fail to compile
- **Fast dispatch**: bitmask modifiers, no per-event timers
- Per-binding `preventDefault`, `stopPropagation`, `repeat`, `when`, `once`
Expand Down
2 changes: 0 additions & 2 deletions bench/dispatch.bench.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// Compares the built microkeys bundle against upstream tinykeys.
// Run with `npm run bench` (builds first).
import { Window } from "happy-dom";
import { Bench } from "tinybench";

Expand All @@ -9,7 +8,6 @@ console.warn = () => {}; // silence tinykeys conflict warnings
const { createKeybindingsHandler: microHandler } = await import("../dist/index.js");
const { createKeybindingsHandler: tinyHandler } = await import("tinykeys");

// 104 realistic bindings: single keys, modifiers, $mod, and sequences.
const map = {};
for (const k of "abcdefghijklmnopqrstuvwxyz") {
map[k] = () => {};
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
"description": "A tiny, fast, type-safe keybinding library.",
"keywords": [
"hotkeys",
"key-sequences",
"keybindings",
"keyboard",
"keyboard-shortcuts",
"react",
"react-hooks",
"shortcuts",
"typescript"
"tinykeys",
"type-safe",
"typescript",
"zero-dependencies"
],
"homepage": "https://microkeys.dhrv.pw",
"bugs": {
Expand Down Expand Up @@ -57,6 +64,7 @@
"test": "vp test run",
"test:watch": "vp test",
"bench": "pnpm build && node bench/dispatch.bench.mjs",
"size": "pnpm build && node scripts/size.mjs",
"check": "vp check && vp test run && vp pack",
"prepublishOnly": "pnpm check",
"site:dev": "pnpm --filter microkeys-site dev",
Expand Down
17 changes: 17 additions & 0 deletions scripts/size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { gzipSync } from "node:zlib";

const LIMIT = 3.25 * 1024;
const dist = new URL("../dist/", import.meta.url);

function bundle(file, seen = new Set()) {
if (seen.has(file)) return "";
seen.add(file);
const code = readFileSync(new URL(file, dist), "utf8");
const imports = [...code.matchAll(/from\s*"\.\/([^"]+)"/g)].map((m) => m[1]);
return code + imports.map((dep) => bundle(dep, seen)).join("");
}

const bytes = gzipSync(bundle("index.js"), { level: 9 }).length;
console.log(`microkeys: ${bytes} B gzipped (limit ${LIMIT} B)`);
if (bytes > LIMIT) process.exit(1);
17 changes: 0 additions & 17 deletions site/.impeccable/hook.cache.json

This file was deleted.

16 changes: 0 additions & 16 deletions site/src/home/demos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ function plainKey(e: KeyboardEvent): string | null | undefined {
return eventModifiers(e) === 0 ? e.key.toLowerCase() : null;
}

// ---------------------------------------------------------------------------
// Command palette: $mod+k, then arrows/enter/escape bound to the input itself
// ---------------------------------------------------------------------------

const COMMANDS = ["New file", "Open recent", "Toggle sidebar", "Settings"];

export function PaletteDemo() {
Expand Down Expand Up @@ -122,10 +118,6 @@ export function PaletteDemo() {
);
}

// ---------------------------------------------------------------------------
// Sequences: g i / g s / g d
// ---------------------------------------------------------------------------

// Visitors press G, then look at the keycaps before the second key, so this
// demo allows more than microkeys' default 1s between presses.
const GOTO_TIMEOUT = 2000;
Expand Down Expand Up @@ -194,10 +186,6 @@ export function SequenceDemo() {
);
}

// ---------------------------------------------------------------------------
// Live modifier visualizer
// ---------------------------------------------------------------------------

const APPLE_MODS = [
["⌃", 8, "Control"],
["⌥", 4, "Alt"],
Expand Down Expand Up @@ -278,10 +266,6 @@ export function ModifiersDemo() {
);
}

// ---------------------------------------------------------------------------
// Konami code: a 10-press sequence
// ---------------------------------------------------------------------------

const KONAMI = [
"ArrowUp",
"ArrowUp",
Expand Down
8 changes: 0 additions & 8 deletions site/src/tests/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ function typed(keys: string, last: string[] = []): Step[] {
}

export const CASES: Case[] = [
// ------------------------------------------------------------- keys
{
id: "key",
group: "Keys",
Expand Down Expand Up @@ -135,7 +134,6 @@ export const CASES: Case[] = [
steps: [{ press: p("+", "shift"), expect: ["Shift++"] }],
},

// ------------------------------------------------------------- modifiers
{
id: "mods-each",
group: "Modifiers",
Expand Down Expand Up @@ -235,7 +233,6 @@ export const CASES: Case[] = [
],
},

// ------------------------------------------------------------- altgraph
{
id: "altgraph",
group: "AltGraph",
Expand Down Expand Up @@ -270,7 +267,6 @@ export const CASES: Case[] = [
steps: [{ press: p("€", "altGraph alt", { code: "KeyE" }), expect: [] }],
},

// ------------------------------------------------------------- sequences
{
id: "seq",
group: "Sequences",
Expand Down Expand Up @@ -370,7 +366,6 @@ export const CASES: Case[] = [
],
},

// ------------------------------------------------------------- config
{
id: "prevent",
group: "Binding options",
Expand Down Expand Up @@ -469,7 +464,6 @@ export const CASES: Case[] = [
],
},

// ------------------------------------------------------------- layouts
{
id: "layout-non-latin",
group: "Layouts",
Expand Down Expand Up @@ -498,7 +492,6 @@ export const CASES: Case[] = [
steps: [{ press: p("a", "", { code: "KeyQ" }), expect: ["a"] }],
},

// ------------------------------------------------------------- ignore
{
id: "ignore-fields",
group: "Ignore",
Expand Down Expand Up @@ -551,7 +544,6 @@ export const CASES: Case[] = [
],
},

// ------------------------------------------------------------- errors
{
id: "error-modifier",
group: "Errors",
Expand Down
1 change: 0 additions & 1 deletion site/src/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,6 @@ export function ThemeToggle() {
);
}

/** Mounts the global key sounds for the page. */
export function KeySounds() {
useEffect(() => installKeySounds(), []);
return null;
Expand Down
11 changes: 1 addition & 10 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** Platform string, preferring the modern `userAgentData` API. */
const PLATFORM: string =
typeof navigator === "object"
? ((navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData
Expand Down Expand Up @@ -29,7 +28,6 @@ const STRICT_MASK = SHIFT | META | ALT | CONTROL;
*/
const ALT_GRAPH_ALIASES = IS_WINDOWS ? ALT | CONTROL : ALT;

/** Lower-cased modifier spelling → bit. */
const MODIFIER_LOOKUP: { readonly [name: string]: number } = {
shift: SHIFT,
meta: META,
Expand All @@ -44,7 +42,6 @@ const MODIFIER_LOOKUP: { readonly [name: string]: number } = {
$mod: IS_APPLE ? META : CONTROL,
};

/** Returns the modifier bit for a name, or `0` if it isn't a modifier. */
export function modifierBit(name: string): number {
return MODIFIER_LOOKUP[name.toLowerCase()] ?? 0;
}
Expand All @@ -65,9 +62,7 @@ export function eventModifiers(event: KeyboardEvent): number {
return mask;
}

/**
* A single parsed press of a keybinding sequence.
*/
/** A single parsed press of a keybinding sequence. */
export interface KeybindingPress {
/** Bitmask of modifiers that must be held. */
readonly required: number;
Expand Down Expand Up @@ -183,9 +178,6 @@ export function eventInfo(event: KeyboardEvent): EventInfo {
};
}

/**
* Tests if a normalized event matches a single press.
*/
export function matchPress(info: EventInfo, press: KeybindingPress): boolean {
const { key, required, optional, keyBit } = press;

Expand Down Expand Up @@ -214,7 +206,6 @@ export function matchPress(info: EventInfo, press: KeybindingPress): boolean {
return false;
}

// Unexpected strict modifiers change the meaning of the press.
let allowed = required | optional | keyBit;
if (required & ALT_GRAPH) allowed |= ALT_GRAPH_ALIASES;
return (info.mods & STRICT_MASK & ~allowed) === 0;
Expand Down
37 changes: 10 additions & 27 deletions test/microkeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,13 @@ describe("single press", () => {
});

describe("sequences", () => {
test("full sequence", () => {
const cb = vi.fn();
bind({ "y e e t": cb });
type("yeet");
expect(cb).toHaveBeenCalledOnce();
});

test("partial sequence", () => {
test("fires only on the last press", () => {
const cb = vi.fn();
bind({ "y e e t": cb });
type("yee");
expect(cb).not.toHaveBeenCalled();
type("t");
expect(cb).toHaveBeenCalledOnce();
});

test("extra whitespace is tolerated", () => {
Expand Down Expand Up @@ -172,18 +167,12 @@ describe("sequences", () => {
const short = vi.fn();
const long = vi.fn();
const onConflict = vi.fn();
bind({ "g i": long, i: short }, { onConflict });
press("g");
press("i");
expect(long).toHaveBeenCalledOnce();
expect(short).not.toHaveBeenCalled();
});

test("conflict callback", () => {
const onConflict = vi.fn();
bind({ "a b": () => {}, a: () => {} }, { onConflict });
bind({ "a b": long, a: short }, { onConflict });
press("a");
expect(onConflict).toHaveBeenCalledWith("a", ["a b"]);
press("b");
expect(long).toHaveBeenCalledOnce();
expect(short).not.toHaveBeenCalled();
});

test("reset() clears pending sequences", () => {
Expand Down Expand Up @@ -278,18 +267,12 @@ describe("modifiers", () => {
});

describe("optional modifiers", () => {
test("fires without optional modifier", () => {
test("fires with or without the optional modifier", () => {
const cb = vi.fn();
bind({ "[Shift]+a": cb });
press("a");
expect(cb).toHaveBeenCalledOnce();
});

test("fires with optional modifier", () => {
const cb = vi.fn();
bind({ "[Shift]+a": cb });
press("a", { shiftKey: true });
expect(cb).toHaveBeenCalledOnce();
expect(cb).toHaveBeenCalledTimes(2);
});

test("optional $mod", () => {
Expand Down Expand Up @@ -562,7 +545,7 @@ describe("ignore", () => {
});

describe("binding config", () => {
test("preventDefault / stopPropagation per binding", () => {
test("preventDefault per binding", () => {
bind({ a: { handler: () => {}, preventDefault: true } });
expect(press("a").defaultPrevented).toBe(true);
expect(press("b").defaultPrevented).toBe(false);
Expand Down
Loading