@sveltia/ui 0.76.1 → 0.77.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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Geometry behind the swipe-to-dismiss gesture of `<Drawer>`, kept apart from the component so the
3
+ * calculations can be tested without a rendered drawer.
4
+ */
5
+ /**
6
+ * @typedef {object} SwipeSample
7
+ * @property {number} offset Distance the drawer has been dragged towards its edge, in pixels.
8
+ * @property {number} time Time of the sample in milliseconds, such as an event’s
9
+ * `timeStamp`.
10
+ */
11
+ /**
12
+ * @typedef {object} SwipeState
13
+ * @property {number} pointerId ID of the pointer dragging the drawer.
14
+ * @property {'X' | 'Y'} axis Axis along which the drawer is dragged.
15
+ * @property {1 | -1} sign Sign of the direction towards the edge. See {@link getSwipeDirection}.
16
+ * @property {number} start Position along the axis where the drag has started, in pixels.
17
+ * @property {SwipeSample[]} samples Recent samples. See {@link addSwipeSample}.
18
+ */
19
+ /**
20
+ * Portion of the drawer’s size it has to be dragged by to be dismissed on release.
21
+ */
22
+ export const SWIPE_DISMISS_DISTANCE_RATIO: 0.3;
23
+ /**
24
+ * Speed in pixels per millisecond towards the edge at which a release dismisses the drawer, however
25
+ * short the drag, so a quick flick is enough.
26
+ */
27
+ export const SWIPE_DISMISS_VELOCITY: 0.5;
28
+ /**
29
+ * Distance in pixels a flick has to cover to dismiss the drawer, so a finger that rolls a little
30
+ * while tapping the header doesn’t count as one.
31
+ */
32
+ export const SWIPE_FLICK_MIN_DISTANCE: 16;
33
+ /**
34
+ * Time window in milliseconds over which the speed of a drag is measured when it’s released.
35
+ */
36
+ export const SWIPE_VELOCITY_WINDOW: 100;
37
+ export function getSwipeDirection(position: "top" | "right" | "bottom" | "left", rtl: boolean): {
38
+ axis: "X" | "Y";
39
+ sign: 1 | -1;
40
+ };
41
+ export function getSwipeOffset(delta: number, sign: 1 | -1): number;
42
+ export function addSwipeSample(samples: SwipeSample[], sample: SwipeSample): SwipeSample[];
43
+ export function getSwipeVelocity(samples: SwipeSample[]): number;
44
+ export function shouldDismissSwipe({ offset, size, velocity }: {
45
+ offset: number;
46
+ size: number;
47
+ velocity: number;
48
+ }): boolean;
49
+ export type SwipeSample = {
50
+ /**
51
+ * Distance the drawer has been dragged towards its edge, in pixels.
52
+ */
53
+ offset: number;
54
+ /**
55
+ * Time of the sample in milliseconds, such as an event’s
56
+ * `timeStamp`.
57
+ */
58
+ time: number;
59
+ };
60
+ export type SwipeState = {
61
+ /**
62
+ * ID of the pointer dragging the drawer.
63
+ */
64
+ pointerId: number;
65
+ /**
66
+ * Axis along which the drawer is dragged.
67
+ */
68
+ axis: "X" | "Y";
69
+ /**
70
+ * Sign of the direction towards the edge. See {@link getSwipeDirection}.
71
+ */
72
+ sign: 1 | -1;
73
+ /**
74
+ * Position along the axis where the drag has started, in pixels.
75
+ */
76
+ start: number;
77
+ /**
78
+ * Recent samples. See {@link addSwipeSample}.
79
+ */
80
+ samples: SwipeSample[];
81
+ };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Geometry behind the swipe-to-dismiss gesture of `<Drawer>`, kept apart from the component so the
3
+ * calculations can be tested without a rendered drawer.
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} SwipeSample
8
+ * @property {number} offset Distance the drawer has been dragged towards its edge, in pixels.
9
+ * @property {number} time Time of the sample in milliseconds, such as an event’s
10
+ * `timeStamp`.
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} SwipeState
15
+ * @property {number} pointerId ID of the pointer dragging the drawer.
16
+ * @property {'X' | 'Y'} axis Axis along which the drawer is dragged.
17
+ * @property {1 | -1} sign Sign of the direction towards the edge. See {@link getSwipeDirection}.
18
+ * @property {number} start Position along the axis where the drag has started, in pixels.
19
+ * @property {SwipeSample[]} samples Recent samples. See {@link addSwipeSample}.
20
+ */
21
+
22
+ /**
23
+ * Portion of the drawer’s size it has to be dragged by to be dismissed on release.
24
+ */
25
+ export const SWIPE_DISMISS_DISTANCE_RATIO = 0.3;
26
+
27
+ /**
28
+ * Speed in pixels per millisecond towards the edge at which a release dismisses the drawer, however
29
+ * short the drag, so a quick flick is enough.
30
+ */
31
+ export const SWIPE_DISMISS_VELOCITY = 0.5;
32
+
33
+ /**
34
+ * Distance in pixels a flick has to cover to dismiss the drawer, so a finger that rolls a little
35
+ * while tapping the header doesn’t count as one.
36
+ */
37
+ export const SWIPE_FLICK_MIN_DISTANCE = 16;
38
+
39
+ /**
40
+ * Time window in milliseconds over which the speed of a drag is measured when it’s released.
41
+ */
42
+ export const SWIPE_VELOCITY_WINDOW = 100;
43
+
44
+ /**
45
+ * Get the axis along which the drawer is swiped away, and the sign of the physical direction
46
+ * towards the edge it’s attached to.
47
+ * @param {'top' | 'right' | 'bottom' | 'left'} position Position of the drawer.
48
+ * @param {boolean} rtl Whether the layout is right-to-left, which mirrors the `left` and `right`
49
+ * positions: they’re logical, like the `inset-inline` properties they’re laid out with.
50
+ * @returns {{ axis: 'X' | 'Y', sign: 1 | -1 }} Axis, named as in `translateX()` and
51
+ * `translateY()`, and sign.
52
+ */
53
+ export const getSwipeDirection = (position, rtl) => {
54
+ if (position === 'top' || position === 'bottom') {
55
+ return { axis: 'Y', sign: position === 'bottom' ? 1 : -1 };
56
+ }
57
+
58
+ return { axis: 'X', sign: (position === 'right') !== rtl ? 1 : -1 };
59
+ };
60
+
61
+ /**
62
+ * Get the distance the drawer has been dragged towards its edge. A drag the other way is ignored:
63
+ * the drawer can’t be pulled out further than it’s open.
64
+ * @param {number} delta Physical distance the pointer has moved along the axis, in pixels.
65
+ * @param {1 | -1} sign Sign of the direction towards the edge. See {@link getSwipeDirection}.
66
+ * @returns {number} Offset in pixels, `0` or more.
67
+ */
68
+ export const getSwipeOffset = (delta, sign) => Math.max(0, delta * sign);
69
+
70
+ /**
71
+ * Add a sample to the list the speed of a drag is measured with, dropping the ones that have left
72
+ * the measuring window.
73
+ * @param {SwipeSample[]} samples Previous samples, oldest first.
74
+ * @param {SwipeSample} sample New sample.
75
+ * @returns {SwipeSample[]} Samples within {@link SWIPE_VELOCITY_WINDOW}, oldest first.
76
+ */
77
+ export const addSwipeSample = (samples, sample) => [
78
+ ...samples.filter(({ time }) => sample.time - time <= SWIPE_VELOCITY_WINDOW),
79
+ sample,
80
+ ];
81
+
82
+ /**
83
+ * Get the speed of a drag towards the edge.
84
+ * @param {SwipeSample[]} samples Recent samples, oldest first. See {@link addSwipeSample}.
85
+ * @returns {number} Speed in pixels per millisecond, negative if the drag was heading back.
86
+ */
87
+ export const getSwipeVelocity = (samples) => {
88
+ const first = samples[0];
89
+ const last = samples[samples.length - 1];
90
+
91
+ if (!first || !last || last.time <= first.time) {
92
+ return 0;
93
+ }
94
+
95
+ return (last.offset - first.offset) / (last.time - first.time);
96
+ };
97
+
98
+ /**
99
+ * Check whether a drag that has just been released should dismiss the drawer: it has been dragged
100
+ * far enough, or flicked towards its edge.
101
+ * @param {object} args Arguments.
102
+ * @param {number} args.offset Distance the drawer has been dragged towards its edge, in pixels.
103
+ * @param {number} args.size Size of the drawer along the axis, in pixels.
104
+ * @param {number} args.velocity Speed of the drag towards the edge, in pixels per millisecond.
105
+ * @returns {boolean} Result.
106
+ */
107
+ export const shouldDismissSwipe = ({ offset, size, velocity }) =>
108
+ offset > 0 &&
109
+ (offset >= size * SWIPE_DISMISS_DISTANCE_RATIO ||
110
+ (offset >= SWIPE_FLICK_MIN_DISTANCE && velocity >= SWIPE_DISMISS_VELOCITY));
@@ -6,14 +6,23 @@
6
6
  -->
