yubisac 0.9.1

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.
@@ -0,0 +1,13 @@
1
+ # Contributing
2
+
3
+ Found a bug? Please open an issue and let me know — I'll fix it on my end.
4
+
5
+ Want a feature? Please open an issue — I know there's still a lot missing, so I'd love to hear what you need.
6
+
7
+ If you'd rather fix it yourself, open an issue and send a PR — I'll usually merge it in.
8
+
9
+ ## `null` vs `undefined`
10
+
11
+ Default to `undefined` for "nothing there". Use `null` only when you deliberately put it there, or
12
+ when the DOM/a framework hands it to you (e.g. `elementFromPoint`, React refs, `render(null)` to
13
+ unmount). So a `null` in this code is always on purpose.
package/EXAMPLES.md ADDED
@@ -0,0 +1,186 @@
1
+ # yubisac examples 🫰
2
+
3
+ Copy-paste recipes. Everything is the same idea: **different inputs → one intent → your handler.** The app
4
+ code only ever names the intent (`inspect`, `place`, `context`), never the gesture.
5
+
6
+ - [Presets — bind without a profile](#presets)
7
+ - [Custom profile — your own intents](#custom-profile)
8
+ - [Keyboard = accessibility, for free](#keyboard)
9
+ - [The conflict cases it resolves](#conflict-cases)
10
+ - [Adapters](#adapters) — core / native · Solid · React · Vue · Svelte · React Native
11
+ - [CDN / plain HTML](#cdn)
12
+
13
+ ---
14
+
15
+ ## Presets
16
+
17
+ Common mappings ship built in — bind them by name, no profile:
18
+
19
+ ```ts
20
+ import { yubisac } from 'yubisac'
21
+
22
+ const card = yubisac(el)
23
+ card.clingTo('activate', open) // mouse click · touch tap · Enter/Space
24
+ card.clingTo('secondary-activate', inspect) // mouse double-click · touch/pen long-press
25
+ card.clingTo('context', showMenu) // mouse right-click · ContextMenu key
26
+ ```
27
+
28
+ | Intent | touch / pen | mouse | keyboard |
29
+ | --- | --- | --- | --- |
30
+ | `activate` | tap | click | Enter / Space |
31
+ | `secondary-activate` | long-press | double-click | — |
32
+ | `context` | — ¹ | right-click | ContextMenu |
33
+
34
+ ¹ `context` is a desktop notion (right-click / ContextMenu key). Touch has no right-click, so use `secondary-activate` (a long-press) for the touch secondary action.
35
+
36
+ Keyboard intents need a focusable element — give it `tabindex="0"` (and a `role`) so it can receive the key.
37
+
38
+ ## Custom profile
39
+
40
+ Define your app's intent vocabulary once; the gesture names never leak past this object:
41
+
42
+ ```ts
43
+ const finger = yubisac(el, {
44
+ open: { touch: 'tap', mouse: 'click', keyboard: ['Enter', 'Space'] },
45
+ inspect: { touch: 'long-press', mouse: 'double-click' },
46
+ context: { touch: 'long-press', mouse: 'right-click', keyboard: 'ContextMenu' },
47
+ })
48
+ finger.clingTo('open', openItem)
49
+ finger.clingTo('inspect', openInspector)
50
+ finger.clingTo('context', showMenu)
51
+
52
+ // later
53
+ finger.destroy() // removes every listener
54
+ ```
55
+
56
+ `intent()` returns an `off()` to unbind just that one:
57
+
58
+ ```ts
59
+ const off = finger.clingTo('inspect', openInspector)
60
+ off()
61
+ ```
62
+
63
+ Tune only what a person reasonably tunes:
64
+
65
+ ```ts
66
+ yubisac(el, profile, { hold: 400, tolerance: 12 }) // long-press ms · move px
67
+ ```
68
+
69
+ ## Keyboard
70
+
71
+ Add a `keyboard` key and the same intent works with no pointer at all — "make it work on tablets"
72
+ quietly becomes "make it work for keyboard users too":
73
+
74
+ ```ts
75
+ yubisac(el, {
76
+ inspect: { touch: 'long-press', mouse: 'double-click', keyboard: 'Enter' },
77
+ }).clingTo('inspect', openInspector)
78
+ ```
79
+
80
+ The handler still gets coordinates for a keyboard fire — the element's centre — so a menu can anchor sensibly.
81
+
82
+ ## Conflict cases
83
+
84
+ The reason to reach for a library instead of 20 lines. Resolved once here, deterministically:
85
+
86
+ - a **single** waits out the double-click window only when a **double is actually bound** — otherwise it fires at once
87
+ - a **hold** is abandoned the moment the finger moves past `tolerance` (it was a drag or a scroll)
88
+ - `pointercancel` (the browser taking over for **scroll**) drops the whole recognition — yubisac never sets `touch-action: none`, so the page keeps scrolling
89
+ - the **compatibility mouse events** a browser fires *after* a touch are ignored, so nothing double-counts
90
+ - mouse / touch / pen interleaving on one device stay untangled — the mapping is chosen per **event** `pointerType`, never a device flag
91
+ - the **OS context menu** is suppressed when a right-click intent is bound, so your own menu wins
92
+
93
+ Opt a subtree out (e.g. a card with its own menu) with `data-yubisac-ignore`:
94
+
95
+ ```html
96
+ <div data-yubisac-ignore> … its own context menu lives here … </div>
97
+ ```
98
+
99
+ Drag lives in [goonteh](https://github.com/mrksye/goonteh); yubisac never drags. A hold cancels on movement,
100
+ goonteh's drag begins on movement — complementary, no coordination needed. Put both on the same element.
101
+
102
+ ## Adapters
103
+
104
+ ### core / native
105
+
106
+ ```ts
107
+ import { yubisac } from 'yubisac'
108
+ const off = yubisac(el, { pick: { touch: 'long-press', mouse: 'double-click' } }).clingTo('pick', onPick)
109
+ ```
110
+
111
+ ### Solid
112
+
113
+ ```tsx
114
+ import { cling, Cling } from 'yubisac/solid'
115
+
116
+ // ref helper — fits an existing element
117
+ <div ref={cling({ inspect: openInspector }, { profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } })} />
118
+
119
+ // or the wrapper
120
+ <Cling on={{ 'secondary-activate': place }}>{children}</Cling>
121
+ ```
122
+
123
+ No provider — yubisac is element-local (unlike a shared drag context).
124
+
125
+ ### React
126
+
127
+ ```tsx
128
+ import { useCling, Cling } from 'yubisac/react'
129
+
130
+ function Card() {
131
+ const ref = useCling({ inspect: openInspector }, { profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } })
132
+ return <div ref={ref} />
133
+ }
134
+ // or the wrapper
135
+ <Cling on={{ 'secondary-activate': place }}>{children}</Cling>
136
+ ```
137
+
138
+ ### Vue
139
+
140
+ ```vue
141
+ <script setup>
142
+ import { vCling } from 'yubisac/vue'
143
+ </script>
144
+ <template>
145
+ <div v-cling="{ on: { inspect: openInspector }, profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } }" />
146
+ </template>
147
+ ```
148
+
149
+ ### Svelte
150
+
151
+ ```svelte
152
+ <script>
153
+ import { cling } from 'yubisac/svelte'
154
+ </script>
155
+ <div use:cling={{ on: { inspect: openInspector }, profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } }} />
156
+ ```
157
+
158
+ ### React Native (experimental)
159
+
160
+ No DOM, so its own `PanResponder` recogniser — touch only, and only the `touch` mapping of each intent
161
+ applies. Spread the handlers onto a `View`, or use the `<Cling>` wrapper:
162
+
163
+ ```tsx
164
+ import { useCling, Cling } from 'yubisac/react-native'
165
+
166
+ function Card() {
167
+ const handlers = useCling({ inspect: (e) => openInspector() }, { profile: { inspect: { touch: 'long-press' } } })
168
+ return <View {...handlers} />
169
+ }
170
+ // or
171
+ <Cling on={{ 'secondary-activate': place }}>{children}</Cling>
172
+ ```
173
+
174
+ ## CDN
175
+
176
+ Zero-dep and framework-free, so one `<script>` binds intents anywhere — even a Google Apps Script
177
+ `HtmlService` iframe with no bundler:
178
+
179
+ ```html
180
+ <script src="https://unpkg.com/yubisac@0.9.1"></script>
181
+ <script>
182
+ yubisac(document.getElementById('card'), {
183
+ inspect: { touch: 'long-press', mouse: 'double-click' },
184
+ }).clingTo('inspect', () => openInspector())
185
+ </script>
186
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mrksye
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # yubisac 🫰
2
+
3
+ **Touch-first intent binding.** Design for the tablet — a tap, a long-press — then let the mouse, pen and keyboard **cling to the same intent.** One code path, touch-native, desktop along for the ride.
4
+
5
+ *Pronounced "yubi-sac" — the name respells 指サック, the little cot worn on a fingertip for precise single-finger work.*
6
+
7
+ ```ts
8
+ import { yubisac } from 'yubisac'
9
+
10
+ yubisac(card, {
11
+ inspect: { touch: 'long-press', mouse: 'double-click', pen: 'long-press', keyboard: 'Enter' },
12
+ }).clingTo('inspect', () => openInspector(card))
13
+ ```
14
+
15
+ `openInspector` fires whether you long-pressed on a tablet, double-clicked on a laptop, or pressed Enter. The app names the intent — `inspect` — never the gesture.
16
+
17
+ ## A shift, not another gesture engine
18
+
19
+ The world is going touch-first — a tablet on every job site — but input libraries are still desktop-first: the click is the canonical action, and touch is bolted on. yubisac flips it. **The intent is the anchor**, and every input **clings** to it — static cling, the everyday electrostatic pull — so a touch long-press, a desktop double-click and an Enter key are the *one* `inspect`.
20
+
21
+ Hammer.js, interact.js, [@use-gesture](https://use-gesture.netlify.app) and tinygesture **recognise** gestures and hand them back as *separate* events; Pointer Events unify the device but stop at *which* device. The layer above — binding different inputs to the same intent — is the one nobody carved out. That's all yubisac is, on purpose: grow pinch/rotate/swipe and it's on Hammer's turf, out-muscled. Drag lives in its sibling [goonteh](https://github.com/mrksye/goonteh) (軍手, work gloves for gripping); yubisac (指サック, a finger cot for precise taps) never drags.
22
+
23
+ Recognition is a deliberately small set — `click` · `double-click` · `long-press` · `right-click` · `Enter`/`Space` — and stays small. The value is the deterministic conflict resolution underneath (single-vs-double wait, move cancels a hold, `pointercancel` on scroll, post-touch compatibility-mouse dedup), plus keyboard as a free accessibility layer.
24
+
25
+ ## Runs where a framework can't — CDN, plain HTML
26
+
27
+ Zero dependencies, framework-free core. One `<script>` and you're clinging intents — no bundler, no `npm install` (even inside a Google Apps Script `HtmlService` page):
28
+
29
+ ```html
30
+ <script src="https://unpkg.com/yubisac@0.9.1"></script>
31
+ <script>
32
+ yubisac(document.getElementById('card'), {
33
+ inspect: { touch: 'long-press', mouse: 'double-click' },
34
+ }).clingTo('inspect', () => openInspector())
35
+ </script>
36
+ ```
37
+
38
+ ## Frameworks
39
+
40
+ ```sh
41
+ npm i yubisac
42
+ ```
43
+
44
+ Thin adapters wrap the same core — install only the one you use (each an optional peer dependency); the core needs nothing.
45
+
46
+ | Import | Framework |
47
+ | --- | --- |
48
+ | `yubisac` / `yubisac/core` | Framework-agnostic engine (vanilla TS + DOM) |
49
+ | `yubisac/native` | Vanilla DOM sugar (`cling`) |
50
+ | `yubisac/solid` | SolidJS — `cling`, `<Cling>` |
51
+ | `yubisac/react` | React ≥ 18 — `useCling`, `<Cling>` |
52
+ | `yubisac/vue` | Vue ≥ 3.2 — `v-cling` directive |
53
+ | `yubisac/svelte` | Svelte ≥ 4 — `use:cling` action |
54
+ | `yubisac/react-native` | React Native (**experimental** — its own PanResponder recogniser, touch only) |
55
+
56
+ > 📖 **[EXAMPLES.md](./EXAMPLES.md)** — presets, custom profiles, keyboard/accessibility, the conflict-resolution cases, and per-adapter recipes.
57
+
58
+ ## API (core)
59
+
60
+ `yubisac(el, profile?, config?)` → `{ clingTo(intent, handler) → off(), destroy() }`.
61
+
62
+ - `profile` — `{ [intent]: { touch?, mouse?, pen?, keyboard? } }`; built-in `PRESETS` (`activate`, `secondary-activate`, `context`) fill in known names.
63
+ - gestures — `click`/`tap`, `double-click`/`double-tap`, `long-press`, `right-click`/`context-menu`.
64
+ - `config` — two knobs only: `{ hold=500, tolerance=8 }`.
65
+ - handler receives `{ intent, pointerType, clientX, clientY, currentTarget, originalEvent }`.
66
+
67
+ ## Status
68
+
69
+ Born on a construction-site scheduling app that had to work on a foreman's tablet (touch) and an office laptop (mouse) from one code path. Recognition stays small; the investment is the binding and the conflict resolution, not more gestures. The core is pinned by real-browser tests (Playwright / Chromium) — `bun run test` — covering single-vs-double timing, long-press vs move/scroll, `pointercancel`, compatibility-mouse dedup, keyboard, and teardown.
70
+
71
+ ## Contributing · License
72
+
73
+ Found a bug? Please **open an issue first** — see [CONTRIBUTING.md](./CONTRIBUTING.md). MIT © mrksye.
74
+
75
+ > *One intent, every finger.*
package/core.ts ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * yubisac core — a framework-agnostic **touch-first intent binding** engine.
3
+ *
4
+ * Not a gesture library. Hammer.js, interact.js, @use-gesture and tinygesture all recognise gestures
5
+ * (tap / double-tap / long-press / pinch …) and hand them to you as *separate* events. Pointer Events
6
+ * unify the input device but stop at "which device". The layer nobody owns is the one above: **every
7
+ * input clinging to the same operation intent** — a mouse double-click, a touch long-press and an Enter
8
+ * key are the one `inspect`. The app speaks in intents (`inspect`, `context`, `activate`); raw event
9
+ * names never leak into its code. Design for the tablet first, and let the desktop cling on.
10
+ *
11
+ * That is all yubisac does, and deliberately all it does: bind different inputs to one intent, and resolve
12
+ * the conflicts between them deterministically (single-vs-double wait, move cancels a hold, a touch's
13
+ * trailing compatibility-mouse events, scroll takeover). Recognition stays a tight set on purpose — the
14
+ * moment it grows into a gesture powerhouse it loses to the incumbents on their turf.
15
+ *
16
+ * Basis is the **per-event `pointerType`**, never a device flag: the same machine sends mouse, touch and
17
+ * pen events interchangeably, so the mapping is chosen per event, not per device.
18
+ *
19
+ * The name respells 指サック (yubi-sakku), the little cot worn on a fingertip for precise single-finger
20
+ * work — where goonteh (軍手, work gloves) is the whole hand gripping and dragging, yubisac is one
21
+ * fingertip tapping. Two hand-worn tools, one drag, one touch.
22
+ */
23
+
24
+ /** Which pointing device an event came from — read per event, not per device. */
25
+ export type PointerKind = 'mouse' | 'touch' | 'pen'
26
+
27
+ /**
28
+ * A recognisable gesture token. Synonyms exist so a mapping reads naturally per device
29
+ * (`mouse: 'double-click'` vs `touch: 'double-tap'`); they collapse to the same recogniser.
30
+ */
31
+ export type Gesture = 'click' | 'tap' | 'double-click' | 'double-tap' | 'long-press' | 'context-menu' | 'right-click'
32
+
33
+ /** A keyboard key that fires an intent — `e.key` values (`'Enter'`, `'ContextMenu'`); `'Space'` is accepted for `' '`. */
34
+ export type Key = string
35
+
36
+ /** How one intent is reached, per input. Omit a device to not bind that device for this intent. */
37
+ export type IntentMapping = Partial<Record<PointerKind, Gesture>> & { keyboard?: Key | Key[] }
38
+
39
+ /** A named set of intent → per-input mappings. The app's intent vocabulary lives here, once. */
40
+ export type Profile = Record<string, IntentMapping>
41
+
42
+ /** What a bound handler receives when its intent fires. `clientX/clientY` is the pointer (or the element centre for keyboard). */
43
+ export type IntentEvent = {
44
+ intent: string
45
+ pointerType: PointerKind | 'keyboard'
46
+ clientX: number
47
+ clientY: number
48
+ currentTarget: HTMLElement
49
+ /** The raw event. A right-click intent carries a `MouseEvent` (contextmenu), a keyboard intent a `KeyboardEvent`. */
50
+ originalEvent: PointerEvent | MouseEvent | KeyboardEvent
51
+ }
52
+
53
+ export type IntentHandler = (e: IntentEvent) => void
54
+
55
+ /** A binding handle for one element. Cling handlers to intents; `destroy()` removes every listener. */
56
+ export type Finger = {
57
+ /** Cling a handler to a named intent (from this element's profile, else a built-in preset). Returns an off(). */
58
+ clingTo(intent: string, handler: IntentHandler): () => void
59
+ destroy(): void
60
+ }
61
+
62
+ /**
63
+ * The only two knobs, on purpose. Everything else (the double window, the post-touch compatibility-mouse
64
+ * window) is a browser-timing fact, not an app preference — kept as internal constants so the surface stays
65
+ * a finger cot, not a powered suit.
66
+ */
67
+ export type YubisacConfig = {
68
+ /** Hold this long (ms) without moving to fire a long-press. Default 500. */
69
+ hold?: number
70
+ /** Move more than this (px) and a tap/hold is abandoned (it's a drag or scroll). Default 8. */
71
+ tolerance?: number
72
+ }
73
+
74
+ /** A second press within this long (ms) resolves the pair as a double. */
75
+ const DOUBLE_MS = 280
76
+ /** After a touch, ignore mouse events for this long — the browser's compatibility mouse events. */
77
+ const COMPAT_MOUSE_MS = 500
78
+
79
+ /**
80
+ * Built-in intent presets — a named default mapping you can bind straight away, or override per element
81
+ * by passing your own profile. Keyboard bindings make these an accessibility layer, not just touch support.
82
+ */
83
+ export const PRESETS: Profile = {
84
+ activate: { touch: 'tap', pen: 'tap', mouse: 'click', keyboard: ['Enter', 'Space'] },
85
+ 'secondary-activate': { touch: 'long-press', pen: 'long-press', mouse: 'double-click' },
86
+ // `context` is a desktop notion (a right-click / the ContextMenu key). Touch has no right-click, so it
87
+ // carries no touch mapping — reach for `secondary-activate` (a long-press) for the touch secondary action.
88
+ context: { mouse: 'right-click', keyboard: 'ContextMenu' },
89
+ }
90
+
91
+ /** The recogniser category a gesture token collapses to — the engine reasons in these, not the tokens. */
92
+ type Recogniser = 'single' | 'double' | 'hold' | 'context'
93
+
94
+ const RECOGNISER: Record<Gesture, Recogniser> = {
95
+ click: 'single',
96
+ tap: 'single',
97
+ 'double-click': 'double',
98
+ 'double-tap': 'double',
99
+ 'long-press': 'hold',
100
+ 'context-menu': 'context',
101
+ 'right-click': 'context',
102
+ }
103
+
104
+ /** Normalise a keyboard token to a comparable `e.key` (`'Space'` → `' '`), case-insensitively. */
105
+ const normalizeKey = (k: Key): string => (k.toLowerCase() === 'space' ? ' ' : k.toLowerCase())
106
+
107
+ type Binding = { intent: string; mapping: IntentMapping; handler: IntentHandler }
108
+
109
+ /**
110
+ * Attach intent binding to one element. `profile` names the intents this element understands (falling back
111
+ * to {@link PRESETS} for built-in names); handlers are clung on later by `clingTo(intent, handler)`.
112
+ *
113
+ * One recogniser runs per element: pointer/keyboard events are watched once, resolved into a category
114
+ * (single / double / hold / context), and dispatched to whichever bound intents map that category for the
115
+ * event's `pointerType`. The app never sees the gesture — only its intent.
116
+ *
117
+ * Keyboard intents need a focusable element (a `keydown` only reaches `el` when it holds focus) — give it
118
+ * `tabindex="0"` and a `role`. Timings compare `event.timeStamp` across event types (all `DOMHighResTimeStamp`).
119
+ */
120
+ export function yubisac(el: HTMLElement, profile: Profile = {}, config: YubisacConfig = {}): Finger {
121
+ const holdMs = config.hold ?? 500
122
+ const moveTolerance = config.tolerance ?? 8
123
+
124
+ const bindings = new Set<Binding>()
125
+ let listening = false
126
+ // Timestamp of the most recent touch activity. −Infinity until a touch happens, so a mouse event is never
127
+ // mistaken for a touch's compatibility event before any touch (the guard below would otherwise swallow
128
+ // early mouse events, whose timeStamp is small). Updated at both the start and the END of a touch.
129
+ let lastTouchAt = -Infinity
130
+
131
+ const mappingOf = (name: string): IntentMapping => {
132
+ const m = profile[name] ?? PRESETS[name]
133
+ if (!m) throw new Error(`yubisac: unknown intent "${name}" (not in profile or presets)`)
134
+ return m
135
+ }
136
+
137
+ /** Does any bound intent want this recogniser category on this device? */
138
+ const wants = (pointerType: PointerKind, category: Recogniser): boolean => {
139
+ for (const b of bindings) {
140
+ const g = b.mapping[pointerType]
141
+ if (g && RECOGNISER[g] === category) return true
142
+ }
143
+ return false
144
+ }
145
+
146
+ const fire = (category: Recogniser, pointerType: PointerKind, x: number, y: number, ev: PointerEvent | MouseEvent): void => {
147
+ for (const b of [...bindings]) {
148
+ // snapshot: a handler may destroy() / unbind mid-iteration
149
+ const g = b.mapping[pointerType]
150
+ if (g && RECOGNISER[g] === category && bindings.has(b)) b.handler({ intent: b.intent, pointerType, clientX: x, clientY: y, currentTarget: el, originalEvent: ev })
151
+ }
152
+ }
153
+
154
+ const fireKey = (name: string, handler: IntentHandler, ev: KeyboardEvent): void => {
155
+ const r = el.getBoundingClientRect()
156
+ handler({ intent: name, pointerType: 'keyboard', clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, currentTarget: el, originalEvent: ev })
157
+ }
158
+
159
+ // A single tap can only be committed once we know it isn't the first half of a double, so it waits
160
+ // out DOUBLE_MS — but only when a double is actually bound for that device (otherwise it fires at once).
161
+ let pendingSingle: { timer: ReturnType<typeof setTimeout>; x: number; y: number; ev: PointerEvent; pointerType: PointerKind } | undefined
162
+ const clearPending = (): void => {
163
+ if (pendingSingle) clearTimeout(pendingSingle.timer)
164
+ pendingSingle = undefined
165
+ }
166
+ /** Cleanup for a press still in flight (window listeners + the hold timer). Cleared when it settles. */
167
+ let activePress: (() => void) | undefined
168
+
169
+ const ignored = (e: Event): boolean => (e.target as Element | null)?.closest('[data-yubisac-ignore]') != null
170
+
171
+ const onPointerDown = (e: PointerEvent): void => {
172
+ if (!e.isPrimary) return // ignore secondary touches of a multi-touch gesture
173
+ if (ignored(e)) return // a descendant opting out (e.g. a card with its own menu) keeps its own handling
174
+ if (e.pointerType === 'touch') lastTouchAt = e.timeStamp
175
+ // The browser fires compatibility mouse events after a touch; ignore them so we don't double-count.
176
+ if (e.pointerType === 'mouse' && e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return
177
+ if (e.pointerType === 'mouse' && e.button !== 0) return // right/middle mouse come through `contextmenu`, not here
178
+ const pointerType = e.pointerType as PointerKind
179
+ const id = e.pointerId
180
+ const sx = e.clientX
181
+ const sy = e.clientY
182
+ let moved = false
183
+ let held = false
184
+ // Touch/pen: the browser fires its own long-press context menu, which pops a native menu over the app's.
185
+ // onContextMenu below deliberately skips the touch-synthesised contextmenu (to avoid double-firing an
186
+ // intent), so it would otherwise slip through — suppress the native menu for the life of this press.
187
+ // isTrusted only; mouse right-click comes through onContextMenu (button != 0 returned above), untouched.
188
+ const suppressNativeContextMenu = pointerType !== 'mouse' ? (ev: Event): void => { if (ev.isTrusted) ev.preventDefault() } : undefined
189
+
190
+ // A second press while a single is pending, close in time and place, resolves the pair as a double.
191
+ if (pendingSingle && pendingSingle.pointerType === pointerType && Math.hypot(sx - pendingSingle.x, sy - pendingSingle.y) < moveTolerance * 3) {
192
+ clearPending()
193
+ held = true // this down belongs to the double; its up must not start another single
194
+ fire('double', pointerType, sx, sy, e)
195
+ }
196
+
197
+ const holdTimer = wants(pointerType, 'hold')
198
+ ? setTimeout(() => {
199
+ if (moved || held) return // moved = drag/scroll; held = this down already completed a double
200
+ held = true // consumes this pointer: its up won't also fire a tap
201
+ fire('hold', pointerType, sx, sy, e)
202
+ }, holdMs)
203
+ : undefined
204
+
205
+ const teardown = (): void => {
206
+ if (holdTimer) clearTimeout(holdTimer)
207
+ // Mark the END of a touch too — the browser's compatibility mouse events arrive after the touch lifts,
208
+ // so the dedup window must be measured from here, not just from the down.
209
+ if (pointerType === 'touch') lastTouchAt = performance.now()
210
+ window.removeEventListener('pointermove', move)
211
+ window.removeEventListener('pointerup', up)
212
+ window.removeEventListener('pointercancel', cancel)
213
+ if (suppressNativeContextMenu) window.removeEventListener('contextmenu', suppressNativeContextMenu)
214
+ activePress = undefined
215
+ }
216
+ const move = (ev: PointerEvent): void => {
217
+ if (ev.pointerId !== id) return
218
+ if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < moveTolerance) return
219
+ moved = true // a drag or a scroll — no tap, no hold
220
+ teardown()
221
+ }
222
+ const up = (ev: PointerEvent): void => {
223
+ if (ev.pointerId !== id) return
224
+ teardown()
225
+ if (moved || held) return // moved = drag/scroll; held = a double or long-press already spoke
226
+ if (wants(pointerType, 'double')) {
227
+ clearPending() // a stray earlier single that never doubled — don't leave its timer running
228
+ const timer = setTimeout(() => {
229
+ if (pendingSingle?.timer === timer) pendingSingle = undefined // only null my own entry
230
+ fire('single', pointerType, ev.clientX, ev.clientY, ev)
231
+ }, DOUBLE_MS)
232
+ pendingSingle = { timer, x: ev.clientX, y: ev.clientY, ev, pointerType }
233
+ } else {
234
+ fire('single', pointerType, ev.clientX, ev.clientY, ev)
235
+ }
236
+ }
237
+ const cancel = (ev: PointerEvent): void => {
238
+ if (ev.pointerId !== id) return
239
+ teardown() // scroll takeover / gesture aborted — drop the whole recognition
240
+ }
241
+ activePress = teardown
242
+ if (suppressNativeContextMenu) window.addEventListener('contextmenu', suppressNativeContextMenu)
243
+ // passive:false on move so a would-be hold that becomes a drag can still let the page scroll — we never
244
+ // preventDefault here (unlike goonteh), so touch scrolling keeps working; a real scroll fires pointercancel.
245
+ window.addEventListener('pointermove', move, { passive: false })
246
+ window.addEventListener('pointerup', up)
247
+ window.addEventListener('pointercancel', cancel)
248
+ }
249
+
250
+ const onContextMenu = (e: MouseEvent): void => {
251
+ if (ignored(e)) return
252
+ if (e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return // a touch long-press can synthesise contextmenu; skip it here
253
+ if (!wants('mouse', 'context')) return
254
+ e.preventDefault() // a right-click intent is bound → the app opens its own menu, not the OS one
255
+ fire('context', 'mouse', e.clientX, e.clientY, e)
256
+ }
257
+
258
+ const onKeyDown = (e: KeyboardEvent): void => {
259
+ if (e.repeat) return // a held key auto-repeats; fire once, not a machine-gun
260
+ if (ignored(e)) return // a focused descendant opting out keeps its own handling
261
+ if (e.ctrlKey || e.metaKey || e.altKey) return // a shortcut, not a plain activation
262
+ const key = e.key.toLowerCase()
263
+ for (const b of [...bindings]) {
264
+ const keys = b.mapping.keyboard
265
+ if (!keys) continue
266
+ const list = Array.isArray(keys) ? keys : [keys]
267
+ if (list.some((k) => normalizeKey(k) === key) && bindings.has(b)) {
268
+ if (key === ' ') e.preventDefault() // Space would scroll the page
269
+ fireKey(b.intent, b.handler, e)
270
+ }
271
+ }
272
+ }
273
+
274
+ const startListening = (): void => {
275
+ if (listening) return
276
+ listening = true
277
+ el.addEventListener('pointerdown', onPointerDown)
278
+ el.addEventListener('contextmenu', onContextMenu)
279
+ el.addEventListener('keydown', onKeyDown)
280
+ }
281
+ const stopListening = (): void => {
282
+ if (!listening) return
283
+ listening = false
284
+ el.removeEventListener('pointerdown', onPointerDown)
285
+ el.removeEventListener('contextmenu', onContextMenu)
286
+ el.removeEventListener('keydown', onKeyDown)
287
+ activePress?.() // a press still in flight: drop its window listeners and hold timer
288
+ clearPending()
289
+ }
290
+
291
+ return {
292
+ clingTo(name, handler) {
293
+ const binding: Binding = { intent: name, mapping: mappingOf(name), handler }
294
+ bindings.add(binding)
295
+ startListening()
296
+ return () => {
297
+ bindings.delete(binding)
298
+ if (bindings.size === 0) stopListening()
299
+ }
300
+ },
301
+ destroy() {
302
+ bindings.clear()
303
+ stopListening()
304
+ },
305
+ }
306
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * yubisac core — a framework-agnostic **touch-first intent binding** engine.
3
+ *
4
+ * Not a gesture library. Hammer.js, interact.js, @use-gesture and tinygesture all recognise gestures
5
+ * (tap / double-tap / long-press / pinch …) and hand them to you as *separate* events. Pointer Events
6
+ * unify the input device but stop at "which device". The layer nobody owns is the one above: **every
7
+ * input clinging to the same operation intent** — a mouse double-click, a touch long-press and an Enter
8
+ * key are the one `inspect`. The app speaks in intents (`inspect`, `context`, `activate`); raw event
9
+ * names never leak into its code. Design for the tablet first, and let the desktop cling on.
10
+ *
11
+ * That is all yubisac does, and deliberately all it does: bind different inputs to one intent, and resolve
12
+ * the conflicts between them deterministically (single-vs-double wait, move cancels a hold, a touch's
13
+ * trailing compatibility-mouse events, scroll takeover). Recognition stays a tight set on purpose — the
14
+ * moment it grows into a gesture powerhouse it loses to the incumbents on their turf.
15
+ *
16
+ * Basis is the **per-event `pointerType`**, never a device flag: the same machine sends mouse, touch and
17
+ * pen events interchangeably, so the mapping is chosen per event, not per device.
18
+ *
19
+ * The name respells 指サック (yubi-sakku), the little cot worn on a fingertip for precise single-finger
20
+ * work — where goonteh (軍手, work gloves) is the whole hand gripping and dragging, yubisac is one
21
+ * fingertip tapping. Two hand-worn tools, one drag, one touch.
22
+ */
23
+ /** Which pointing device an event came from — read per event, not per device. */
24
+ export type PointerKind = 'mouse' | 'touch' | 'pen';
25
+ /**
26
+ * A recognisable gesture token. Synonyms exist so a mapping reads naturally per device
27
+ * (`mouse: 'double-click'` vs `touch: 'double-tap'`); they collapse to the same recogniser.
28
+ */
29
+ export type Gesture = 'click' | 'tap' | 'double-click' | 'double-tap' | 'long-press' | 'context-menu' | 'right-click';
30
+ /** A keyboard key that fires an intent — `e.key` values (`'Enter'`, `'ContextMenu'`); `'Space'` is accepted for `' '`. */
31
+ export type Key = string;
32
+ /** How one intent is reached, per input. Omit a device to not bind that device for this intent. */
33
+ export type IntentMapping = Partial<Record<PointerKind, Gesture>> & {
34
+ keyboard?: Key | Key[];
35
+ };
36
+ /** A named set of intent → per-input mappings. The app's intent vocabulary lives here, once. */
37
+ export type Profile = Record<string, IntentMapping>;
38
+ /** What a bound handler receives when its intent fires. `clientX/clientY` is the pointer (or the element centre for keyboard). */
39
+ export type IntentEvent = {
40
+ intent: string;
41
+ pointerType: PointerKind | 'keyboard';
42
+ clientX: number;
43
+ clientY: number;
44
+ currentTarget: HTMLElement;
45
+ /** The raw event. A right-click intent carries a `MouseEvent` (contextmenu), a keyboard intent a `KeyboardEvent`. */
46
+ originalEvent: PointerEvent | MouseEvent | KeyboardEvent;
47
+ };
48
+ export type IntentHandler = (e: IntentEvent) => void;
49
+ /** A binding handle for one element. Cling handlers to intents; `destroy()` removes every listener. */
50
+ export type Finger = {
51
+ /** Cling a handler to a named intent (from this element's profile, else a built-in preset). Returns an off(). */
52
+ clingTo(intent: string, handler: IntentHandler): () => void;
53
+ destroy(): void;
54
+ };
55
+ /**
56
+ * The only two knobs, on purpose. Everything else (the double window, the post-touch compatibility-mouse
57
+ * window) is a browser-timing fact, not an app preference — kept as internal constants so the surface stays
58
+ * a finger cot, not a powered suit.
59
+ */
60
+ export type YubisacConfig = {
61
+ /** Hold this long (ms) without moving to fire a long-press. Default 500. */
62
+ hold?: number;
63
+ /** Move more than this (px) and a tap/hold is abandoned (it's a drag or scroll). Default 8. */
64
+ tolerance?: number;
65
+ };
66
+ /**
67
+ * Built-in intent presets — a named default mapping you can bind straight away, or override per element
68
+ * by passing your own profile. Keyboard bindings make these an accessibility layer, not just touch support.
69
+ */
70
+ export declare const PRESETS: Profile;
71
+ /**
72
+ * Attach intent binding to one element. `profile` names the intents this element understands (falling back
73
+ * to {@link PRESETS} for built-in names); handlers are clung on later by `clingTo(intent, handler)`.
74
+ *
75
+ * One recogniser runs per element: pointer/keyboard events are watched once, resolved into a category
76
+ * (single / double / hold / context), and dispatched to whichever bound intents map that category for the
77
+ * event's `pointerType`. The app never sees the gesture — only its intent.
78
+ *
79
+ * Keyboard intents need a focusable element (a `keydown` only reaches `el` when it holds focus) — give it
80
+ * `tabindex="0"` and a `role`. Timings compare `event.timeStamp` across event types (all `DOMHighResTimeStamp`).
81
+ */
82
+ export declare function yubisac(el: HTMLElement, profile?: Profile, config?: YubisacConfig): Finger;
package/dist/core.js ADDED
@@ -0,0 +1,178 @@
1
+ // core.ts
2
+ var DOUBLE_MS = 280;
3
+ var COMPAT_MOUSE_MS = 500;
4
+ var PRESETS = {
5
+ activate: { touch: "tap", pen: "tap", mouse: "click", keyboard: ["Enter", "Space"] },
6
+ "secondary-activate": { touch: "long-press", pen: "long-press", mouse: "double-click" },
7
+ // `context` is a desktop notion (a right-click / the ContextMenu key). Touch has no right-click, so it
8
+ // carries no touch mapping — reach for `secondary-activate` (a long-press) for the touch secondary action.
9
+ context: { mouse: "right-click", keyboard: "ContextMenu" }
10
+ };
11
+ var RECOGNISER = {
12
+ click: "single",
13
+ tap: "single",
14
+ "double-click": "double",
15
+ "double-tap": "double",
16
+ "long-press": "hold",
17
+ "context-menu": "context",
18
+ "right-click": "context"
19
+ };
20
+ var normalizeKey = (k) => k.toLowerCase() === "space" ? " " : k.toLowerCase();
21
+ function yubisac(el, profile = {}, config = {}) {
22
+ const holdMs = config.hold ?? 500;
23
+ const moveTolerance = config.tolerance ?? 8;
24
+ const bindings = /* @__PURE__ */ new Set();
25
+ let listening = false;
26
+ let lastTouchAt = -Infinity;
27
+ const mappingOf = (name) => {
28
+ const m = profile[name] ?? PRESETS[name];
29
+ if (!m) throw new Error(`yubisac: unknown intent "${name}" (not in profile or presets)`);
30
+ return m;
31
+ };
32
+ const wants = (pointerType, category) => {
33
+ for (const b of bindings) {
34
+ const g = b.mapping[pointerType];
35
+ if (g && RECOGNISER[g] === category) return true;
36
+ }
37
+ return false;
38
+ };
39
+ const fire = (category, pointerType, x, y, ev) => {
40
+ for (const b of [...bindings]) {
41
+ const g = b.mapping[pointerType];
42
+ if (g && RECOGNISER[g] === category && bindings.has(b)) b.handler({ intent: b.intent, pointerType, clientX: x, clientY: y, currentTarget: el, originalEvent: ev });
43
+ }
44
+ };
45
+ const fireKey = (name, handler, ev) => {
46
+ const r = el.getBoundingClientRect();
47
+ handler({ intent: name, pointerType: "keyboard", clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, currentTarget: el, originalEvent: ev });
48
+ };
49
+ let pendingSingle;
50
+ const clearPending = () => {
51
+ if (pendingSingle) clearTimeout(pendingSingle.timer);
52
+ pendingSingle = void 0;
53
+ };
54
+ let activePress;
55
+ const ignored = (e) => e.target?.closest("[data-yubisac-ignore]") != null;
56
+ const onPointerDown = (e) => {
57
+ if (!e.isPrimary) return;
58
+ if (ignored(e)) return;
59
+ if (e.pointerType === "touch") lastTouchAt = e.timeStamp;
60
+ if (e.pointerType === "mouse" && e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
61
+ if (e.pointerType === "mouse" && e.button !== 0) return;
62
+ const pointerType = e.pointerType;
63
+ const id = e.pointerId;
64
+ const sx = e.clientX;
65
+ const sy = e.clientY;
66
+ let moved = false;
67
+ let held = false;
68
+ const suppressNativeContextMenu = pointerType !== "mouse" ? (ev) => {
69
+ if (ev.isTrusted) ev.preventDefault();
70
+ } : void 0;
71
+ if (pendingSingle && pendingSingle.pointerType === pointerType && Math.hypot(sx - pendingSingle.x, sy - pendingSingle.y) < moveTolerance * 3) {
72
+ clearPending();
73
+ held = true;
74
+ fire("double", pointerType, sx, sy, e);
75
+ }
76
+ const holdTimer = wants(pointerType, "hold") ? setTimeout(() => {
77
+ if (moved || held) return;
78
+ held = true;
79
+ fire("hold", pointerType, sx, sy, e);
80
+ }, holdMs) : void 0;
81
+ const teardown = () => {
82
+ if (holdTimer) clearTimeout(holdTimer);
83
+ if (pointerType === "touch") lastTouchAt = performance.now();
84
+ window.removeEventListener("pointermove", move);
85
+ window.removeEventListener("pointerup", up);
86
+ window.removeEventListener("pointercancel", cancel);
87
+ if (suppressNativeContextMenu) window.removeEventListener("contextmenu", suppressNativeContextMenu);
88
+ activePress = void 0;
89
+ };
90
+ const move = (ev) => {
91
+ if (ev.pointerId !== id) return;
92
+ if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < moveTolerance) return;
93
+ moved = true;
94
+ teardown();
95
+ };
96
+ const up = (ev) => {
97
+ if (ev.pointerId !== id) return;
98
+ teardown();
99
+ if (moved || held) return;
100
+ if (wants(pointerType, "double")) {
101
+ clearPending();
102
+ const timer = setTimeout(() => {
103
+ if (pendingSingle?.timer === timer) pendingSingle = void 0;
104
+ fire("single", pointerType, ev.clientX, ev.clientY, ev);
105
+ }, DOUBLE_MS);
106
+ pendingSingle = { timer, x: ev.clientX, y: ev.clientY, ev, pointerType };
107
+ } else {
108
+ fire("single", pointerType, ev.clientX, ev.clientY, ev);
109
+ }
110
+ };
111
+ const cancel = (ev) => {
112
+ if (ev.pointerId !== id) return;
113
+ teardown();
114
+ };
115
+ activePress = teardown;
116
+ if (suppressNativeContextMenu) window.addEventListener("contextmenu", suppressNativeContextMenu);
117
+ window.addEventListener("pointermove", move, { passive: false });
118
+ window.addEventListener("pointerup", up);
119
+ window.addEventListener("pointercancel", cancel);
120
+ };
121
+ const onContextMenu = (e) => {
122
+ if (ignored(e)) return;
123
+ if (e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
124
+ if (!wants("mouse", "context")) return;
125
+ e.preventDefault();
126
+ fire("context", "mouse", e.clientX, e.clientY, e);
127
+ };
128
+ const onKeyDown = (e) => {
129
+ if (e.repeat) return;
130
+ if (ignored(e)) return;
131
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
132
+ const key = e.key.toLowerCase();
133
+ for (const b of [...bindings]) {
134
+ const keys = b.mapping.keyboard;
135
+ if (!keys) continue;
136
+ const list = Array.isArray(keys) ? keys : [keys];
137
+ if (list.some((k) => normalizeKey(k) === key) && bindings.has(b)) {
138
+ if (key === " ") e.preventDefault();
139
+ fireKey(b.intent, b.handler, e);
140
+ }
141
+ }
142
+ };
143
+ const startListening = () => {
144
+ if (listening) return;
145
+ listening = true;
146
+ el.addEventListener("pointerdown", onPointerDown);
147
+ el.addEventListener("contextmenu", onContextMenu);
148
+ el.addEventListener("keydown", onKeyDown);
149
+ };
150
+ const stopListening = () => {
151
+ if (!listening) return;
152
+ listening = false;
153
+ el.removeEventListener("pointerdown", onPointerDown);
154
+ el.removeEventListener("contextmenu", onContextMenu);
155
+ el.removeEventListener("keydown", onKeyDown);
156
+ activePress?.();
157
+ clearPending();
158
+ };
159
+ return {
160
+ clingTo(name, handler) {
161
+ const binding = { intent: name, mapping: mappingOf(name), handler };
162
+ bindings.add(binding);
163
+ startListening();
164
+ return () => {
165
+ bindings.delete(binding);
166
+ if (bindings.size === 0) stopListening();
167
+ };
168
+ },
169
+ destroy() {
170
+ bindings.clear();
171
+ stopListening();
172
+ }
173
+ };
174
+ }
175
+ export {
176
+ PRESETS,
177
+ yubisac
178
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";var __yubisac=(()=>{var M=Object.defineProperty;var B=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var U=Object.prototype.hasOwnProperty;var G=(t,s)=>{for(var a in s)M(t,a,{get:s[a],enumerable:!0})},z=(t,s,a,m)=>{if(s&&typeof s=="object"||typeof s=="function")for(let u of H(s))!U.call(t,u)&&u!==a&&M(t,u,{get:()=>s[u],enumerable:!(m=B(s,u))||m.enumerable});return t};var F=t=>z(M({},"__esModule",{value:!0}),t);var j={};G(j,{PRESETS:()=>X,yubisac:()=>$});var X={activate:{touch:"tap",pen:"tap",mouse:"click",keyboard:["Enter","Space"]},"secondary-activate":{touch:"long-press",pen:"long-press",mouse:"double-click"},context:{mouse:"right-click",keyboard:"ContextMenu"}},O={click:"single",tap:"single","double-click":"double","double-tap":"double","long-press":"hold","context-menu":"context","right-click":"context"},N=t=>t.toLowerCase()==="space"?" ":t.toLowerCase();function $(t,s={},a={}){let m=a.hold??500,u=a.tolerance??8,l=new Set,y=!1,v=-1/0,_=e=>{let n=s[e]??X[e];if(!n)throw new Error(`yubisac: unknown intent "${e}" (not in profile or presets)`);return n},w=(e,n)=>{for(let r of l){let i=r.mapping[e];if(i&&O[i]===n)return!0}return!1},g=(e,n,r,i,p)=>{for(let d of[...l]){let f=d.mapping[n];f&&O[f]===e&&l.has(d)&&d.handler({intent:d.intent,pointerType:n,clientX:r,clientY:i,currentTarget:t,originalEvent:p})}},D=(e,n,r)=>{let i=t.getBoundingClientRect();n({intent:e,pointerType:"keyboard",clientX:i.left+i.width/2,clientY:i.top+i.height/2,currentTarget:t,originalEvent:r})},c,h=()=>{c&&clearTimeout(c.timer),c=void 0},x,T=e=>e.target?.closest("[data-yubisac-ignore]")!=null,P=e=>{if(!e.isPrimary||T(e)||(e.pointerType==="touch"&&(v=e.timeStamp),e.pointerType==="mouse"&&e.timeStamp-v<500)||e.pointerType==="mouse"&&e.button!==0)return;let n=e.pointerType,r=e.pointerId,i=e.clientX,p=e.clientY,d=!1,f=!1,E=n!=="mouse"?o=>{o.isTrusted&&o.preventDefault()}:void 0;c&&c.pointerType===n&&Math.hypot(i-c.x,p-c.y)<u*3&&(h(),f=!0,g("double",n,i,p,e));let S=w(n,"hold")?setTimeout(()=>{d||f||(f=!0,g("hold",n,i,p,e))},m):void 0,b=()=>{S&&clearTimeout(S),n==="touch"&&(v=performance.now()),window.removeEventListener("pointermove",I),window.removeEventListener("pointerup",C),window.removeEventListener("pointercancel",R),E&&window.removeEventListener("contextmenu",E),x=void 0},I=o=>{o.pointerId===r&&(Math.hypot(o.clientX-i,o.clientY-p)<u||(d=!0,b()))},C=o=>{if(o.pointerId===r&&(b(),!(d||f)))if(w(n,"double")){h();let Y=setTimeout(()=>{c?.timer===Y&&(c=void 0),g("single",n,o.clientX,o.clientY,o)},280);c={timer:Y,x:o.clientX,y:o.clientY,ev:o,pointerType:n}}else g("single",n,o.clientX,o.clientY,o)},R=o=>{o.pointerId===r&&b()};x=b,E&&window.addEventListener("contextmenu",E),window.addEventListener("pointermove",I,{passive:!1}),window.addEventListener("pointerup",C),window.addEventListener("pointercancel",R)},L=e=>{T(e)||e.timeStamp-v<500||w("mouse","context")&&(e.preventDefault(),g("context","mouse",e.clientX,e.clientY,e))},k=e=>{if(e.repeat||T(e)||e.ctrlKey||e.metaKey||e.altKey)return;let n=e.key.toLowerCase();for(let r of[...l]){let i=r.mapping.keyboard;if(!i)continue;(Array.isArray(i)?i:[i]).some(d=>N(d)===n)&&l.has(r)&&(n===" "&&e.preventDefault(),D(r.intent,r.handler,e))}},A=()=>{y||(y=!0,t.addEventListener("pointerdown",P),t.addEventListener("contextmenu",L),t.addEventListener("keydown",k))},K=()=>{y&&(y=!1,t.removeEventListener("pointerdown",P),t.removeEventListener("contextmenu",L),t.removeEventListener("keydown",k),x?.(),h())};return{clingTo(e,n){let r={intent:e,mapping:_(e),handler:n};return l.add(r),A(),()=>{l.delete(r),l.size===0&&K()}},destroy(){l.clear(),K()}}}return F(j);})();
2
+ window.yubisac=__yubisac.yubisac;
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './core'
package/native.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { yubisac, type Finger, type IntentHandler, type Profile, type YubisacConfig } from './core'
2
+
3
+ /**
4
+ * yubisac — native (vanilla DOM) adapter.
5
+ *
6
+ * The core is already framework-free (`yubisac(el, …)`), so this only adds one convenience: bind a whole
7
+ * map of intent → handler in a single call. Everything from `./core` is re-exported. Needs no dependencies.
8
+ *
9
+ * ```ts
10
+ * import { cling } from 'yubisac/native'
11
+ * const finger = cling(card, { inspect: openInspector }, { profile: { inspect: { mouse: 'double-click', touch: 'long-press' } } })
12
+ * finger.destroy()
13
+ * ```
14
+ */
15
+ export * from './core'
16
+
17
+ /** Attach a map of intents to an element in one call; returns the {@link Finger} (call `destroy()` to unbind). */
18
+ export function cling(el: HTMLElement, on: Record<string, IntentHandler>, opts?: { profile?: Profile; config?: YubisacConfig }): Finger {
19
+ const finger = yubisac(el, opts?.profile, opts?.config)
20
+ for (const [name, handler] of Object.entries(on)) finger.clingTo(name, handler)
21
+ return finger
22
+ }
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "yubisac",
3
+ "version": "0.9.1",
4
+ "description": "Touch-first intent binding. Design for the tablet, then let mouse, pen and keyboard cling to the same intent — so your app speaks in intents, not events.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "mrksye",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mrksye/yubisac.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/mrksye/yubisac/issues"
14
+ },
15
+ "homepage": "https://github.com/mrksye/yubisac#readme",
16
+ "keywords": [
17
+ "intent",
18
+ "gesture",
19
+ "pointer-events",
20
+ "touch",
21
+ "pen",
22
+ "long-press",
23
+ "double-click",
24
+ "accessibility",
25
+ "input",
26
+ "solid-js",
27
+ "solidjs",
28
+ "react",
29
+ "vue",
30
+ "svelte"
31
+ ],
32
+ "sideEffects": false,
33
+ "scripts": {
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "bun test/yubisac.test.mjs",
36
+ "build": "rm -rf dist && node build.mjs && tsc -p tsconfig.build.json",
37
+ "prepack": "npm run build"
38
+ },
39
+ "devDependencies": {
40
+ "typescript": "^5.4.0",
41
+ "esbuild": "^0.28.1",
42
+ "playwright": "^1.40.0",
43
+ "solid-js": "^1.8.0",
44
+ "react": "^18.3.0",
45
+ "@types/react": "^18.3.0",
46
+ "vue": "^3.4.0",
47
+ "svelte": "^5.0.0"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "core.ts",
52
+ "native.ts",
53
+ "solid.tsx",
54
+ "react.tsx",
55
+ "react-native.tsx",
56
+ "vue.ts",
57
+ "svelte.ts",
58
+ "index.ts",
59
+ "README.md",
60
+ "EXAMPLES.md",
61
+ "CONTRIBUTING.md",
62
+ "LICENSE"
63
+ ],
64
+ "main": "./dist/core.js",
65
+ "module": "./dist/core.js",
66
+ "types": "./dist/core.d.ts",
67
+ "unpkg": "./dist/yubisac.global.js",
68
+ "jsdelivr": "./dist/yubisac.global.js",
69
+ "exports": {
70
+ ".": { "types": "./dist/core.d.ts", "import": "./dist/core.js" },
71
+ "./core": { "types": "./dist/core.d.ts", "import": "./dist/core.js" },
72
+ "./global": "./dist/yubisac.global.js",
73
+ "./native": "./native.ts",
74
+ "./solid": "./solid.tsx",
75
+ "./react": "./react.tsx",
76
+ "./react-native": "./react-native.tsx",
77
+ "./vue": "./vue.ts",
78
+ "./svelte": "./svelte.ts"
79
+ },
80
+ "peerDependencies": {
81
+ "solid-js": ">=1.6.0",
82
+ "react": ">=18.0.0",
83
+ "react-native": ">=0.70.0",
84
+ "vue": ">=3.2.0",
85
+ "svelte": ">=4.0.0"
86
+ },
87
+ "peerDependenciesMeta": {
88
+ "solid-js": { "optional": true },
89
+ "react": { "optional": true },
90
+ "react-native": { "optional": true },
91
+ "vue": { "optional": true },
92
+ "svelte": { "optional": true }
93
+ }
94
+ }
@@ -0,0 +1,138 @@
1
+ /** @jsxImportSource react */
2
+ import { useEffect, useMemo, useRef, type ReactNode } from 'react'
3
+ import { PanResponder, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native'
4
+ import { PRESETS, type Profile, type YubisacConfig } from './core'
5
+
6
+ /**
7
+ * yubisac — React Native adapter (experimental).
8
+ *
9
+ * React Native has no DOM or Pointer Events, so this does NOT reuse the web core (`./core`) — it ships a
10
+ * tiny `PanResponder`-based recogniser instead (no extra deps). Only the `touch` mapping of each intent
11
+ * applies (there is no mouse/pen/keyboard here), and recognition is the same small set: tap / double-tap /
12
+ * long-press. It claims the touch on start but yields to a parent (a `ScrollView`) on request, so scrolling
13
+ * still works. Outside the web core's guarantees. `react` and `react-native` are optional peer dependencies.
14
+ */
15
+
16
+ /** What a bound handler receives on React Native (screen coordinates; no DOM node). */
17
+ export type RNIntentEvent = { intent: string; x: number; y: number }
18
+ export type RNIntentHandler = (e: RNIntentEvent) => void
19
+
20
+ /** The `touch` gestures that map to a recogniser on RN (mouse/pen/keyboard tokens are ignored here). */
21
+ const CATEGORY: Record<string, 'single' | 'double' | 'hold' | undefined> = {
22
+ tap: 'single',
23
+ click: 'single',
24
+ 'double-tap': 'double',
25
+ 'double-click': 'double',
26
+ 'long-press': 'hold',
27
+ }
28
+ /** A second tap within this long (ms) resolves the pair as a double. */
29
+ const DOUBLE_MS = 280
30
+
31
+ /** Bind intents to a View. Spread the returned handlers onto a `<View {...panHandlers}>`. */
32
+ export function useCling(on: Record<string, RNIntentHandler>, opts?: { profile?: Profile; config?: YubisacConfig }) {
33
+ const latest = useRef({ on, opts })
34
+ latest.current = { on, opts }
35
+ const s = useRef({ startX: 0, startY: 0, moved: false, held: false, lastTapAt: 0, holdTimer: undefined as ReturnType<typeof setTimeout> | undefined, singleTimer: undefined as ReturnType<typeof setTimeout> | undefined })
36
+ // Clear any armed timer on unmount so it never fires a handler on a gone component.
37
+ useEffect(() => () => {
38
+ if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
39
+ if (s.current.singleTimer) clearTimeout(s.current.singleTimer)
40
+ }, [])
41
+
42
+ const panHandlers = useMemo(() => {
43
+ const cfg = () => latest.current.opts?.config
44
+ const mappingOf = (name: string) => latest.current.opts?.profile?.[name] ?? PRESETS[name]
45
+ const wants = (cat: 'single' | 'double' | 'hold'): boolean =>
46
+ Object.keys(latest.current.on).some((n) => {
47
+ const g = mappingOf(n)?.touch
48
+ return g !== undefined && CATEGORY[g] === cat
49
+ })
50
+ const fire = (cat: 'single' | 'double' | 'hold', x: number, y: number): void => {
51
+ for (const [name, handler] of Object.entries(latest.current.on)) {
52
+ const g = mappingOf(name)?.touch
53
+ if (g !== undefined && CATEGORY[g] === cat) handler({ intent: name, x, y })
54
+ }
55
+ }
56
+ const clearTimers = (): void => {
57
+ if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
58
+ if (s.current.singleTimer) clearTimeout(s.current.singleTimer)
59
+ s.current.holdTimer = undefined
60
+ s.current.singleTimer = undefined
61
+ }
62
+
63
+ return PanResponder.create({
64
+ onStartShouldSetPanResponder: () => true,
65
+ onMoveShouldSetPanResponder: () => false, // never claim a move → a parent ScrollView keeps scrolling
66
+ onPanResponderTerminationRequest: () => true, // let scroll/parent win; we abandon recognition
67
+ onPanResponderGrant: (e: GestureResponderEvent) => {
68
+ clearTimers()
69
+ s.current.startX = e.nativeEvent.pageX
70
+ s.current.startY = e.nativeEvent.pageY
71
+ s.current.moved = false
72
+ s.current.held = false
73
+ if (wants('hold')) {
74
+ s.current.holdTimer = setTimeout(() => {
75
+ if (s.current.moved) return
76
+ s.current.held = true
77
+ fire('hold', s.current.startX, s.current.startY)
78
+ }, cfg()?.hold ?? 500)
79
+ }
80
+ },
81
+ onPanResponderMove: (e: GestureResponderEvent) => {
82
+ const dx = e.nativeEvent.pageX - s.current.startX
83
+ const dy = e.nativeEvent.pageY - s.current.startY
84
+ if (Math.hypot(dx, dy) < (cfg()?.tolerance ?? 8)) return
85
+ s.current.moved = true
86
+ if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
87
+ },
88
+ onPanResponderRelease: (e: GestureResponderEvent) => {
89
+ if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
90
+ if (s.current.moved || s.current.held) return
91
+ const x = e.nativeEvent.pageX
92
+ const y = e.nativeEvent.pageY
93
+ const now = Date.now()
94
+ if (wants('double') && now - s.current.lastTapAt < DOUBLE_MS) {
95
+ if (s.current.singleTimer) clearTimeout(s.current.singleTimer)
96
+ s.current.singleTimer = undefined
97
+ s.current.lastTapAt = 0
98
+ fire('double', x, y)
99
+ } else if (wants('double')) {
100
+ s.current.lastTapAt = now
101
+ s.current.singleTimer = setTimeout(() => fire('single', x, y), DOUBLE_MS)
102
+ } else {
103
+ fire('single', x, y)
104
+ }
105
+ },
106
+ onPanResponderTerminate: () => {
107
+ clearTimers()
108
+ s.current.moved = false
109
+ s.current.held = false
110
+ },
111
+ }).panHandlers
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [])
114
+
115
+ return panHandlers
116
+ }
117
+
118
+ /** JSX wrapper: a View that binds `on` (intent → handler). */
119
+ export function Cling({
120
+ on,
121
+ profile,
122
+ config,
123
+ style,
124
+ children,
125
+ }: {
126
+ on: Record<string, RNIntentHandler>
127
+ profile?: Profile
128
+ config?: YubisacConfig
129
+ style?: StyleProp<ViewStyle>
130
+ children: ReactNode
131
+ }) {
132
+ const panHandlers = useCling(on, { profile, config })
133
+ return (
134
+ <View style={style} {...panHandlers}>
135
+ {children}
136
+ </View>
137
+ )
138
+ }
package/react.tsx ADDED
@@ -0,0 +1,55 @@
1
+ /** @jsxImportSource react */
2
+ import { useEffect, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react'
3
+ import { yubisac, type IntentHandler, type Profile, type YubisacConfig } from './core'
4
+
5
+ /**
6
+ * yubisac — React adapter.
7
+ *
8
+ * yubisac is element-local — no shared context, so no provider. `useCling` returns a ref you put on an
9
+ * element; it binds the intents on mount and removes every listener on unmount. Handlers are read through a
10
+ * ref so the stable binding always calls the current one. `react` is an optional peer dependency.
11
+ */
12
+
13
+ /** Bind intents to an element via the returned ref. Pass a `profile` for custom intents; presets work without one. */
14
+ export function useCling<T extends HTMLElement = HTMLDivElement>(
15
+ on: Record<string, IntentHandler>,
16
+ opts?: { profile?: Profile; config?: YubisacConfig },
17
+ ): RefObject<T> {
18
+ const ref = useRef<T>(null)
19
+ const latest = useRef(on)
20
+ latest.current = on
21
+ useEffect(() => {
22
+ const el = ref.current
23
+ if (!el) return
24
+ const finger = yubisac(el, opts?.profile, opts?.config)
25
+ for (const name of Object.keys(latest.current)) finger.clingTo(name, (e) => latest.current[name]?.(e))
26
+ return () => finger.destroy()
27
+ // Bind once; profile/config are treated as create-time options (like goonteh's kind).
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps
29
+ }, [])
30
+ return ref
31
+ }
32
+
33
+ /** JSX wrapper: binds `on` (intent → handler) to its own element. */
34
+ export function Cling({
35
+ on,
36
+ profile,
37
+ config,
38
+ className,
39
+ style,
40
+ children,
41
+ }: {
42
+ on: Record<string, IntentHandler>
43
+ profile?: Profile
44
+ config?: YubisacConfig
45
+ className?: string
46
+ style?: CSSProperties
47
+ children: ReactNode
48
+ }) {
49
+ const ref = useCling<HTMLDivElement>(on, { profile, config })
50
+ return (
51
+ <div ref={ref} className={className} style={style}>
52
+ {children}
53
+ </div>
54
+ )
55
+ }
package/solid.tsx ADDED
@@ -0,0 +1,38 @@
1
+ import { onCleanup, type JSX } from 'solid-js'
2
+ import { yubisac, type IntentHandler, type Profile, type YubisacConfig } from './core'
3
+
4
+ /**
5
+ * SolidJS binding for yubisac. yubisac is element-local (no shared/global state like goonteh's drag
6
+ * context), so there is no Provider — you just attach intents to an element.
7
+ *
8
+ * `cling` fits an element's `ref` and binds a map of intent → handler; `<Cling>` is the JSX wrapper.
9
+ * Both register on mount and remove every listener on cleanup.
10
+ */
11
+
12
+ /** Bind intents to an element via its `ref`. Pass a `profile` to define custom intents; built-in presets work without one. */
13
+ export function cling(
14
+ on: Record<string, IntentHandler>,
15
+ opts?: { profile?: Profile; config?: YubisacConfig },
16
+ ): (el: HTMLElement) => void {
17
+ return (el) => {
18
+ const finger = yubisac(el, opts?.profile, opts?.config)
19
+ for (const [name, handler] of Object.entries(on)) finger.clingTo(name, handler)
20
+ onCleanup(() => finger.destroy())
21
+ }
22
+ }
23
+
24
+ /** JSX wrapper: binds `on` (intent → handler) to its own element. `class`/`style` pass through. */
25
+ export function Cling(props: {
26
+ on: Record<string, IntentHandler>
27
+ profile?: Profile
28
+ config?: YubisacConfig
29
+ class?: string
30
+ style?: JSX.CSSProperties
31
+ children: JSX.Element
32
+ }): JSX.Element {
33
+ return (
34
+ <div ref={cling(props.on, { profile: props.profile, config: props.config })} class={props.class} style={props.style}>
35
+ {props.children}
36
+ </div>
37
+ )
38
+ }
package/svelte.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { yubisac, type Finger, type IntentHandler, type Profile, type YubisacConfig } from './core'
2
+
3
+ /**
4
+ * yubisac — Svelte adapter.
5
+ *
6
+ * Idiomatic Svelte: a use-directive **action** that attaches intents to an element. Built on the
7
+ * framework-agnostic core (`./core`); `svelte` is an optional peer dependency.
8
+ *
9
+ * ```svelte
10
+ * <script>
11
+ * import { cling } from 'yubisac/svelte'
12
+ * </script>
13
+ * <div use:cling={{ on: { inspect: openInspector }, profile: { inspect: { mouse: 'double-click', touch: 'long-press' } } }}>…</div>
14
+ * ```
15
+ */
16
+ export type ClingParams = { on: Record<string, IntentHandler>; profile?: Profile; config?: YubisacConfig }
17
+
18
+ const bind = (el: HTMLElement, p: ClingParams): Finger => {
19
+ const finger = yubisac(el, p.profile, p.config)
20
+ for (const [name, handler] of Object.entries(p.on)) finger.clingTo(name, handler)
21
+ return finger
22
+ }
23
+
24
+ /** `use:cling={{ on, profile?, config? }}` — binds intents to the element; rebinds on param change. */
25
+ export function cling(el: HTMLElement, params: ClingParams): { update(p: ClingParams): void; destroy(): void } {
26
+ let finger = bind(el, params)
27
+ return {
28
+ update: (next) => {
29
+ finger.destroy()
30
+ finger = bind(el, next)
31
+ },
32
+ destroy: () => finger.destroy(),
33
+ }
34
+ }
package/vue.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Directive } from 'vue'
2
+ import { yubisac, type Finger, type IntentHandler, type Profile, type YubisacConfig } from './core'
3
+
4
+ /**
5
+ * yubisac — Vue 3 adapter.
6
+ *
7
+ * yubisac attaches behaviour to one element with no shared state, which is exactly what a Vue **directive**
8
+ * is for — so this is a directive, not components. `vue` is an optional peer dependency.
9
+ *
10
+ * ```vue
11
+ * <script setup>
12
+ * import { vCling } from 'yubisac/vue'
13
+ * </script>
14
+ * <div v-cling="{ on: { inspect: openInspector }, profile: { inspect: { mouse: 'double-click', touch: 'long-press' } } }">…</div>
15
+ * ```
16
+ */
17
+ export type YubisacBinding = { on: Record<string, IntentHandler>; profile?: Profile; config?: YubisacConfig }
18
+
19
+ type BoundEl = HTMLElement & { __yubisac?: Finger }
20
+
21
+ const bind = (el: BoundEl, value: YubisacBinding): void => {
22
+ el.__yubisac?.destroy()
23
+ const finger = yubisac(el, value.profile, value.config)
24
+ for (const [name, handler] of Object.entries(value.on)) finger.clingTo(name, handler)
25
+ el.__yubisac = finger
26
+ }
27
+
28
+ /** `v-cling="{ on, profile?, config? }"` — binds intents to the element for its lifetime. */
29
+ export const vCling: Directive<BoundEl, YubisacBinding> = {
30
+ mounted: (el, binding) => bind(el, binding.value),
31
+ // Only rebind when the binding value actually changed — a re-render alone must not tear down a live press.
32
+ updated: (el, binding) => {
33
+ if (binding.value !== binding.oldValue) bind(el, binding.value)
34
+ },
35
+ unmounted: (el) => {
36
+ el.__yubisac?.destroy()
37
+ el.__yubisac = undefined
38
+ },
39
+ }