diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..1951ef3
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,41 @@
+name: Build & Test
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, reopened, ready_for_review]
+
+jobs:
+ main:
+ if: ${{ !github.event.pull_request.draft }}
+ name: "Build & Test"
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ container:
+ image: mcr.microsoft.com/playwright:v1.59.1-noble
+ options: --user 1001:1001 # Run as non-root user for security
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: latest
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm i
+
+ - name: Build
+ run: pnpm run build
+
+ - name: Test
+ run: pnpm run test
+ env:
+ CI: true
diff --git a/.gitignore b/.gitignore
index 0245aad..d0f9ec9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
-coverage
+coverage/
node_modules/
.idea/
.vscode/
.vs/
dist/
+playwright-report/
+test-results/
diff --git a/package.json b/package.json
index 858c424..1aaa956 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
],
"scripts": {
"build": "rimraf dist && rollup -c",
+ "test": "playwright test",
"size-report": "node scripts/compare-package-sizes.js"
},
"keywords": [
@@ -35,6 +36,7 @@
"vue": "^3.4.0"
},
"devDependencies": {
+ "@playwright/test": "^1.59.1",
"@ramstack/hotkey": "^1.2.1",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
@@ -44,6 +46,7 @@
"rollup": "^4.60.1",
"rollup-plugin-bundle-size": "^1.0.3",
"terser": "^5.46.1",
- "tslib": "^2.8.1"
+ "tslib": "^2.8.1",
+ "vue": "^3.4.0"
}
}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..b8f6c59
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,32 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests",
+ fullyParallel: true,
+ // Fail the build on CI if you accidentally left test.only in the source code.
+ forbidOnly: !!process.env.CI,
+ // Retry on CI only
+ retries: process.env.CI ? 2 : 0,
+ // Opt out of parallel tests on CI.
+ workers: process.env.CI ? 2 : undefined,
+ reporter: [
+ ["html", { open: "never", outputFolder: "playwright-report" }]
+ ],
+ use: {
+ trace: "on-first-retry",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"]
+ },
+ },
+ {
+ name: "firefox",
+ use: {
+ ...devices["Desktop Firefox"]
+ },
+ }
+ ]
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be52849..2c1891f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,11 +7,10 @@ settings:
importers:
.:
- dependencies:
- vue:
- specifier: ^3.4.0
- version: 3.5.30(typescript@5.9.3)
devDependencies:
+ '@playwright/test':
+ specifier: ^1.59.1
+ version: 1.59.1
'@ramstack/hotkey':
specifier: ^1.2.1
version: 1.2.1
@@ -42,6 +41,9 @@ importers:
tslib:
specifier: ^2.8.1
version: 2.8.1
+ vue:
+ specifier: ^3.4.0
+ version: 3.5.30(typescript@5.9.3)
packages:
@@ -78,6 +80,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@playwright/test@1.59.1':
+ resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@ramstack/hotkey@1.2.1':
resolution: {integrity: sha512-Sbe0lY6i3pkPS/guxX0yLDIme0Y3QC/oBrwvEkxbk6iXqQW1gygnOt+ia/kpziPyPS/GeEyIljbXMCi9lgTPIg==}
@@ -350,6 +357,11 @@ packages:
resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==}
engines: {node: '>=0.10.0'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -430,6 +442,16 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ playwright-core@1.59.1:
+ resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.59.1:
+ resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@@ -542,6 +564,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@playwright/test@1.59.1':
+ dependencies:
+ playwright: 1.59.1
+
'@ramstack/hotkey@1.2.1': {}
'@rollup/plugin-node-resolve@16.0.3(rollup@4.60.1)':
@@ -753,6 +779,9 @@ snapshots:
escape-string-regexp: 1.0.5
object-assign: 4.1.1
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -820,6 +849,14 @@ snapshots:
picomatch@4.0.3: {}
+ playwright-core@1.59.1: {}
+
+ playwright@1.59.1:
+ dependencies:
+ playwright-core: 1.59.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
diff --git a/tests/assets/index.html b/tests/assets/index.html
new file mode 100644
index 0000000..eb604d1
--- /dev/null
+++ b/tests/assets/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/vue-hotkey.spec.js b/tests/vue-hotkey.spec.js
new file mode 100644
index 0000000..be62c51
--- /dev/null
+++ b/tests/vue-hotkey.spec.js
@@ -0,0 +1,417 @@
+import process from "node:process";
+import { test, expect } from "@playwright/test";
+
+test.beforeEach(async ({ page }) => {
+ await page.goto(`file://${process.cwd()}/tests/assets/index.html`);
+});
+
+async function mount(page, template, data = {}) {
+ await page.evaluate(
+ state => window.__hotkeyTest.mount(state),
+ { template, data }
+ );
+}
+
+test("should trigger when Ctrl+K is pressed on element", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should trigger when Shift+K is pressed on element", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Shift+K");
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should trigger when Ctrl+Alt+Shift+K is pressed", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+Alt+Shift+K");
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should not trigger when additional modifier keys are pressed", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Alt+Shift+K");
+
+ await expect(page.locator("#count")).toHaveText("0");
+});
+
+test("should not trigger on non-matching keys", async ({ page }) => {
+ await mount(page, `
+
+
count++">
+
{{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.keyboard.press("Control+J");
+ await page.keyboard.press("Control+Shift+K");
+ await page.keyboard.press("K");
+
+ await expect(page.locator("#count")).toHaveText("0");
+});
+
+test("should trigger on window when .window modifier is set", async ({ page }) => {
+ await mount(page, `
+
+
+
count++">
+
{{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#other").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should trigger on document when .document modifier is set", async ({ page }) => {
+ await mount(page, `
+
+
+
count++">
+
{{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#other").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should trigger only once when .once modifier is set", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should trigger multiple times without .once modifier", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("2");
+});
+
+test("should trigger on keydown by default", async ({ page }) => {
+ await mount(page, `
+
+ type = e.type">
+ {{ type }}
+
`,
+ { type: "" }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#type")).toHaveText("keydown");
+});
+
+test("should trigger on keyup when arg is 'keyup'", async ({ page }) => {
+ await mount(page, `
+
+ type = e.type">
+ {{ type }}
+
`,
+ { type: "" }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.down("Control");
+ await page.keyboard.down("K");
+
+ await expect(page.locator("#type")).toHaveText("");
+
+ await page.keyboard.up("K");
+
+ await expect(page.locator("#type")).toHaveText("keyup");
+});
+
+test("should register multiple hotkeys from multiple modifiers", async ({ page }) => {
+ await mount(page, `
+
+ hotkey = e.hotkey">
+ {{ hotkey }}
+
`,
+ { hotkey: "" }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+S");
+ await expect(page.locator("#hotkey")).toHaveText("ctrl+s");
+
+ await page.keyboard.press("Shift+S");
+ await expect(page.locator("#hotkey")).toHaveText("shift+s");
+});
+
+test("should set hotkey property on the event", async ({ page }) => {
+ await mount(page, `
+
+ hotkey = e.hotkey">
+ {{ hotkey }}
+
`,
+ { hotkey: "" }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#hotkey")).toHaveText("ctrl+k");
+});
+
+test("should call preventDefault when .prevent modifier is set", async ({ page }) => {
+ await mount(page, `
+
+ prevented = e.defaultPrevented">
+ {{ prevented }}
+
`,
+ { prevented: false }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#prevented")).toHaveText("true");
+});
+
+test("should not call preventDefault without .prevent modifier", async ({ page }) => {
+ await mount(page, `
+
+ prevented = e.defaultPrevented">
+ {{ prevented }}
+
`,
+ { prevented: false }
+ );
+
+ await page.locator("#text").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#prevented")).toHaveText("false");
+});
+
+test("should call stopPropagation when .stop modifier is set", async ({ page }) => {
+ await mount(page, `
+
+
+
+
+
{{ outer }}
+
{{ inner }}
+
`,
+ {
+ outer: 0,
+ inner: 0
+ }
+ );
+
+ await page.locator("#btn").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#inner")).toHaveText("1");
+ await expect(page.locator("#outer")).toHaveText("0");
+});
+
+test("should propagate event without .stop modifier", async ({ page }) => {
+ await mount(page, `
+
+
+
+
+
{{ outer }}
+
{{ inner }}
+
`,
+ {
+ outer: 0,
+ inner: 0
+ }
+ );
+
+ await page.locator("#btn").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#inner")).toHaveText("1");
+ await expect(page.locator("#outer")).toHaveText("1");
+});
+
+test("should not trigger on untrusted events when .trusted modifier is set", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").evaluate(el => {
+ el.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "k",
+ code: "KeyK",
+ ctrlKey: true,
+ bubbles: true
+ })
+ );
+ });
+
+ await expect(page.locator("#count")).toHaveText("0");
+});
+
+test("should trigger on untrusted events without .trusted modifier", async ({ page }) => {
+ await mount(page, `
+
+ count++">
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#text").evaluate(el => {
+ el.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "k",
+ code: "KeyK",
+ ctrlKey: true,
+ bubbles: true
+ })
+ );
+ });
+
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should clean up listener when element is removed", async ({ page }) => {
+ await mount(page, `
+
+
count++">
+
+
{{ count }}
+
`,
+ {
+ count: 0,
+ visible: true
+ }
+ );
+
+ await page.keyboard.press("Control+K");
+ await expect(page.locator("#count")).toHaveText("1");
+
+ const target = page.locator("#target");
+ await expect(target).toBeAttached();
+
+ await page.locator("#remove").click();
+ await target.waitFor({ state: "detached" });
+
+ await page.keyboard.press("Control+K");
+ await expect(page.locator("#count")).toHaveText("1");
+});
+
+test("should not trigger on elements with [data-hotkey-ignore]", async ({ page }) => {
+ await mount(page, `
+ count++">
+
+ {{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#ignored").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("0");
+});
+
+test("should not trigger when parent has [data-hotkey-ignore]", async ({ page }) => {
+ await mount(page, `
+ count++">
+
+
{{ count }}
+
`,
+ { count: 0 }
+ );
+
+ await page.locator("#ignored-via-parent").focus();
+ await page.keyboard.press("Control+K");
+
+ await expect(page.locator("#count")).toHaveText("0");
+});
+
+test("should not throw when directive has no handler", async ({ page }) => {
+ const errors = [];
+ page.on("pageerror", e => errors.push(e));
+
+ await mount(page, `
+
+ `);
+
+ await page.keyboard.press("Control+K");
+
+ expect(errors).toHaveLength(0);
+});
diff --git a/tsconfig.json b/tsconfig.json
index 81629e3..a56628e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,13 +1,13 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "module": "ES2020",
- "declaration": true,
- "outDir": "./dist",
- "declarationDir": "./dist/types",
- "moduleResolution": "Bundler"
- },
- "vueCompilerOptions": {
- "target": 3.3
- }
-}
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "es2020",
+ "declaration": true,
+ "outDir": "./dist",
+ "declarationDir": "./dist/types",
+ "moduleResolution": "Bundler"
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}