@joknoll/svelte-attach-key 0.1.0

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 ADDED
@@ -0,0 +1,102 @@
1
+ # @joknoll/svelte-attach-key
2
+
3
+ Svelte 5 attachments for wiring keyboard shortcuts to DOM elements.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ npm install @joknoll/svelte-attach-key
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```svelte
14
+ <script lang="ts">
15
+ import { hotkey, pressed, formatHint } from "@joknoll/svelte-attach-key";
16
+ </script>
17
+
18
+ <button
19
+ {@attach hotkey("mod+k")}
20
+ onclick={() => console.log("opened")}
21
+ >
22
+ Open command menu
23
+ </button>
24
+ ```
25
+
26
+ `hotkey("mod+k")` sets `aria-keyshortcuts="mod+k"` on the element automatically. `pressed()` reads it back to know which key to watch.
27
+
28
+ Pass alternative shortcuts as an array — either key triggers the element:
29
+
30
+ ```svelte
31
+ <button
32
+ title={formatHint(["j", "arrowdown"])}
33
+ {@attach hotkey(["j", "arrowdown"])}
34
+ >
35
+ Move down
36
+ </button>
37
+ ```
38
+
39
+ Attach a callback instead of relying on `click`:
40
+
41
+ ```svelte
42
+ <button {@attach hotkey("mod+s", (e) => save())}>Save</button>
43
+ ```
44
+
45
+ Conditional attachment:
46
+
47
+ ```svelte
48
+ <button {@attach enabled && hotkey("k")}>Press K</button>
49
+ ```
50
+
51
+ ## API
52
+
53
+ ### `hotkey(keys, onTrigger?, options?)`
54
+
55
+ Triggers `onTrigger` (or `node.click()`) when a matching key is pressed. Sets `aria-keyshortcuts` on the element and restores the prior value on cleanup.
56
+
57
+ | Parameter | Type | Default |
58
+ | ------------------------- | ----------------------------------------------- | -------------- |
59
+ | `keys` | `string \| string[]` | required |
60
+ | `onTrigger` | `(e: KeyboardEvent, node: HTMLElement) => void` | `node.click()` |
61
+ | `options.preventDefault` | `boolean` | `true` |
62
+ | `options.stopPropagation` | `boolean` | `false` |
63
+ | `options.ignoreInputs` | `boolean` | `true` |
64
+ | `options.ignoreRepeat` | `boolean` | `true` |
65
+
66
+ Shortcut strings: `k`, `ctrl+s`, `shift+space`, `mod+/`. `mod` resolves to `⌘` on Mac and `Ctrl` elsewhere.
67
+
68
+ ### `pressed(keys?, className?)`
69
+
70
+ Adds `className` while a matching key is held, removes it on keyup. If `keys` is omitted, reads from `aria-keyshortcuts` (set by `hotkey`).
71
+
72
+ | Parameter | Type | Default |
73
+ | ----------- | -------------------- | ------------------------- |
74
+ | `keys` | `string \| string[]` | reads `aria-keyshortcuts` |
75
+ | `className` | `string` | `"is-pressed"` |
76
+
77
+ ### `formatHint(keys)`
78
+
79
+ Formats a shortcut for display. `mod+s` → `⌘ S` on Mac, `Ctrl + S` on PC.
80
+
81
+ ```ts
82
+ formatHint("mod+s"); // "⌘ S" / "Ctrl + S"
83
+ formatHint(["j", "down"]); // "J / Down"
84
+ ```
85
+
86
+ ### `addTransform(fn)`
87
+
88
+ Registers a transform applied before parsing and formatting. Returns a remover function.
89
+
90
+ ```ts
91
+ const remove = addTransform((s) => s.replaceAll("primary", "mod"));
92
+ ```
93
+
94
+ ### `likelyWithKeyboard()`
95
+
96
+ Returns `true` when the primary pointer is not coarse (i.e. likely has a keyboard). Use to conditionally render shortcut hints.
97
+
98
+ ## Notes
99
+
100
+ - Shortcuts are global by default (fire regardless of which element has focus).
101
+ - Inputs, textareas, selects, and `contenteditable` are ignored by default.
102
+ - Add `data-keyshortcuts-ignore` to a container to suppress shortcuts while focus is inside that subtree.
@@ -0,0 +1,48 @@
1
+ import { Attachment } from "svelte/attachments";
2
+
3
+ //#region src/attachments.d.ts
4
+ interface HotkeyOptions {
5
+ preventDefault?: boolean;
6
+ stopPropagation?: boolean;
7
+ ignoreInputs?: boolean;
8
+ ignoreRepeat?: boolean;
9
+ }
10
+ type HotkeyTrigger = (e: KeyboardEvent, node: HTMLElement) => void;
11
+ /**
12
+ * Attachment that fires `onTrigger` (or `node.click()`) when one of `keys` is pressed.
13
+ * Sets `aria-keyshortcuts` on the element and restores the prior value on cleanup.
14
+ *
15
+ * @example
16
+ * ```svelte
17
+ * <button {@attach hotkey("mod+s", save)}>Save</button>
18
+ * ```
19
+ */
20
+ declare function hotkey(keys: string | string[], onTrigger?: HotkeyTrigger, options?: HotkeyOptions): Attachment<HTMLElement>;
21
+ /**
22
+ * Attachment that adds `className` while one of `keys` is held, removes it on keyup.
23
+ * If `keys` is omitted, reads the shortcut from `aria-keyshortcuts` (set by `hotkey`).
24
+ *
25
+ * @example
26
+ * ```svelte
27
+ * <button {@attach hotkey("mod+s", save)} {@attach pressed("mod+s")}>Save</button>
28
+ * ```
29
+ */
30
+ declare function pressed(keys?: string | string[], className?: string): Attachment<HTMLElement>;
31
+ //#endregion
32
+ //#region src/hint.d.ts
33
+ /**
34
+ * Format a shortcut string (or array of alternatives) into a human-readable hint.
35
+ *
36
+ * @example
37
+ * formatHint("mod+s") // → "⌘ S" on Mac, "Ctrl + S" on PC
38
+ * formatHint(["j", "down"]) // → "J / Down"
39
+ */
40
+ declare function formatHint(shortcut: string | string[]): string;
41
+ /** Returns true when the primary pointing device is likely a mouse/trackpad (not touch-only). SSR-safe. */
42
+ declare function likelyWithKeyboard(): boolean;
43
+ //#endregion
44
+ //#region src/dispatcher.d.ts
45
+ type Transform = (shortcut: string) => string;
46
+ declare function addTransform(fn: Transform): () => void;
47
+ //#endregion
48
+ export { type HotkeyOptions, type HotkeyTrigger, addTransform, formatHint, hotkey, likelyWithKeyboard, pressed };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{on as e}from"svelte/events";const t={esc:`escape`,return:`enter`,del:`delete`,space:` `,up:`arrowup`,down:`arrowdown`,left:`arrowleft`,right:`arrowright`,option:`alt`,command:`meta`,win:`meta`,plus:`+`},n=new Set([`ctrl`,`shift`,`alt`,`meta`,`cmd`,`mod`]),r=typeof navigator<`u`&&/Mac|iPod|iPhone|iPad/.test(navigator.userAgent);function i(e){let n=e.toLowerCase();return t[n]??n}function a(e){let t=e.split(`+`).map(e=>i(e.trim())).filter(Boolean),a={key:``,ctrl:!1,shift:!1,alt:!1,meta:!1};for(let e of t){if(!n.has(e)){a.key=e;continue}e===`ctrl`?a.ctrl=!0:e===`shift`?a.shift=!0:e===`alt`?a.alt=!0:e===`meta`||e===`cmd`?a.meta=!0:e===`mod`&&(r?a.meta=!0:a.ctrl=!0)}return a}function o(e){return e.trim().split(/\s+/).filter(Boolean).map(a)}function s(e){return Array.isArray(e)?e.join(` `):e}function c(e,t){return i(e.key)===t.key&&e.ctrlKey===t.ctrl&&e.shiftKey===t.shift&&e.altKey===t.alt&&e.metaKey===t.meta}const l=new Set([`INPUT`,`TEXTAREA`,`SELECT`]),u=new Set,d=[];let f=null;const p=e=>{let t=e.target;return!!t&&(l.has(t.tagName)||t.isContentEditable)},m=e=>{let t=e.target.parentElement;for(;t;){if(t.hasAttribute(`data-keyshortcuts-ignore`))return document.activeElement!==t;t=t.parentElement}return!1},h=e=>{let t;for(let n of u)n.ignoreRepeat&&e.repeat||n.ignoreInputs&&p(e)||(t??=m(e),!t&&n.shortcuts.some(t=>c(e,t))&&n.handler(e))};function g(t){return u.size===0&&(f=e(window,`keydown`,h)),u.add(t),()=>{u.delete(t),u.size===0&&f&&(f(),f=null)}}function _(e){return d.push(e),()=>{let t=d.indexOf(e);t!==-1&&d.splice(t,1)}}function v(e){return d.reduce((e,t)=>t(e),e)}function y(e){return o(v(e))}function b(e,t,n){return r=>{let{preventDefault:i=!0,stopPropagation:a=!1,ignoreInputs:o=!0,ignoreRepeat:c=!0}=n??{},l=s(e),u=r.getAttribute(`aria-keyshortcuts`);r.setAttribute(`aria-keyshortcuts`,l);let d=g({shortcuts:y(l),handler:e=>{i&&e.preventDefault(),a&&e.stopPropagation(),t?t(e,r):r.click()},ignoreInputs:o,ignoreRepeat:c});return()=>{d(),u===null?r.removeAttribute(`aria-keyshortcuts`):r.setAttribute(`aria-keyshortcuts`,u)}}}function x(t,n=`is-pressed`){return r=>{let i=t===void 0?r.getAttribute(`aria-keyshortcuts`)??``:s(t);if(!i)return;let a=y(i),o=!1,l=()=>{o=!1,r.classList.remove(n)},u=g({shortcuts:a,handler:()=>{o=!0,r.classList.add(n)},ignoreInputs:!0,ignoreRepeat:!0}),d=e(window,`keyup`,e=>{o&&a.some(t=>c(e,t))&&l()}),f=e(window,`blur`,l);return()=>{u(),d(),f(),l()}}}const S={ctrl:[`⌃`,`Ctrl`],shift:[`⇧`,`Shift`],alt:[`⌥`,`Alt`],meta:[`⌘`,`Win`]},C={" ":`Space`,arrowup:`Up`,arrowdown:`Down`,arrowleft:`Left`,arrowright:`Right`,escape:`Esc`,enter:`Enter`,delete:`Delete`,backspace:`Backspace`,tab:`Tab`,home:`Home`,end:`End`,pageup:`PageUp`,pagedown:`PageDown`},w=typeof window<`u`?matchMedia(`(pointer: coarse)`):null;function T(e){return y(s(e)).map(e=>{let t=[];for(let n of[`ctrl`,`shift`,`alt`,`meta`])e[n]&&t.push(S[n][+!r]);let n=e.key;return t.push(C[n]??(n.length===1?n.toUpperCase():n)),t.join(r?` `:` + `)}).join(` / `)}function E(){return w?!w.matches:!1}export{_ as addTransform,T as formatHint,b as hotkey,E as likelyWithKeyboard,x as pressed};
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@joknoll/svelte-attach-key",
3
+ "version": "0.1.0",
4
+ "description": "A Svelte attachment for binding keyboard shortcuts to DOM elements.",
5
+ "keywords": [
6
+ "attachment",
7
+ "hotkey",
8
+ "key",
9
+ "keymap",
10
+ "svelte",
11
+ "svelte5"
12
+ ],
13
+ "homepage": "https://github.com/joknoll/svelte-attach/tree/main/packages/key#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/joknoll/svelte-attach/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "joknoll",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/joknoll/svelte-attach.git",
22
+ "directory": "packages/key"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "type": "module",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": "./dist/index.js",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^25.6.2",
38
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
39
+ "bumpp": "^11.1.0",
40
+ "svelte": "^5.55.0",
41
+ "typescript": "^6.0.3",
42
+ "vite-plus": "latest"
43
+ },
44
+ "peerDependencies": {
45
+ "svelte": ">=5.55.0"
46
+ },
47
+ "scripts": {
48
+ "build": "vp pack",
49
+ "dev": "vp pack --watch",
50
+ "test": "vp test --passWithNoTests",
51
+ "check": "vp check"
52
+ }
53
+ }