7
7
  <script>
8
8
  import { _ } from '@sveltia/i18n';
9
+ import { untrack } from 'svelte';
9
10
  import Button from '../button/button.svelte';
10
11
  import Spacer from '../divider/spacer.svelte';
11
12
  import Icon from '../icon/icon.svelte';
12
13
  import Modal from '../util/modal.svelte';
14
+ import {
15
+ addSwipeSample,
16
+ getSwipeDirection,
17
+ getSwipeOffset,
18
+ getSwipeVelocity,
19
+ shouldDismissSwipe,
20
+ } from './drawer.js';
13
21
 
14
22
  /**
15
23
  * @import { Snippet } from 'svelte';
16
24
  * @import { ModalProps } from '../../typedefs';
25
+ * @import { SwipeState } from './drawer.js';
17
26
  */
18
27
 
19
28
  /**
@@ -29,6 +38,11 @@
29
38
  * @property {'small' | 'medium' | 'large' | 'x-large' | 'full'} [size] Width or height of the
30
39
  * drawer.
31
40
  * @property {'inside' | 'outside' | false} [showClose] Whether to show the Close button.
41
+ * @property {boolean} [swipeDismiss] Whether the drawer can be dismissed by dragging it towards
42
+ * the edge it’s attached to, as a bottom sheet on a mobile device is. The drag starts from the
43
+ * header, or from a grab handle shown on a top or bottom drawer; the main content and the footer
44
+ * are left alone, so they can be scrolled and interacted with. The Close button and the Escape
45
+ * key still work, as the gesture isn’t available to everyone.
32
46
  * @property {Snippet} [children] Primary slot content.
33
47
  * @property {Snippet} [header] Header slot content.
34
48
  * @property {Snippet} [headerExtra] Header extra slot content.
@@ -49,6 +63,7 @@
49
63
  position = 'right',
50
64
  size = 'small',
51
65
  showClose = 'outside',
66
+ swipeDismiss = false,
52
67
  children,
53
68
  header,
54
69
  headerExtra,
@@ -58,6 +73,23 @@
58
73
  /* eslint-enable prefer-const */
