@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 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.4/dist/alacris.js",
50
- "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.7/src/index.js",
51
- "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.7/src/theme/index.js",
52
- "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.7/src/components/"
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.7",
4
- "description": "Themeable design system for Alacris — Material defaults, sixty-eight custom elements, ESM-only, no build step.",
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=${(el) => (modalSurfaceEl = el)}>
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=${(el) => (surfaceEl = el)}>
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') host.emit('close', { reason: 'esc' });
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" @click=${() => host.emit('close', { reason: 'scrim' })}></div>
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: () => host.emit('closed'),
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" @click=${() => requestClose('scrim')}></div>
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=${(el) => { surfaceEl = el; releaseFill(animate(el, fx.sheetIn, { duration: 'medium2', easing: 'emphasizedDecelerate' })); }}>
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: () => host.emit('closed'),
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" @click=${() => requestClose('scrim')}></div>
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: () => host.emit('closed'),
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
+ }
@@ -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';
@@ -60,6 +60,7 @@ declare global {
60
60
  'ui-step': HTMLElement;
61
61
  'ui-stepper': HTMLElement;
62
62
  'ui-surface': HTMLElement;
63
+ 'ui-swipe-row': HTMLElement;
63
64
  'ui-switch': HTMLElement;
64
65
  'ui-tab': HTMLElement;
65
66
  'ui-tab-panel': HTMLElement;
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,