@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 +21 -0
- package/README.md +245 -0
- package/dist/types/index.d.ts +145 -0
- package/index.js +813 -0
- package/package.json +59 -0
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
|
+
}
|