@wrium/evasive-button 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Khalife
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,245 @@
1
+ # @wrium/evasive-button
2
+
3
+ A fun, highly practical plugin for [Wrium](https://github.com/wrium/wrium) that makes buttons (or any interactive element) playfully and smoothly dodge the user's cursor when approached, hovered, or clicked.
4
+
5
+ Ideal for gamified forms, smart validation ("complete fields to unlock button"), interactive confirmations, or playful login screens!
6
+
7
+ ---
8
+
9
+ ## Highlights
10
+
11
+ - 🏃 **Smooth Tangential Gliding**: Physics-based wall-sliding that avoids erratic jumps, teleportation, or sticking against boundaries.
12
+ - 📐 **Boundary & Arena Control**: Confine movement to `'parent'`, `'viewport'`, or any custom CSS selector (`bounds: '#arena'`).
13
+ - 🎯 **Movement Limits**:
14
+ - `maxRadius`: Restrict evasion distance from the button's home anchor so it stays in its neighborhood.
15
+ - `duration`: Auto-timeout (in milliseconds) after which the button gives up and settles.
16
+ - `maxAttempts`: Auto-settle after a maximum number of dodge attempts.
17
+ - 🔄 **Form Validation / Reactive Toggles**: Bind directly to expressions like `v-evade="{ active: !isFormValid }"`. When valid, the button smoothly glides home to `(0, 0)`!
18
+ - 🎨 **State Classes & Attributes**: Automatically manages `.is-settled`, `.is-evading`, and `data-state="settled|evading|gaveup"` for styling.
19
+ - 🔔 **Rich Lifecycle Events**: `@evade`, `@settle`, and `@giveup` DOM events.
20
+ - ♿ **Accessible by Design**: Respects `prefers-reduced-motion` and preserves keyboard navigation (`Tab` + `Enter`/`Space`).
21
+ - ⚡ **Minimalist**: Zero dependencies, GPU-accelerated transforms, ~1.5 KB.
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @wrium/evasive-button
29
+ # or
30
+ bun add @wrium/evasive-button
31
+ ```
32
+
33
+ `@wrium/wrium` is a peer dependency — install it too if you haven't already.
34
+
35
+ ---
36
+
37
+ ## Quick Start
38
+
39
+ ### Basic Usage
40
+
41
+ ```html
42
+ <div id="app">
43
+ <button v-evade>Try to Click Me!</button>
44
+ </div>
45
+
46
+ <script type="module">
47
+ import { createApp } from '@wrium/wrium';
48
+ import { EvasiveButtonPlugin } from '@wrium/evasive-button';
49
+
50
+ createApp(() => ({}))
51
+ .use(EvasiveButtonPlugin)
52
+ .mount('#app');
53
+ </script>
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Practical Form Validation Scenario
59
+
60
+ The button evades the cursor while required fields are incomplete; as soon as the form is valid, it stops evading and smoothly returns to its default position, ready to be clicked!
61
+
62
+ ```html
63
+ <div id="app" class="login-card">
64
+ <h2>Secure Login</h2>
65
+
66
+ <input type="text" v-model="username" placeholder="Username (3+ chars)" />
67
+ <input type="password" v-model="password" placeholder="Password (6+ chars)" />
68
+
69
+ <!-- Evasion container arena -->
70
+ <div id="button-arena" class="arena">
71
+ <button
72
+ type="submit"
73
+ class="login-btn"
74
+ v-evade="{
75
+ active: !isFormValid,
76
+ bounds: '#button-arena',
77
+ maxRadius: 150,
78
+ threshold: 75,
79
+ duration: 10000
80
+ }"
81
+ @settle="onButtonSettled"
82
+ @evade="onButtonEvaded"
83
+ >
84
+ {{ isFormValid ? 'Log In' : 'Fill fields first!' }}
85
+ </button>
86
+ </div>
87
+ </div>
88
+
89
+ <script type="module">
90
+ import { createApp, ref, computed } from '@wrium/wrium';
91
+ import { EvasiveButtonPlugin } from '@wrium/evasive-button';
92
+
93
+ createApp(() => {
94
+ const username = ref('');
95
+ const password = ref('');
96
+
97
+ const isFormValid = computed(() => {
98
+ return username.value.trim().length >= 3 && password.value.length >= 6;
99
+ });
100
+
101
+ function onButtonSettled(e) {
102
+ console.log('Button has returned home and settled!', e.detail);
103
+ }
104
+
105
+ function onButtonEvaded(e) {
106
+ console.log('Button evaded, total attempts:', e.detail.attempts);
107
+ }
108
+
109
+ return { username, password, isFormValid, onButtonSettled, onButtonEvaded };
110
+ })
111
+ .use(EvasiveButtonPlugin)
112
+ .mount('#app');
113
+ </script>
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Directive Options Reference
119
+
120
+ Options can be passed directly as an object to `v-evade="{ ... }"` or globally when installing via `app.use(EvasiveButtonPlugin, { ... })`:
121
+
122
+ | Option | Type | Default | Description |
123
+ |--------|------|---------|-------------|
124
+ | `active` | `boolean` | `true` | When `false`, evasion stops and the button smoothly returns to `(0, 0)` |
125
+ | `bounds` | `string` | `'parent'` | Bounding container: `'parent'`, `'viewport'`, or a CSS selector (e.g. `'#arena'`) |
126
+ | `maxRadius` | `number` | `Infinity` | Maximum radius (in pixels) the button is allowed to stray from its natural anchor |
127
+ | `duration` | `number` | `Infinity` | Maximum time (in ms) to evade before giving up and settling |
128
+ | `maxAttempts` | `number` | `Infinity` | Maximum number of dodges before giving up and settling |
129
+ | `threshold` | `number` | `75` | Proximity trigger distance (in pixels) from the cursor |
130
+ | `distance` | `number` | `100` | Base leap distance (in pixels) away from the cursor |
131
+ | `random` | `boolean` | `false` | Adds angular jitter to evasion trajectories |
132
+ | `teleport` | `boolean` | `false` | Jump instantly without smooth CSS transition animation |
133
+ | `allowKeyboard` | `boolean` | `true` | Allows keyboard users (`Tab` + `Enter`/`Space`) to submit without triggering evasion |
134
+ | `respectReducedMotion` | `boolean` | `true` | Automatically disables evasion if OS `prefers-reduced-motion` is active |
135
+
136
+ ---
137
+
138
+ ## State Classes & Attributes
139
+
140
+ The directive automatically maintains CSS classes and data attributes on the element, making visual styling effortless:
141
+
142
+ - **`.is-settled`** / **`data-state="settled"`**: Present when the button is at rest at its natural origin.
143
+ - **`.is-evading`** / **`data-state="evading"`**: Present while the button is actively evading or displaced.
144
+ - **`data-state="gaveup"`**: Present when evasion stopped due to `duration` timeout or `maxAttempts`.
145
+
146
+ ### Styling Example:
147
+ ```css
148
+ /* Normal state */
149
+ .login-btn.is-settled {
150
+ background: #0284c7;
151
+ }
152
+
153
+ /* While dodging the cursor */
154
+ .login-btn.is-evading {
155
+ background: #e11d48;
156
+ box-shadow: 0 4px 14px rgba(225, 29, 72, 0.4);
157
+ }
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Lifecycle Events
163
+
164
+ Elements dispatch custom DOM events that can be listened to via `@event` in Wrium templates:
165
+
166
+ ### `@evade`
167
+ Fires on each evasion:
168
+ ```ts
169
+ e.detail: {
170
+ attempts: number, // Total number of evasions triggered
171
+ x: number, // Current translation X in px
172
+ y: number, // Current translation Y in px
173
+ dx: number, // Delta X moved this step
174
+ dy: number, // Delta Y moved this step
175
+ source: string // 'proximity' | 'enter' | 'click_attempt'
176
+ }
177
+ ```
178
+
179
+ ### `@settle`
180
+ Fires when evasion stops and the button has smoothly landed back at `(0, 0)`:
181
+ ```ts
182
+ e.detail: {
183
+ attempts: number,
184
+ reason: string // 'disabled' | 'duration' | 'maxAttempts'
185
+ }
186
+ ```
187
+
188
+ ### `@giveup`
189
+ Fires when evasion automatically stops because `duration` or `maxAttempts` was reached:
190
+ ```ts
191
+ e.detail: {
192
+ attempts: number,
193
+ reason: string // 'duration' | 'maxAttempts'
194
+ }
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Standalone Helper Functions
200
+
201
+ Pure mathematical functions exported for standalone use outside of DOM bindings:
202
+
203
+ ```js
204
+ import { calculateEvasionOffset, isNearTarget, isReducedMotionPreferred } from '@wrium/evasive-button';
205
+
206
+ // Check proximity
207
+ const { isNear, distance } = isNearTarget(cursor, targetRect, 80);
208
+
209
+ // Calculate next offset respecting boundaries and maxRadius
210
+ const nextOffset = calculateEvasionOffset(
211
+ cursor,
212
+ elSize,
213
+ naturalPos,
214
+ containerBounds,
215
+ currentOffset,
216
+ { distance: 100, threshold: 80, maxRadius: 150 }
217
+ );
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Testing
223
+
224
+ Run unit tests (Vitest):
225
+ ```bash
226
+ bun run test
227
+ # or: npm test
228
+ ```
229
+
230
+ Run End-to-End tests (Playwright):
231
+ ```bash
232
+ bun run test:e2e
233
+ # or: npm run test:e2e
234
+ ```
235
+
236
+ Interactive UI Test Mode:
237
+ ```bash
238
+ bun run test:e2e:ui
239
+ ```
240
+
241
+ ---
242
+
243
+ ## License
244
+
245
+ MIT License.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Evasive Button Plugin for Wrium.
3
+ *
4
+ * Causes buttons (or any interactive element) to playfully and smoothly dodge the cursor
5
+ * when hovered, approached, or clicked. Ideal for playful login screens,
6
+ * gamified forms, prank pages, or interactive validation feedback.
7
+ *
8
+ * Phase 2 Capabilities:
9
+ * - Fluid tangential gliding and perimeter wall-sliding (no erratic corner teleporting).
10
+ * - Anti-stuck corner escape mechanics (never gets trapped in corners or pinned against walls).
11
+ * - Bounded environment control: 'parent', 'viewport', or custom selector (e.g. bounds: '#arena').
12
+ * - Movement limits:
13
+ * - maxRadius: limits evasion distance from the button's home anchor.
14
+ * - duration: timeout in milliseconds after which evasion gives up and settles.
15
+ * - maxAttempts: count limit after which evasion gives up and settles.
16
+ * - Guaranteed smooth return to default position (0, 0) when disabled or form becomes valid.
17
+ * - Lifecycle state classes (.is-evading, .is-settled) and events (@evade, @settle, @giveup).
18
+ * - Accessibility: prefers-reduced-motion auto-detection and Tab+Enter keyboard support.
19
+ *
20
+ * @example
21
+ * import { createApp, ref, computed } from '@wrium/wrium';
22
+ * import { EvasiveButtonPlugin } from '@wrium/evasive-button';
23
+ *
24
+ * createApp(() => {
25
+ * const username = ref('');
26
+ * const password = ref('');
27
+ * const isFormValid = computed(() => username.value.length >= 3 && password.value.length >= 6);
28
+ * return { username, password, isFormValid };
29
+ * })
30
+ * .use(EvasiveButtonPlugin)
31
+ * .mount('#app');
32
+ *
33
+ * <!-- Evasive until fields are filled, then smoothly glides home and settles -->
34
+ * <button v-evade="{ active: !isFormValid, bounds: '#arena', maxRadius: 150 }" @settle="onSettled">
35
+ * Log In
36
+ * </button>
37
+ */
38
+ /**
39
+ * Check if user's OS has prefers-reduced-motion enabled.
40
+ *
41
+ * @returns {boolean}
42
+ */
43
+ export declare function isReducedMotionPreferred(): boolean;
44
+ /**
45
+ * Check if the cursor is within the proximity threshold of a target bounding rect.
46
+ *
47
+ * @param {{ x: number, y: number }} cursor
48
+ * @param {{ left: number, top: number, width: number, height: number, right?: number, bottom?: number }} targetRect
49
+ * @param {number} threshold
50
+ * @returns {{ isNear: boolean, distance: number, center: { x: number, y: number } }}
51
+ */
52
+ export declare function isNearTarget(cursor: {
53
+ x: number;
54
+ y: number;
55
+ }, targetRect: {
56
+ left: number;
57
+ top: number;
58
+ width: number;
59
+ height: number;
60
+ right?: number;
61
+ bottom?: number;
62
+ }, threshold?: number): {
63
+ isNear: boolean;
64
+ distance: number;
65
+ center: {
66
+ x: number;
67
+ y: number;
68
+ };
69
+ };
70
+ /**
71
+ * Calculates a new translation offset { x, y } that evades the cursor
72
+ * while staying strictly within the container bounds and respecting maxRadius.
73
+ *
74
+ * Pure function - completely decoupled from DOM mutations for clean testability.
75
+ *
76
+ * @param {{ x: number, y: number }} cursor - Cursor coordinates in viewport space
77
+ * @param {{ width: number, height: number }} elSize - Element dimensions
78
+ * @param {{ left: number, top: number }} naturalPos - Natural element origin (translate = 0)
79
+ * @param {{ left: number, top: number, right: number, bottom: number }} bounds - Allowed boundary box
80
+ * @param {{ x: number, y: number }} currentOffset - Current translation { x, y }
81
+ * @param {Object} [options]
82
+ * @param {number} [options.distance=100] - Base leap distance in pixels
83
+ * @param {number} [options.threshold=80] - Proximity trigger distance in pixels
84
+ * @param {number} [options.maxRadius] - Maximum allowable distance from home origin (0, 0)
85
+ * @param {boolean} [options.random=false] - Apply random angle jitter
86
+ * @returns {{ x: number, y: number, escaped: boolean }}
87
+ */
88
+ export declare function calculateEvasionOffset(cursor: {
89
+ x: number;
90
+ y: number;
91
+ }, elSize: {
92
+ width: number;
93
+ height: number;
94
+ }, naturalPos: {
95
+ left: number;
96
+ top: number;
97
+ }, bounds: {
98
+ left: number;
99
+ top: number;
100
+ right: number;
101
+ bottom: number;
102
+ }, currentOffset?: {
103
+ x: number;
104
+ y: number;
105
+ }, options?: {
106
+ distance?: number;
107
+ threshold?: number;
108
+ maxRadius?: number;
109
+ random?: boolean;
110
+ }): {
111
+ x: number;
112
+ y: number;
113
+ escaped: boolean;
114
+ };
115
+ /**
116
+ * Resolve the bounding box to constrain element movement within.
117
+ *
118
+ * @param {HTMLElement} el
119
+ * @param {'parent'|'viewport'|string} [boundsType='parent']
120
+ * @returns {{ left: number, top: number, right: number, bottom: number, width: number, height: number }}
121
+ */
122
+ export declare function getBoundingContainer(el: HTMLElement, boundsType?: 'parent' | 'viewport' | string): {
123
+ left: number;
124
+ top: number;
125
+ right: number;
126
+ bottom: number;
127
+ width: number;
128
+ height: number;
129
+ };
130
+ /**
131
+ * EvasiveButtonPlugin definition for Wrium.
132
+ */
133
+ export declare const EvasiveButtonPlugin: {
134
+ install(api: any, options?: {}): void;
135
+ };
136
+ /**
137
+ * Convenient aliases
138
+ */
139
+ export declare const EvadePlugin: {
140
+ install(api: any, options?: {}): void;
141
+ };
142
+ export declare const EvasivePlugin: {
143
+ install(api: any, options?: {}): void;
144
+ };
145
+ export default EvasiveButtonPlugin;
package/index.js ADDED
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Evasive Button Plugin for Wrium.
3
+ *
4
+ * Causes buttons (or any interactive element) to playfully and smoothly dodge the cursor
5
+ * when hovered, approached, or clicked. Ideal for playful login screens,
6
+ * gamified forms, prank pages, or interactive validation feedback.
7
+ *
8
+ * Phase 2 Capabilities:
9
+ * - Fluid tangential gliding and perimeter wall-sliding (no erratic corner teleporting).
10
+ * - Anti-stuck corner escape mechanics (never gets trapped in corners or pinned against walls).
11
+ * - Bounded environment control: 'parent', 'viewport', or custom selector (e.g. bounds: '#arena').
12
+ * - Movement limits:
13
+ * - maxRadius: limits evasion distance from the button's home anchor.
14
+ * - duration: timeout in milliseconds after which evasion gives up and settles.
15
+ * - maxAttempts: count limit after which evasion gives up and settles.
16
+ * - Guaranteed smooth return to default position (0, 0) when disabled or form becomes valid.
17
+ * - Lifecycle state classes (.is-evading, .is-settled) and events (@evade, @settle, @giveup).
18
+ * - Accessibility: prefers-reduced-motion auto-detection and Tab+Enter keyboard support.
19
+ *
20
+ * @example
21
+ * import { createApp, ref, computed } from '@wrium/wrium';
22
+ * import { EvasiveButtonPlugin } from '@wrium/evasive-button';
23
+ *
24
+ * createApp(() => {
25
+ * const username = ref('');
26
+ * const password = ref('');
27
+ * const isFormValid = computed(() => username.value.length >= 3 && password.value.length >= 6);
28
+ * return { username, password, isFormValid };
29
+ * })
30
+ * .use(EvasiveButtonPlugin)
31
+ * .mount('#app');
32
+ *
33
+ * <!-- Evasive until fields are filled, then smoothly glides home and settles -->
34
+ * <button v-evade="{ active: !isFormValid, bounds: '#arena', maxRadius: 150 }" @settle="onSettled">
35
+ * Log In
36
+ * </button>
37
+ */
38
+
39
+ /**
40
+ * Check if user's OS has prefers-reduced-motion enabled.
41
+ *
42
+ * @returns {boolean}
43
+ */
44
+ export function isReducedMotionPreferred() {
45
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
46
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Check if the cursor is within the proximity threshold of a target bounding rect.
53
+ *
54
+ * @param {{ x: number, y: number }} cursor
55
+ * @param {{ left: number, top: number, width: number, height: number, right?: number, bottom?: number }} targetRect
56
+ * @param {number} threshold
57
+ * @returns {{ isNear: boolean, distance: number, center: { x: number, y: number } }}
58
+ */
59
+ export function isNearTarget(cursor, targetRect, threshold = 80) {
60
+ const center = {
61
+ x: targetRect.left + targetRect.width / 2,
62
+ y: targetRect.top + targetRect.height / 2
63
+ };
64
+
65
+ // Calculate shortest distance from cursor point to element rectangle
66
+ const nearestX = Math.max(targetRect.left, Math.min(cursor.x, targetRect.left + targetRect.width));
67
+ const nearestY = Math.max(targetRect.top, Math.min(cursor.y, targetRect.top + targetRect.height));
68
+ const edgeDist = Math.hypot(cursor.x - nearestX, cursor.y - nearestY);
69
+
70
+ return {
71
+ isNear: edgeDist < threshold,
72
+ distance: edgeDist,
73
+ center
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Calculates a new translation offset { x, y } that evades the cursor
79
+ * while staying strictly within the container bounds and respecting maxRadius.
80
+ *
81
+ * Pure function - completely decoupled from DOM mutations for clean testability.
82
+ *
83
+ * @param {{ x: number, y: number }} cursor - Cursor coordinates in viewport space
84
+ * @param {{ width: number, height: number }} elSize - Element dimensions
85
+ * @param {{ left: number, top: number }} naturalPos - Natural element origin (translate = 0)
86
+ * @param {{ left: number, top: number, right: number, bottom: number }} bounds - Allowed boundary box
87
+ * @param {{ x: number, y: number }} currentOffset - Current translation { x, y }
88
+ * @param {Object} [options]
89
+ * @param {number} [options.distance=100] - Base leap distance in pixels
90
+ * @param {number} [options.threshold=80] - Proximity trigger distance in pixels
91
+ * @param {number} [options.maxRadius] - Maximum allowable distance from home origin (0, 0)
92
+ * @param {boolean} [options.random=false] - Apply random angle jitter
93
+ * @returns {{ x: number, y: number, escaped: boolean }}
94
+ */
95
+ export function calculateEvasionOffset(
96
+ cursor,
97
+ elSize,
98
+ naturalPos,
99
+ bounds,
100
+ currentOffset = { x: 0, y: 0 },
101
+ options = {}
102
+ ) {
103
+ const rawDistance = options.distance ?? 100;
104
+ const threshold = options.threshold ?? 80;
105
+ const maxRadius = (typeof options.maxRadius === 'number' && options.maxRadius > 0)
106
+ ? options.maxRadius
107
+ : Infinity;
108
+ const randomJitter = options.random ?? false;
109
+
110
+ // Translation limits relative to natural position
111
+ const minX = bounds.left - naturalPos.left;
112
+ const maxX = bounds.right - naturalPos.left - elSize.width;
113
+ const minY = bounds.top - naturalPos.top;
114
+ const maxY = bounds.bottom - naturalPos.top - elSize.height;
115
+
116
+ // Safe clamped ranges (handles tight/reversed bounds)
117
+ const safeMinX = Math.min(minX, maxX);
118
+ const safeMaxX = Math.max(minX, maxX);
119
+ const safeMinY = Math.min(minY, maxY);
120
+ const safeMaxY = Math.max(minY, maxY);
121
+
122
+ const spanX = safeMaxX - safeMinX;
123
+ const spanY = safeMaxY - safeMinY;
124
+
125
+ // Adapt step distance to container dimensions and maxRadius
126
+ const minSpan = Math.min(spanX, spanY);
127
+ const maxSpan = Math.max(spanX, spanY);
128
+ let adaptiveDistance = maxSpan > 0
129
+ ? Math.min(rawDistance, Math.max(30, Math.min(rawDistance, minSpan > 40 ? minSpan * 0.75 : maxSpan * 0.6)))
130
+ : 0;
131
+
132
+ if (maxRadius !== Infinity) {
133
+ adaptiveDistance = Math.min(adaptiveDistance, maxRadius * 0.8);
134
+ }
135
+
136
+ // Current visual center
137
+ const currentCenter = {
138
+ x: naturalPos.left + currentOffset.x + elSize.width / 2,
139
+ y: naturalPos.top + currentOffset.y + elSize.height / 2
140
+ };
141
+
142
+ // Vector pointing away from cursor towards element center
143
+ let vx = currentCenter.x - cursor.x;
144
+ let vy = currentCenter.y - cursor.y;
145
+ let d = Math.hypot(vx, vy);
146
+
147
+ if (d === 0) {
148
+ vx = (Math.random() - 0.5) || 1;
149
+ vy = (Math.random() - 0.5) || 1;
150
+ d = Math.hypot(vx, vy);
151
+ }
152
+
153
+ let ux = vx / d;
154
+ let uy = vy / d;
155
+
156
+ if (randomJitter) {
157
+ const jitterAngle = (Math.random() - 0.5) * (Math.PI / 4);
158
+ const cosJ = Math.cos(jitterAngle);
159
+ const sinJ = Math.sin(jitterAngle);
160
+ const nux = ux * cosJ - uy * sinJ;
161
+ const nuy = ux * sinJ + uy * cosJ;
162
+ ux = nux;
163
+ uy = nuy;
164
+ }
165
+
166
+ // Step 1: Direct Flee with Tangential Deflection (Wall Sliding)
167
+ const stepX = ux * adaptiveDistance;
168
+ const stepY = uy * adaptiveDistance;
169
+
170
+ let targetX = currentOffset.x + stepX;
171
+ let targetY = currentOffset.y + stepY;
172
+
173
+ // Boundary collision handling: deflect overflow into sliding along the open wall
174
+ if (targetX > safeMaxX) {
175
+ const overflow = targetX - safeMaxX;
176
+ targetX = safeMaxX;
177
+ const dirY = uy !== 0 ? Math.sign(uy) : (currentCenter.y >= cursor.y ? 1 : -1);
178
+ targetY += dirY * overflow * 0.85;
179
+ } else if (targetX < safeMinX) {
180
+ const overflow = safeMinX - targetX;
181
+ targetX = safeMinX;
182
+ const dirY = uy !== 0 ? Math.sign(uy) : (currentCenter.y >= cursor.y ? 1 : -1);
183
+ targetY += dirY * overflow * 0.85;
184
+ }
185
+
186
+ if (targetY > safeMaxY) {
187
+ const overflow = targetY - safeMaxY;
188
+ targetY = safeMaxY;
189
+ const dirX = ux !== 0 ? Math.sign(ux) : (currentCenter.x >= cursor.x ? 1 : -1);
190
+ targetX += dirX * overflow * 0.85;
191
+ } else if (targetY < safeMinY) {
192
+ const overflow = safeMinY - targetY;
193
+ targetY = safeMinY;
194
+ const dirX = ux !== 0 ? Math.sign(ux) : (currentCenter.x >= cursor.x ? 1 : -1);
195
+ targetX += dirX * overflow * 0.85;
196
+ }
197
+
198
+ // Apply maxRadius constraint from natural origin (0, 0)
199
+ if (maxRadius !== Infinity) {
200
+ const distFromOrigin = Math.hypot(targetX, targetY);
201
+ if (distFromOrigin > maxRadius) {
202
+ const scale = maxRadius / distFromOrigin;
203
+ targetX *= scale;
204
+ targetY *= scale;
205
+ }
206
+ }
207
+
208
+ // Ensure within boundary limits
209
+ targetX = Math.max(safeMinX, Math.min(safeMaxX, targetX));
210
+ targetY = Math.max(safeMinY, Math.min(safeMaxY, targetY));
211
+
212
+ // Step 2: Anti-Stuck Corner Escape
213
+ // If trapped in a corner or pinned against a wall (effective displacement < 15px),
214
+ // evaluate smooth sliding paths along open perimeter walls rather than freezing or teleporting.
215
+ const moveDist = Math.hypot(targetX - currentOffset.x, targetY - currentOffset.y);
216
+
217
+ if (moveDist < 15 && (spanX > 0 || spanY > 0)) {
218
+ const escapeCandidates = [];
219
+
220
+ // Slide horizontally along wall
221
+ if (spanX > 0) {
222
+ escapeCandidates.push({
223
+ x: Math.max(safeMinX, Math.min(safeMaxX, currentOffset.x + adaptiveDistance)),
224
+ y: currentOffset.y
225
+ });
226
+ escapeCandidates.push({
227
+ x: Math.max(safeMinX, Math.min(safeMaxX, currentOffset.x - adaptiveDistance)),
228
+ y: currentOffset.y
229
+ });
230
+ }
231
+
232
+ // Slide vertically along wall
233
+ if (spanY > 0) {
234
+ escapeCandidates.push({
235
+ x: currentOffset.x,
236
+ y: Math.max(safeMinY, Math.min(safeMaxY, currentOffset.y + adaptiveDistance))
237
+ });
238
+ escapeCandidates.push({
239
+ x: currentOffset.x,
240
+ y: Math.max(safeMinY, Math.min(safeMaxY, currentOffset.y - adaptiveDistance))
241
+ });
242
+ }
243
+
244
+ // Diagonal perimeter glides away from corner
245
+ if (spanX > 0 && spanY > 0) {
246
+ escapeCandidates.push({
247
+ x: Math.max(safeMinX, Math.min(safeMaxX, currentOffset.x + adaptiveDistance * 0.7)),
248
+ y: Math.max(safeMinY, Math.min(safeMaxY, currentOffset.y + adaptiveDistance * 0.7))
249
+ });
250
+ escapeCandidates.push({
251
+ x: Math.max(safeMinX, Math.min(safeMaxX, currentOffset.x - adaptiveDistance * 0.7)),
252
+ y: Math.max(safeMinY, Math.min(safeMaxY, currentOffset.y - adaptiveDistance * 0.7))
253
+ });
254
+ escapeCandidates.push({
255
+ x: Math.max(safeMinX, Math.min(safeMaxX, currentOffset.x + adaptiveDistance * 0.7)),
256
+ y: Math.max(safeMinY, Math.min(safeMaxY, currentOffset.y - adaptiveDistance * 0.7))
257
+ });
258
+ escapeCandidates.push({
259
+ x: Math.max(safeMinX, Math.min(safeMaxX, currentOffset.x - adaptiveDistance * 0.7)),
260
+ y: Math.max(safeMinY, Math.min(safeMaxY, currentOffset.y + adaptiveDistance * 0.7))
261
+ });
262
+ }
263
+
264
+ let bestCandidate = null;
265
+ let maxCandidateDist = -Infinity;
266
+
267
+ for (const cand of escapeCandidates) {
268
+ // Apply maxRadius constraint to candidate
269
+ if (maxRadius !== Infinity) {
270
+ const candR = Math.hypot(cand.x, cand.y);
271
+ if (candR > maxRadius) {
272
+ cand.x *= maxRadius / candR;
273
+ cand.y *= maxRadius / candR;
274
+ }
275
+ }
276
+
277
+ cand.x = Math.max(safeMinX, Math.min(safeMaxX, cand.x));
278
+ cand.y = Math.max(safeMinY, Math.min(safeMaxY, cand.y));
279
+
280
+ const candMoveDist = Math.hypot(cand.x - currentOffset.x, cand.y - currentOffset.y);
281
+ if (candMoveDist >= 15) {
282
+ const candCenter = {
283
+ x: naturalPos.left + cand.x + elSize.width / 2,
284
+ y: naturalPos.top + cand.y + elSize.height / 2
285
+ };
286
+ const distFromCursor = Math.hypot(candCenter.x - cursor.x, candCenter.y - cursor.y);
287
+ if (distFromCursor > maxCandidateDist) {
288
+ maxCandidateDist = distFromCursor;
289
+ bestCandidate = cand;
290
+ }
291
+ }
292
+ }
293
+
294
+ if (bestCandidate) {
295
+ targetX = bestCandidate.x;
296
+ targetY = bestCandidate.y;
297
+ }
298
+ }
299
+
300
+ const finalCenter = {
301
+ x: naturalPos.left + targetX + elSize.width / 2,
302
+ y: naturalPos.top + targetY + elSize.height / 2
303
+ };
304
+ const finalDistFromCursor = Math.hypot(finalCenter.x - cursor.x, finalCenter.y - cursor.y);
305
+
306
+ return {
307
+ x: Math.round(targetX),
308
+ y: Math.round(targetY),
309
+ escaped: finalDistFromCursor >= threshold
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Resolve the bounding box to constrain element movement within.
315
+ *
316
+ * @param {HTMLElement} el
317
+ * @param {'parent'|'viewport'|string} [boundsType='parent']
318
+ * @returns {{ left: number, top: number, right: number, bottom: number, width: number, height: number }}
319
+ */
320
+ export function getBoundingContainer(el, boundsType = 'parent') {
321
+ const defaultW = typeof window !== 'undefined' && window.innerWidth ? window.innerWidth : 1024;
322
+ const defaultH = typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 768;
323
+
324
+ if (boundsType === 'viewport' || typeof window === 'undefined') {
325
+ const padding = 12;
326
+ return {
327
+ left: padding,
328
+ top: padding,
329
+ right: defaultW - padding,
330
+ bottom: defaultH - padding,
331
+ width: defaultW - 2 * padding,
332
+ height: defaultH - 2 * padding
333
+ };
334
+ }
335
+
336
+ let container = null;
337
+ if (typeof boundsType === 'string' && boundsType !== 'parent') {
338
+ container = document.querySelector(boundsType);
339
+ }
340
+ if (!container) {
341
+ container = el.offsetParent || el.parentElement || document.body;
342
+ }
343
+
344
+ const r = container ? container.getBoundingClientRect() : { left: 0, top: 0, right: defaultW, bottom: defaultH };
345
+ const w = (container ? container.clientWidth : 0) || r.width || defaultW;
346
+ const h = (container ? container.clientHeight : 0) || r.height || defaultH;
347
+
348
+ // Safety margin inside container so the button never clips borders
349
+ const pad = 6;
350
+ return {
351
+ left: (r.left || 0) + pad,
352
+ top: (r.top || 0) + pad,
353
+ right: (r.right || (r.left + w)) - pad,
354
+ bottom: (r.bottom || (r.top + h)) - pad,
355
+ width: Math.max(0, w - 2 * pad),
356
+ height: Math.max(0, h - 2 * pad)
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Creates the directive handler for `v-evade` / `v-evasive`.
362
+ */
363
+ function createEvadeDirective(pluginOptions = {}) {
364
+ return (el, exp, { scope, cs, evalExp, watchEffect, arg, modifiers }) => {
365
+ let attempts = 0;
366
+ let currentTranslate = { x: 0, y: 0 };
367
+ let naturalRect = null;
368
+ let lastEvadeTime = 0;
369
+ let evasionStartTime = null;
370
+ let hasGivenUp = false;
371
+ let settleTimer = null;
372
+ const COOLDOWN_MS = 130; // Minimum time between proximity triggers for smooth fluid motion
373
+
374
+ const isTeleport = modifiers.teleport || pluginOptions.teleport;
375
+ // Fluid, natural easing curve
376
+ const transitionDuration = isTeleport ? 0 : 280;
377
+ const transitionStyle = isTeleport
378
+ ? 'none'
379
+ : `transform ${transitionDuration / 1000}s cubic-bezier(0.25, 1, 0.5, 1)`;
380
+
381
+ el.style.willChange = 'transform';
382
+ el.style.transition = transitionStyle;
383
+
384
+ // Initial state
385
+ function setButtonState(state) {
386
+ el.dataset.state = state;
387
+ el.dataset.evading = state === 'evading' ? 'true' : 'false';
388
+ if (state === 'evading') {
389
+ el.classList.add('is-evading');
390
+ el.classList.remove('is-settled');
391
+ } else if (state === 'settled') {
392
+ el.classList.remove('is-evading');
393
+ el.classList.add('is-settled');
394
+ } else if (state === 'gaveup') {
395
+ el.classList.remove('is-evading');
396
+ el.classList.add('is-settled');
397
+ }
398
+ }
399
+
400
+ setButtonState('settled');
401
+
402
+ function resolveContainerElement() {
403
+ if (config.bounds === 'viewport') return null;
404
+ if (typeof config.bounds === 'string' && config.bounds !== 'parent') {
405
+ const found = document.querySelector(config.bounds);
406
+ if (found) return found;
407
+ }
408
+ return el.offsetParent || el.parentElement || document.body;
409
+ }
410
+
411
+ // Measure natural position dynamically without layout shift or scroll drift
412
+ function getNaturalPosition() {
413
+ const currentW = el.offsetWidth || (naturalRect ? naturalRect.width : 100);
414
+ const currentH = el.offsetHeight || (naturalRect ? naturalRect.height : 36);
415
+ const containerEl = resolveContainerElement();
416
+
417
+ if (containerEl && containerEl !== document.body) {
418
+ const cRect = containerEl.getBoundingClientRect();
419
+ let ox = 0;
420
+ let oy = 0;
421
+ let curr = el;
422
+ while (curr && curr !== containerEl && curr !== document.body) {
423
+ ox += curr.offsetLeft || 0;
424
+ oy += curr.offsetTop || 0;
425
+ curr = curr.offsetParent;
426
+ }
427
+ naturalRect = {
428
+ left: cRect.left + ox,
429
+ top: cRect.top + oy,
430
+ width: currentW,
431
+ height: currentH
432
+ };
433
+ return naturalRect;
434
+ }
435
+
436
+ // Fallback for viewport or document.body
437
+ const r = el.getBoundingClientRect();
438
+ naturalRect = {
439
+ left: r.left - currentTranslate.x,
440
+ top: r.top - currentTranslate.y,
441
+ width: currentW,
442
+ height: currentH
443
+ };
444
+ return naturalRect;
445
+ }
446
+
447
+ // Configuration resolution (reactively updated if expression is used)
448
+ let active = true;
449
+ let config = {
450
+ threshold: pluginOptions.threshold ?? 75,
451
+ distance: pluginOptions.distance ?? 100,
452
+ maxRadius: pluginOptions.maxRadius ?? Infinity,
453
+ duration: pluginOptions.duration ?? Infinity,
454
+ bounds: arg || pluginOptions.bounds || (modifiers.viewport ? 'viewport' : 'parent'),
455
+ random: modifiers.random || pluginOptions.random || false,
456
+ maxAttempts: pluginOptions.maxAttempts ?? Infinity,
457
+ allowKeyboard: pluginOptions.allowKeyboard ?? true,
458
+ respectReducedMotion: pluginOptions.respectReducedMotion ?? true,
459
+ onEvade: null,
460
+ onSettle: null,
461
+ onGiveup: null
462
+ };
463
+
464
+ // Smooth return to default (0, 0) position with .is-settled state
465
+ function resetPosition(reason = 'disabled') {
466
+ currentTranslate = { x: 0, y: 0 };
467
+ el.style.transition = transitionStyle;
468
+ el.style.transform = 'translate(0px, 0px)';
469
+
470
+ if (settleTimer) clearTimeout(settleTimer);
471
+ settleTimer = setTimeout(() => {
472
+ if (hasGivenUp) {
473
+ setButtonState('gaveup');
474
+ } else {
475
+ setButtonState('settled');
476
+ }
477
+
478
+ if (typeof config.onSettle === 'function') {
479
+ config.onSettle({ attempts, reason });
480
+ }
481
+
482
+ el.dispatchEvent(
483
+ new CustomEvent('settle', {
484
+ bubbles: true,
485
+ cancelable: true,
486
+ detail: { attempts, reason }
487
+ })
488
+ );
489
+ }, transitionDuration);
490
+ }
491
+
492
+ let durationTimer = null;
493
+
494
+ function startDurationTimer() {
495
+ clearDurationTimer();
496
+ if (config.duration === Infinity || !active || hasGivenUp) return;
497
+
498
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
499
+ if (evasionStartTime === null) {
500
+ evasionStartTime = now;
501
+ }
502
+ const elapsed = now - evasionStartTime;
503
+ const remaining = Math.max(0, config.duration - elapsed);
504
+
505
+ durationTimer = setTimeout(() => {
506
+ durationTimer = null;
507
+ if (active && !hasGivenUp) {
508
+ triggerGiveup('duration');
509
+ }
510
+ }, remaining);
511
+ }
512
+
513
+ function clearDurationTimer() {
514
+ if (durationTimer) {
515
+ clearTimeout(durationTimer);
516
+ durationTimer = null;
517
+ }
518
+ }
519
+
520
+ function triggerGiveup(reason) {
521
+ clearDurationTimer();
522
+ hasGivenUp = true;
523
+ setButtonState('gaveup');
524
+
525
+ if (typeof config.onGiveup === 'function') {
526
+ config.onGiveup({ reason, attempts });
527
+ }
528
+
529
+ el.dispatchEvent(
530
+ new CustomEvent('giveup', {
531
+ bubbles: true,
532
+ cancelable: true,
533
+ detail: { reason, attempts }
534
+ })
535
+ );
536
+
537
+ resetPosition(reason);
538
+ }
539
+
540
+ let previousDuration = config.duration;
541
+ let lastResetKey = undefined;
542
+
543
+ // Handle expression binding (boolean flag, config object, or ref)
544
+ if (exp && exp.trim()) {
545
+ cs.addEffect(watchEffect(() => {
546
+ const val = evalExp(exp, scope);
547
+
548
+ if (typeof val === 'boolean') {
549
+ active = val;
550
+ } else if (typeof val === 'number') {
551
+ active = true;
552
+ } else if (val && typeof val === 'object') {
553
+ if ('active' in val) active = Boolean(val.active);
554
+ if ('threshold' in val) config.threshold = val.threshold;
555
+ if ('distance' in val) config.distance = val.distance;
556
+ if ('maxRadius' in val) config.maxRadius = val.maxRadius;
557
+ if ('duration' in val) config.duration = val.duration;
558
+ if ('bounds' in val) config.bounds = val.bounds;
559
+ if ('random' in val) config.random = Boolean(val.random);
560
+ if ('maxAttempts' in val) config.maxAttempts = val.maxAttempts;
561
+ if ('allowKeyboard' in val) config.allowKeyboard = Boolean(val.allowKeyboard);
562
+ if ('respectReducedMotion' in val) config.respectReducedMotion = Boolean(val.respectReducedMotion);
563
+ if ('onEvade' in val) config.onEvade = val.onEvade;
564
+ if ('onSettle' in val) config.onSettle = val.onSettle;
565
+ if ('onGiveup' in val) config.onGiveup = val.onGiveup;
566
+
567
+ // Manual reset trigger via resetKey
568
+ if ('resetKey' in val && val.resetKey !== lastResetKey) {
569
+ lastResetKey = val.resetKey;
570
+ clearDurationTimer();
571
+ evasionStartTime = null;
572
+ hasGivenUp = false;
573
+ attempts = 0;
574
+ setButtonState('settled');
575
+ }
576
+ } else {
577
+ active = Boolean(val);
578
+ }
579
+
580
+ // If duration configuration changed, reset timer state
581
+ if (config.duration !== previousDuration) {
582
+ previousDuration = config.duration;
583
+ clearDurationTimer();
584
+ evasionStartTime = null;
585
+ hasGivenUp = false;
586
+ attempts = 0;
587
+ setButtonState('settled');
588
+ }
589
+
590
+ // If active toggled from false back to true, reset given-up state
591
+ if (active) {
592
+ if (hasGivenUp) {
593
+ hasGivenUp = false;
594
+ evasionStartTime = null;
595
+ attempts = 0;
596
+ }
597
+ } else {
598
+ clearDurationTimer();
599
+ resetPosition('disabled');
600
+ }
601
+ }));
602
+ }
603
+
604
+ function triggerEvade(cursor, source = 'proximity') {
605
+ if (!active || hasGivenUp) return false;
606
+
607
+ // Accessibility: Respect prefers-reduced-motion
608
+ if (config.respectReducedMotion && isReducedMotionPreferred()) {
609
+ return false;
610
+ }
611
+
612
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
613
+
614
+ // Track duration limit with active timer
615
+ if (config.duration !== Infinity) {
616
+ if (evasionStartTime === null) {
617
+ evasionStartTime = now;
618
+ startDurationTimer();
619
+ } else if (now - evasionStartTime >= config.duration) {
620
+ triggerGiveup('duration');
621
+ return false;
622
+ }
623
+ }
624
+
625
+ // Track maxAttempts limit
626
+ if (attempts >= config.maxAttempts) {
627
+ triggerGiveup('maxAttempts');
628
+ return false;
629
+ }
630
+
631
+ // Proximity triggers respect cooldown to allow transitions to complete smoothly
632
+ if (source === 'proximity' && (now - lastEvadeTime < COOLDOWN_MS)) {
633
+ return false;
634
+ }
635
+
636
+ const nat = getNaturalPosition();
637
+ const container = getBoundingContainer(el, config.bounds);
638
+
639
+ const safeCursor = {
640
+ x: (cursor && typeof cursor.x === 'number' && !isNaN(cursor.x))
641
+ ? cursor.x
642
+ : (nat.left + currentTranslate.x + nat.width / 2),
643
+ y: (cursor && typeof cursor.y === 'number' && !isNaN(cursor.y))
644
+ ? cursor.y
645
+ : (nat.top + currentTranslate.y + nat.height / 2)
646
+ };
647
+
648
+ const nextOffset = calculateEvasionOffset(
649
+ safeCursor,
650
+ { width: nat.width, height: nat.height },
651
+ { left: nat.left, top: nat.top },
652
+ container,
653
+ currentTranslate,
654
+ {
655
+ distance: config.distance,
656
+ threshold: config.threshold,
657
+ maxRadius: config.maxRadius,
658
+ random: config.random
659
+ }
660
+ );
661
+
662
+ // Calculate actual displacement. If button cannot move further away (blocked/at limit), do nothing
663
+ const moveDelta = Math.hypot(nextOffset.x - currentTranslate.x, nextOffset.y - currentTranslate.y);
664
+ if (moveDelta < 4) {
665
+ return false;
666
+ }
667
+
668
+ lastEvadeTime = now;
669
+ const dx = nextOffset.x - currentTranslate.x;
670
+ const dy = nextOffset.y - currentTranslate.y;
671
+ currentTranslate = { x: nextOffset.x, y: nextOffset.y };
672
+ attempts++;
673
+
674
+ // Update state
675
+ setButtonState('evading');
676
+
677
+ el.style.transition = transitionStyle;
678
+ el.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`;
679
+
680
+ // Update write-back ref if expression points directly to a ref holding attempts
681
+ const targetRef = scope[exp];
682
+ if (targetRef && targetRef._isRef && typeof targetRef.value === 'number') {
683
+ targetRef.value = attempts;
684
+ }
685
+
686
+ const eventDetail = {
687
+ attempts,
688
+ x: nextOffset.x,
689
+ y: nextOffset.y,
690
+ dx,
691
+ dy,
692
+ source
693
+ };
694
+
695
+ if (typeof config.onEvade === 'function') {
696
+ config.onEvade(eventDetail);
697
+ }
698
+
699
+ // Dispatch custom evade event
700
+ el.dispatchEvent(
701
+ new CustomEvent('evade', {
702
+ bubbles: true,
703
+ cancelable: true,
704
+ detail: eventDetail
705
+ })
706
+ );
707
+
708
+ return true;
709
+ }
710
+
711
+ // Pointer proximity detection with zero layout thrashing
712
+ const onPointerMove = e => {
713
+ if (!active || hasGivenUp) return;
714
+
715
+ const cx = typeof e.clientX === 'number' ? e.clientX : 0;
716
+ const cy = typeof e.clientY === 'number' ? e.clientY : 0;
717
+
718
+ const nat = getNaturalPosition();
719
+ const currentRect = {
720
+ left: nat.left + currentTranslate.x,
721
+ top: nat.top + currentTranslate.y,
722
+ width: nat.width,
723
+ height: nat.height,
724
+ right: nat.left + currentTranslate.x + nat.width,
725
+ bottom: nat.top + currentTranslate.y + nat.height
726
+ };
727
+
728
+ const { isNear } = isNearTarget(
729
+ { x: cx, y: cy },
730
+ currentRect,
731
+ config.threshold
732
+ );
733
+
734
+ if (isNear) {
735
+ triggerEvade({ x: cx, y: cy }, 'proximity');
736
+ }
737
+ };
738
+
739
+ // Pointer enter (cursor flicked fast into element)
740
+ const onPointerEnter = e => {
741
+ if (!active || hasGivenUp) return;
742
+ const cx = typeof e.clientX === 'number' ? e.clientX : undefined;
743
+ const cy = typeof e.clientY === 'number' ? e.clientY : undefined;
744
+ triggerEvade({ x: cx, y: cy }, 'enter');
745
+ };
746
+
747
+ // Intercept mouse click / pointerdown attempts
748
+ const onPointerDown = e => {
749
+ if (!active || hasGivenUp) return;
750
+
751
+ if (e.pointerType === 'mouse' || e.pointerType === 'touch' || e.pointerType === 'pen' || e.clientX !== 0 || e.clientY !== 0) {
752
+ e.preventDefault();
753
+ e.stopPropagation();
754
+ const cx = typeof e.clientX === 'number' ? e.clientX : undefined;
755
+ const cy = typeof e.clientY === 'number' ? e.clientY : undefined;
756
+ triggerEvade({ x: cx, y: cy }, 'click_attempt');
757
+ }
758
+ };
759
+
760
+ const onClick = e => {
761
+ if (!active || hasGivenUp) return;
762
+
763
+ // Synthetic click triggered with mouse cursor
764
+ if (e.clientX !== 0 || e.clientY !== 0 || e.detail > 0) {
765
+ if (e.pointerType !== '') {
766
+ e.preventDefault();
767
+ e.stopPropagation();
768
+ triggerEvade({ x: e.clientX, y: e.clientY }, 'click_attempt');
769
+ }
770
+ } else if (!config.allowKeyboard) {
771
+ e.preventDefault();
772
+ e.stopPropagation();
773
+ const rect = el.getBoundingClientRect();
774
+ triggerEvade({ x: rect.left, y: rect.top }, 'keyboard_attempt');
775
+ }
776
+ };
777
+
778
+ const onResize = () => {
779
+ naturalRect = null;
780
+ };
781
+
782
+ // Attach listeners with automatic scope cleanup
783
+ window.addEventListener('pointermove', onPointerMove, { passive: true });
784
+ el.addEventListener('pointerenter', onPointerEnter);
785
+ el.addEventListener('pointerdown', onPointerDown);
786
+ el.addEventListener('click', onClick);
787
+ window.addEventListener('resize', onResize);
788
+
789
+ cs.addListener(window, 'pointermove', onPointerMove);
790
+ cs.addListener(el, 'pointerenter', onPointerEnter);
791
+ cs.addListener(el, 'pointerdown', onPointerDown);
792
+ cs.addListener(el, 'click', onClick);
793
+ cs.addListener(window, 'resize', onResize);
794
+ };
795
+ }
796
+
797
+ /**
798
+ * EvasiveButtonPlugin definition for Wrium.
799
+ */
800
+ export const EvasiveButtonPlugin = {
801
+ install(api, options = {}) {
802
+ const handler = createEvadeDirective(options);
803
+ api.directive('evade', handler);
804
+ api.directive('evasive', handler);
805
+ }
806
+ };
807
+
808
+ /**
809
+ * Convenient aliases
810
+ */
811
+ export const EvadePlugin = EvasiveButtonPlugin;
812
+ export const EvasivePlugin = EvasiveButtonPlugin;
813
+ export default EvasiveButtonPlugin;
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@wrium/evasive-button",
3
+ "version": "1.0.0",
4
+ "description": "A fun evasive button plugin for Wrium - causes buttons to playfully dodge the cursor when clicked or hovered.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./dist/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/types/index.d.ts",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "dist/types"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/wrium/wrium-evasive-button.git"
24
+ },
25
+ "homepage": "https://github.com/wrium/wrium-evasive-button#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/wrium/wrium-evasive-button/issues"
28
+ },
29
+ "peerDependencies": {
30
+ "@wrium/wrium": "^1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@playwright/test": "^1.63.0",
34
+ "@wrium/wrium": "^1.0.0",
35
+ "jsdom": "^27.3.0",
36
+ "typescript": "^7.0.2",
37
+ "vite": "^7.2.4",
38
+ "vitest": "^4.0.15"
39
+ },
40
+ "scripts": {
41
+ "dev": "vite",
42
+ "build": "tsc -p tsconfig.json",
43
+ "build:types": "tsc -p tsconfig.json",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "test:e2e": "playwright test",
47
+ "test:e2e:ui": "playwright test --ui",
48
+ "prepublishOnly": "npm run test && npm run test:e2e && npm run build:types"
49
+ },
50
+ "keywords": [
51
+ "wrium",
52
+ "wrium-plugin",
53
+ "evasive-button",
54
+ "runaway-button",
55
+ "fun-ui"
56
+ ],
57
+ "author": "Benjamin Khalife",
58
+ "license": "MIT"
59
+ }