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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ Provides `x-destroy` provides directive, which is the opposite of `x-init` and a
</template>
```

**[@ramstack/alpinegear-typegrab](https://www.npmjs.com/package/@ramstack/alpinegear-typegrab)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/typegrab))<br>
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
<input
type="search"
placeholder="Type to search..."
x-typegrab
/>
```

**[@ramstack/alpinegear-main](https://www.npmjs.com/package/@ramstack/alpinegear-main)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/main))<br>
Is a combined plugin that includes several directives, providing a convenient all-in-one package.

Expand Down
77 changes: 77 additions & 0 deletions src/plugins/typegrab/README.md
Original file line number Diff line number Diff line change
@@ -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
<!-- alpine.js plugin -->
<script src="https://cdn.jsdelivr.net/npm/@ramstack/alpinegear-typegrab@1/alpinegear-typegrab.min.js" defer></script>

<!-- alpine.js -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
```

### 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
<input
type="search"
placeholder="Type to search..."
x-typegrab
/>
```

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.
34 changes: 34 additions & 0 deletions src/plugins/typegrab/index.js
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions src/plugins/typegrab/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions tests/playwright/assets/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

<script src="../../../dist/main/alpinegear-main.js"></script>
<script src="../../../dist/destroy/alpinegear-destroy.js"></script>
<script src="../../../dist/typegrab/alpinegear-typegrab.js"></script>
<script src="alpine.3.14.1.js"></script>
</body>
</html>
164 changes: 164 additions & 0 deletions tests/playwright/x-typegrab.spec.js
Original file line number Diff line number Diff line change
@@ -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, `
<div x-data>
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<input id="active" />
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<textarea id="active"></textarea>
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<div id="active" contenteditable="true"></div>
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<input id="target" x-typegrab />
</div>
`);

// 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, `
<div x-data>
<input id="target" x-typegrab />
</div>
`);

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, `
<div x-data>
<input id="target" x-typegrab />
</div>
`);

await page.keyboard.press("@");

await expect(page.locator("#target")).toBeFocused();
await expect(page.locator("#target")).toHaveValue("@");
});