@winkintel/bootstrap-svelte 1.0.2 → 1.0.4

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
@@ -54,7 +54,7 @@ yarn add @winkintel/bootstrap-svelte bootstrap
54
54
  This package requires Svelte 5:
55
55
 
56
56
  ```bash
57
- pnpm add svelte@^5.0.0
57
+ pnpm add svelte@^5.29.0
58
58
  ```
59
59
 
60
60
  ## Bootstrap CSS
@@ -174,7 +174,7 @@ Interactive components are implemented as Svelte components rather than requirin
174
174
 
175
175
  ## Status and feedback
176
176
 
177
- This package is published as `1.0.1`, but feedback from Svelte developers is still very welcome. Useful feedback includes:
177
+ This package is published as `1.0.4`, but feedback from Svelte developers is still very welcome. Useful feedback includes:
178
178
 
179
179
  - Component API ergonomics
180
180
  - Svelte 5 idioms and runes compatibility
@@ -62,6 +62,12 @@ let classes = $derived(uniqueClsx('carousel-item', {
62
62
  let styles = $derived(`transition: transform ${itemState.root.transitionDuration / 1000}s ease-in-out !important;` + (style ? style : ''));
63
63
  </script>
64
64
 
65
+ <!--
66
+ The slide and crossfade branches use in: (never transition:) — their exit visuals are
67
+ driven by the carousel-item-start/-end CSS classes during the transition window, and
68
+ Svelte freezes class bindings inside an outroing block, so an outro would hold the
69
+ outgoing item in the DOM with a stale 'active' class after the commit.
70
+ -->
65
71
  {#if itemState.doSlideItem}
66
72
  <div
67
73
  bind:this={elementRef}
@@ -70,7 +76,7 @@ let styles = $derived(`transition: transform ${itemState.root.transitionDuration
70
76
  onintrostart={onSlide}
71
77
  onintroend={onSlid}
72
78
  style={styles}
73
- transition:fly={{
79
+ in:fly={{
74
80
  duration: itemState.root.transitionDuration,
75
81
  x: flyX,
76
82
  y: undefined,
@@ -109,7 +115,7 @@ let styles = $derived(`transition: transform ${itemState.root.transitionDuration
109
115
  {id}
110
116
  onintrostart={onSlide}
111
117
  onintroend={onSlid}
112
- transition:fly={{
118
+ in:fly={{
113
119
  duration: itemState.root.transitionDuration,
114
120
  x: '0%',
115
121
  y: undefined,
@@ -19,6 +19,8 @@ export class CarouselRootState {
19
19
  #indicators = [];
20
20
  #interval = $state(0);
21
21
  #timeoutId = $state(null);
22
+ #pendingTimeoutIds = [];
23
+ #isDisposed = false;
22
24
  #isAnimating = $state(false);
23
25
  #isHovered = $state(false);
24
26
  #isPaused = $state(true);
@@ -87,6 +89,16 @@ export class CarouselRootState {
87
89
  shouldCycle() {
88
90
  return this.#ride === 'carousel' || (this.#ride === true && this.#hasUserInteraction);
89
91
  }
92
+ // Schedules a setTimeout whose id is tracked so dispose() can cancel it if the
93
+ // instance is torn down before the callback fires. The id de-registers itself
94
+ // once the callback runs, keeping #pendingTimeoutIds from growing unbounded.
95
+ #schedule(callback, delay) {
96
+ const timeoutId = window.setTimeout(() => {
97
+ this.#pendingTimeoutIds = this.#pendingTimeoutIds.filter((id) => id !== timeoutId);
98
+ callback();
99
+ }, delay);
100
+ this.#pendingTimeoutIds.push(timeoutId);
101
+ }
90
102
  get animation() {
91
103
  return this.#animation;
92
104
  }
@@ -226,7 +238,7 @@ export class CarouselRootState {
226
238
  toItem.direction = 'end';
227
239
  toItem.order = 'prev';
228
240
  }
229
- setTimeout(() => {
241
+ this.#schedule(() => {
230
242
  fromItem.order = undefined;
231
243
  fromItem.direction = undefined;
232
244
  toItem.order = undefined;
@@ -248,7 +260,7 @@ export class CarouselRootState {
248
260
  if (isPrev) {
249
261
  toItem.order = 'prev';
250
262
  }
251
- setTimeout(() => {
263
+ this.#schedule(() => {
252
264
  toItem.order = undefined;
253
265
  this.#isAnimating = false;
254
266
  }, this.#transitionDuration / 2);
@@ -280,6 +292,9 @@ export class CarouselRootState {
280
292
  if (typeof window === 'undefined') {
281
293
  return;
282
294
  }
295
+ if (this.#isDisposed) {
296
+ return;
297
+ }
283
298
  // Don't start cycling if we're hovered and pause='hover'
284
299
  if (this.#pauseInteraction === 'hover' && this.#isHovered) {
285
300
  return;
@@ -367,16 +382,21 @@ export class CarouselRootState {
367
382
  this.#isPointerDown = false;
368
383
  // Resume cycling after touch with a delay to prevent immediate transition
369
384
  if (this.shouldCycle()) {
370
- setTimeout(() => {
385
+ this.#schedule(() => {
371
386
  this.cycle();
372
387
  }, 1000);
373
388
  }
374
389
  }
375
390
  dispose() {
391
+ this.#isDisposed = true;
376
392
  if (this.#timeoutId) {
377
393
  window.clearTimeout(this.#timeoutId);
378
394
  this.#timeoutId = null;
379
395
  }
396
+ for (const id of this.#pendingTimeoutIds) {
397
+ window.clearTimeout(id);
398
+ }
399
+ this.#pendingTimeoutIds = [];
380
400
  // Remove visibility change listener
381
401
  if (typeof document !== 'undefined' && this.#visibilityListener) {
382
402
  document.removeEventListener('visibilitychange', this.#visibilityListener);
@@ -210,10 +210,13 @@ export class DropdownMenuState {
210
210
  if (!this.isShown) {
211
211
  return;
212
212
  }
213
+ if (!this.root.props.id) {
214
+ return;
215
+ }
213
216
  const target = event.target;
214
217
  // Check if the click occurred within this specific dropdown instance.
215
218
  // This prevents closing if a click happens inside the dropdown menu or on its toggle.
216
- const nearestDropdown = target.closest(`#${this.root.props.id}`);
219
+ const nearestDropdown = target.closest(`#${CSS.escape(this.root.props.id)}`);
217
220
  if (nearestDropdown && nearestDropdown.id === this.root.props.id) {
218
221
  // The click is within the current dropdown, so do nothing.
219
222
  return;
@@ -47,9 +47,9 @@ Add dialogs to your site for lightboxes, user notifications, or completely custo
47
47
  - `useBackdrop` (boolean | 'static'): Optional. Controls whether the modal has a backdrop and if it's static (non-dismissible).
48
48
  - `useFade` (boolean): Optional. Controls whether the modal uses a fade transition.
49
49
  -->
50
- <script lang="ts">import { disableBodyScrolling, noop, resetBodyScrolling, uniqueClsx } from '../common/index.js';
50
+ <script lang="ts">import { acquireBodyScrollLock, noop, releaseBodyScrollLock, uniqueClsx } from '../common/index.js';
51
51
  import { Portal } from '../index.js';
52
- import { onMount, tick } from 'svelte';
52
+ import { onDestroy, onMount, tick } from 'svelte';
53
53
  import { fade, fly } from 'svelte/transition';
54
54
  import { initModalRootState, ModalRootState } from './modal.svelte.js';
55
55
  // Generate a unique ID for the modal element, in case one is not provided...
@@ -57,6 +57,7 @@ const uid = $props.id();
57
57
  let { children, class: classValues, elementRef = $bindable(null), id = `modal-${uid}`, isShown = false, isKeyboardDismissible = true, isFocusable = true, onHide = noop, onHidePrevented = noop, onHidden = noop, onShow = noop, onShown = noop, useBackdrop = true, useFade = true, ...restOfProps } = $props();
58
58
  // Initialize the root state of the modal component...
59
59
  let bodyElement = $state(null);
60
+ let holdsScrollLock = false;
60
61
  let previouslyFocusedElement = null;
61
62
  const unset = Symbol('unset');
62
63
  let previousIsShown = unset;
@@ -115,15 +116,21 @@ $effect(() => {
115
116
  });
116
117
  const handleOnShow = (event) => {
117
118
  if (bodyElement) {
118
- disableBodyScrolling(bodyElement);
119
+ if (!holdsScrollLock) {
120
+ acquireBodyScrollLock(bodyElement);
121
+ holdsScrollLock = true;
122
+ }
119
123
  bodyElement.classList.add('modal-open');
120
124
  }
121
125
  onShow(event);
122
126
  };
123
127
  const handleOnHidden = (event) => {
124
- if (bodyElement) {
125
- resetBodyScrolling(bodyElement);
126
- bodyElement.classList.remove('modal-open');
128
+ if (bodyElement && holdsScrollLock) {
129
+ const remainingLocks = releaseBodyScrollLock(bodyElement);
130
+ holdsScrollLock = false;
131
+ if (remainingLocks === 0) {
132
+ bodyElement.classList.remove('modal-open');
133
+ }
127
134
  }
128
135
  onHidden(event);
129
136
  };
@@ -166,6 +173,17 @@ onMount(() => {
166
173
  bodyElement = document.querySelector('body');
167
174
  }
168
175
  });
176
+ // Release a held scroll lock if the modal is destroyed while shown, so a
177
+ // removed-while-open modal doesn't permanently disable body scrolling...
178
+ onDestroy(() => {
179
+ if (holdsScrollLock && bodyElement) {
180
+ const remainingLocks = releaseBodyScrollLock(bodyElement);
181
+ holdsScrollLock = false;
182
+ if (remainingLocks === 0) {
183
+ bodyElement.classList.remove('modal-open');
184
+ }
185
+ }
186
+ });
169
187
  // Dismiss the modal when the backdrop is clicked...
170
188
  const handleBackdropMouseDown = (event) => {
171
189
  const target = event.target;
@@ -58,14 +58,15 @@ Build hidden sidebars into your project for navigation, shopping carts, and more
58
58
  -->
59
59
  <script lang="ts">import { noop, uniqueClsx } from '../common/index.js';
60
60
  import { initOffcanvasRootState, OffcanvasRootState } from '../common/navbar-offcanvas.svelte.js';
61
- import { disableBodyScrolling, resetBodyScrolling } from '../common/scrollbar.js';
62
- import { onMount, tick } from 'svelte';
61
+ import { acquireBodyScrollLock, releaseBodyScrollLock } from '../common/scrollbar.js';
62
+ import { onDestroy, onMount, tick } from 'svelte';
63
63
  import { fade, fly } from 'svelte/transition';
64
64
  // Generate a unique ID for the offcanvas element, in case one is not provided...
65
65
  const uid = $props.id();
66
66
  let { children, class: classValues, dir = 'ltr', elementRef = $bindable(null), id = `offcanvas-${uid}`, isBodyScrollable = false, isKeyboardDismissible = true, isShown = false, placement = 'start', showOnBreakpoint, useBackdrop = true, onHide = noop, onHidePrevented = noop, onHidden = noop, onShow = noop, onShown = noop, ...restOfProps } = $props();
67
67
  // Initialize the root state of the offcanvas component...
68
68
  let bodyElement = $state(null);
69
+ let holdsScrollLock = false;
69
70
  const unset = Symbol('unset');
70
71
  let previousIsShown = unset;
71
72
  let previousShowOnBreakpoint = unset;
@@ -151,18 +152,29 @@ onMount(() => {
151
152
  });
152
153
  // Listen to the transition events to properly manage the body scrollbar...
153
154
  const handleOnShow = (event) => {
154
- if (bodyElement && !isBodyScrollable && !rootState.isMediaQueryMatched) {
155
- disableBodyScrolling(bodyElement);
155
+ if (bodyElement && !isBodyScrollable && !rootState.isMediaQueryMatched && !holdsScrollLock) {
156
+ acquireBodyScrollLock(bodyElement);
157
+ holdsScrollLock = true;
156
158
  }
157
159
  onShow(event);
158
160
  };
159
161
  // Listen to the transition events to properly manage the body scrollbar...
160
162
  const handleOnHidden = (event) => {
161
- if (bodyElement && !isBodyScrollable && !rootState.isMediaQueryMatched) {
162
- resetBodyScrolling(bodyElement);
163
+ if (holdsScrollLock && bodyElement) {
164
+ releaseBodyScrollLock(bodyElement);
165
+ holdsScrollLock = false;
163
166
  }
164
167
  onHidden(event);
165
168
  };
169
+ // Release a held scroll lock if the offcanvas is destroyed while shown,
170
+ // so a removed-while-open offcanvas doesn't permanently disable body
171
+ // scrolling...
172
+ onDestroy(() => {
173
+ if (holdsScrollLock && bodyElement) {
174
+ releaseBodyScrollLock(bodyElement);
175
+ holdsScrollLock = false;
176
+ }
177
+ });
166
178
  // Dismiss the offcanvas when the backdrop is clicked...
167
179
  const handleBackdropMouseDown = (event) => {
168
180
  const target = event.target;
@@ -138,7 +138,7 @@ onMount(() => {
138
138
  }
139
139
  // accommodate the id containing a hash or not...
140
140
  const elementId = referenceElementId.startsWith('#') ? referenceElementId.substring(1) : referenceElementId;
141
- referenceElement = document.querySelector(`#${elementId}`);
141
+ referenceElement = document.getElementById(elementId);
142
142
  addEventListeners();
143
143
  return () => {
144
144
  destroyPopper();
@@ -1,5 +1,5 @@
1
1
  import { type EasingFunction } from 'bezier-easing';
2
- import clsx from 'clsx';
2
+ import type clsx from 'clsx';
3
3
  /**
4
4
  * This easing function will match the CSS `ease-in-out` easing function using a cubic Bezier curve defined by the points (0.25, 0.1, 0.25, 1).
5
5
  * @see https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function#ease
@@ -1,5 +1,50 @@
1
1
  import BezierEasing, {} from 'bezier-easing';
2
- import clsx from 'clsx';
2
+ const whitespacePattern = /\s/;
3
+ const classTokenPattern = /\S+/g;
4
+ function appendClassTokens(result, value) {
5
+ const classValue = typeof value === 'string' ? value : String(value);
6
+ if (!classValue) {
7
+ return;
8
+ }
9
+ if (!whitespacePattern.test(classValue)) {
10
+ if (result.indexOf(classValue) === -1) {
11
+ result.push(classValue);
12
+ }
13
+ return;
14
+ }
15
+ const tokens = classValue.match(classTokenPattern);
16
+ if (!tokens) {
17
+ return;
18
+ }
19
+ for (let i = 0; i < tokens.length; i++) {
20
+ const token = tokens[i];
21
+ if (result.indexOf(token) === -1) {
22
+ result.push(token);
23
+ }
24
+ }
25
+ }
26
+ function appendClassValue(result, value) {
27
+ if (typeof value === 'string' || typeof value === 'number') {
28
+ appendClassTokens(result, value);
29
+ return;
30
+ }
31
+ if (!value || typeof value !== 'object') {
32
+ return;
33
+ }
34
+ if (Array.isArray(value)) {
35
+ for (let i = 0; i < value.length; i++) {
36
+ if (value[i]) {
37
+ appendClassValue(result, value[i]);
38
+ }
39
+ }
40
+ return;
41
+ }
42
+ for (const key in value) {
43
+ if (value[key]) {
44
+ appendClassTokens(result, key);
45
+ }
46
+ }
47
+ }
3
48
  /**
4
49
  * This easing function will match the CSS `ease-in-out` easing function using a cubic Bezier curve defined by the points (0.25, 0.1, 0.25, 1).
5
50
  * @see https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function#ease
@@ -42,20 +87,17 @@ export const cubicBezier = (x1, y1, x2, y2) => {
42
87
  * @returns A string containing unique class names.
43
88
  */
44
89
  export function uniqueClsx(...args) {
45
- const classString = clsx(...args);
46
- // Early return for empty strings
47
- if (!classString) {
48
- return '';
49
- }
50
- // Using a regex split will normalize whitespace and split simultaneously...
51
- const classes = classString.split(/\s+/);
52
- const seen = new Set();
53
90
  const result = [];
54
- for (let i = 0; i < classes.length; i++) {
55
- const cls = classes[i];
56
- if (cls && !seen.has(cls)) {
57
- seen.add(cls);
58
- result.push(cls);
91
+ for (let i = 0; i < args.length; i++) {
92
+ const value = args[i];
93
+ if (!value) {
94
+ continue;
95
+ }
96
+ if (typeof value === 'string' || typeof value === 'number') {
97
+ appendClassTokens(result, value);
98
+ }
99
+ else {
100
+ appendClassValue(result, value);
59
101
  }
60
102
  }
61
103
  return result.join(' ');
@@ -8,6 +8,9 @@ export declare const getScrollbarWidth: () => number;
8
8
  * Disables body scrolling by setting overflow to hidden and
9
9
  * adjusting padding to prevent layout shift from scrollbar removal
10
10
  *
11
+ * @remarks Prefer {@link acquireBodyScrollLock} in library code — calling
12
+ * this directly is not safe when multiple overlays may lock scrolling at
13
+ * once, since a second call overwrites the saved "original" values.
11
14
  * @param {HTMLElement} bodyElement - The body element to disable scrolling on
12
15
  */
13
16
  export declare const disableBodyScrolling: (bodyElement: HTMLElement) => void;
@@ -15,6 +18,25 @@ export declare const disableBodyScrolling: (bodyElement: HTMLElement) => void;
15
18
  * Restores body scrolling by resetting overflow and padding-right to their
16
19
  * original values before scrolling was disabled
17
20
  *
21
+ * @remarks Prefer {@link releaseBodyScrollLock} in library code — pairing
22
+ * this directly with {@link disableBodyScrolling} corrupts scroll state
23
+ * when more than one overlay locks scrolling at the same time.
18
24
  * @param {HTMLElement} bodyElement - The body element to restore scrolling on
19
25
  */
20
26
  export declare const resetBodyScrolling: (bodyElement: HTMLElement) => void;
27
+ /**
28
+ * Acquires a body scroll lock. The first acquisition disables scrolling;
29
+ * nested acquisitions only increment the count.
30
+ *
31
+ * @param {HTMLElement} bodyElement - The body element to lock scrolling on
32
+ * @returns {number} the lock count after acquiring
33
+ */
34
+ export declare const acquireBodyScrollLock: (bodyElement: HTMLElement) => number;
35
+ /**
36
+ * Releases a body scroll lock. Scrolling is restored only when the last
37
+ * holder releases. Releasing with no lock held is a no-op.
38
+ *
39
+ * @param {HTMLElement} bodyElement - The body element to unlock scrolling on
40
+ * @returns {number} the lock count after releasing
41
+ */
42
+ export declare const releaseBodyScrollLock: (bodyElement: HTMLElement) => number;
@@ -1,6 +1,7 @@
1
1
  import { hasAttribute, isRTL, setAttribute } from './dom.js';
2
2
  const SCROLLBAR_INITIAL_OVERFLOW = 'data-scrollbar-initial-overflow-value';
3
3
  const SCROLLBAR_INITIAL_PADDING = 'data-scrollbar-initial-padding-value';
4
+ const SCROLLBAR_LOCK_COUNT = 'data-scrollbar-lock-count';
4
5
  /**
5
6
  * Calculates the width of the browser's scrollbar
6
7
  *
@@ -14,6 +15,9 @@ export const getScrollbarWidth = () => {
14
15
  * Disables body scrolling by setting overflow to hidden and
15
16
  * adjusting padding to prevent layout shift from scrollbar removal
16
17
  *
18
+ * @remarks Prefer {@link acquireBodyScrollLock} in library code — calling
19
+ * this directly is not safe when multiple overlays may lock scrolling at
20
+ * once, since a second call overwrites the saved "original" values.
17
21
  * @param {HTMLElement} bodyElement - The body element to disable scrolling on
18
22
  */
19
23
  export const disableBodyScrolling = (bodyElement) => {
@@ -33,6 +37,9 @@ export const disableBodyScrolling = (bodyElement) => {
33
37
  * Restores body scrolling by resetting overflow and padding-right to their
34
38
  * original values before scrolling was disabled
35
39
  *
40
+ * @remarks Prefer {@link releaseBodyScrollLock} in library code — pairing
41
+ * this directly with {@link disableBodyScrolling} corrupts scroll state
42
+ * when more than one overlay locks scrolling at the same time.
36
43
  * @param {HTMLElement} bodyElement - The body element to restore scrolling on
37
44
  */
38
45
  export const resetBodyScrolling = (bodyElement) => {
@@ -51,3 +58,40 @@ export const resetBodyScrolling = (bodyElement) => {
51
58
  bodyElement.removeAttribute(SCROLLBAR_INITIAL_OVERFLOW);
52
59
  bodyElement.removeAttribute(SCROLLBAR_INITIAL_PADDING);
53
60
  };
61
+ /**
62
+ * Acquires a body scroll lock. The first acquisition disables scrolling;
63
+ * nested acquisitions only increment the count.
64
+ *
65
+ * @param {HTMLElement} bodyElement - The body element to lock scrolling on
66
+ * @returns {number} the lock count after acquiring
67
+ */
68
+ export const acquireBodyScrollLock = (bodyElement) => {
69
+ const count = parseInt(bodyElement.getAttribute(SCROLLBAR_LOCK_COUNT) ?? '0', 10) + 1;
70
+ bodyElement.setAttribute(SCROLLBAR_LOCK_COUNT, `${count}`);
71
+ if (count === 1) {
72
+ disableBodyScrolling(bodyElement);
73
+ }
74
+ return count;
75
+ };
76
+ /**
77
+ * Releases a body scroll lock. Scrolling is restored only when the last
78
+ * holder releases. Releasing with no lock held is a no-op.
79
+ *
80
+ * @param {HTMLElement} bodyElement - The body element to unlock scrolling on
81
+ * @returns {number} the lock count after releasing
82
+ */
83
+ export const releaseBodyScrollLock = (bodyElement) => {
84
+ const current = parseInt(bodyElement.getAttribute(SCROLLBAR_LOCK_COUNT) ?? '0', 10);
85
+ if (current <= 0) {
86
+ return 0;
87
+ }
88
+ const count = current - 1;
89
+ if (count === 0) {
90
+ bodyElement.removeAttribute(SCROLLBAR_LOCK_COUNT);
91
+ resetBodyScrolling(bodyElement);
92
+ }
93
+ else {
94
+ bodyElement.setAttribute(SCROLLBAR_LOCK_COUNT, `${count}`);
95
+ }
96
+ return count;
97
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "author": "Wink, Inc.",
3
3
  "name": "@winkintel/bootstrap-svelte",
4
- "version": "1.0.2",
4
+ "version": "1.0.4",
5
5
  "description": "Bootstrap components for Svelte 5 with TypeScript support.",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -54,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "peerDependencies": {
57
- "svelte": "^5.0.0"
57
+ "svelte": "^5.29.0"
58
58
  },
59
59
  "dependencies": {
60
60
  "@popperjs/core": "~2.11.8",