@alacris/ui 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/components/ui-drawer.js +83 -6
- package/src/components/ui-sheet.js +87 -7
- package/src/components/ui-side-sheet.js +79 -6
- package/src/components/ui-swipe-row.js +253 -0
- package/src/index.js +2 -0
- package/src/motion/gesture.js +196 -0
- package/src/motion/index.js +1 -0
- package/types/elements.d.ts +1 -0
- package/types/index.d.ts +20 -0
package/README.md
CHANGED
|
@@ -47,9 +47,9 @@ The published package is plain ESM. Point an import map at a pinned CDN build of
|
|
|
47
47
|
{
|
|
48
48
|
"imports": {
|
|
49
49
|
"@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.4/dist/alacris.js",
|
|
50
|
-
"@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.
|
|
51
|
-
"@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.
|
|
52
|
-
"@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.
|
|
50
|
+
"@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.5.0/src/index.js",
|
|
51
|
+
"@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.5.0/src/theme/index.js",
|
|
52
|
+
"@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.5.0/src/components/"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
</script>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alacris/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Themeable design system for Alacris — Material defaults, sixty-
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Themeable design system for Alacris — Material defaults, sixty-nine custom elements, ESM-only, no build step.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
// @prop {boolean} open=false
|
|
15
15
|
// @prop {string} variant='modal' — modal | standard
|
|
16
16
|
// @prop {string} anchor='start' — start | end (which edge it slides from)
|
|
17
|
+
// @prop {boolean} persistent=false — Escape/scrim/swipe do not request closing
|
|
18
|
+
// @prop {boolean} swipable=true — swipe in anchor direction to dismiss
|
|
17
19
|
// @prop {string} label='' — accessible name; falls back to "Navigation"
|
|
18
|
-
// @event close — modal dismissed; detail: { reason: 'esc' | 'scrim' }
|
|
20
|
+
// @event close — modal dismissed; detail: { reason: 'esc' | 'scrim' | 'swipe' }
|
|
19
21
|
// @event opened — modal enter animation finished
|
|
20
22
|
// @event closed — modal exit animation finished, DOM removed
|
|
21
23
|
// @slot (default) — drawer content
|
|
@@ -27,6 +29,7 @@ import { sys } from '../tokens/sys.js';
|
|
|
27
29
|
import { base } from './base.js';
|
|
28
30
|
import { presence } from '../motion/presence.js';
|
|
29
31
|
import { animate, fx, releaseFill } from '../motion/animate.js';
|
|
32
|
+
import { createSwipeTracker, rubberBand } from '../motion/gesture.js';
|
|
30
33
|
import { focusTrap, scrollLock } from '../util/focus.js';
|
|
31
34
|
|
|
32
35
|
const t = vars('ui-drawer', {
|
|
@@ -56,6 +59,7 @@ const styles = css`
|
|
|
56
59
|
padding: ${t.pad};
|
|
57
60
|
overflow-y: auto;
|
|
58
61
|
box-shadow: ${sys.elevation[1]};
|
|
62
|
+
touch-action: pan-y;
|
|
59
63
|
}
|
|
60
64
|
.surface.start {
|
|
61
65
|
inset-inline-start: 0;
|
|
@@ -88,12 +92,19 @@ const styles = css`
|
|
|
88
92
|
`;
|
|
89
93
|
|
|
90
94
|
define('ui-drawer', {
|
|
91
|
-
props: { open: false, variant: 'modal', anchor: 'start', label: '' },
|
|
95
|
+
props: { open: false, variant: 'modal', anchor: 'start', persistent: false, swipable: true, label: '' },
|
|
92
96
|
styles: [base, styles],
|
|
93
|
-
setup({ open, variant, anchor, label }, host) {
|
|
97
|
+
setup({ open, variant, anchor, persistent, swipable, label }, host) {
|
|
94
98
|
let releaseTrap = null;
|
|
95
99
|
let unlock = null;
|
|
96
100
|
let surfaceEl = null;
|
|
101
|
+
let scrimEl = null;
|
|
102
|
+
let tracker = null;
|
|
103
|
+
|
|
104
|
+
const requestClose = (reason) => {
|
|
105
|
+
if (reason !== 'method' && persistent()) return;
|
|
106
|
+
host.emit('close', { reason });
|
|
107
|
+
};
|
|
97
108
|
|
|
98
109
|
const slideIn = () => (anchor.peek() === 'start' ? fx.slideInLeft : fx.slideInRight);
|
|
99
110
|
const slideOut = () => (anchor.peek() === 'start' ? fx.slideOutLeft : fx.slideOutRight);
|
|
@@ -101,7 +112,7 @@ define('ui-drawer', {
|
|
|
101
112
|
// Escape must work wherever focus is, so listen at the document while
|
|
102
113
|
// the modal drawer is open.
|
|
103
114
|
const onDocKeydown = (e) => {
|
|
104
|
-
if (e.key === 'Escape')
|
|
115
|
+
if (e.key === 'Escape') requestClose('esc');
|
|
105
116
|
};
|
|
106
117
|
|
|
107
118
|
// Trap focus + lock scroll exactly while the modal drawer is open.
|
|
@@ -127,6 +138,7 @@ define('ui-drawer', {
|
|
|
127
138
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
128
139
|
releaseTrap?.();
|
|
129
140
|
unlock?.();
|
|
141
|
+
tracker?.destroy();
|
|
130
142
|
});
|
|
131
143
|
|
|
132
144
|
// The panel slides out while the presence overlay (scrim included) fades.
|
|
@@ -139,11 +151,73 @@ define('ui-drawer', {
|
|
|
139
151
|
const surfaceRef = (el) => {
|
|
140
152
|
surfaceEl = el;
|
|
141
153
|
releaseFill(animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
154
|
+
|
|
155
|
+
tracker?.destroy();
|
|
156
|
+
tracker = createSwipeTracker(el, {
|
|
157
|
+
axis: 'x',
|
|
158
|
+
threshold: 8,
|
|
159
|
+
filter(e) {
|
|
160
|
+
if (!swipable() || persistent() || variant() !== 'modal') return false;
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
163
|
+
onStart() {
|
|
164
|
+
el.style.transition = 'none';
|
|
165
|
+
},
|
|
166
|
+
onMove({ dx }) {
|
|
167
|
+
const isEnd = anchor.peek() === 'end';
|
|
168
|
+
let effectiveDx = dx;
|
|
169
|
+
if (isEnd) {
|
|
170
|
+
if (dx < 0) effectiveDx = rubberBand(dx, 0.2);
|
|
171
|
+
} else {
|
|
172
|
+
if (dx > 0) effectiveDx = rubberBand(dx, 0.2);
|
|
173
|
+
}
|
|
174
|
+
el.style.transform = `translateX(${effectiveDx}px)`;
|
|
175
|
+
const w = el.offsetWidth || 300;
|
|
176
|
+
const progress = Math.min(1, Math.max(0, Math.abs(effectiveDx) / w));
|
|
177
|
+
if (scrimEl) scrimEl.style.opacity = String(1 - progress * 0.7);
|
|
178
|
+
},
|
|
179
|
+
onEnd({ dx, vx, cancelled }) {
|
|
180
|
+
const isEnd = anchor.peek() === 'end';
|
|
181
|
+
const w = el.offsetWidth || 300;
|
|
182
|
+
const dismissDirection = isEnd ? (vx > 0.4 || dx > w * 0.35) : (vx < -0.4 || dx < -w * 0.35);
|
|
183
|
+
const shouldDismiss = !cancelled && dismissDirection;
|
|
184
|
+
|
|
185
|
+
if (shouldDismiss) {
|
|
186
|
+
const targetTransform = isEnd ? 'translateX(100%)' : 'translateX(-100%)';
|
|
187
|
+
const remaining = Math.max(0, w - Math.abs(dx));
|
|
188
|
+
const ms = Math.min(300, Math.max(120, Math.round(remaining / (Math.max(Math.abs(vx), 0.8)))));
|
|
189
|
+
if (scrimEl) {
|
|
190
|
+
animate(scrimEl, fx.fadeOut, { duration: ms, easing: 'emphasizedAccelerate' });
|
|
191
|
+
}
|
|
192
|
+
const anim = animate(el, [
|
|
193
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
194
|
+
{ transform: targetTransform },
|
|
195
|
+
], { duration: ms, easing: 'emphasizedAccelerate' });
|
|
196
|
+
anim.finished.then(() => {
|
|
197
|
+
requestClose('swipe');
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
if (scrimEl) {
|
|
201
|
+
animate(scrimEl, [{ opacity: scrimEl.style.opacity || '0.5' }, { opacity: 1 }], {
|
|
202
|
+
duration: 'short4', easing: 'emphasizedDecelerate',
|
|
203
|
+
}).finished.then(() => { if (scrimEl) scrimEl.style.opacity = ''; });
|
|
204
|
+
}
|
|
205
|
+
animate(el, [
|
|
206
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
207
|
+
{ transform: 'translateX(0)' },
|
|
208
|
+
], { duration: 'short4', easing: 'emphasizedDecelerate' }).finished.then(() => {
|
|
209
|
+
if (el) el.style.transform = '';
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
142
214
|
};
|
|
143
215
|
|
|
144
216
|
const overlay = () => html`
|
|
145
217
|
<div class="overlay">
|
|
146
|
-
<div class="scrim" part="scrim" aria-hidden="true"
|
|
218
|
+
<div class="scrim" part="scrim" aria-hidden="true"
|
|
219
|
+
ref=${(el) => { scrimEl = el; }}
|
|
220
|
+
@click=${() => requestClose('scrim')}></div>
|
|
147
221
|
<aside class=${() => `surface ${anchor()}`} part="surface" role="dialog" aria-modal="true"
|
|
148
222
|
aria-label=${() => label() || 'Navigation'} tabindex="-1" ref=${surfaceRef}>
|
|
149
223
|
<slot></slot>
|
|
@@ -163,7 +237,10 @@ define('ui-drawer', {
|
|
|
163
237
|
exit: fx.fadeOut,
|
|
164
238
|
exitDuration: 'short4',
|
|
165
239
|
onEntered: () => host.emit('opened'),
|
|
166
|
-
onExited: () =>
|
|
240
|
+
onExited: () => {
|
|
241
|
+
tracker?.destroy();
|
|
242
|
+
host.emit('closed');
|
|
243
|
+
},
|
|
167
244
|
})}`;
|
|
168
245
|
},
|
|
169
246
|
});
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
//
|
|
15
15
|
// @prop {boolean} open=false
|
|
16
16
|
// @prop {string} variant='modal' — modal | standard
|
|
17
|
-
// @prop {boolean} persistent=false — Escape/scrim do not request closing
|
|
17
|
+
// @prop {boolean} persistent=false — Escape/scrim/swipe do not request closing
|
|
18
|
+
// @prop {boolean} swipable=true — swipe down to dismiss
|
|
18
19
|
// @prop {string} label='' — accessible name if no headline slot
|
|
19
|
-
// @event close — detail: { reason: 'esc' | 'scrim' | 'method' }
|
|
20
|
+
// @event close — detail: { reason: 'esc' | 'scrim' | 'swipe' | 'method' }
|
|
20
21
|
// @event opened — enter animation finished
|
|
21
22
|
// @event closed — exit animation finished, DOM removed
|
|
22
23
|
// @slot (default) — body content
|
|
@@ -30,6 +31,7 @@ import { sys } from '../tokens/sys.js';
|
|
|
30
31
|
import { base } from './base.js';
|
|
31
32
|
import { presence } from '../motion/presence.js';
|
|
32
33
|
import { animate, fx, releaseFill } from '../motion/animate.js';
|
|
34
|
+
import { createSwipeTracker, rubberBand } from '../motion/gesture.js';
|
|
33
35
|
import { focusTrap, scrollLock } from '../util/focus.js';
|
|
34
36
|
|
|
35
37
|
const t = vars('ui-sheet', {
|
|
@@ -63,6 +65,7 @@ const styles = css`
|
|
|
63
65
|
border-start-start-radius: ${t.radius};
|
|
64
66
|
border-start-end-radius: ${t.radius};
|
|
65
67
|
box-shadow: ${sys.elevation[3]};
|
|
68
|
+
touch-action: pan-x;
|
|
66
69
|
}
|
|
67
70
|
.handle {
|
|
68
71
|
flex: none;
|
|
@@ -71,12 +74,15 @@ const styles = css`
|
|
|
71
74
|
margin: ${sys.space(4)} auto ${sys.space(2)};
|
|
72
75
|
border-radius: ${sys.radius.full};
|
|
73
76
|
background: ${t.handle};
|
|
77
|
+
cursor: grab;
|
|
78
|
+
touch-action: none;
|
|
74
79
|
}
|
|
75
80
|
.headline {
|
|
76
81
|
padding-inline: ${sys.space(6)};
|
|
77
82
|
padding-block-end: ${sys.space(2)};
|
|
78
83
|
font: ${sys.type.titleLg};
|
|
79
84
|
letter-spacing: ${sys.tracking.titleLg};
|
|
85
|
+
user-select: none;
|
|
80
86
|
}
|
|
81
87
|
.headline:not(.has) { display: none; }
|
|
82
88
|
.body {
|
|
@@ -109,12 +115,14 @@ const styles = css`
|
|
|
109
115
|
`;
|
|
110
116
|
|
|
111
117
|
define('ui-sheet', {
|
|
112
|
-
props: { open: false, variant: 'modal', persistent: false, label: '' },
|
|
118
|
+
props: { open: false, variant: 'modal', persistent: false, swipable: true, label: '' },
|
|
113
119
|
styles: [base, styles],
|
|
114
|
-
setup({ open, variant, persistent, label }, host) {
|
|
120
|
+
setup({ open, variant, persistent, swipable, label }, host) {
|
|
115
121
|
let releaseTrap = null;
|
|
116
122
|
let unlock = null;
|
|
117
123
|
let surfaceEl = null;
|
|
124
|
+
let scrimEl = null;
|
|
125
|
+
let tracker = null;
|
|
118
126
|
const hasHeadline = signal(false);
|
|
119
127
|
|
|
120
128
|
const requestClose = (reason) => {
|
|
@@ -151,6 +159,7 @@ define('ui-sheet', {
|
|
|
151
159
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
152
160
|
releaseTrap?.();
|
|
153
161
|
unlock?.();
|
|
162
|
+
tracker?.destroy();
|
|
154
163
|
});
|
|
155
164
|
|
|
156
165
|
const hasSlot = (el, set) => {
|
|
@@ -159,14 +168,81 @@ define('ui-sheet', {
|
|
|
159
168
|
sync();
|
|
160
169
|
};
|
|
161
170
|
|
|
171
|
+
const surfaceRef = (el) => {
|
|
172
|
+
surfaceEl = el;
|
|
173
|
+
releaseFill(animate(el, fx.sheetIn, { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
174
|
+
|
|
175
|
+
tracker?.destroy();
|
|
176
|
+
tracker = createSwipeTracker(el, {
|
|
177
|
+
axis: 'y',
|
|
178
|
+
threshold: 8,
|
|
179
|
+
filter(e) {
|
|
180
|
+
if (!swipable() || persistent() || variant() === 'standard') return false;
|
|
181
|
+
const body = el.querySelector('.body');
|
|
182
|
+
if (body && body.contains(e.target) && body.scrollTop > 0) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
onStart() {
|
|
188
|
+
el.style.transition = 'none';
|
|
189
|
+
},
|
|
190
|
+
onMove({ dy }) {
|
|
191
|
+
if (dy < 0) {
|
|
192
|
+
const damped = rubberBand(dy, 0.2);
|
|
193
|
+
el.style.transform = `translateY(${damped}px)`;
|
|
194
|
+
if (scrimEl) scrimEl.style.opacity = '1';
|
|
195
|
+
} else {
|
|
196
|
+
el.style.transform = `translateY(${dy}px)`;
|
|
197
|
+
const h = el.offsetHeight || 300;
|
|
198
|
+
const progress = Math.min(1, Math.max(0, dy / h));
|
|
199
|
+
if (scrimEl) scrimEl.style.opacity = String(1 - progress * 0.7);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
onEnd({ dy, vy, cancelled }) {
|
|
203
|
+
const h = el.offsetHeight || 300;
|
|
204
|
+
const shouldDismiss = !cancelled && (vy > 0.4 || dy > h * 0.35);
|
|
205
|
+
|
|
206
|
+
if (shouldDismiss) {
|
|
207
|
+
const remaining = Math.max(0, h - dy);
|
|
208
|
+
const ms = Math.min(300, Math.max(120, Math.round(remaining / (Math.max(vy, 0.8)))));
|
|
209
|
+
if (scrimEl) {
|
|
210
|
+
animate(scrimEl, fx.fadeOut, { duration: ms, easing: 'emphasizedAccelerate' });
|
|
211
|
+
}
|
|
212
|
+
const anim = animate(el, [
|
|
213
|
+
{ transform: el.style.transform || `translateY(${dy}px)` },
|
|
214
|
+
{ transform: 'translateY(100%)' },
|
|
215
|
+
], { duration: ms, easing: 'emphasizedAccelerate' });
|
|
216
|
+
anim.finished.then(() => {
|
|
217
|
+
requestClose('swipe');
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
if (scrimEl) {
|
|
221
|
+
animate(scrimEl, [{ opacity: scrimEl.style.opacity || '0.5' }, { opacity: 1 }], {
|
|
222
|
+
duration: 'short4', easing: 'emphasizedDecelerate',
|
|
223
|
+
}).finished.then(() => { if (scrimEl) scrimEl.style.opacity = ''; });
|
|
224
|
+
}
|
|
225
|
+
animate(el, [
|
|
226
|
+
{ transform: el.style.transform || `translateY(${dy}px)` },
|
|
227
|
+
{ transform: 'translateY(0)' },
|
|
228
|
+
], { duration: 'short4', easing: 'emphasizedDecelerate' }).finished.then(() => {
|
|
229
|
+
if (el) el.style.transform = '';
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
162
236
|
const modalView = () => html`
|
|
163
237
|
<div class="overlay">
|
|
164
|
-
<div class="scrim" part="scrim" aria-hidden="true"
|
|
238
|
+
<div class="scrim" part="scrim" aria-hidden="true"
|
|
239
|
+
ref=${(el) => { scrimEl = el; }}
|
|
240
|
+
@click=${() => requestClose('scrim')}></div>
|
|
165
241
|
<div class="surface" part="surface" role="dialog" aria-modal="true"
|
|
166
242
|
aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
|
|
167
243
|
aria-label=${() => (hasHeadline() ? null : (label() || 'Sheet'))}
|
|
168
244
|
tabindex="-1"
|
|
169
|
-
ref=${
|
|
245
|
+
ref=${surfaceRef}>
|
|
170
246
|
<div class="handle" part="handle" aria-hidden="true"></div>
|
|
171
247
|
<div class="headline" part="headline" id="headline"
|
|
172
248
|
ref=${(el) => hasSlot(el.querySelector('slot'), (has) => {
|
|
@@ -199,10 +275,14 @@ define('ui-sheet', {
|
|
|
199
275
|
exit: fx.fadeOut,
|
|
200
276
|
exitDuration: 'short4',
|
|
201
277
|
onEntered: () => host.emit('opened'),
|
|
202
|
-
onExited: () =>
|
|
278
|
+
onExited: () => {
|
|
279
|
+
tracker?.destroy();
|
|
280
|
+
host.emit('closed');
|
|
281
|
+
},
|
|
203
282
|
})}`;
|
|
204
283
|
},
|
|
205
284
|
});
|
|
206
285
|
|
|
207
286
|
export const tag = 'ui-sheet';
|
|
208
287
|
export const themeVars = t;
|
|
288
|
+
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
// @prop {boolean} open=false
|
|
15
15
|
// @prop {string} variant='modal' — modal | standard
|
|
16
16
|
// @prop {string} anchor='end' — start | end
|
|
17
|
-
// @prop {boolean} persistent=false — Escape/scrim do not request closing
|
|
17
|
+
// @prop {boolean} persistent=false — Escape/scrim/swipe do not request closing
|
|
18
|
+
// @prop {boolean} swipable=true — swipe in anchor direction to dismiss
|
|
18
19
|
// @prop {string} label='' — accessible name if no headline slot
|
|
19
|
-
// @event close — detail: { reason: 'esc' | 'scrim' | 'method' }
|
|
20
|
+
// @event close — detail: { reason: 'esc' | 'scrim' | 'swipe' | 'method' }
|
|
20
21
|
// @event opened — enter animation finished
|
|
21
22
|
// @event closed — exit animation finished, DOM removed
|
|
22
23
|
// @slot (default) — body content
|
|
@@ -30,6 +31,7 @@ import { sys } from '../tokens/sys.js';
|
|
|
30
31
|
import { base } from './base.js';
|
|
31
32
|
import { presence } from '../motion/presence.js';
|
|
32
33
|
import { animate, fx, releaseFill } from '../motion/animate.js';
|
|
34
|
+
import { createSwipeTracker, rubberBand } from '../motion/gesture.js';
|
|
33
35
|
import { focusTrap, scrollLock } from '../util/focus.js';
|
|
34
36
|
import './ui-icon-button.js';
|
|
35
37
|
|
|
@@ -58,6 +60,7 @@ const styles = css`
|
|
|
58
60
|
background: ${t.bg};
|
|
59
61
|
color: ${t.fg};
|
|
60
62
|
box-shadow: ${sys.elevation[1]};
|
|
63
|
+
touch-action: pan-y;
|
|
61
64
|
}
|
|
62
65
|
.surface.start {
|
|
63
66
|
inset-inline-start: 0;
|
|
@@ -75,6 +78,7 @@ const styles = css`
|
|
|
75
78
|
gap: ${sys.space(1)};
|
|
76
79
|
min-block-size: 64px;
|
|
77
80
|
padding-inline: ${sys.space(4)} ${sys.space(2)};
|
|
81
|
+
user-select: none;
|
|
78
82
|
}
|
|
79
83
|
.headline {
|
|
80
84
|
flex: 1;
|
|
@@ -110,12 +114,14 @@ const styles = css`
|
|
|
110
114
|
`;
|
|
111
115
|
|
|
112
116
|
define('ui-side-sheet', {
|
|
113
|
-
props: { open: false, variant: 'modal', anchor: 'end', persistent: false, label: '' },
|
|
117
|
+
props: { open: false, variant: 'modal', anchor: 'end', persistent: false, swipable: true, label: '' },
|
|
114
118
|
styles: [base, styles],
|
|
115
|
-
setup({ open, variant, anchor, persistent, label }, host) {
|
|
119
|
+
setup({ open, variant, anchor, persistent, swipable, label }, host) {
|
|
116
120
|
let releaseTrap = null;
|
|
117
121
|
let unlock = null;
|
|
118
122
|
let surfaceEl = null;
|
|
123
|
+
let scrimEl = null;
|
|
124
|
+
let tracker = null;
|
|
119
125
|
const hasHeadline = signal(false);
|
|
120
126
|
|
|
121
127
|
const requestClose = (reason) => {
|
|
@@ -157,11 +163,72 @@ define('ui-side-sheet', {
|
|
|
157
163
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
158
164
|
releaseTrap?.();
|
|
159
165
|
unlock?.();
|
|
166
|
+
tracker?.destroy();
|
|
160
167
|
});
|
|
161
168
|
|
|
162
169
|
const surfaceRef = (el) => {
|
|
163
170
|
surfaceEl = el;
|
|
164
171
|
releaseFill(animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
172
|
+
|
|
173
|
+
tracker?.destroy();
|
|
174
|
+
tracker = createSwipeTracker(el, {
|
|
175
|
+
axis: 'x',
|
|
176
|
+
threshold: 8,
|
|
177
|
+
filter(e) {
|
|
178
|
+
if (!swipable() || persistent() || variant() === 'standard') return false;
|
|
179
|
+
return true;
|
|
180
|
+
},
|
|
181
|
+
onStart() {
|
|
182
|
+
el.style.transition = 'none';
|
|
183
|
+
},
|
|
184
|
+
onMove({ dx }) {
|
|
185
|
+
const isEnd = anchor.peek() === 'end';
|
|
186
|
+
let effectiveDx = dx;
|
|
187
|
+
if (isEnd) {
|
|
188
|
+
if (dx < 0) effectiveDx = rubberBand(dx, 0.2);
|
|
189
|
+
} else {
|
|
190
|
+
if (dx > 0) effectiveDx = rubberBand(dx, 0.2);
|
|
191
|
+
}
|
|
192
|
+
el.style.transform = `translateX(${effectiveDx}px)`;
|
|
193
|
+
const w = el.offsetWidth || 300;
|
|
194
|
+
const progress = Math.min(1, Math.max(0, Math.abs(effectiveDx) / w));
|
|
195
|
+
if (scrimEl) scrimEl.style.opacity = String(1 - progress * 0.7);
|
|
196
|
+
},
|
|
197
|
+
onEnd({ dx, vx, cancelled }) {
|
|
198
|
+
const isEnd = anchor.peek() === 'end';
|
|
199
|
+
const w = el.offsetWidth || 300;
|
|
200
|
+
const dismissDirection = isEnd ? (vx > 0.4 || dx > w * 0.35) : (vx < -0.4 || dx < -w * 0.35);
|
|
201
|
+
const shouldDismiss = !cancelled && dismissDirection;
|
|
202
|
+
|
|
203
|
+
if (shouldDismiss) {
|
|
204
|
+
const targetTransform = isEnd ? 'translateX(100%)' : 'translateX(-100%)';
|
|
205
|
+
const remaining = Math.max(0, w - Math.abs(dx));
|
|
206
|
+
const ms = Math.min(300, Math.max(120, Math.round(remaining / (Math.max(Math.abs(vx), 0.8)))));
|
|
207
|
+
if (scrimEl) {
|
|
208
|
+
animate(scrimEl, fx.fadeOut, { duration: ms, easing: 'emphasizedAccelerate' });
|
|
209
|
+
}
|
|
210
|
+
const anim = animate(el, [
|
|
211
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
212
|
+
{ transform: targetTransform },
|
|
213
|
+
], { duration: ms, easing: 'emphasizedAccelerate' });
|
|
214
|
+
anim.finished.then(() => {
|
|
215
|
+
requestClose('swipe');
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
if (scrimEl) {
|
|
219
|
+
animate(scrimEl, [{ opacity: scrimEl.style.opacity || '0.5' }, { opacity: 1 }], {
|
|
220
|
+
duration: 'short4', easing: 'emphasizedDecelerate',
|
|
221
|
+
}).finished.then(() => { if (scrimEl) scrimEl.style.opacity = ''; });
|
|
222
|
+
}
|
|
223
|
+
animate(el, [
|
|
224
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
225
|
+
{ transform: 'translateX(0)' },
|
|
226
|
+
], { duration: 'short4', easing: 'emphasizedDecelerate' }).finished.then(() => {
|
|
227
|
+
if (el) el.style.transform = '';
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
});
|
|
165
232
|
};
|
|
166
233
|
|
|
167
234
|
const hasSlot = (el, set) => {
|
|
@@ -172,7 +239,9 @@ define('ui-side-sheet', {
|
|
|
172
239
|
|
|
173
240
|
const modalView = () => html`
|
|
174
241
|
<div class="overlay">
|
|
175
|
-
<div class="scrim" part="scrim" aria-hidden="true"
|
|
242
|
+
<div class="scrim" part="scrim" aria-hidden="true"
|
|
243
|
+
ref=${(el) => { scrimEl = el; }}
|
|
244
|
+
@click=${() => requestClose('scrim')}></div>
|
|
176
245
|
<aside class=${() => `surface ${anchor()}`} part="surface" role="dialog" aria-modal="true"
|
|
177
246
|
aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
|
|
178
247
|
aria-label=${() => (hasHeadline() ? null : (label() || 'Side sheet'))}
|
|
@@ -215,10 +284,14 @@ define('ui-side-sheet', {
|
|
|
215
284
|
exit: fx.fadeOut,
|
|
216
285
|
exitDuration: 'short4',
|
|
217
286
|
onEntered: () => host.emit('opened'),
|
|
218
|
-
onExited: () =>
|
|
287
|
+
onExited: () => {
|
|
288
|
+
tracker?.destroy();
|
|
289
|
+
host.emit('closed');
|
|
290
|
+
},
|
|
219
291
|
})}`;
|
|
220
292
|
},
|
|
221
293
|
});
|
|
222
294
|
|
|
223
295
|
export const tag = 'ui-side-sheet';
|
|
224
296
|
export const themeVars = t;
|
|
297
|
+
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// <ui-swipe-row> — an Android-style horizontal swipe-to-reveal row container.
|
|
2
|
+
//
|
|
3
|
+
// <ui-swipe-row>
|
|
4
|
+
// <ui-icon-button slot="end" icon="delete" label="Delete" danger></ui-icon-button>
|
|
5
|
+
// <ui-icon-button slot="end" icon="archive" label="Archive"></ui-icon-button>
|
|
6
|
+
// <ui-list-item headline="Swipeable message" supporting="Swipe left to reveal actions"></ui-list-item>
|
|
7
|
+
// </ui-swipe-row>
|
|
8
|
+
//
|
|
9
|
+
// @prop {string} open='' — '' | 'start' | 'end'
|
|
10
|
+
// @prop {boolean} disabled=false — disables gesture tracking
|
|
11
|
+
// @prop {number} threshold=0.4 — fraction of action width to snap open
|
|
12
|
+
// @prop {boolean} fullSwipe=false — swiping past 65% width triggers action
|
|
13
|
+
// @event open — detail: { side: 'start' | 'end' }
|
|
14
|
+
// @event close — detail: {}
|
|
15
|
+
// @event action — detail: { side: 'start' | 'end' }
|
|
16
|
+
// @slot (default) — primary row content (e.g. <ui-list-item>, <ui-card>)
|
|
17
|
+
// @slot start — actions revealed when swiping right
|
|
18
|
+
// @slot end — actions revealed when swiping left
|
|
19
|
+
// @part container, content, start-actions, end-actions
|
|
20
|
+
// @vars see `t` below (`themeVars.names`)
|
|
21
|
+
|
|
22
|
+
import { define, html, css, vars, effect, onCleanup, signal } from '@alacris/core';
|
|
23
|
+
import { sys } from '../tokens/sys.js';
|
|
24
|
+
import { base } from './base.js';
|
|
25
|
+
import { animate } from '../motion/animate.js';
|
|
26
|
+
import { createSwipeTracker, rubberBand } from '../motion/gesture.js';
|
|
27
|
+
|
|
28
|
+
const t = vars('ui-swipe-row', {
|
|
29
|
+
bg: sys.color.surface,
|
|
30
|
+
actionStartBg: sys.color.primaryContainer,
|
|
31
|
+
actionStartFg: sys.color.onPrimaryContainer,
|
|
32
|
+
actionEndBg: sys.color.errorContainer,
|
|
33
|
+
actionEndFg: sys.color.onErrorContainer,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const styles = css`
|
|
37
|
+
:host { display: block; overflow: hidden; isolation: isolate; }
|
|
38
|
+
.container {
|
|
39
|
+
position: relative;
|
|
40
|
+
inline-size: 100%;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
user-select: none;
|
|
43
|
+
touch-action: pan-y;
|
|
44
|
+
}
|
|
45
|
+
.actions {
|
|
46
|
+
position: absolute;
|
|
47
|
+
inset-block: 0;
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
z-index: 0;
|
|
51
|
+
}
|
|
52
|
+
.actions.start {
|
|
53
|
+
inset-inline-start: 0;
|
|
54
|
+
background: ${t.actionStartBg};
|
|
55
|
+
color: ${t.actionStartFg};
|
|
56
|
+
}
|
|
57
|
+
.actions.end {
|
|
58
|
+
inset-inline-end: 0;
|
|
59
|
+
background: ${t.actionEndBg};
|
|
60
|
+
color: ${t.actionEndFg};
|
|
61
|
+
}
|
|
62
|
+
.actions:not(.has) { display: none; }
|
|
63
|
+
.content {
|
|
64
|
+
position: relative;
|
|
65
|
+
z-index: 1;
|
|
66
|
+
background: ${t.bg};
|
|
67
|
+
inline-size: 100%;
|
|
68
|
+
touch-action: pan-y;
|
|
69
|
+
}
|
|
70
|
+
.disabled .content, .disabled .container { touch-action: auto; }
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
define('ui-swipe-row', {
|
|
74
|
+
props: { open: '', disabled: false, threshold: 0.4, fullSwipe: false },
|
|
75
|
+
styles: [base, styles],
|
|
76
|
+
setup({ open, disabled, threshold, fullSwipe }, host) {
|
|
77
|
+
let containerEl = null;
|
|
78
|
+
let contentEl = null;
|
|
79
|
+
let startActionsEl = null;
|
|
80
|
+
let endActionsEl = null;
|
|
81
|
+
let tracker = null;
|
|
82
|
+
let currentOpen = '';
|
|
83
|
+
let startOffset = 0;
|
|
84
|
+
|
|
85
|
+
const hasStart = signal(false);
|
|
86
|
+
const hasEnd = signal(false);
|
|
87
|
+
|
|
88
|
+
const getStartWidth = () => (startActionsEl?.offsetWidth || 0);
|
|
89
|
+
const getEndWidth = () => (endActionsEl?.offsetWidth || 0);
|
|
90
|
+
|
|
91
|
+
const snapTo = (x, ms = 200, easing = 'emphasizedDecelerate') => {
|
|
92
|
+
if (!contentEl) return Promise.resolve();
|
|
93
|
+
const currentTransform = contentEl.style.transform || 'translateX(0px)';
|
|
94
|
+
const targetTransform = `translateX(${x}px)`;
|
|
95
|
+
const anim = animate(contentEl, [
|
|
96
|
+
{ transform: currentTransform },
|
|
97
|
+
{ transform: targetTransform },
|
|
98
|
+
], { duration: ms, easing });
|
|
99
|
+
return anim.finished.then(() => {
|
|
100
|
+
if (contentEl) contentEl.style.transform = targetTransform;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const setOpenState = (side, emitEvent = true) => {
|
|
105
|
+
currentOpen = side;
|
|
106
|
+
if (side === 'end' && hasEnd.peek()) {
|
|
107
|
+
const w = getEndWidth();
|
|
108
|
+
snapTo(-w);
|
|
109
|
+
if (emitEvent) host.emit('open', { side: 'end' });
|
|
110
|
+
} else if (side === 'start' && hasStart.peek()) {
|
|
111
|
+
const w = getStartWidth();
|
|
112
|
+
snapTo(w);
|
|
113
|
+
if (emitEvent) host.emit('open', { side: 'start' });
|
|
114
|
+
} else {
|
|
115
|
+
currentOpen = '';
|
|
116
|
+
snapTo(0);
|
|
117
|
+
if (emitEvent) host.emit('close');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
effect(() => {
|
|
122
|
+
const target = open();
|
|
123
|
+
if (target !== currentOpen) {
|
|
124
|
+
setOpenState(target, false);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const onDocClick = (e) => {
|
|
129
|
+
if (currentOpen && !host.contains(e.target)) {
|
|
130
|
+
setOpenState('');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
document.addEventListener('click', onDocClick);
|
|
135
|
+
onCleanup(() => {
|
|
136
|
+
document.removeEventListener('click', onDocClick);
|
|
137
|
+
tracker?.destroy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const onContentClick = (e) => {
|
|
141
|
+
if (currentOpen !== '') {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
setOpenState('');
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const hasSlot = (el, set) => {
|
|
149
|
+
const sync = () => set(el.assignedElements().length > 0);
|
|
150
|
+
el.addEventListener('slotchange', sync);
|
|
151
|
+
sync();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const initTracker = (el) => {
|
|
155
|
+
contentEl = el;
|
|
156
|
+
tracker?.destroy();
|
|
157
|
+
|
|
158
|
+
tracker = createSwipeTracker(el, {
|
|
159
|
+
axis: 'x',
|
|
160
|
+
threshold: 8,
|
|
161
|
+
filter(e) {
|
|
162
|
+
if (disabled()) return false;
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
onStart() {
|
|
166
|
+
if (currentOpen === 'end') startOffset = -getEndWidth();
|
|
167
|
+
else if (currentOpen === 'start') startOffset = getStartWidth();
|
|
168
|
+
else startOffset = 0;
|
|
169
|
+
el.style.transition = 'none';
|
|
170
|
+
},
|
|
171
|
+
onMove({ dx }) {
|
|
172
|
+
const totalDx = startOffset + dx;
|
|
173
|
+
const startW = getStartWidth();
|
|
174
|
+
const endW = getEndWidth();
|
|
175
|
+
let effectiveDx = totalDx;
|
|
176
|
+
|
|
177
|
+
if (totalDx > 0) {
|
|
178
|
+
if (!hasStart.peek()) effectiveDx = rubberBand(totalDx, 0.15);
|
|
179
|
+
else if (totalDx > startW && !fullSwipe()) {
|
|
180
|
+
effectiveDx = startW + rubberBand(totalDx - startW, 0.25);
|
|
181
|
+
}
|
|
182
|
+
} else if (totalDx < 0) {
|
|
183
|
+
if (!hasEnd.peek()) effectiveDx = rubberBand(totalDx, 0.15);
|
|
184
|
+
else if (-totalDx > endW && !fullSwipe()) {
|
|
185
|
+
effectiveDx = -endW - rubberBand(-totalDx - endW, 0.25);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
el.style.transform = `translateX(${effectiveDx}px)`;
|
|
190
|
+
},
|
|
191
|
+
onEnd({ dx, vx, cancelled }) {
|
|
192
|
+
if (cancelled) {
|
|
193
|
+
setOpenState(currentOpen, false);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const totalDx = startOffset + dx;
|
|
198
|
+
const containerW = containerEl?.offsetWidth || 300;
|
|
199
|
+
const startW = getStartWidth() || 80;
|
|
200
|
+
const endW = getEndWidth() || 80;
|
|
201
|
+
const thresh = Number(threshold()) || 0.4;
|
|
202
|
+
|
|
203
|
+
// Check full-swipe action
|
|
204
|
+
if (fullSwipe() && Math.abs(totalDx) > containerW * 0.65) {
|
|
205
|
+
const side = totalDx > 0 ? 'start' : 'end';
|
|
206
|
+
const targetX = totalDx > 0 ? containerW : -containerW;
|
|
207
|
+
snapTo(targetX, 150, 'emphasizedAccelerate').then(() => {
|
|
208
|
+
host.emit('action', { side });
|
|
209
|
+
setOpenState('');
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check reveal / snap
|
|
215
|
+
if (totalDx < 0 && hasEnd.peek()) {
|
|
216
|
+
if (vx < -0.4 || -totalDx > endW * thresh) {
|
|
217
|
+
setOpenState('end');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
} else if (totalDx > 0 && hasStart.peek()) {
|
|
221
|
+
if (vx > 0.4 || totalDx > startW * thresh) {
|
|
222
|
+
setOpenState('start');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
setOpenState('');
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return html`
|
|
233
|
+
<div class=${() => `container${disabled() ? ' disabled' : ''}`} part="container"
|
|
234
|
+
ref=${(el) => { containerEl = el; }}>
|
|
235
|
+
<div class=${() => `actions start${hasStart() ? ' has' : ''}`} part="start-actions"
|
|
236
|
+
ref=${(el) => { startActionsEl = el; }}>
|
|
237
|
+
<slot name="start" ref=${(el) => hasSlot(el, hasStart.set)}></slot>
|
|
238
|
+
</div>
|
|
239
|
+
<div class=${() => `actions end${hasEnd() ? ' has' : ''}`} part="end-actions"
|
|
240
|
+
ref=${(el) => { endActionsEl = el; }}>
|
|
241
|
+
<slot name="end" ref=${(el) => hasSlot(el, hasEnd.set)}></slot>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="content" part="content"
|
|
244
|
+
ref=${initTracker}
|
|
245
|
+
@click=${onContentClick}>
|
|
246
|
+
<slot></slot>
|
|
247
|
+
</div>
|
|
248
|
+
</div>`;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
export const tag = 'ui-swipe-row';
|
|
253
|
+
export const themeVars = t;
|
package/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { animate, releaseFill, settled, duration, easing, fx, prefersReducedMoti
|
|
|
24
24
|
export { presence } from './motion/presence.js';
|
|
25
25
|
export { withFlip } from './motion/flip.js';
|
|
26
26
|
export { ripple } from './motion/ripple.js';
|
|
27
|
+
export { createSwipeTracker, calculateVelocity, rubberBand } from './motion/gesture.js';
|
|
27
28
|
|
|
28
29
|
// Utilities
|
|
29
30
|
export { position, autoUpdate } from './util/position.js';
|
|
@@ -73,6 +74,7 @@ import './components/ui-badge.js';
|
|
|
73
74
|
import './components/ui-divider.js';
|
|
74
75
|
import './components/ui-list.js';
|
|
75
76
|
import './components/ui-list-item.js';
|
|
77
|
+
import './components/ui-swipe-row.js';
|
|
76
78
|
import './components/ui-table.js';
|
|
77
79
|
import './components/ui-table-toolbar.js';
|
|
78
80
|
import './components/ui-table-footer.js';
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Gesture — fluid pointer drag and velocity tracking for Material gestures.
|
|
2
|
+
//
|
|
3
|
+
// Features:
|
|
4
|
+
// - Pointer event coordination with pointer capture and directional locking.
|
|
5
|
+
// - Rolling-window velocity calculation (px/ms) for natural inertia flicks.
|
|
6
|
+
// - Resistance / rubber-band calculation for out-of-bounds dragging.
|
|
7
|
+
// - Safe cleanup when elements disconnect.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Calculate velocity from a rolling history of pointer positions.
|
|
11
|
+
* @param {Array<{ x: number, y: number, t: number }>} history
|
|
12
|
+
* @param {number} now
|
|
13
|
+
* @param {number} windowMs
|
|
14
|
+
* @returns {{ vx: number, vy: number }} in px/ms
|
|
15
|
+
*/
|
|
16
|
+
export function calculateVelocity(history, now = Date.now(), windowMs = 120) {
|
|
17
|
+
if (!history || history.length < 2) return { vx: 0, vy: 0 };
|
|
18
|
+
const cutoff = now - windowMs;
|
|
19
|
+
const recent = history.filter((p) => p.t >= cutoff);
|
|
20
|
+
if (recent.length < 2) return { vx: 0, vy: 0 };
|
|
21
|
+
|
|
22
|
+
const first = recent[0];
|
|
23
|
+
const last = recent[recent.length - 1];
|
|
24
|
+
const dt = Math.max(1, last.t - first.t);
|
|
25
|
+
return {
|
|
26
|
+
vx: (last.x - first.x) / dt,
|
|
27
|
+
vy: (last.y - first.y) / dt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply logarithmic / exponential rubber-band damping.
|
|
33
|
+
* @param {number} delta Current displacement
|
|
34
|
+
* @param {number} factor Damping factor
|
|
35
|
+
* @returns {number} Damped displacement
|
|
36
|
+
*/
|
|
37
|
+
export function rubberBand(delta, factor = 0.25) {
|
|
38
|
+
return delta * factor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* createSwipeTracker(element, options)
|
|
43
|
+
*
|
|
44
|
+
* Tracks 1D or 2D drag gestures with directional locking and velocity calculation.
|
|
45
|
+
*
|
|
46
|
+
* Options:
|
|
47
|
+
* - axis: 'x' | 'y' | 'both' (default 'both')
|
|
48
|
+
* - threshold: minimum drag distance before locking (default 6)
|
|
49
|
+
* - filter: (e) => boolean (return false to ignore pointerdown)
|
|
50
|
+
* - onStart: ({ x, y, event }) => void
|
|
51
|
+
* - onMove: ({ dx, dy, x, y, vx, vy, event }) => void
|
|
52
|
+
* - onEnd: ({ dx, dy, vx, vy, event, cancelled }) => void
|
|
53
|
+
*
|
|
54
|
+
* Returns: { destroy() }
|
|
55
|
+
*/
|
|
56
|
+
export function createSwipeTracker(element, opts = {}) {
|
|
57
|
+
const {
|
|
58
|
+
axis = 'both',
|
|
59
|
+
threshold = 6,
|
|
60
|
+
filter = null,
|
|
61
|
+
onStart = null,
|
|
62
|
+
onMove = null,
|
|
63
|
+
onEnd = null,
|
|
64
|
+
} = opts;
|
|
65
|
+
|
|
66
|
+
let activePointerId = null;
|
|
67
|
+
let startX = 0;
|
|
68
|
+
let startY = 0;
|
|
69
|
+
let isTracking = false;
|
|
70
|
+
let isLocked = false;
|
|
71
|
+
let history = [];
|
|
72
|
+
|
|
73
|
+
const onPointerDown = (e) => {
|
|
74
|
+
if (activePointerId !== null) return;
|
|
75
|
+
if (e.button !== 0 && e.isPrimary === false) return;
|
|
76
|
+
if (typeof filter === 'function' && !filter(e)) return;
|
|
77
|
+
|
|
78
|
+
activePointerId = e.pointerId;
|
|
79
|
+
startX = e.clientX;
|
|
80
|
+
startY = e.clientY;
|
|
81
|
+
isTracking = true;
|
|
82
|
+
isLocked = false;
|
|
83
|
+
history = [{ x: e.clientX, y: e.clientY, t: Date.now() }];
|
|
84
|
+
|
|
85
|
+
element.addEventListener('pointermove', onPointerMove);
|
|
86
|
+
element.addEventListener('pointerup', onPointerUp);
|
|
87
|
+
element.addEventListener('pointercancel', onPointerCancel);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onPointerMove = (e) => {
|
|
91
|
+
if (!isTracking || e.pointerId !== activePointerId) return;
|
|
92
|
+
|
|
93
|
+
const currentX = e.clientX;
|
|
94
|
+
const currentY = e.clientY;
|
|
95
|
+
const dx = currentX - startX;
|
|
96
|
+
const dy = currentY - startY;
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
|
|
99
|
+
history.push({ x: currentX, y: currentY, t: now });
|
|
100
|
+
if (history.length > 20) history.shift();
|
|
101
|
+
|
|
102
|
+
if (!isLocked) {
|
|
103
|
+
const absX = Math.abs(dx);
|
|
104
|
+
const absY = Math.abs(dy);
|
|
105
|
+
|
|
106
|
+
if (axis === 'x') {
|
|
107
|
+
if (absY > absX && absY > threshold) {
|
|
108
|
+
// Vertical movement dominates -> cancel horizontal swipe
|
|
109
|
+
cleanupListeners();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (absX >= threshold) {
|
|
113
|
+
isLocked = true;
|
|
114
|
+
try { element.setPointerCapture(activePointerId); } catch {}
|
|
115
|
+
onStart?.({ x: currentX, y: currentY, event: e });
|
|
116
|
+
}
|
|
117
|
+
} else if (axis === 'y') {
|
|
118
|
+
if (absX > absY && absX > threshold) {
|
|
119
|
+
// Horizontal movement dominates -> cancel vertical swipe
|
|
120
|
+
cleanupListeners();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (absY >= threshold) {
|
|
124
|
+
isLocked = true;
|
|
125
|
+
try { element.setPointerCapture(activePointerId); } catch {}
|
|
126
|
+
onStart?.({ x: currentX, y: currentY, event: e });
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
if (absX >= threshold || absY >= threshold) {
|
|
130
|
+
isLocked = true;
|
|
131
|
+
try { element.setPointerCapture(activePointerId); } catch {}
|
|
132
|
+
onStart?.({ x: currentX, y: currentY, event: e });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isLocked) {
|
|
138
|
+
if (e.cancelable) e.preventDefault();
|
|
139
|
+
const { vx, vy } = calculateVelocity(history, now);
|
|
140
|
+
onMove?.({ dx, dy, x: currentX, y: currentY, vx, vy, event: e });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const onPointerUp = (e) => {
|
|
145
|
+
if (!isTracking || e.pointerId !== activePointerId) return;
|
|
146
|
+
const dx = e.clientX - startX;
|
|
147
|
+
const dy = e.clientY - startY;
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
history.push({ x: e.clientX, y: e.clientY, t: now });
|
|
150
|
+
const { vx, vy } = calculateVelocity(history, now);
|
|
151
|
+
const wasLocked = isLocked;
|
|
152
|
+
|
|
153
|
+
cleanupListeners();
|
|
154
|
+
|
|
155
|
+
if (wasLocked) {
|
|
156
|
+
onEnd?.({ dx, dy, vx, vy, event: e, cancelled: false });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const onPointerCancel = (e) => {
|
|
161
|
+
if (!isTracking || e.pointerId !== activePointerId) return;
|
|
162
|
+
const dx = e.clientX - startX;
|
|
163
|
+
const dy = e.clientY - startY;
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const { vx, vy } = calculateVelocity(history, now);
|
|
166
|
+
const wasLocked = isLocked;
|
|
167
|
+
|
|
168
|
+
cleanupListeners();
|
|
169
|
+
|
|
170
|
+
if (wasLocked) {
|
|
171
|
+
onEnd?.({ dx, dy, vx, vy, event: e, cancelled: true });
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const cleanupListeners = () => {
|
|
176
|
+
if (activePointerId !== null) {
|
|
177
|
+
try { element.releasePointerCapture(activePointerId); } catch {}
|
|
178
|
+
}
|
|
179
|
+
element.removeEventListener('pointermove', onPointerMove);
|
|
180
|
+
element.removeEventListener('pointerup', onPointerUp);
|
|
181
|
+
element.removeEventListener('pointercancel', onPointerCancel);
|
|
182
|
+
activePointerId = null;
|
|
183
|
+
isTracking = false;
|
|
184
|
+
isLocked = false;
|
|
185
|
+
history = [];
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
element.addEventListener('pointerdown', onPointerDown);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
destroy() {
|
|
192
|
+
cleanupListeners();
|
|
193
|
+
element.removeEventListener('pointerdown', onPointerDown);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/motion/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { animate, releaseFill, settled, duration, easing, fx, prefersReducedMoti
|
|
|
2
2
|
export { presence } from './presence.js';
|
|
3
3
|
export { withFlip } from './flip.js';
|
|
4
4
|
export { ripple } from './ripple.js';
|
|
5
|
+
export { createSwipeTracker, calculateVelocity, rubberBand } from './gesture.js';
|
package/types/elements.d.ts
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -108,6 +108,26 @@ export function ripple(
|
|
|
108
108
|
opts?: { disabled?: boolean | Signal<boolean> | (() => boolean); centered?: boolean }
|
|
109
109
|
): Element;
|
|
110
110
|
|
|
111
|
+
export function createSwipeTracker(
|
|
112
|
+
el: Element,
|
|
113
|
+
opts?: {
|
|
114
|
+
axis?: 'x' | 'y' | 'both';
|
|
115
|
+
threshold?: number;
|
|
116
|
+
filter?: (e: PointerEvent) => boolean;
|
|
117
|
+
onStart?: (info: { x: number; y: number; event: PointerEvent }) => void;
|
|
118
|
+
onMove?: (info: { dx: number; dy: number; x: number; y: number; vx: number; vy: number; event: PointerEvent }) => void;
|
|
119
|
+
onEnd?: (info: { dx: number; dy: number; vx: number; vy: number; event: PointerEvent; cancelled: boolean }) => void;
|
|
120
|
+
}
|
|
121
|
+
): { destroy: () => void };
|
|
122
|
+
|
|
123
|
+
export function calculateVelocity(
|
|
124
|
+
history: Array<{ x: number; y: number; t: number }>,
|
|
125
|
+
now?: number,
|
|
126
|
+
windowMs?: number
|
|
127
|
+
): { vx: number; vy: number };
|
|
128
|
+
|
|
129
|
+
export function rubberBand(delta: number, factor?: number): number;
|
|
130
|
+
|
|
111
131
|
export function position(
|
|
112
132
|
panel: HTMLElement,
|
|
113
133
|
anchor: Element,
|