@hortonstudio/main 1.9.10 → 1.9.11

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.
@@ -14,12 +14,11 @@ Complete pagination system for paginated lists with controls, counters, dot navi
14
14
  - **Responsive Layouts**: Different items per page for desktop/mobile
15
15
  - **Page Controls**: Next/Previous buttons with infinite looping
16
16
  - **Page Counter**: Live-updating "X / Y" page indicator
17
- - **Dot Navigation**: Visual page indicators with click/keyboard support
17
+ - **Dot Navigation**: Visual page indicators with click/keyboard support (semantic buttons)
18
18
  - **Auto-height Management**: Smooth height transitions between pages
19
19
  - **Focus Management**: Uses `inert` attribute for inactive pages
20
20
  - **ARIA Live Announcements**: Screen reader announcements for page changes
21
21
  - **Mobile Auto-scroll**: Scrolls controls into view on mobile after navigation
22
- - **Infinite Mode**: Disable all pagination for continuous scrolling
23
22
 
24
23
  ---
25
24
 
@@ -45,21 +44,35 @@ Complete pagination system for paginated lists with controls, counters, dot navi
45
44
 
46
45
  ### **Next Button** *(required)*
47
46
 
48
- **Attribute:** `data-hs-pagination="next"`
47
+ **Attribute:** `data-hs-pagination="next"` on wrapper element
49
48
 
50
49
  **What it does:** Navigate to next page. Loops to first page after last page.
51
50
 
52
- **Accessibility:** Automatically receives `aria-label="Go to next page"`
51
+ **Structure:** Uses `data-site-clickable="element"` pattern:
52
+ ```html
53
+ <div data-hs-pagination="next" data-site-clickable="element">
54
+ <button type="button">Next</button>
55
+ </div>
56
+ ```
57
+
58
+ **Accessibility:** Automatically receives `aria-label="Go to next page"` on the button element
53
59
 
54
60
  ---
55
61
 
56
62
  ### **Previous Button** *(required)*
57
63
 
58
- **Attribute:** `data-hs-pagination="previous"`
64
+ **Attribute:** `data-hs-pagination="previous"` on wrapper element
59
65
 
60
66
  **What it does:** Navigate to previous page. Loops to last page from first page.
61
67
 
62
- **Accessibility:** Automatically receives `aria-label="Go to previous page"`
68
+ **Structure:** Uses `data-site-clickable="element"` pattern:
69
+ ```html
70
+ <div data-hs-pagination="previous" data-site-clickable="element">
71
+ <button type="button">Previous</button>
72
+ </div>
73
+ ```
74
+
75
+ **Accessibility:** Automatically receives `aria-label="Go to previous page"` on the button element
63
76
 
64
77
  ---
65
78
 
@@ -67,40 +80,33 @@ Complete pagination system for paginated lists with controls, counters, dot navi
67
80
 
68
81
  **Attribute:** `data-hs-pagination="controls"`
69
82
 
70
- **What it does:** Container for pagination controls AND configuration via `data-hs-config`.
71
-
72
- **Configuration Syntax:**
73
- ```
74
- data-hs-config="[option], [option], ..."
75
- ```
83
+ **What it does:** Container for pagination controls AND configuration.
76
84
 
77
- **Configuration Options:**
85
+ **Configuration Attributes:**
78
86
 
79
- **Show Items (Desktop):**
87
+ **`data-hs-pagination-show`** - Desktop items per page (default: 6)
80
88
  ```html
81
- data-hs-config="show-6"
89
+ data-hs-pagination-show="4"
82
90
  ```
83
- Shows 6 items per page on desktop (default: 6)
84
91
 
85
- **Show Items (Mobile):**
92
+ **`data-hs-pagination-show-mobile`** - Mobile items per page (optional, defaults to desktop value)
86
93
  ```html
87
- data-hs-config="show-3-mobile"
94
+ data-hs-pagination-show-mobile="2"
88
95
  ```
89
- Shows 3 items per page on mobile (default: desktop value)
90
96
 
91
- **Infinite Mode:**
97
+ **Example:**
92
98
  ```html
93
- data-hs-config="infinite"
94
- ```
95
- Disables pagination completely (hides controls and dots, shows all items)
96
-
97
- **Combined Example:**
98
- ```html
99
- <div data-hs-pagination="controls" data-hs-config="show-4, show-2-mobile">
99
+ <div data-hs-pagination="controls"
100
+ data-hs-pagination-show="4"
101
+ data-hs-pagination-show-mobile="2">
100
102
  <!-- Buttons, counter, etc. -->
101
103
  </div>
102
104
  ```
103
105
 
106
+ **No Pagination (Infinite Mode):**
107
+
108
+ To disable pagination and show all items, simply don't render the controls element at all. Without controls, the pagination system won't initialize and the list displays naturally.
109
+
104
110
  ---
105
111
 
106
112
  ### **Counter** *(optional)*
@@ -125,10 +131,11 @@ Disables pagination completely (hides controls and dots, shows all items)
125
131
  - At least one child element (used as template)
126
132
  - Use `.is-active` class to designate active template
127
133
  - If only one template, same used for active/inactive states
134
+ - **Dots are semantic `<button>` elements with `type="button"`**
128
135
 
129
136
  **Accessibility:** System automatically adds:
130
137
  - Individual dots via separate pagination dots system
131
- - `role="button"`, `tabindex="0"`, `aria-label` on each dot
138
+ - `role="button"`, `tabindex="0"`, `aria-label` on each dot button
132
139
  - `aria-current="page"` on active dot
133
140
 
134
141
  ---
@@ -154,23 +161,34 @@ Disables pagination completely (hides controls and dots, shows all items)
154
161
  </div>
155
162
 
156
163
  <!-- Controls -->
