diff --git a/README.md b/README.md index 2d87c5f..91661ce 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,18 @@ Provides `x-destroy` provides directive, which is the opposite of `x-init` and a ``` +**[@ramstack/alpinegear-typegrab](https://www.npmjs.com/package/@ramstack/alpinegear-typegrab)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/typegrab))
+Provides the `x-typegrab` directive, which automatically focuses an element when the user starts typing, +as long as no editable element is currently focused. This is useful for search inputs or similar UX patterns +where typing should immediately direct input to a specific field. +```html + +``` + **[@ramstack/alpinegear-main](https://www.npmjs.com/package/@ramstack/alpinegear-main)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/main))
Is a combined plugin that includes several directives, providing a convenient all-in-one package. diff --git a/src/plugins/typegrab/README.md b/src/plugins/typegrab/README.md new file mode 100644 index 0000000..1c22ec8 --- /dev/null +++ b/src/plugins/typegrab/README.md @@ -0,0 +1,77 @@ +# @ramstack/alpinegear-typegrab + +[![NPM](https://img.shields.io/npm/v/@ramstack/alpinegear-typegrab)](https://www.npmjs.com/package/@ramstack/alpinegear-typegrab) +[![MIT](https://img.shields.io/github/license/rameel/ramstack.alpinegear.js)](https://github.com/rameel/ramstack.alpinegear.js/blob/main/LICENSE) + +`@ramstack/alpinegear-typegrab` is a lightweight plugin for [Alpine.js](https://alpinejs.dev/) that provides the `x-typegrab` directive. + +The directive automatically focuses an element when the user starts typing **any printable, non-whitespace character**, +as long as no editable element is currently focused. This is useful for search inputs, command palettes, +and similar UX patterns where typing should immediately direct input to a specific field. + +## How it works + +* Listens globally to the `keydown` event. +* Triggers only for **printable, non-whitespace characters**. +* Ignores events with `Ctrl`, `Alt`, or `Meta` modifiers. +* Does nothing if the active element is `input`, `textarea`, or an element with `contenteditable`. +* Focuses the element marked with `x-typegrab`. + +## Installation + +### Using CDN + +```html + + + + + +``` + +### Using NPM + +```bash +npm install --save @ramstack/alpinegear-typegrab +``` + +```js +import Alpine from "alpinejs"; +import Typegrab from "@ramstack/alpinegear-typegrab"; + +Alpine.plugin(Typegrab); +Alpine.start(); +``` + +## Usage + +```html + +``` + +When the user presses any printable character key (excluding whitespace) while no other editable element is focused, +this input will automatically receive focus. + +## Notes + +* The directive does not cancel or modify the original keyboard event. +* The target element must be focusable. +* Focus will not be stolen from active editable elements. + +## Source code + +You can find the source code for this plugin on GitHub: +https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/typegrab + +## Contributions + +Bug reports and contributions are welcome. + +## License + +This package is released as open source under the **MIT License**. +See the [LICENSE](https://github.com/rameel/ramstack.alpinegear.js/blob/main/LICENSE) file for more details. diff --git a/src/plugins/typegrab/index.js b/src/plugins/typegrab/index.js new file mode 100644 index 0000000..86ce958 --- /dev/null +++ b/src/plugins/typegrab/index.js @@ -0,0 +1,34 @@ +import { listen } from "@/utilities/utils"; + +const matches = (el, selector) => el.matches(selector); + +const is_active_element_editable = () => { + const el = document.activeElement; + if (el === document.body || !el) { + return false; + } + + return matches(el, "input") + || matches(el, "textarea") + || el.isContentEditable; +} + +const is_printable_key_pressed = ({ key, metaKey, ctrlKey, altKey }) => + !metaKey && !ctrlKey && !altKey && /^[^\p{M}\p{Z}\p{C}]$/u.test(key); + +function plugin({ directive }) { + directive("typegrab", (el, _, { cleanup }) => { + cleanup( + listen(document, "keydown", e => { + if (!is_active_element_editable() && is_printable_key_pressed(e)) { + el.focus(); + } + }, { passive: true }) + ); + }); +} + +export default plugin; +export { + plugin as typegrab +} diff --git a/src/plugins/typegrab/package.json b/src/plugins/typegrab/package.json new file mode 100644 index 0000000..16856e0 --- /dev/null +++ b/src/plugins/typegrab/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ramstack/alpinegear-typegrab", + "version": "0.0.0", + "description": "@ramstack/alpinegear-typegrab provides the x-typegrab Alpine.js directive, which automatically focuses an element when the user starts typing and no editable element is active.", + "author": "Rameel Burhan", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rameel/ramstack.alpinegear.js.git", + "directory": "src/plugins/typegrab" + }, + "keywords": [ + "alpine.js", + "alpinejs", + "alpinejs-directive", + "alpinejs-plugin", + "keyboard", + "focus", + "ux" + ], + "main": "alpinegear-typegrab.js", + "module": "alpinegear-typegrab.esm.js" +} diff --git a/tests/playwright/assets/page.html b/tests/playwright/assets/page.html index 1ddd5c2..a5ded7f 100644 --- a/tests/playwright/assets/page.html +++ b/tests/playwright/assets/page.html @@ -15,6 +15,7 @@ + diff --git a/tests/playwright/x-typegrab.spec.js b/tests/playwright/x-typegrab.spec.js new file mode 100644 index 0000000..77c28a5 --- /dev/null +++ b/tests/playwright/x-typegrab.spec.js @@ -0,0 +1,164 @@ +import { expect, test } from "@playwright/test"; +import { set_html } from "./assets/utils"; + +test("x-typegrab focuses element on printable key press", async ({ page }) => { + await set_html(page, ` +
+ +
+ `); + + await page.keyboard.press("a"); + + const is_focused = await page.evaluate(() => + document.activeElement?.id === "target" + ); + + expect(is_focused).toBe(true); +}); + +test("x-typegrab does not steal focus from input", async ({ page }) => { + await set_html(page, ` +
+ + +
+ `); + + await page.locator("#active").focus(); + await page.keyboard.press("a"); + + const active_id = await page.evaluate(() => + document.activeElement?.id + ); + + expect(active_id).toBe("active"); +}); + +test("x-typegrab does not steal focus from textarea", async ({ page }) => { + await set_html(page, ` +
+ + +
+ `); + + await page.locator("#active").focus(); + await page.keyboard.press("b"); + + const active_id = await page.evaluate(() => + document.activeElement?.id + ); + + expect(active_id).toBe("active"); +}); + +test("x-typegrab does not steal focus from contenteditable", async ({ page }) => { + await set_html(page, ` +
+
+ +
+ `); + + await page.locator("#active").focus(); + await page.keyboard.press("c"); + + const active_id = await page.evaluate(() => + document.activeElement?.id + ); + + expect(active_id).toBe("active"); +}); + +test("x-typegrab ignores modifier keys", async ({ page }) => { + await set_html(page, ` +
+ +
+ `); + + await page.keyboard.press("Control+a"); + + const is_focused = await page.evaluate(() => + document.activeElement?.id === "target" + ); + + expect(is_focused).toBe(false); +}); + +test("x-typegrab ignores non-printable keys", async ({ page }) => { + await set_html(page, ` +
+ +
+ `); + + const is_focused = () => page.evaluate(() => document.activeElement?.id === "target"); + + await page.keyboard.press("Enter"); + expect(await is_focused()).toBe(false); + + await page.keyboard.press("Space"); + expect(await is_focused()).toBe(false); + + await page.keyboard.press(" "); + expect(await is_focused()).toBe(false); +}); + +test("x-typegrab supports unicode alphabetic keys", async ({ page }) => { + await set_html(page, ` +
+ +
+ `); + + // keyboard.press() supports only predefined keys + // and cannot be used for arbitrary Unicode characters. + // keyboard.type() does not trigger a keydown event with the actual "key" value. + // Therefore, we manually dispatch a KeyboardEvent to verify Unicode handling. + await page.evaluate(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ф", + bubbles: true + }) + ); + }); + + const is_focused = await page.evaluate(() => + document.activeElement?.id === "target" + ); + + expect(is_focused).toBe(true); +}); + +test("x-typegrab works when body is active element", async ({ page }) => { + await set_html(page, ` +
+ +
+ `); + + await page.evaluate(() => document.body.focus()); + await page.keyboard.press("z"); + + const is_focused = await page.evaluate(() => + document.activeElement?.id === "target" + ); + + expect(is_focused).toBe(true); +}); + +test("x-typegrab preserves typed character", async ({ page }) => { + await set_html(page, ` +
+ +
+ `); + + await page.keyboard.press("@"); + + await expect(page.locator("#target")).toBeFocused(); + await expect(page.locator("#target")).toHaveValue("@"); +});