59
74
  } = $props();
60
75
 
76
+ /**
77
+ * Elements a drag can’t start from: the parts of the drawer that can scroll or hold text to
78
+ * select, and anything interactive.
79
+ */
80
+ const EXCLUDED_SWIPE_TARGETS = [
81
+ '.main',
82
+ '.footer',
83
+ '.extra-control',
84
+ 'a',
85
+ 'button',
86
+ 'input',
87
+ 'select',
88
+ 'textarea',
89
+ '[contenteditable]',
90
+ '[tabindex]',
91
+ ].join(', ');
92
+
61
93
  /**
62
94
  * The ID of the drawer.
63
95
  * @type {string}
@@ -74,6 +106,109 @@
74
106
  position === 'right' || position === 'left' ? 'vertical' : 'horizontal',
75
107
  );
76
108
 
109
+ /**
110
+ * A reference to the content element.
111
+ * @type {HTMLElement | undefined}
112
+ */
113
+ let content = $state();
114
+ /**
115
+ * The drag in progress, if any. The object is replaced rather than updated, except for the
116
+ * samples, which only feed the speed calculation on release, so nothing needs to track them.
117
+ * @type {SwipeState | undefined}
118
+ */
119
+ let swipe = $state.raw();
120
+ /**
121
+ * Distance the drawer has been dragged towards its edge, in pixels.
122
+ * @type {number}
123
+ */
124
+ let swipeOffset = $state(0);
125
+
126
+ /**
127
+ * Handle the `pointerdown` event on the content, starting a drag from anywhere but the main
128
+ * content, the footer and the controls.
129
+ * @param {PointerEvent} event `pointerdown` event.
130
+ */
131
+ const onPointerDown = (event) => {
132
+ const { button, isPrimary, pointerId, target, clientX, clientY } = event;
133
+
134
+ if (!swipeDismiss || !content || swipe || button !== 0 || !isPrimary) {
135
+ return;
136
+ }
137
+
138
+ // The dialog around the content is focusable too, so only look inside the content
139
+ const excluded = /** @type {Element} */ (target).closest(EXCLUDED_SWIPE_TARGETS);
140
+
141
+ if (excluded && content.contains(excluded)) {
142
+ return;
143
+ }
144
+
145
+ const { axis, sign } = getSwipeDirection(position, content.matches(':dir(rtl)'));
146
+
147
+ swipe = { pointerId, axis, sign, start: axis === 'Y' ? clientY : clientX, samples: [] };
148
+ swipeOffset = 0;
149
+ content.setPointerCapture(pointerId);
150
+ };
151
+
152
+ /**
153
+ * Handle the `pointermove` event on the content while dragging.
154
+ * @param {PointerEvent} event `pointermove` event.
155
+ */
156
+ const onPointerMove = (event) => {
157
+ const { pointerId, clientX, clientY, timeStamp } = event;
158
+
159
+ if (!swipe || pointerId !== swipe.pointerId) {
160
+ return;
161
+ }
162
+
163
+ swipeOffset = getSwipeOffset(
164
+ (swipe.axis === 'Y' ? clientY : clientX) - swipe.start,
165
+ swipe.sign,
166
+ );
167
+ swipe.samples = addSwipeSample(swipe.samples, { offset: swipeOffset, time: timeStamp });
168
+ };
169
+
170
+ /**
171
+ * Handle the `pointerup` and `pointercancel` events on the content, ending the drag. The drawer
172
+ * is dismissed if it has been dragged far enough or flicked, or goes back into place otherwise.
173
+ * Either way, the transition picks up from where the drag has left the drawer.
174
+ * @param {PointerEvent} event `pointerup` or `pointercancel` event.
175
+ */
176
+ const onPointerUp = (event) => {
177
+ if (!swipe || !content || event.pointerId !== swipe.pointerId) {
178
+ return;
179
+ }
180
+
181
+ const { width, height } = content.getBoundingClientRect();
182
+ // The pointer may have been held still since it last moved, which ends any flick
183
+ const samples = addSwipeSample(swipe.samples, { offset: swipeOffset, time: event.timeStamp });
184
+
185
+ const dismiss =
186
+ event.type === 'pointerup' &&
187
+ shouldDismissSwipe({
188
+ offset: swipeOffset,
189
+ size: swipe.axis === 'Y' ? height : width,
190
+ velocity: getSwipeVelocity(samples),
191
+ });
192
+
193
+ swipe = undefined;
194
+ swipeOffset = 0;
195
+
196
+ if (dismiss) {
197
+ modal?.close('close');
198
+ }
199
+ };
200
+
201
+ $effect(() => {
202
+ if (!open) {
203
+ // The drawer can be closed in the middle of a drag, e.g. with the Escape key, and should
204
+ // then slide away rather than stay where the drag has left it
205
+ untrack(() => {
206
+ swipe = undefined;
207
+ swipeOffset = 0;
208
+ });
209
+ }
210
+ });
211
+
77
212
  /**
78
213
  * Accessible name for the `<dialog>`, resolved the same way as `<Dialog>`: the built-in header
79
214
  * renders the title in an element the dialog can point at; with a custom header the title is
@@ -93,7 +228,27 @@
93
228
  aria-labelledby={labelledby}
94
229
  showBackdrop
95
230
  >
96
- <div role="none" class={['content', className, size, position, orientation]}>
231
+ <div
232
+ bind:this={content}
233
+ role="none"
234
+ class={[
235
+ 'content',
236
+ className,
237
+ size,
238
+ position,
239
+ orientation,
240
+ { 'swipe-dismiss': swipeDismiss, swiping: !!swipe },
241
+ ]}
242
+ style:transform={swipe ? `translate${swipe.axis}(${swipe.sign * swipeOffset}px)` : undefined}
243
+ style:transition-duration={swipe ? '0s' : undefined}
244
+ onpointerdown={onPointerDown}
245
+ onpointermove={onPointerMove}
246
+ onpointerup={onPointerUp}
247
+ onpointercancel={onPointerUp}
248
+ >
249
+ {#if swipeDismiss && position === 'bottom'}
250
+ <div role="none" class="handle"></div>
251
+ {/if}
97
252
  <div role="none" class="extra-control">
98
253
  {#if showClose === 'outside'}
99
254
  <Button
@@ -157,6 +312,9 @@
157
312
  {@render footer?.()}
158
313
  </div>
159
314
  {/if}
315
+ {#if swipeDismiss && position === 'top'}
316
+ <div role="none" class="handle"></div>
317
+ {/if}
160
318
  </div>
161
319
  </Modal>
162
320
 
@@ -323,6 +481,30 @@
323
481
  max-height: 100dvh;
324
482
  }
325
483
 
484
+ .content.swipe-dismiss > :global(:not(.main, .footer, .extra-control)) {
485
+ touch-action: none;
486
+ user-select: none;
487
+ }
488
+ .content.swipe-dismiss.swiping, .content.swipe-dismiss.swiping .handle {
489
+ cursor: grabbing;
490
+ }
491
+
492
+ .handle {
493
+ flex: none;
494
+ display: flex;
495
+ justify-content: center;
496
+ padding: 8px 0;
497
+ cursor: grab;
498
+ }
499
+ .handle::before {
500
+ content: "";
501
+ border-radius: 2px;
502
+ width: 32px;
503
+ height: 4px;
504
+ background-color: var(--sui-drawer-handle-color, var(--sui-secondary-foreground-color));
505
+ opacity: 0.5;
506
+ }
507
+
326
508
  :is(.header, .footer) {
327
509
  display: flex;
328
510
  align-items: center;
@@ -41,6 +41,14 @@ declare const Drawer: import("svelte").Component<ModalProps & {
41
41
  * Whether to show the Close button.
42
42
  */