157
- <div data-hs-pagination="controls" data-hs-config="show-4, show-2-mobile">
158
- <button data-hs-pagination="previous">Previous</button>
164
+ <div data-hs-pagination="controls"
165
+ data-hs-pagination-show="4"
166
+ data-hs-pagination-show-mobile="2">
167
+
168
+ <div data-hs-pagination="previous" data-site-clickable="element">
169
+ <button type="button">Previous</button>
170
+ </div>
171
+
159
172
  <div data-hs-pagination="counter">1 / 2</div>
160
- <button data-hs-pagination="next">Next</button>
173
+
174
+ <div data-hs-pagination="next" data-site-clickable="element">
175
+ <button type="button">Next</button>
176
+ </div>
161
177
  </div>
162
178
 
163
179
  <!-- Dots -->
164
180
  <div data-hs-pagination="dots">
165
- <div class="dot is-active"></div> <!-- Active template -->
166
- <div class="dot"></div> <!-- Inactive template -->
181
+ <button type="button" class="dot is-active"></button> <!-- Active template -->
182
+ <button type="button" class="dot"></button> <!-- Inactive template -->
167
183
  </div>
168
184
  </div>
169
185
  ```
170
186
 
171
187
  ---
172
188
 
173
- ### **Infinite Mode (No Pagination)**
189
+ ### **No Pagination (Infinite Mode)**
190
+
191
+ To disable pagination and show all items without pagination controls, simply don't render the controls element:
174
192
 
175
193
  ```html
176
194
  <div data-hs-pagination="wrapper">
@@ -180,14 +198,12 @@ Disables pagination completely (hides controls and dots, shows all items)
180
198
  <div>Item 1</div>
181
199
  <div>Item 2</div>
182
200
  <div>Item 3</div>
201
+ <div>Item 4</div>
202
+ <div>Item 5</div>
183
203
  </div>
184
204
  </div>
185
205
 
186
- <div data-hs-pagination="controls" data-hs-config="infinite">
187
- <!-- Controls hidden automatically -->
188
- <button data-hs-pagination="previous">Previous</button>
189
- <button data-hs-pagination="next">Next</button>
190
- </div>
206
+ <!-- No controls = no pagination initialization -->
191
207
  </div>
192
208
  ```
193
209
 
@@ -223,9 +239,11 @@ Uses CSS custom property `--data-hs-break` to detect layout:
223
239
  | ----- | ----- | ----- | ----- |
224
240
  | `data-hs-pagination="wrapper"` | Main container | **Required** | Wrapper div |
225
241
  | `data-hs-pagination="list"` | Items to paginate | **Required** | List container |
226
- | `data-hs-pagination="next"` | Next button | **Required** | `<button>` |
227
- | `data-hs-pagination="previous"` | Previous button | **Required** | `<button>` |
242
+ | `data-hs-pagination="next"` | Next button wrapper | **Required** | Wrapper div with `data-site-clickable="element"` |
243
+ | `data-hs-pagination="previous"` | Previous button wrapper | **Required** | Wrapper div with `data-site-clickable="element"` |
228
244
  | `data-hs-pagination="controls"` | Controls + config | Required for config | Wrapper div |
245
+ | `data-hs-pagination-show` | Desktop items per page | Required on controls | Attribute (number) |
246
+ | `data-hs-pagination-show-mobile` | Mobile items per page | Optional on controls | Attribute (number) |
229
247
  | `data-hs-pagination="counter"` | Page counter | Optional | Any element |
230
248
  | `data-hs-pagination="dots"` | Dots container | Optional | Wrapper div |
231
249
 
@@ -259,7 +277,7 @@ Uses CSS custom property `--data-hs-break` to detect layout:
259
277
 
260
278
  ### **Initialization:**
261
279
  1. Finds all `[data-hs-pagination="wrapper"]` containers
262
- 2. Reads configuration from `data-hs-config`
280
+ 2. Reads configuration from `data-hs-pagination-show` attributes
263
281
  3. Calculates total pages based on items and config
264
282
  4. Splits items into page lists
265
283
  5. Clones pages for infinite loop (adds before/after)
@@ -280,10 +298,10 @@ Uses CSS custom property `--data-hs-break` to detect layout:
280
298
  3. Re-initializes pagination if breakpoint changes
281
299
  4. Maintains current page position when possible
282
300
 
283
- ### **Infinite Mode:**
284
- 1. Hides all pagination controls and dots
285
- 2. Shows all items without splitting
286
- 3. No page transitions or animations
301
+ ### **No Pagination (Infinite Mode):**
302
+ 1. Don't render controls element in HTML
303
+ 2. Pagination system won't initialize
304
+ 3. All items display naturally in list
287
305
 
288
306
  ---
289
307
 
@@ -329,22 +347,27 @@ The pagination system integrates with the separate pagination dots system:
329
347
 
330
348
  ### **Desktop: 6 items, Mobile: 3 items**
331
349
  ```html
332
- <div data-hs-config="show-6, show-3-mobile">
350
+ <div data-hs-pagination="controls"
351
+ data-hs-pagination-show="6"
352
+ data-hs-pagination-show-mobile="3">
333
353
  ```
334
354
 
335
355
  ### **Desktop: 4 items, Mobile: 2 items**
336
356
  ```html
337
- <div data-hs-config="show-4, show-2-mobile">
357
+ <div data-hs-pagination="controls"
358
+ data-hs-pagination-show="4"
359
+ data-hs-pagination-show-mobile="2">
338
360
  ```
339
361
 
340
362
  ### **Same on all devices (8 items)**
341
363
  ```html
342
- <div data-hs-config="show-8">
364
+ <div data-hs-pagination="controls"
365
+ data-hs-pagination-show="8">
343
366
  ```
344
367
 
345
- ### **Disable pagination (infinite mode)**
368
+ ### **No pagination (infinite mode)**
346
369
  ```html
