@alacris/ui 0.4.7 → 0.5.0

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