43
43
  showClose?: false | "inside" | "outside" | undefined;
44
+ /**
45
+ * Whether the drawer can be dismissed by dragging it towards
46
+ * the edge it’s attached to, as a bottom sheet on a mobile device is. The drag starts from the
47
+ * header, or from a grab handle shown on a top or bottom drawer; the main content and the footer
48
+ * are left alone, so they can be scrolled and interacted with. The Close button and the Escape
49
+ * key still work, as the gesture isn’t available to everyone.
50
+ */
51
+ swipeDismiss?: boolean | undefined;
44
52
  /**
45
53
  * Primary slot content.
46
54
  */
@@ -99,6 +107,14 @@ type Props = {
99
107
  * Whether to show the Close button.
100
108
  */
101
109
  showClose?: false | "inside" | "outside" | undefined;
110
+ /**
111
+ * Whether the drawer can be dismissed by dragging it towards
112
+ * the edge it’s attached to, as a bottom sheet on a mobile device is. The drag starts from the
113
+ * header, or from a grab handle shown on a top or bottom drawer; the main content and the footer
114
+ * are left alone, so they can be scrolled and interacted with. The Close button and the Escape
115
+ * key still work, as the gesture isn’t available to everyone.
116
+ */
117
+ swipeDismiss?: boolean | undefined;
102
118
  /**
103
119
  * Primary slot content.
104
120
  */
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
3
3
  */
4
- export const UI_VERSION: "0.76.1";
4
+ export const UI_VERSION: "0.77.1";
@@ -3,4 +3,4 @@
3
3
  /**
4
4
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
5
5
  */
6
- export const UI_VERSION = '0.76.1';
6
+ export const UI_VERSION = '0.77.1';
@@ -129,7 +129,8 @@ export const getFontFaceDescriptors = ({ weight, display, unicodeRange, sizeAdju
129
129
  * Call it before the `AppShell` component is mounted, so no font is fetched from the CDN first.
130
130
  * @param {Record<string, string>} urls URLs keyed with the Fontsource file name, e.g.
131
131
  * `source-sans-3-latin-wght-normal.woff2`. Any font omitted, or whose file fails to load, is still
132
- * loaded from the CDN; a failure is reported in the console.
132
+ * loaded from the CDN, unless the app has removed the `@font-face` rules for it; a failure is
133
+ * reported in the console.
133
134
  */
134
135
  export const setFontURLs = (urls) => {
135
136
  if (typeof document === 'undefined' || !document.fonts) {
@@ -153,13 +154,13 @@ export const setFontURLs = (urls) => {
153
154
 
154
155
  document.fonts.add(fontFace);
155
156
 
156
- // The browser falls back to the `@font-face` rule for the CDN if the file can’t be loaded,
157
- // which goes unnoticed otherwise. `loaded` settles once the font is used, without forcing a
158
- // download
157
+ // The browser falls back to the `@font-face` rule for the CDN if the file can’t be loaded, or
158
+ // to another font if the app has removed the rule, which goes unnoticed otherwise. `loaded`
159
+ // settles once the font is used, without forcing a download
159
160
  fontFace.loaded.catch(() => {
160
161
  // eslint-disable-next-line no-console
161
162
  console.warn(
162
- `Failed to load the ${font.family} font from ${url}. It’s loaded from the CDN instead.`,
163
+ `Failed to load the ${font.family} font from ${url}. A fallback font is used instead.`,
163
164
  );
164
165
  });
165
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.76.1",
3
+ "version": "0.77.1",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -68,10 +68,10 @@
68
68
  "@sveltejs/adapter-auto": "^7.0.1",
69
69
  "@sveltejs/kit": "^2.70.3",
70
70
  "@sveltejs/package": "^2.5.8",
71
- "@sveltejs/vite-plugin-svelte": "^7.3.0",
72
- "@vitest/browser-playwright": "^5.0.1",
73
- "@vitest/coverage-v8": "^5.0.1",
74
- "cspell": "^10.3.3",
71
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
72
+ "@vitest/browser-playwright": "^5.0.2",
73
+ "@vitest/coverage-v8": "^5.0.2",
74
+ "cspell": "^10.3.4",
75
75
  "eslint": "^9.39.5",
76
76
  "eslint-config-airbnb-extended": "^3.2.0",
77
77
  "eslint-config-prettier": "^10.1.8",
@@ -85,9 +85,9 @@
85
85
  "playwright": "^1.63.0",
86
86
  "postcss": "^8.5.28",
87
87
  "postcss-html": "^2.0.0",
88
- "prettier": "^3.9.8",
88
+ "prettier": "^3.9.9",
89
89
  "prettier-plugin-svelte": "^4.1.1",
90
- "rolldown": "^1.2.9",
90
+ "rolldown": "^1.2.11",
91
91
  "sass": "^1.105.0",
92
92
  "shiki": "^4.4.3",
93
93
  "stylelint": "^17.15.0",
@@ -97,8 +97,8 @@
97
97
  "svelte-check": "^4.7.6",
98
98
  "svelte-preprocess": "^6.0.5",
99
99
  "tslib": "^2.8.1",
100
- "vite": "^8.3.0",
101
- "vitest": "^5.0.1",
100
+ "vite": "^8.3.1",
101
+ "vitest": "^5.0.2",
102
102
  "vitest-browser-svelte": "^3.1.0"
103
103
  },
104
104
  "peerDependencies": {