347
- <div data-hs-config="infinite">
370
+ <!-- Don't render controls element at all -->
348
371
  ```
349
372
 
350
373
  ---
@@ -360,10 +383,10 @@ The pagination system integrates with the separate pagination dots system:
360
383
  - Dots hidden similarly
361
384
  - All items displayed on single page
362
385
 
363
- ### **Infinite Mode:**
364
- - Controls completely hidden
365
- - Dots completely hidden
366
- - List displayed as-is
386
+ ### **No Pagination (Infinite Mode):**
387
+ - Don't render controls element
388
+ - Pagination system won't initialize
389
+ - List displays all items naturally
367
390
  - No pagination logic runs
368
391
 
369
392
  ### **Layout Changes:**
@@ -388,12 +411,13 @@ The pagination system integrates with the separate pagination dots system:
388
411
 
389
412
  **Pagination not working:**
390
413
  1. Ensure wrapper, list, next, and previous elements exist
391
- 2. Check `data-hs-config` has valid configuration
414
+ 2. Check `data-hs-pagination-show` attribute has valid number
392
415
  3. Verify list has children elements
393
- 4. Check console for warnings
416
+ 4. Ensure button structure uses `data-site-clickable="element"` pattern
417
+ 5. Check console for warnings
394
418
 
395
419
  **Items not splitting into pages:**
396
- 1. Ensure `data-hs-config` specifies `show-X`
420
+ 1. Ensure `data-hs-pagination-show` specifies a number
397
421
  2. Check that total items > items per page
398
422
  3. Verify CSS custom property `--data-hs-break` is set
399
423
 
@@ -409,9 +433,9 @@ The pagination system integrates with the separate pagination dots system:
409
433
  3. Check ResizeObserver is not being blocked
410
434
 
411
435
  **Controls always hidden:**
412
- 1. Check `data-hs-config` doesn't include "infinite"
413
- 2. Ensure total items > items per page
414
- 3. Verify controls container exists
436
+ 1. Ensure total items > items per page
437
+ 2. Verify controls container exists in DOM
438
+ 3. Check that `data-hs-pagination-show` is set properly
415
439
 
416
440
  ---
417
441
 
@@ -12,26 +12,48 @@ export function init() {
12
12
  };
13
13
 
14
14
  // Initialize all pagination containers
15
- document.querySelectorAll('[data-hs-pagination="wrapper"]')
16
- .forEach(container => {
17
- const instance = initPaginationInstance(container, cleanup);
18
- if (!instance) {
19
- console.warn('[hs-pagination] Failed to initialize container', container);
20
- }
21
- });
15
+ try {
16
+ document.querySelectorAll('[data-hs-pagination="wrapper"]')
17
+ .forEach(container => {
18
+ try {
19
+ const instance = initPaginationInstance(container, cleanup);
20
+ if (!instance) {
21
+ console.warn('[hs-pagination] Failed to initialize container', container);
22
+ }
23
+ } catch (error) {
24
+ console.error('[hs-pagination] Error initializing container:', error, container);
25
+ }
26
+ });
27
+ } catch (error) {
28
+ console.error('[hs-pagination] Critical error during initialization:', error);
29
+ }
22
30
 
23
31
  return {
24
32
  result: "pagination initialized",
25
33
  destroy: () => {
26
- // Disconnect all observers
27
- cleanup.observers.forEach(obs => obs.disconnect());
28
- cleanup.observers.length = 0;
29
-
30
- // Remove all event listeners
31
- cleanup.handlers.forEach(({ element, event, handler }) => {
32
- element.removeEventListener(event, handler);
33
- });
34
- cleanup.handlers.length = 0;
34
+ try {
35
+ // Disconnect all observers
36
+ cleanup.observers.forEach(obs => {
37
+ try {
38
+ obs.disconnect();
39
+ } catch (error) {
40
+ console.error('[hs-pagination] Error disconnecting observer:', error);
41
+ }
42
+ });
43
+ cleanup.observers.length = 0;
44
+
45
+ // Remove all event listeners
46
+ cleanup.handlers.forEach(({ element, event, handler }) => {
47
+ try {
48
+ element.removeEventListener(event, handler);
49
+ } catch (error) {
50
+ console.error('[hs-pagination] Error removing event listener:', error);
51
+ }
52
+ });
53
+ cleanup.handlers.length = 0;
54
+ } catch (error) {
55
+ console.error('[hs-pagination] Critical error during cleanup:', error);
56
+ }
35
57
  }
36
58
  };
37
59
  }
@@ -47,18 +69,30 @@ function initPaginationInstance(container, cleanup) {
47
69
  if (!wrapper) return null;
48
70
 
49
71
  const elements = {
50
- nextBtn: container.querySelector('[data-hs-pagination="next"]'),
51
- prevBtn: container.querySelector('[data-hs-pagination="previous"]'),
52
- counter: container.querySelector('[data-hs-pagination="counter"]'),
53
72
  controls: container.querySelector('[data-hs-pagination="controls"]'),
73
+ counter: container.querySelector('[data-hs-pagination="counter"]'),
54
74
  dotsWrap: container.querySelector('[data-hs-pagination="dots"]')
55
75
  };
56
76
 
57
- if (!elements.nextBtn || !elements.prevBtn) {
77
+ // Early exit for infinite mode - no controls means no pagination
78
+ if (!elements.controls) {
79
+ return { initialized: false };
80
+ }
81
+
82
+ // Find next/previous buttons using data-site-clickable pattern
83
+ const nextClickable = container.querySelector('[data-hs-pagination="next"]');
84
+ const prevClickable = container.querySelector('[data-hs-pagination="previous"]');
85
+ const nextBtn = nextClickable?.children[0];
86
+ const prevBtn = prevClickable?.children[0];
87
+
88
+ if (!nextBtn || !prevBtn) {
58
89
  console.warn('[hs-pagination] Missing required navigation buttons');
59
90
  return null;
60
91
  }
61
92
 
93
+ elements.nextBtn = nextBtn;
94
+ elements.prevBtn = prevBtn;
95
+
62
96
  // Add ARIA attributes to buttons
63
97
  elements.nextBtn.setAttribute('aria-label', 'Go to next page');
64
98
  elements.prevBtn.setAttribute('aria-label', 'Go to previous page');
@@ -67,26 +101,9 @@ function initPaginationInstance(container, cleanup) {
67
101
  elements.counter.setAttribute('aria-label', 'Current page');
68
102
  }
69
103
 
70
- // Parse configuration
71
- const config = elements.controls?.getAttribute('data-hs-config') || '';
72
- const configOptions = config.split(', ').map(opt => opt.trim());
73
- const isInfiniteMode = configOptions.includes('infinite');
74
-
75
- const desktopItems = !isInfiniteMode ? (parseInt(config.match(/show-(\d+)(?!-mobile)/)?.[1]) || 6) : 0;
76
- const mobileItems = !isInfiniteMode ? (parseInt(config.match(/show-(\d+)-mobile/)?.[1]) || desktopItems) : 0;
77
-
78
- // Early exit for infinite mode - disable pagination completely
79
- if (isInfiniteMode) {
80
- if (elements.controls) {
81
- elements.controls.style.display = 'none';
82
- elements.controls.setAttribute('aria-hidden', 'true');
83
- }
84
- if (elements.dotsWrap) {
85
- elements.dotsWrap.style.display = 'none';
86
- elements.dotsWrap.setAttribute('aria-hidden', 'true');
87
- }
88
- return { initialized: false };
89
- }
104
+ // Parse configuration from new attributes
105
+ const desktopItems = parseInt(elements.controls?.getAttribute('data-hs-pagination-show')) || 6;
106
+ const mobileItems = parseInt(elements.controls?.getAttribute('data-hs-pagination-show-mobile')) || desktopItems;
90
107
 
91
108
  const isMobileLayout = () => {
92
109
  const breakpoint = getComputedStyle(list).getPropertyValue('--data-hs-break').trim().replace(/"/g, '');
@@ -102,7 +119,8 @@ function initPaginationInstance(container, cleanup) {
102
119
  currentIndex: 1,
103
120
  currentPage: 1,
104
121
  isAnimating: false,
105
- itemsPerPage: desktopItems
122
+ itemsPerPage: desktopItems,
123
+ dotTemplates: { active: null, inactive: null }
106
124
  };
107
125
  let wrapperChildren = [];
108
126
 
@@ -133,38 +151,84 @@ function initPaginationInstance(container, cleanup) {
133
151
 
134
152
  const updateHeight = () => {
135
153
  const targetPage = wrapperChildren[state.currentIndex];
136
- if (targetPage) wrapper.style.height = targetPage.offsetHeight + 'px';
154
+ if (targetPage && targetPage.offsetHeight !== undefined) {
155
+ wrapper.style.height = targetPage.offsetHeight + 'px';
156
+ }
137
157
  };
138
158
 
139
159
  const initializeDots = () => {
140
160
  if (!elements.dotsWrap) return;
141
161
 
142
- // Set pagination attributes for dot system
143
- elements.dotsWrap.setAttribute('data-hs-pagination', '');
144
- elements.dotsWrap.setAttribute('data-hs-pagination-total', state.totalPages);
145
- elements.dotsWrap.setAttribute('data-hs-pagination-current', state.currentPage);
146
-
147
- // Wait for pagination system to initialize dots
148
- setTimeout(() => {
149
- // Listen for dot clicks
150
- const paginationChangeHandler = (e) => {
151
- if (e.detail && e.detail.page) {
152
- navigateToPage(e.detail.page);
162
+ // Find existing dots as templates
163
+ const existingDots = Array.from(elements.dotsWrap.children);
164
+ if (!existingDots.length) return;
165
+
166
+ // Identify active and inactive templates
167
+ const activeDot = existingDots.find(dot => dot.classList.contains('is-active'));
168
+ const inactiveDot = existingDots.find(dot => !dot.classList.contains('is-active'));
169
+
170
+ // Store templates (use same template for both if only one exists)
171
+ state.dotTemplates.active = activeDot ? activeDot.cloneNode(true) : existingDots[0].cloneNode(true);
172
+ state.dotTemplates.inactive = inactiveDot ? inactiveDot.cloneNode(true) : existingDots[0].cloneNode(true);
173
+
174
+ // Clear existing dots
175
+ elements.dotsWrap.innerHTML = '';
176
+
177
+ // Add pagination accessibility attributes
178
+ elements.dotsWrap.setAttribute('role', 'group');
179
+ elements.dotsWrap.setAttribute('aria-label', 'Page navigation');
180
+
181
+ // Create dots for each page
182
+ for (let i = 1; i <= state.totalPages; i++) {
183
+ const dot = (i === 1 ? state.dotTemplates.active : state.dotTemplates.inactive).cloneNode(true);
184
+
185
+ // Add accessibility attributes
186
+ dot.setAttribute('role', 'button');
187
+ dot.setAttribute('tabindex', '0');
188
+ dot.setAttribute('aria-label', `Go to page ${i}`);
189
+ dot.setAttribute('aria-current', i === 1 ? 'page' : 'false');
190
+ dot.setAttribute('data-page', i);
191
+
192
+ // Set initial active state
193
+ if (i === 1) {
194
+ dot.classList.add('is-active');
195
+ } else {
196
+ dot.classList.remove('is-active');
197
+ }
198
+
199
+ // Click handler
200
+ const clickHandler = () => navigateToPage(i);
201
+ dot.addEventListener('click', clickHandler);
202
+ cleanup.handlers.push({ element: dot, event: 'click', handler: clickHandler });
203
+
204
+ // Keyboard handler
205
+ const keyHandler = (e) => {
206
+ if (e.key === 'Enter' || e.key === ' ') {
207
+ e.preventDefault();
208
+ navigateToPage(i);
153
209
  }
154
210
  };
155
- elements.dotsWrap.addEventListener('hs-pagination-change', paginationChangeHandler);
156
- cleanup.handlers.push({
157
- element: elements.dotsWrap,
158
- event: 'hs-pagination-change',
159
- handler: paginationChangeHandler
160
- });
161
- }, 50);
211
+ dot.addEventListener('keydown', keyHandler);
212
+ cleanup.handlers.push({ element: dot, event: 'keydown', handler: keyHandler });
213
+
214
+ elements.dotsWrap.appendChild(dot);
215
+ }
162
216
  };
163
217
 
164
- const updateDots = () => {
165
- if (!elements.dotsWrap || !elements.dotsWrap.hsPaginationUpdate) return;
166
- elements.dotsWrap.setAttribute('data-hs-pagination-current', state.currentPage);
167
- elements.dotsWrap.hsPaginationUpdate(state.currentPage);
218
+ const updateActiveDot = () => {
219
+ if (!elements.dotsWrap) return;
220
+
221
+ const dots = Array.from(elements.dotsWrap.children);
222
+ dots.forEach((dot, index) => {
223
+ const dotPage = index + 1;
224
+ if (dotPage === state.currentPage) {
225
+ dot.classList.add('is-active');
226
+ dot.setAttribute('aria-current', 'page');
227
+ } else {
228
+ dot.classList.remove('is-active');
229
+ dot.setAttribute('aria-current', 'false');
230
+ }
231
+ });
168
232
  };
169
233
 
170
234
  const initializePagination = (forceItemsPerPage = null) => {
@@ -172,6 +236,13 @@ function initPaginationInstance(container, cleanup) {
172
236
  state.itemsPerPage = forceItemsPerPage || (currentIsMobile ? mobileItems : desktopItems);
173
237
  state.totalPages = Math.ceil(totalItems / state.itemsPerPage);
174
238
 
239
+ // Remove old dot event handlers to prevent memory leaks
240
+ if (elements.dotsWrap) {
241
+ cleanup.handlers = cleanup.handlers.filter(({ element }) => {
242
+ return !elements.dotsWrap.contains(element);
243
+ });
244
+ }
245
+
175
246
  // Clean up previous page lists
176
247
  Array.from(wrapper.children).forEach(child => {
177
248
  if (child !== list) wrapper.removeChild(child);
@@ -238,6 +309,9 @@ function initPaginationInstance(container, cleanup) {
238
309
  let currentLayoutIsMobile = isMobileLayout();
239
310
 
240
311
  const checkLayoutChange = () => {
312
+ // Prevent resize during animation
313
+ if (state.isAnimating) return;
314
+
241
315
  const newIsMobile = isMobileLayout();
242
316
  if (newIsMobile !== currentLayoutIsMobile) {
243
317
  currentLayoutIsMobile = newIsMobile;
@@ -263,7 +337,7 @@ function initPaginationInstance(container, cleanup) {
263
337
  updateCounter();
264
338
  announcePageChange();
265
339
  updateHeight();
266
- updateDots();
340
+ updateActiveDot();
267
341
 
268
342
  if (isMobileLayout() && elements.controls) {
269
343
  setTimeout(() => {
@@ -277,7 +351,10 @@ function initPaginationInstance(container, cleanup) {
277
351
  wrapper.style.transition = 'transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
278
352
  wrapper.style.transform = `translateX(${-state.currentIndex * 100}%)`;
279
353
 
354
+ let transitionTimeout = null;
355
+
280
356
  const handleTransitionEnd = () => {
357
+ clearTimeout(transitionTimeout);
281
358
  wrapper.removeEventListener('transitionend', handleTransitionEnd);
282
359
  wrapper.style.transition = '';
283
360
 
@@ -285,10 +362,13 @@ function initPaginationInstance(container, cleanup) {
285
362
  announcePageChange();
286
363
  updateHeight();
287
364
  manageFocus();
288
- updateDots();
365
+ updateActiveDot();
289
366
  state.isAnimating = false;
290
367
  };
291
368
 
369
+ // Safety timeout in case transitionend never fires
370
+ transitionTimeout = setTimeout(handleTransitionEnd, 1000);
371
+
292
372
  wrapper.addEventListener('transitionend', handleTransitionEnd);
293
373
  };
294
374
 
@@ -303,7 +383,7 @@ function initPaginationInstance(container, cleanup) {
303
383
  updateCounter();
304
384
  announcePageChange();
305
385
  updateHeight();
306
- updateDots();
386
+ updateActiveDot();
307
387
 
308
388
  if (isMobileLayout() && elements.controls) {
309
389
  setTimeout(() => {
@@ -317,7 +397,10 @@ function initPaginationInstance(container, cleanup) {
317
397
  wrapper.style.transition = 'transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
318
398
  wrapper.style.transform = `translateX(${-state.currentIndex * 100}%)`;
319
399
 
400
+ let transitionTimeout = null;
401
+
320
402
  const handleTransitionEnd = () => {
403
+ clearTimeout(transitionTimeout);
321
404
  wrapper.removeEventListener('transitionend', handleTransitionEnd);
322
405
  wrapper.style.transition = '';
323
406
 
@@ -335,10 +418,13 @@ function initPaginationInstance(container, cleanup) {
335
418
  announcePageChange();
336
419
  updateHeight();
337
420
  manageFocus();
338
- updateDots();
421
+ updateActiveDot();
339
422
  state.isAnimating = false;
340
423
  };
341
424
 
425
+ // Safety timeout in case transitionend never fires
426
+ transitionTimeout = setTimeout(handleTransitionEnd, 1000);
427
+
342
428
  wrapper.addEventListener('transitionend', handleTransitionEnd);
343
429
  };
344
430
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## **Overview**
4
4
 
5
- The site settings system collects dynamic values from designated elements and replaces placeholder text (`{{name}}`) throughout the entire page. This allows for centralized content management where values can be defined once and used multiple times across text and links.
5
+ The site settings system collects dynamic values from designated elements and replaces placeholder text (`{{name}}`) throughout the entire page. Supports both text and href values, allowing for proper handling of links like `tel:`, `mailto:`, and URLs without Webflow's default prefixes breaking them.
6
6
 
7
7
  ---
8
8
 
@@ -22,7 +22,8 @@ The site settings system collects dynamic values from designated elements and re
22
22
 
23
23
  * data-site-settings="name"
24
24
  * The attribute value is the placeholder name (e.g., "company", "email", "phone")
25
- * The element's text content is the value to replace
25
+ * The element's text content is used for text replacements
26
+ * The element's href attribute (if present) is used for link replacements
26
27
 
27
28
  **Typical element layout:**
28
29
 
@@ -30,17 +31,29 @@ The site settings system collects dynamic values from designated elements and re
30
31
  1. Settings List (data-site-settings-element="list")
31
32
  1. Setting Element (data-site-settings="company")
32
33
  1. Text: "Compass Facilities"
33
- 2. Setting Element (data-site-settings="email")
34
+ 2. Link Setting (data-site-settings="email", href="mailto:info@compass.com")
34
35
  1. Text: "info@compass.com"
35
- 3. Setting Element (data-site-settings="phone")
36
- 1. Text: "555-1234"
36
+ 3. Link Setting (data-site-settings="phone", href="tel:+15551234567")
37
+ 1. Text: "(555) 123-4567"
37
38
 
38
39
  ### **What It Does**
39
40
 
40
- 1. **Collects settings** - Finds all elements with `[data-site-settings]` and stores their text values
41
- 2. **Ignores empty elements** - Elements without text (like images) are skipped
42
- 3. **Replaces text placeholders** - Finds all `{{name}}` in page text and replaces with values
43
- 4. **Replaces link placeholders** - Finds all `{{name}}` in link hrefs and replaces with values
41
+ 1. **Collects settings** - Finds all elements with `[data-site-settings]` and stores both text and href values
42
+ 2. **Separates text and href** - Distinguishes between display text and link URLs
43
+ 3. **Replaces text placeholders** - Finds all `{{name}}` in page text and replaces with text value (or href as fallback)
44
+ 4. **Replaces link hrefs** - Finds all `{{name}}` in link hrefs:
45
+ - **With dedicated href:** Completely replaces entire href attribute (prevents Webflow's "https://" prefix)
46
+ - **Without dedicated href:** Does normal placeholder replacement using text value
47
+
48
+ ### **Text vs Href Behavior**
49
+
50
+ **Text replacement** (in page content):
51
+ - **First choice:** Use text value
52
+ - **Fallback:** Use href value if no text
53
+
54
+ **Href replacement** (in link attributes):
55
+ - **With dedicated href:** Completely replace entire href (fixes Webflow prefix issue)
56
+ - **Without dedicated href:** Normal placeholder replacement with text value
44
57
 
45
58
  ### **When It Runs**
46
59
 
@@ -88,7 +101,7 @@ For dynamic content where certain parts should be hidden based on CMS conditions
88
101
  2. Finds all descendants with `[data-site-settings-hide]`
89
102
  3. Checks if element has any matching classes
90
103
  4. Removes matching elements from clone
91
- 5. Extracts text from cleaned clone
104
+ 5. Extracts text and href from cleaned clone
92
105
 
93
106
  ---
94
107
 
@@ -96,19 +109,38 @@ For dynamic content where certain parts should be hidden based on CMS conditions
96
109
 
97
110
  ### **Define Settings**
98
111
 
112
+ #### **Basic Text Settings**
113
+
99
114
  ```html
