@alacris/ui 0.4.7 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +2 -2
- package/src/components/ui-date-picker.js +7 -7
- package/src/components/ui-dialog.js +7 -7
- package/src/components/ui-drawer.js +92 -7
- package/src/components/ui-sheet.js +96 -8
- package/src/components/ui-side-sheet.js +88 -7
- package/src/components/ui-swipe-row.js +255 -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
|
@@ -46,10 +46,10 @@ The published package is plain ESM. Point an import map at a pinned CDN build of
|
|
|
46
46
|
<script type="importmap">
|
|
47
47
|
{
|
|
48
48
|
"imports": {
|
|
49
|
-
"@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.
|
|
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.
|
|
49
|
+
"@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.5/dist/alacris.js",
|
|
50
|
+
"@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.5.1/src/index.js",
|
|
51
|
+
"@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.5.1/src/theme/index.js",
|
|
52
|
+
"@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.5.1/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.1",
|
|
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
|
".": {
|
|
@@ -36,7 +36,7 @@ import { sys } from '../tokens/sys.js';
|
|
|
36
36
|
import { base } from './base.js';
|
|
37
37
|
import { formBind } from '../util/form.js';
|
|
38
38
|
import { presence } from '../motion/presence.js';
|
|
39
|
-
import { animate, fx } from '../motion/animate.js';
|
|
39
|
+
import { animate, fx, releaseFill } from '../motion/animate.js';
|
|
40
40
|
import { autoUpdate } from '../util/position.js';
|
|
41
41
|
import { escapeLayer } from '../util/keys.js';
|
|
42
42
|
import { popupEvent } from '../util/popup.js';
|
|
@@ -279,12 +279,7 @@ const styles = css`
|
|
|
279
279
|
place-items: center;
|
|
280
280
|
}
|
|
281
281
|
.scrim { position: absolute; inset: 0; background: ${t.scrim}; }
|
|
282
|
-
@keyframes ui-date-picker-in {
|
|
283
|
-
from { transform: scale(0.8); }
|
|
284
|
-
to { transform: none; }
|
|
285
|
-
}
|
|
286
282
|
.modal-surface {
|
|
287
|
-
animation: ui-date-picker-in ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate};
|
|
288
283
|
position: relative;
|
|
289
284
|
display: flex;
|
|
290
285
|
flex-direction: column;
|
|
@@ -714,12 +709,17 @@ define('ui-date-picker', {
|
|
|
714
709
|
${calGrid}
|
|
715
710
|
</div>`;
|
|
716
711
|
|
|
712
|
+
const modalSurfaceRef = (el) => {
|
|
713
|
+
modalSurfaceEl = el;
|
|
714
|
+
releaseFill(animate(el, fx.scaleIn, { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
715
|
+
};
|
|
716
|
+
|
|
717
717
|
const modalView = () => html`
|
|
718
718
|
<div class="overlay">
|
|
719
719
|
<div class="scrim" aria-hidden="true" @click=${closePanel}></div>
|
|
720
720
|
<div class="modal-surface" part="panel" role="dialog" aria-modal="true"
|
|
721
721
|
aria-label=${() => label() || 'Choose date'}
|
|
722
|
-
ref=${
|
|
722
|
+
ref=${modalSurfaceRef}>
|
|
723
723
|
<div class="headline">${() => (range() ? 'Select dates' : 'Select date')}</div>
|
|
724
724
|
<div class="picked">${pickedLabel}</div>
|
|
725
725
|
<div class="cal-header">
|
|
@@ -29,7 +29,7 @@ import { define, html, css, vars, effect, onCleanup, signal } from '@alacris/cor
|
|
|
29
29
|
import { sys } from '../tokens/sys.js';
|
|
30
30
|
import { base } from './base.js';
|
|
31
31
|
import { presence } from '../motion/presence.js';
|
|
32
|
-
import { animate, fx } from '../motion/animate.js';
|
|
32
|
+
import { animate, fx, releaseFill } from '../motion/animate.js';
|
|
33
33
|
import { focusTrap, scrollLock } from '../util/focus.js';
|
|
34
34
|
|
|
35
35
|
const t = vars('ui-dialog', {
|
|
@@ -51,12 +51,7 @@ const styles = css`
|
|
|
51
51
|
place-items: center;
|
|
52
52
|
}
|
|
53
53
|
.scrim { position: absolute; inset: 0; background: ${t.scrim}; }
|
|
54
|
-
@keyframes ui-dialog-in {
|
|
55
|
-
from { transform: scale(0.8); }
|
|
56
|
-
to { transform: none; }
|
|
57
|
-
}
|
|
58
54
|
.surface {
|
|
59
|
-
animation: ui-dialog-in ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate};
|
|
60
55
|
position: relative;
|
|
61
56
|
display: flex;
|
|
62
57
|
flex-direction: column;
|
|
@@ -146,13 +141,18 @@ define('ui-dialog', {
|
|
|
146
141
|
sync();
|
|
147
142
|
};
|
|
148
143
|
|
|
144
|
+
const surfaceRef = (el) => {
|
|
145
|
+
surfaceEl = el;
|
|
146
|
+
releaseFill(animate(el, fx.scaleIn, { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
149
|
const view = () => html`
|
|
150
150
|
<div class="overlay">
|
|
151
151
|
<div class="scrim" part="scrim" aria-hidden="true" @click=${() => requestClose('scrim')}></div>
|
|
152
152
|
<div class="surface" part="surface" role="dialog" aria-modal="true"
|
|
153
153
|
aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
|
|
154
154
|
aria-label=${() => (hasHeadline() ? null : (label() || 'Dialog'))}
|
|
155
|
-
tabindex="-1" ref=${
|
|
155
|
+
tabindex="-1" ref=${surfaceRef}>
|
|
156
156
|
<div class="headline" part="headline" id="headline"
|
|
157
157
|
ref=${(el) => hasSlot(el.querySelector('slot'), (has) => {
|
|
158
158
|
hasHeadline.set(has);
|
|
@@ -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,13 +112,15 @@ 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.
|
|
119
|
+
let closingViaSwipe = false;
|
|
108
120
|
let prevActive = null;
|
|
109
121
|
effect(() => {
|
|
110
122
|
if (open() && variant() === 'modal') {
|
|
123
|
+
closingViaSwipe = false;
|
|
111
124
|
prevActive = document.activeElement;
|
|
112
125
|
document.addEventListener('keydown', onDocKeydown, true);
|
|
113
126
|
if (!unlock) unlock = scrollLock();
|
|
@@ -127,11 +140,12 @@ define('ui-drawer', {
|
|
|
127
140
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
128
141
|
releaseTrap?.();
|
|
129
142
|
unlock?.();
|
|
143
|
+
tracker?.destroy();
|
|
130
144
|
});
|
|
131
145
|
|
|
132
146
|
// The panel slides out while the presence overlay (scrim included) fades.
|
|
133
147
|
effect(() => {
|
|
134
|
-
if (!open() && surfaceEl?.isConnected) {
|
|
148
|
+
if (!open() && surfaceEl?.isConnected && !closingViaSwipe) {
|
|
135
149
|
animate(surfaceEl, slideOut(), { duration: 'short4', easing: 'emphasizedAccelerate' });
|
|
136
150
|
}
|
|
137
151
|
});
|
|
@@ -139,11 +153,79 @@ define('ui-drawer', {
|
|
|
139
153
|
const surfaceRef = (el) => {
|
|
140
154
|
surfaceEl = el;
|
|
141
155
|
releaseFill(animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
156
|
+
|
|
157
|
+
tracker?.destroy();
|
|
158
|
+
tracker = createSwipeTracker(el, {
|
|
159
|
+
axis: 'x',
|
|
160
|
+
threshold: 8,
|
|
161
|
+
filter(e) {
|
|
162
|
+
if (!swipable() || persistent() || variant() !== 'modal') return false;
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
onStart() {
|
|
166
|
+
el.getAnimations?.()?.forEach((a) => a.cancel());
|
|
167
|
+
el.style.transition = 'none';
|
|
168
|
+
},
|
|
169
|
+
onMove({ dx }) {
|
|
170
|
+
const isEnd = anchor.peek() === 'end';
|
|
171
|
+
let effectiveDx = dx;
|
|
172
|
+
if (isEnd) {
|
|
173
|
+
if (dx < 0) effectiveDx = rubberBand(dx, 0.2);
|
|
174
|
+
} else {
|
|
175
|
+
if (dx > 0) effectiveDx = rubberBand(dx, 0.2);
|
|
176
|
+
}
|
|
177
|
+
el.style.transform = `translateX(${effectiveDx}px)`;
|
|
178
|
+
const w = el.offsetWidth || 300;
|
|
179
|
+
const progress = Math.min(1, Math.max(0, Math.abs(effectiveDx) / w));
|
|
180
|
+
if (scrimEl) scrimEl.style.opacity = String(1 - progress * 0.7);
|
|
181
|
+
},
|
|
182
|
+
onEnd({ dx, vx, cancelled }) {
|
|
183
|
+
const isEnd = anchor.peek() === 'end';
|
|
184
|
+
const w = el.offsetWidth || 300;
|
|
185
|
+
const dismissDirection = isEnd ? (vx > 0.4 || dx > w * 0.35) : (vx < -0.4 || dx < -w * 0.35);
|
|
186
|
+
const shouldDismiss = !cancelled && dismissDirection;
|
|
187
|
+
|
|
188
|
+
if (shouldDismiss) {
|
|
189
|
+
closingViaSwipe = true;
|
|
190
|
+
const targetTransform = isEnd ? 'translateX(100%)' : 'translateX(-100%)';
|
|
191
|
+
const remaining = Math.max(0, w - Math.abs(dx));
|
|
192
|
+
const ms = Math.min(300, Math.max(120, Math.round(remaining / (Math.max(Math.abs(vx), 0.8)))));
|
|
193
|
+
if (scrimEl) {
|
|
194
|
+
animate(scrimEl, fx.fadeOut, { duration: ms, easing: 'emphasizedAccelerate' });
|
|
195
|
+
}
|
|
196
|
+
animate(el, [
|
|
197
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
198
|
+
{ transform: targetTransform },
|
|
199
|
+
], { duration: ms, easing: 'emphasizedAccelerate', fill: 'forwards' });
|
|
200
|
+
requestClose('swipe');
|
|
201
|
+
} else {
|
|
202
|
+
if (scrimEl) {
|
|
203
|
+
const scrimSnap = animate(scrimEl, [{ opacity: scrimEl.style.opacity || '0.5' }, { opacity: 1 }], {
|
|
204
|
+
duration: 'short4', easing: 'emphasizedDecelerate',
|
|
205
|
+
});
|
|
206
|
+
scrimSnap.finished.then(() => {
|
|
207
|
+
try { scrimSnap.cancel(); } catch {}
|
|
208
|
+
if (scrimEl) scrimEl.style.opacity = '';
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const snapAnim = animate(el, [
|
|
212
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
213
|
+
{ transform: 'translateX(0)' },
|
|
214
|
+
], { duration: 'short4', easing: 'emphasizedDecelerate' });
|
|
215
|
+
snapAnim.finished.then(() => {
|
|
216
|
+
try { snapAnim.cancel(); } catch {}
|
|
217
|
+
if (el) el.style.transform = '';
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
142
222
|
};
|
|
143
223
|
|
|
144
224
|
const overlay = () => html`
|
|
145
225
|
<div class="overlay">
|
|
146
|
-
<div class="scrim" part="scrim" aria-hidden="true"
|
|
226
|
+
<div class="scrim" part="scrim" aria-hidden="true"
|
|
227
|
+
ref=${(el) => { scrimEl = el; }}
|
|
228
|
+
@click=${() => requestClose('scrim')}></div>
|
|
147
229
|
<aside class=${() => `surface ${anchor()}`} part="surface" role="dialog" aria-modal="true"
|
|
148
230
|
aria-label=${() => label() || 'Navigation'} tabindex="-1" ref=${surfaceRef}>
|
|
149
231
|
<slot></slot>
|
|
@@ -163,7 +245,10 @@ define('ui-drawer', {
|
|
|
163
245
|
exit: fx.fadeOut,
|
|
164
246
|
exitDuration: 'short4',
|
|
165
247
|
onEntered: () => host.emit('opened'),
|
|
166
|
-
onExited: () =>
|
|
248
|
+
onExited: () => {
|
|
249
|
+
tracker?.destroy();
|
|
250
|
+
host.emit('closed');
|
|
251
|
+
},
|
|
167
252
|
})}`;
|
|
168
253
|
},
|
|
169
254
|
});
|
|
@@ -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) => {
|
|
@@ -126,9 +134,11 @@ define('ui-sheet', {
|
|
|
126
134
|
if (e.key === 'Escape') requestClose('esc');
|
|
127
135
|
};
|
|
128
136
|
|
|
137
|
+
let closingViaSwipe = false;
|
|
129
138
|
let prevActive = null;
|
|
130
139
|
effect(() => {
|
|
131
140
|
if (open() && variant() !== 'standard') {
|
|
141
|
+
closingViaSwipe = false;
|
|
132
142
|
prevActive = document.activeElement;
|
|
133
143
|
document.addEventListener('keydown', onDocKeydown, true);
|
|
134
144
|
unlock = scrollLock();
|
|
@@ -142,7 +152,7 @@ define('ui-sheet', {
|
|
|
142
152
|
prevActive = null;
|
|
143
153
|
unlock?.();
|
|
144
154
|
unlock = null;
|
|
145
|
-
if (surfaceEl?.isConnected) {
|
|
155
|
+
if (surfaceEl?.isConnected && !closingViaSwipe) {
|
|
146
156
|
animate(surfaceEl, fx.sheetOut, { duration: 'short4', easing: 'emphasizedAccelerate' });
|
|
147
157
|
}
|
|
148
158
|
}
|
|
@@ -151,6 +161,7 @@ define('ui-sheet', {
|
|
|
151
161
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
152
162
|
releaseTrap?.();
|
|
153
163
|
unlock?.();
|
|
164
|
+
tracker?.destroy();
|
|
154
165
|
});
|
|
155
166
|
|
|
156
167
|
const hasSlot = (el, set) => {
|
|
@@ -159,14 +170,87 @@ define('ui-sheet', {
|
|
|
159
170
|
sync();
|
|
160
171
|
};
|
|
161
172
|
|
|
173
|
+
const surfaceRef = (el) => {
|
|
174
|
+
surfaceEl = el;
|
|
175
|
+
releaseFill(animate(el, fx.sheetIn, { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
176
|
+
|
|
177
|
+
tracker?.destroy();
|
|
178
|
+
tracker = createSwipeTracker(el, {
|
|
179
|
+
axis: 'y',
|
|
180
|
+
threshold: 8,
|
|
181
|
+
filter(e) {
|
|
182
|
+
if (!swipable() || persistent() || variant() === 'standard') return false;
|
|
183
|
+
const body = el.querySelector('.body');
|
|
184
|
+
if (body && body.contains(e.target) && body.scrollTop > 0) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
},
|
|
189
|
+
onStart() {
|
|
190
|
+
el.getAnimations?.()?.forEach((a) => a.cancel());
|
|
191
|
+
el.style.transition = 'none';
|
|
192
|
+
},
|
|
193
|
+
onMove({ dy }) {
|
|
194
|
+
if (dy < 0) {
|
|
195
|
+
const damped = rubberBand(dy, 0.2);
|
|
196
|
+
el.style.transform = `translateY(${damped}px)`;
|
|
197
|
+
if (scrimEl) scrimEl.style.opacity = '1';
|
|
198
|
+
} else {
|
|
199
|
+
el.style.transform = `translateY(${dy}px)`;
|
|
200
|
+
const h = el.offsetHeight || 300;
|
|
201
|
+
const progress = Math.min(1, Math.max(0, dy / h));
|
|
202
|
+
if (scrimEl) scrimEl.style.opacity = String(1 - progress * 0.7);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
onEnd({ dy, vy, cancelled }) {
|
|
206
|
+
const h = el.offsetHeight || 300;
|
|
207
|
+
const shouldDismiss = !cancelled && (vy > 0.4 || dy > h * 0.35);
|
|
208
|
+
|
|
209
|
+
if (shouldDismiss) {
|
|
210
|
+
closingViaSwipe = true;
|
|
211
|
+
const remaining = Math.max(0, h - dy);
|
|
212
|
+
const ms = Math.min(300, Math.max(120, Math.round(remaining / (Math.max(vy, 0.8)))));
|
|
213
|
+
if (scrimEl) {
|
|
214
|
+
animate(scrimEl, fx.fadeOut, { duration: ms, easing: 'emphasizedAccelerate' });
|
|
215
|
+
}
|
|
216
|
+
animate(el, [
|
|
217
|
+
{ transform: el.style.transform || `translateY(${dy}px)` },
|
|
218
|
+
{ transform: 'translateY(100%)' },
|
|
219
|
+
], { duration: ms, easing: 'emphasizedAccelerate', fill: 'forwards' });
|
|
220
|
+
requestClose('swipe');
|
|
221
|
+
} else {
|
|
222
|
+
if (scrimEl) {
|
|
223
|
+
const scrimSnap = animate(scrimEl, [{ opacity: scrimEl.style.opacity || '0.5' }, { opacity: 1 }], {
|
|
224
|
+
duration: 'short4', easing: 'emphasizedDecelerate',
|
|
225
|
+
});
|
|
226
|
+
scrimSnap.finished.then(() => {
|
|
227
|
+
try { scrimSnap.cancel(); } catch {}
|
|
228
|
+
if (scrimEl) scrimEl.style.opacity = '';
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const snapAnim = animate(el, [
|
|
232
|
+
{ transform: el.style.transform || `translateY(${dy}px)` },
|
|
233
|
+
{ transform: 'translateY(0)' },
|
|
234
|
+
], { duration: 'short4', easing: 'emphasizedDecelerate' });
|
|
235
|
+
snapAnim.finished.then(() => {
|
|
236
|
+
try { snapAnim.cancel(); } catch {}
|
|
237
|
+
if (el) el.style.transform = '';
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
162
244
|
const modalView = () => html`
|
|
163
245
|
<div class="overlay">
|
|
164
|
-
<div class="scrim" part="scrim" aria-hidden="true"
|
|
246
|
+
<div class="scrim" part="scrim" aria-hidden="true"
|
|
247
|
+
ref=${(el) => { scrimEl = el; }}
|
|
248
|
+
@click=${() => requestClose('scrim')}></div>
|
|
165
249
|
<div class="surface" part="surface" role="dialog" aria-modal="true"
|
|
166
250
|
aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
|
|
167
251
|
aria-label=${() => (hasHeadline() ? null : (label() || 'Sheet'))}
|
|
168
252
|
tabindex="-1"
|
|
169
|
-
ref=${
|
|
253
|
+
ref=${surfaceRef}>
|
|
170
254
|
<div class="handle" part="handle" aria-hidden="true"></div>
|
|
171
255
|
<div class="headline" part="headline" id="headline"
|
|
172
256
|
ref=${(el) => hasSlot(el.querySelector('slot'), (has) => {
|
|
@@ -199,10 +283,14 @@ define('ui-sheet', {
|
|
|
199
283
|
exit: fx.fadeOut,
|
|
200
284
|
exitDuration: 'short4',
|
|
201
285
|
onEntered: () => host.emit('opened'),
|
|
202
|
-
onExited: () =>
|
|
286
|
+
onExited: () => {
|
|
287
|
+
tracker?.destroy();
|
|
288
|
+
host.emit('closed');
|
|
289
|
+
},
|
|
203
290
|
})}`;
|
|
204
291
|
},
|
|
205
292
|
});
|
|
206
293
|
|
|
207
294
|
export const tag = 'ui-sheet';
|
|
208
295
|
export const themeVars = t;
|
|
296
|
+
|
|
@@ -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) => {
|
|
@@ -130,9 +136,11 @@ define('ui-side-sheet', {
|
|
|
130
136
|
if (e.key === 'Escape') requestClose('esc');
|
|
131
137
|
};
|
|
132
138
|
|
|
139
|
+
let closingViaSwipe = false;
|
|
133
140
|
let prevActive = null;
|
|
134
141
|
effect(() => {
|
|
135
142
|
if (open() && variant() !== 'standard') {
|
|
143
|
+
closingViaSwipe = false;
|
|
136
144
|
prevActive = document.activeElement;
|
|
137
145
|
document.addEventListener('keydown', onDocKeydown, true);
|
|
138
146
|
unlock = scrollLock();
|
|
@@ -149,7 +157,7 @@ define('ui-side-sheet', {
|
|
|
149
157
|
}
|
|
150
158
|
});
|
|
151
159
|
effect(() => {
|
|
152
|
-
if (!open() && surfaceEl?.isConnected) {
|
|
160
|
+
if (!open() && surfaceEl?.isConnected && !closingViaSwipe) {
|
|
153
161
|
animate(surfaceEl, slideOut(), { duration: 'short4', easing: 'emphasizedAccelerate' });
|
|
154
162
|
}
|
|
155
163
|
});
|
|
@@ -157,11 +165,78 @@ define('ui-side-sheet', {
|
|
|
157
165
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
158
166
|
releaseTrap?.();
|
|
159
167
|
unlock?.();
|
|
168
|
+
tracker?.destroy();
|
|
160
169
|
});
|
|
161
170
|
|
|
162
171
|
const surfaceRef = (el) => {
|
|
163
172
|
surfaceEl = el;
|
|
164
173
|
releaseFill(animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' }));
|
|
174
|
+
|
|
175
|
+
tracker?.destroy();
|
|
176
|
+
tracker = createSwipeTracker(el, {
|
|
177
|
+
axis: 'x',
|
|
178
|
+
threshold: 8,
|
|
179
|
+
filter(e) {
|
|
180
|
+
if (!swipable() || persistent() || variant() === 'standard') return false;
|
|
181
|
+
return true;
|
|
182
|
+
},
|
|
183
|
+
onStart() {
|
|
184
|
+
el.getAnimations?.()?.forEach((a) => a.cancel());
|
|
185
|
+
el.style.transition = 'none';
|
|
186
|
+
},
|
|
187
|
+
onMove({ dx }) {
|
|
188
|
+
const isEnd = anchor.peek() === 'end';
|
|
189
|
+
let effectiveDx = dx;
|
|
190
|
+
if (isEnd) {
|
|
191
|
+
if (dx < 0) effectiveDx = rubberBand(dx, 0.2);
|
|
192
|
+
} else {
|
|
193
|
+
if (dx > 0) effectiveDx = rubberBand(dx, 0.2);
|
|
194
|
+
}
|
|
195
|
+
el.style.transform = `translateX(${effectiveDx}px)`;
|
|
196
|
+
const w = el.offsetWidth || 300;
|
|
197
|
+
const progress = Math.min(1, Math.max(0, Math.abs(effectiveDx) / w));
|
|
198
|
+
if (scrimEl) scrimEl.style.opacity = String(1 - progress * 0.7);
|
|
199
|
+
},
|
|
200
|
+
onEnd({ dx, vx, cancelled }) {
|
|
201
|
+
const isEnd = anchor.peek() === 'end';
|
|
202
|
+
const w = el.offsetWidth || 300;
|
|
203
|
+
const dismissDirection = isEnd ? (vx > 0.4 || dx > w * 0.35) : (vx < -0.4 || dx < -w * 0.35);
|
|
204
|
+
const shouldDismiss = !cancelled && dismissDirection;
|
|
205
|
+
|
|
206
|
+
if (shouldDismiss) {
|
|
207
|
+
closingViaSwipe = true;
|
|
208
|
+
const targetTransform = isEnd ? 'translateX(100%)' : 'translateX(-100%)';
|
|
209
|
+
const remaining = Math.max(0, w - Math.abs(dx));
|
|
210
|
+
const ms = Math.min(300, Math.max(120, Math.round(remaining / (Math.max(Math.abs(vx), 0.8)))));
|
|
211
|
+
if (scrimEl) {
|
|
212
|
+
animate(scrimEl, fx.fadeOut, { duration: ms, easing: 'emphasizedAccelerate' });
|
|
213
|
+
}
|
|
214
|
+
animate(el, [
|
|
215
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
216
|
+
{ transform: targetTransform },
|
|
217
|
+
], { duration: ms, easing: 'emphasizedAccelerate', fill: 'forwards' });
|
|
218
|
+
requestClose('swipe');
|
|
219
|
+
} else {
|
|
220
|
+
if (scrimEl) {
|
|
221
|
+
const scrimSnap = animate(scrimEl, [{ opacity: scrimEl.style.opacity || '0.5' }, { opacity: 1 }], {
|
|
222
|
+
duration: 'short4', easing: 'emphasizedDecelerate',
|
|
223
|
+
});
|
|
224
|
+
scrimSnap.finished.then(() => {
|
|
225
|
+
try { scrimSnap.cancel(); } catch {}
|
|
226
|
+
if (scrimEl) scrimEl.style.opacity = '';
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const snapAnim = animate(el, [
|
|
230
|
+
{ transform: el.style.transform || `translateX(${dx}px)` },
|
|
231
|
+
{ transform: 'translateX(0)' },
|
|
232
|
+
], { duration: 'short4', easing: 'emphasizedDecelerate' });
|
|
233
|
+
snapAnim.finished.then(() => {
|
|
234
|
+
try { snapAnim.cancel(); } catch {}
|
|
235
|
+
if (el) el.style.transform = '';
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
165
240
|
};
|
|
166
241
|
|
|
167
242
|
const hasSlot = (el, set) => {
|
|
@@ -172,7 +247,9 @@ define('ui-side-sheet', {
|
|
|
172
247
|
|
|
173
248
|
const modalView = () => html`
|
|
174
249
|
<div class="overlay">
|
|
175
|
-
<div class="scrim" part="scrim" aria-hidden="true"
|
|
250
|
+
<div class="scrim" part="scrim" aria-hidden="true"
|
|
251
|
+
ref=${(el) => { scrimEl = el; }}
|
|
252
|
+
@click=${() => requestClose('scrim')}></div>
|
|
176
253
|
<aside class=${() => `surface ${anchor()}`} part="surface" role="dialog" aria-modal="true"
|
|
177
254
|
aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
|
|
178
255
|
aria-label=${() => (hasHeadline() ? null : (label() || 'Side sheet'))}
|
|
@@ -215,10 +292,14 @@ define('ui-side-sheet', {
|
|
|
215
292
|
exit: fx.fadeOut,
|
|
216
293
|
exitDuration: 'short4',
|
|
217
294
|
onEntered: () => host.emit('opened'),
|
|
218
|
-
onExited: () =>
|
|
295
|
+
onExited: () => {
|
|
296
|
+
tracker?.destroy();
|
|
297
|
+
host.emit('closed');
|
|
298
|
+
},
|
|
219
299
|
})}`;
|
|
220
300
|
},
|
|
221
301
|
});
|
|
222
302
|
|
|
223
303
|
export const tag = 'ui-side-sheet';
|
|
224
304
|
export const themeVars = t;
|
|
305
|
+
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
try { anim.cancel(); } catch {}
|
|
101
|
+
if (contentEl) contentEl.style.transform = targetTransform;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const setOpenState = (side, emitEvent = true) => {
|
|
106
|
+
currentOpen = side;
|
|
107
|
+
if (side === 'end' && hasEnd.peek()) {
|
|
108
|
+
const w = getEndWidth();
|
|
109
|
+
snapTo(-w);
|
|
110
|
+
if (emitEvent) host.emit('open', { side: 'end' });
|
|
111
|
+
} else if (side === 'start' && hasStart.peek()) {
|
|
112
|
+
const w = getStartWidth();
|
|
113
|
+
snapTo(w);
|
|
114
|
+
if (emitEvent) host.emit('open', { side: 'start' });
|
|
115
|
+
} else {
|
|
116
|
+
currentOpen = '';
|
|
117
|
+
snapTo(0);
|
|
118
|
+
if (emitEvent) host.emit('close');
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
effect(() => {
|
|
123
|
+
const target = open();
|
|
124
|
+
if (target !== currentOpen) {
|
|
125
|
+
setOpenState(target, false);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const onDocClick = (e) => {
|
|
130
|
+
if (currentOpen && !host.contains(e.target)) {
|
|
131
|
+
setOpenState('');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
document.addEventListener('click', onDocClick);
|
|
136
|
+
onCleanup(() => {
|
|
137
|
+
document.removeEventListener('click', onDocClick);
|
|
138
|
+
tracker?.destroy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const onContentClick = (e) => {
|
|
142
|
+
if (currentOpen !== '') {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
setOpenState('');
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const hasSlot = (el, set) => {
|
|
150
|
+
const sync = () => set(el.assignedElements().length > 0);
|
|
151
|
+
el.addEventListener('slotchange', sync);
|
|
152
|
+
sync();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const initTracker = (el) => {
|
|
156
|
+
contentEl = el;
|
|
157
|
+
tracker?.destroy();
|
|
158
|
+
|
|
159
|
+
tracker = createSwipeTracker(el, {
|
|
160
|
+
axis: 'x',
|
|
161
|
+
threshold: 8,
|
|
162
|
+
filter(e) {
|
|
163
|
+
if (disabled()) return false;
|
|
164
|
+
return true;
|
|
165
|
+
},
|
|
166
|
+
onStart() {
|
|
167
|
+
el.getAnimations?.()?.forEach((a) => a.cancel());
|
|
168
|
+
if (currentOpen === 'end') startOffset = -getEndWidth();
|
|
169
|
+
else if (currentOpen === 'start') startOffset = getStartWidth();
|
|
170
|
+
else startOffset = 0;
|
|
171
|
+
el.style.transition = 'none';
|
|
172
|
+
},
|
|
173
|
+
onMove({ dx }) {
|
|
174
|
+
const totalDx = startOffset + dx;
|
|
175
|
+
const startW = getStartWidth();
|
|
176
|
+
const endW = getEndWidth();
|
|
177
|
+
let effectiveDx = totalDx;
|
|
178
|
+
|
|
179
|
+
if (totalDx > 0) {
|
|
180
|
+
if (!hasStart.peek()) effectiveDx = rubberBand(totalDx, 0.15);
|
|
181
|
+
else if (totalDx > startW && !fullSwipe()) {
|
|
182
|
+
effectiveDx = startW + rubberBand(totalDx - startW, 0.25);
|
|
183
|
+
}
|
|
184
|
+
} else if (totalDx < 0) {
|
|
185
|
+
if (!hasEnd.peek()) effectiveDx = rubberBand(totalDx, 0.15);
|
|
186
|
+
else if (-totalDx > endW && !fullSwipe()) {
|
|
187
|
+
effectiveDx = -endW - rubberBand(-totalDx - endW, 0.25);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
el.style.transform = `translateX(${effectiveDx}px)`;
|
|
192
|
+
},
|
|
193
|
+
onEnd({ dx, vx, cancelled }) {
|
|
194
|
+
if (cancelled) {
|
|
195
|
+
setOpenState(currentOpen, false);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const totalDx = startOffset + dx;
|
|
200
|
+
const containerW = containerEl?.offsetWidth || 300;
|
|
201
|
+
const startW = getStartWidth() || 80;
|
|
202
|
+
const endW = getEndWidth() || 80;
|
|
203
|
+
const thresh = Number(threshold()) || 0.4;
|
|
204
|
+
|
|
205
|
+
// Check full-swipe action
|
|
206
|
+
if (fullSwipe() && Math.abs(totalDx) > containerW * 0.65) {
|
|
207
|
+
const side = totalDx > 0 ? 'start' : 'end';
|
|
208
|
+
const targetX = totalDx > 0 ? containerW : -containerW;
|
|
209
|
+
snapTo(targetX, 150, 'emphasizedAccelerate').then(() => {
|
|
210
|
+
host.emit('action', { side });
|
|
211
|
+
setOpenState('');
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check reveal / snap
|
|
217
|
+
if (totalDx < 0 && hasEnd.peek()) {
|
|
218
|
+
if (vx < -0.4 || -totalDx > endW * thresh) {
|
|
219
|
+
setOpenState('end');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
} else if (totalDx > 0 && hasStart.peek()) {
|
|
223
|
+
if (vx > 0.4 || totalDx > startW * thresh) {
|
|
224
|
+
setOpenState('start');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
setOpenState('');
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return html`
|
|
235
|
+
<div class=${() => `container${disabled() ? ' disabled' : ''}`} part="container"
|
|
236
|
+
ref=${(el) => { containerEl = el; }}>
|
|
237
|
+
<div class=${() => `actions start${hasStart() ? ' has' : ''}`} part="start-actions"
|
|
238
|
+
ref=${(el) => { startActionsEl = el; }}>
|
|
239
|
+
<slot name="start" ref=${(el) => hasSlot(el, hasStart.set)}></slot>
|
|
240
|
+
</div>
|
|
241
|
+
<div class=${() => `actions end${hasEnd() ? ' has' : ''}`} part="end-actions"
|
|
242
|
+
ref=${(el) => { endActionsEl = el; }}>
|
|
243
|
+
<slot name="end" ref=${(el) => hasSlot(el, hasEnd.set)}></slot>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="content" part="content"
|
|
246
|
+
ref=${initTracker}
|
|
247
|
+
@click=${onContentClick}>
|
|
248
|
+
<slot></slot>
|
|
249
|
+
</div>
|
|
250
|
+
</div>`;
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
export const tag = 'ui-swipe-row';
|
|
255
|
+
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,
|