100
115
  <div data-site-settings-element="wrapper" style="display: none;">
101
116
  <div data-site-settings-element="list">
102
117
  <div data-site-settings="company">Compass Facilities</div>
103
- <div data-site-settings="email">info@compass.com</div>
104
- <div data-site-settings="phone">555-1234</div>
105
118
  <div data-site-settings="address">123 Main St, Dallas, TX 75001</div>
106
119
  </div>
107
120
  </div>
108
121
  ```
109
122
 
123
+ #### **Link Settings with Href**
124
+
125
+ ```html
126
+ <div data-site-settings-element="wrapper" style="display: none;">
127
+ <div data-site-settings-element="list">
128
+ <!-- Email with both display text and mailto: link -->
129
+ <a data-site-settings="email" href="mailto:info@compass.com">info@compass.com</a>
130
+
131
+ <!-- Phone with formatted display and tel: link -->
132
+ <a data-site-settings="phone" href="tel:+15551234567">(555) 123-4567</a>
133
+
134
+ <!-- Social link -->
135
+ <a data-site-settings="facebook" href="https://facebook.com/compassfacilities">Facebook</a>
136
+ </div>
137
+ </div>
138
+ ```
139
+
110
140
  **Note:** The wrapper is typically hidden with `display: none` since it's only for data storage.
111
141
 
142
+ ---
143
+
112
144
  ### **Use Settings in Text**
113
145
 
114
146
  ```html
@@ -120,23 +152,52 @@ For dynamic content where certain parts should be hidden based on CMS conditions
120
152
  **Result:**
121
153
  ```html
122
154
  <h1>Welcome to Compass Facilities</h1>
123
- <p>Contact us at info@compass.com or call 555-1234</p>
155
+ <p>Contact us at info@compass.com or call (555) 123-4567</p>
124
156
  <p>Visit us at 123 Main St, Dallas, TX 75001</p>
125
157
  ```
126
158
 
127
- ### **Use Settings in Links**
159
+ ---
160
+
161
+ ### **Use Settings in Links (With Dedicated Href)**
162
+
163
+ **IMPORTANT:** When setting has a dedicated href, the ENTIRE href attribute is replaced (not just the placeholder). This prevents Webflow's default "https://" prefix from breaking tel:, mailto:, and other special link types.
128
164
 
129
165
  ```html
130
- <a href="mailto:{{email}}">Email Us</a>
131
- <a href="tel:{{phone}}">Call Us</a>
132
- <a href="https://maps.google.com/?q={{address}}">Get Directions</a>
166
+ <!-- In Webflow, these links might have href="https://{{email}}" -->
167
+ <a href="{{email}}">Email Us</a>
168
+ <a href="{{phone}}">Call Us</a>
169
+ <a href="{{facebook}}">Follow Us</a>
133
170
  ```
134
171
 
135
172
  **Result:**
136
173
  ```html
174
+ <!-- Entire href replaced with dedicated href value -->
137
175
  <a href="mailto:info@compass.com">Email Us</a>
138
- <a href="tel:555-1234">Call Us</a>
139
- <a href="https://maps.google.com/?q=123 Main St, Dallas, TX 75001">Get Directions</a>
176
+ <a href="tel:+15551234567">Call Us</a>
177
+ <a href="https://facebook.com/compassfacilities">Follow Us</a>
178
+ ```
179
+
180
+ **Why this matters:** Webflow often adds `https://` prefix by default. Without complete href replacement:
181
+ - `https://tel:+15551234567` ❌ (broken)
182
+ - `tel:+15551234567` ✅ (works)
183
+
184
+ ---
185
+
186
+ ### **Use Settings in Links (Without Dedicated Href)**
187
+
188
+ When setting has only text (no href), normal placeholder replacement occurs:
189
+
190
+ ```html
191
+ <!-- Setting with only text -->
192
+ <div data-site-settings="company-url">compassfacilities.com</div>
193
+
194
+ <!-- Link in page -->
195
+ <a href="https://{{company-url}}">Visit Site</a>
196
+ ```
197
+
198
+ **Result:**
199
+ ```html
200
+ <a href="https://compassfacilities.com">Visit Site</a>
140
201
  ```
141
202
 
142
203
  ---
@@ -146,11 +207,11 @@ For dynamic content where certain parts should be hidden based on CMS conditions
146
207
  You can have multiple lists inside the wrapper for organization:
147
208
 
148
209
  ```html
149
- <div data-site-settings-element="wrapper">
210
+ <div data-site-settings-element="wrapper" style="display: none;">
150
211
  <!-- Contact info -->
151
212
  <div data-site-settings-element="list">
152
- <div data-site-settings="email">info@compass.com</div>
153
- <div data-site-settings="phone">555-1234</div>
213
+ <a data-site-settings="email" href="mailto:info@compass.com">info@compass.com</a>
214
+ <a data-site-settings="phone" href="tel:+15551234567">(555) 123-4567</a>
154
215
  </div>
155
216
 
156
217
  <!-- Company info -->
@@ -158,6 +219,12 @@ You can have multiple lists inside the wrapper for organization:
158
219
  <div data-site-settings="company">Compass Facilities</div>
159
220
  <div data-site-settings="established">2020</div>
160
221
  </div>
222
+
223
+ <!-- Social links -->
224
+ <div data-site-settings-element="list">
225
+ <a data-site-settings="facebook" href="https://facebook.com/compass">Facebook</a>
226
+ <a data-site-settings="twitter" href="https://twitter.com/compass">Twitter</a>
227
+ </div>
161
228
  </div>
162
229
  ```
163
230
 
@@ -172,6 +239,7 @@ All settings are collected regardless of which list they're in.
172
239
  | `data-site-settings-element="wrapper"` | Settings container | Wrapper div |
173
240
  | `data-site-settings-element="list"` | Settings list | List div (at least one) |
174
241
  | `data-site-settings="name"` | Setting with name | Individual setting elements |
242
+ | `href` attribute | Link URL for href replacement | Optional (on link settings) |
175
243
  | `data-site-settings-hide="class"` | Conditional hide | Optional (inside setting elements) |
176
244
 
177
245
  ---
@@ -187,13 +255,74 @@ All settings are collected regardless of which list they're in.
187
255
 
188
256
  ---
189
257
 
258
+ ## **Replacement Logic**
259
+
260
+ ### **For Text Content (`{{name}}` in page text):**
261
+ 1. If setting has text value → use text
262
+ 2. If no text but has href value → use href
263
+ 3. If neither → skip replacement
264
+
265
+ ### **For Link Hrefs (`{{name}}` in href attribute):**
266
+ 1. If setting has href value → **completely replace entire href**
267
+ 2. If no href but has text value → normal placeholder replacement
268
+ 3. If neither → skip replacement
269
+
270
+ ---
271
+
272
+ ## **Common Use Cases**
273
+
274
+ ### **Phone Number**
275
+ ```html
276
+ <!-- Setting with both formatted display and tel: link -->
277
+ <a data-site-settings="phone" href="tel:+15551234567">(555) 123-4567</a>
278
+
279
+ <!-- Text usage -->
280
+ <p>Call us at {{phone}}</p>
281
+ <!-- Result: Call us at (555) 123-4567 -->
282
+
283
+ <!-- Link usage -->
284
+ <a href="{{phone}}">Call Now</a>
285
+ <!-- Result: <a href="tel:+15551234567">Call Now</a> -->
286
+ ```
287
+
288
+ ### **Email Address**
289
+ ```html
290
+ <!-- Setting -->
291
+ <a data-site-settings="email" href="mailto:contact@example.com">contact@example.com</a>
292
+
293
+ <!-- Text usage -->
294
+ <p>Email: {{email}}</p>
295
+ <!-- Result: Email: contact@example.com -->
296
+
297
+ <!-- Link usage -->
298
+ <a href="{{email}}">Email Us</a>
299
+ <!-- Result: <a href="mailto:contact@example.com">Email Us</a> -->
300
+ ```
301
+
302
+ ### **Social Media**
303
+ ```html
304
+ <!-- Setting -->
305
+ <a data-site-settings="facebook" href="https://facebook.com/mypage">@mypage</a>
306
+
307
+ <!-- Text usage -->
308
+ <p>Follow us: {{facebook}}</p>
309
+ <!-- Result: Follow us: @mypage -->
310
+
311
+ <!-- Link usage -->
312
+ <a href="{{facebook}}">Facebook</a>
313
+ <!-- Result: <a href="https://facebook.com/mypage">Facebook</a> -->
314
+ ```
315
+
316
+ ---
317
+
190
318
  ## **Notes**
191
319
 
192
- 1. **Empty elements ignored** - Elements without text (images, empty divs) are skipped
320
+ 1. **Empty elements ignored** - Elements without text or href are skipped
193
321
  2. **No visual component** - Settings wrapper should be hidden with CSS
194
322
  3. **Page-wide replacement** - Placeholders are replaced throughout entire page
195
323
  4. **Multiple replacements** - Same placeholder can be used unlimited times
196
324
  5. **Barba.js compatible** - Automatically reinitializes on page transitions
325
+ 6. **Webflow-friendly** - Complete href replacement prevents default "https://" prefix issues
197
326
 
198
327
  ---
199
328
 
@@ -202,17 +331,35 @@ All settings are collected regardless of which list they're in.
202
331
  **Placeholder not replacing:**
203
332
 
204
333
  1. Verify `data-site-settings` attribute matches placeholder name exactly
205
- 2. Check that setting element has text content
334
+ 2. Check that setting element has text content or href attribute
206
335
  3. Ensure double curly braces: `{{name}}` not `{name}` or `{{ name }}`
207
336
 
208
337
  **Wrong value appearing:**
209
338
 
210
339
  1. Check for duplicate setting names (last one wins)
211
340
  2. Verify conditional hide elements are working correctly
212
- 3. Inspect the settings wrapper to confirm expected text
341
+ 3. Inspect the settings wrapper to confirm expected text and href
342
+
343
+ **Links not working (tel:, mailto:, etc.):**
344
+
345
+ 1. Ensure setting element has `href` attribute with proper protocol
346
+ 2. Use `<a>` element for settings that need href replacement
347
+ 3. Verify placeholder is directly in href: `href="{{phone}}"` not `href="tel:{{phone}}"`
348
+ 4. The system will completely replace the href when setting has dedicated href value
349
+
350
+ **Webflow adding "https://" prefix:**
351
+
352
+ 1. This is expected Webflow behavior
353
+ 2. System automatically fixes this when setting has dedicated href
354
+ 3. Entire href is replaced, removing Webflow's prefix
355
+ 4. Example: `https://{{phone}}` → `tel:+15551234567` ✓
356
+
357
+ ---
213
358
 
214
- **Links not working:**
359
+ ## **Best Practices**
215
360
 
216
- 1. Ensure placeholder syntax is correct in href attribute
217
- 2. Check that value is URL-safe (encode special characters if needed)
218
- 3. For tel: links, ensure phone number has no spaces or dashes in setting value
361
+ 1. **Use links for contact info** - Phone, email, social media should be `<a>` elements with href
362
+ 2. **Format display text** - Use human-readable text, proper href for functionality
363
+ 3. **Hide the wrapper** - Always use `display: none` or similar to hide settings container
364
+ 4. **Organize with lists** - Group related settings in separate lists for clarity
365
+ 5. **Test replacements** - Check both text and link replacements work as expected
@@ -47,11 +47,15 @@ export function init() {
47
47
  });
48
48
 
49
49
  const settingName = element.getAttribute('data-site-settings');
50
- const settingValue = clonedElement.textContent.trim();
51
-
52
- // Only store if both name and value exist (ignore empty elements like images)
53
- if (settingName && settingValue) {
54
- siteSettings[settingName] = settingValue;
50
+ const settingText = clonedElement.textContent.trim();
51
+ const settingHref = element.getAttribute('href');
52
+
53
+ // Store if name exists and either text or href exists
54
+ if (settingName && (settingText || settingHref)) {
55
+ siteSettings[settingName] = {
56
+ text: settingText || null,
57
+ href: settingHref || null
58
+ };
55
59
  }
56
60
  });
57
61
  });
@@ -93,8 +97,13 @@ export function init() {
93
97
  for (const name in siteSettings) {
94
98
  const placeholder = `{{${name}}}`;
95
99
  if (newText.includes(placeholder)) {
96
- newText = newText.replace(new RegExp(placeholder, 'g'), siteSettings[name]);
97
- hasChanges = true;
100
+ const setting = siteSettings[name];
101
+ // Use text value if available, otherwise use href value, otherwise skip
102
+ const replacementValue = setting.text || setting.href;
103
+ if (replacementValue) {
104
+ newText = newText.replace(new RegExp(placeholder, 'g'), replacementValue);
105
+ hasChanges = true;
106
+ }
98
107
  }
99
108
  }
100
109
 
@@ -112,8 +121,19 @@ export function init() {
112
121
  for (const name in siteSettings) {
113
122
  const placeholder = `{{${name}}}`;
114
123
  if (href.includes(placeholder)) {
115
- href = href.replace(new RegExp(placeholder, 'g'), siteSettings[name]);
116
- hasChanges = true;
124
+ const setting = siteSettings[name];
125
+
126
+ // If setting has a dedicated href, completely replace the entire href
127
+ // (prevents Webflow's default "https://" from breaking tel:, mailto:, etc.)
128
+ if (setting.href) {
129
+ href = setting.href;
130
+ hasChanges = true;
131
+ }
132
+ // Otherwise, do normal placeholder replacement with text value
133
+ else if (setting.text) {
134
+ href = href.replace(new RegExp(placeholder, 'g'), setting.text);
135
+ hasChanges = true;
136
+ }
117
137
  }
118
138
  }
119
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.9.10",
3
+ "version": "1.9.11",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",