@keenthemes/ktui 1.1.5 → 1.1.6

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.
Files changed (98) hide show
  1. package/dist/ktui.js +11232 -11095
  2. package/dist/ktui.min.js +1 -1
  3. package/dist/ktui.min.js.map +1 -1
  4. package/dist/styles.css +33 -27
  5. package/lib/cjs/components/collapse/collapse.js +0 -2
  6. package/lib/cjs/components/collapse/collapse.js.map +1 -1
  7. package/lib/cjs/components/component.js +3 -1
  8. package/lib/cjs/components/component.js.map +1 -1
  9. package/lib/cjs/components/datatable/datatable-sort.js +1 -2
  10. package/lib/cjs/components/datatable/datatable-sort.js.map +1 -1
  11. package/lib/cjs/components/datatable/datatable.js +45 -23
  12. package/lib/cjs/components/datatable/datatable.js.map +1 -1
  13. package/lib/cjs/components/drawer/drawer.js +21 -9
  14. package/lib/cjs/components/drawer/drawer.js.map +1 -1
  15. package/lib/cjs/components/dropdown/dropdown.js.map +1 -1
  16. package/lib/cjs/components/scrollto/scrollto.js +0 -2
  17. package/lib/cjs/components/scrollto/scrollto.js.map +1 -1
  18. package/lib/cjs/components/select/combobox.js.map +1 -1
  19. package/lib/cjs/components/select/dropdown.js.map +1 -1
  20. package/lib/cjs/components/select/remote.js.map +1 -1
  21. package/lib/cjs/components/select/search.js +9 -5
  22. package/lib/cjs/components/select/search.js.map +1 -1
  23. package/lib/cjs/components/select/select.js +22 -5
  24. package/lib/cjs/components/select/select.js.map +1 -1
  25. package/lib/cjs/components/select/tags.js.map +1 -1
  26. package/lib/cjs/components/select/templates.js.map +1 -1
  27. package/lib/cjs/components/select/utils.js +10 -0
  28. package/lib/cjs/components/select/utils.js.map +1 -1
  29. package/lib/cjs/components/sticky/sticky.js +104 -24
  30. package/lib/cjs/components/sticky/sticky.js.map +1 -1
  31. package/lib/cjs/components/theme-switch/theme-switch.js +0 -2
  32. package/lib/cjs/components/theme-switch/theme-switch.js.map +1 -1
  33. package/lib/cjs/components/toast/toast.js +1 -2
  34. package/lib/cjs/components/toast/toast.js.map +1 -1
  35. package/lib/cjs/helpers/dom.js +0 -2
  36. package/lib/cjs/helpers/dom.js.map +1 -1
  37. package/lib/esm/components/collapse/collapse.js +0 -2
  38. package/lib/esm/components/collapse/collapse.js.map +1 -1
  39. package/lib/esm/components/component.js +3 -1
  40. package/lib/esm/components/component.js.map +1 -1
  41. package/lib/esm/components/datatable/datatable-sort.js +1 -2
  42. package/lib/esm/components/datatable/datatable-sort.js.map +1 -1
  43. package/lib/esm/components/datatable/datatable.js +45 -23
  44. package/lib/esm/components/datatable/datatable.js.map +1 -1
  45. package/lib/esm/components/drawer/drawer.js +21 -9
  46. package/lib/esm/components/drawer/drawer.js.map +1 -1
  47. package/lib/esm/components/dropdown/dropdown.js.map +1 -1
  48. package/lib/esm/components/scrollto/scrollto.js +0 -2
  49. package/lib/esm/components/scrollto/scrollto.js.map +1 -1
  50. package/lib/esm/components/select/combobox.js.map +1 -1
  51. package/lib/esm/components/select/dropdown.js.map +1 -1
  52. package/lib/esm/components/select/remote.js.map +1 -1
  53. package/lib/esm/components/select/search.js +9 -5
  54. package/lib/esm/components/select/search.js.map +1 -1
  55. package/lib/esm/components/select/select.js +22 -5
  56. package/lib/esm/components/select/select.js.map +1 -1
  57. package/lib/esm/components/select/tags.js.map +1 -1
  58. package/lib/esm/components/select/templates.js.map +1 -1
  59. package/lib/esm/components/select/utils.js +10 -0
  60. package/lib/esm/components/select/utils.js.map +1 -1
  61. package/lib/esm/components/sticky/sticky.js +104 -24
  62. package/lib/esm/components/sticky/sticky.js.map +1 -1
  63. package/lib/esm/components/theme-switch/theme-switch.js +0 -2
  64. package/lib/esm/components/theme-switch/theme-switch.js.map +1 -1
  65. package/lib/esm/components/toast/toast.js +1 -2
  66. package/lib/esm/components/toast/toast.js.map +1 -1
  67. package/lib/esm/helpers/dom.js +0 -2
  68. package/lib/esm/helpers/dom.js.map +1 -1
  69. package/package.json +14 -7
  70. package/src/components/collapse/collapse.ts +0 -3
  71. package/src/components/component.ts +5 -5
  72. package/src/components/datatable/__tests__/currency-sort.test.ts +108 -0
  73. package/src/components/datatable/__tests__/multi-row-headers.test.ts +121 -0
  74. package/src/components/datatable/__tests__/pagination-reset.test.ts +13 -5
  75. package/src/components/datatable/__tests__/race-conditions.test.ts +138 -78
  76. package/src/components/datatable/__tests__/setup.ts +9 -4
  77. package/src/components/datatable/datatable-sort.ts +12 -16
  78. package/src/components/datatable/datatable.css +4 -4
  79. package/src/components/datatable/datatable.ts +56 -26
  80. package/src/components/datatable/types.ts +3 -1
  81. package/src/components/drawer/drawer.ts +61 -24
  82. package/src/components/dropdown/dropdown.ts +3 -1
  83. package/src/components/scrollto/scrollto.ts +0 -3
  84. package/src/components/select/__tests__/ux-behaviors.test.ts +274 -8
  85. package/src/components/select/combobox.ts +0 -1
  86. package/src/components/select/dropdown.ts +0 -2
  87. package/src/components/select/remote.ts +1 -6
  88. package/src/components/select/search.ts +14 -7
  89. package/src/components/select/select.ts +29 -29
  90. package/src/components/select/tags.ts +0 -1
  91. package/src/components/select/templates.ts +8 -8
  92. package/src/components/select/utils.ts +15 -2
  93. package/src/components/sticky/__tests__/sticky.test.ts +205 -0
  94. package/src/components/sticky/sticky.ts +119 -21
  95. package/src/components/sticky/types.ts +3 -0
  96. package/src/components/theme-switch/theme-switch.ts +0 -3
  97. package/src/components/toast/toast.ts +3 -2
  98. package/src/helpers/dom.ts +0 -3
@@ -141,13 +141,15 @@ export class KTSelect extends KTComponent {
141
141
  /**
142
142
  * Override _dispatchEvent to also dispatch on document for global listeners (jQuery compatibility)
143
143
  */
144
- protected override _dispatchEvent(eventType: string, payload: object = null): void {
144
+ protected override _dispatchEvent(
145
+ eventType: string,
146
+ payload: object = null,
147
+ ): void {
145
148
  // Call parent method to dispatch on element (existing behavior)
146
149
  super._dispatchEvent(eventType, payload);
147
150
 
148
151
  // Also dispatch on document if configured
149
- const dispatchGlobalEvents =
150
- this._config.dispatchGlobalEvents !== false; // Default to true
152
+ const dispatchGlobalEvents = this._config.dispatchGlobalEvents !== false; // Default to true
151
153
  if (dispatchGlobalEvents) {
152
154
  // Create event detail structure
153
155
  const eventDetail = {
@@ -183,7 +185,6 @@ export class KTSelect extends KTComponent {
183
185
  private _initializeRemoteData() {
184
186
  if (!this._remoteModule || !this._config.remote) return;
185
187
 
186
-
187
188
  // For remote data, we need to create the HTML structure first
188
189
  // so that the component can be properly initialized
189
190
  this._createHtmlStructure();
@@ -196,7 +197,6 @@ export class KTSelect extends KTComponent {
196
197
  this._remoteModule
197
198
  .fetchData()
198
199
  .then((items) => {
199
-
200
200
  // Remove placeholder/loading options before setting new items
201
201
  this._clearExistingOptions();
202
202
 
@@ -207,7 +207,6 @@ export class KTSelect extends KTComponent {
207
207
  // Generate options from the fetched data
208
208
  this._generateOptionsHtml(this._element);
209
209
 
210
-
211
210
  // Update the dropdown to show the new options
212
211
  this._updateDropdownWithNewOptions();
213
212
 
@@ -316,7 +315,6 @@ export class KTSelect extends KTComponent {
316
315
  this._options = this._dropdownContentElement.querySelectorAll(
317
316
  '[data-kt-select-option]',
318
317
  ) as NodeListOf<HTMLElement>;
319
-
320
318
  }
321
319
 
322
320
  /**
@@ -348,7 +346,6 @@ export class KTSelect extends KTComponent {
348
346
 
349
347
  // Apply pre-selected values captured before remote data was loaded
350
348
  if (this._preSelectedValues.length > 0) {
351
-
352
349
  // Get all available option values from the loaded remote data
353
350
  const availableValues = Array.from(
354
351
  this._element.querySelectorAll('option'),
@@ -365,7 +362,6 @@ export class KTSelect extends KTComponent {
365
362
  ? validPreSelectedValues
366
363
  : [validPreSelectedValues[0]];
367
364
 
368
-
369
365
  // Get any existing selections from _preSelectOptions (e.g., data-kt-select-pre-selected)
370
366
  const existingSelections = this._state.getSelectedOptions();
371
367
 
@@ -636,7 +632,6 @@ export class KTSelect extends KTComponent {
636
632
  this._options = this._dropdownContentElement.querySelectorAll(
637
633
  `[data-kt-select-option]`,
638
634
  ) as NodeListOf<HTMLElement>;
639
-
640
635
  }
641
636
 
642
637
  /**
@@ -961,7 +956,6 @@ export class KTSelect extends KTComponent {
961
956
  private _generateOptionsHtml(element: HTMLElement) {
962
957
  const items = this._state.getItems() || [];
963
958
 
964
-
965
959
  // Only modify options if we have items to replace them with
966
960
  if (items && items.length > 0) {
967
961
  // Clear existing options except the first empty one
@@ -997,7 +991,6 @@ export class KTSelect extends KTComponent {
997
991
  extractedLabel !== null ? String(extractedLabel) : 'Unnamed option';
998
992
  }
999
993
 
1000
-
1001
994
  // Set option attributes
1002
995
  optionElement.value = value;
1003
996
  optionElement.textContent = label || 'Unnamed option';
@@ -1008,7 +1001,6 @@ export class KTSelect extends KTComponent {
1008
1001
 
1009
1002
  element.appendChild(optionElement);
1010
1003
  });
1011
-
1012
1004
  }
1013
1005
  }
1014
1006
 
@@ -1076,8 +1068,7 @@ export class KTSelect extends KTComponent {
1076
1068
  }
1077
1069
 
1078
1070
  // Global dropdown management: close other open dropdowns if configured
1079
- const closeOnOtherOpen =
1080
- this._config.closeOnOtherOpen !== false; // Default to true
1071
+ const closeOnOtherOpen = this._config.closeOnOtherOpen !== false; // Default to true
1081
1072
  if (closeOnOtherOpen) {
1082
1073
  // Close all other open dropdowns
1083
1074
  const otherSelectsToClose: KTSelect[] = [];
@@ -1093,7 +1084,6 @@ export class KTSelect extends KTComponent {
1093
1084
  });
1094
1085
  }
1095
1086
 
1096
-
1097
1087
  // Set our internal flag to match what we're doing
1098
1088
  this._dropdownIsOpen = true;
1099
1089
 
@@ -1283,16 +1273,19 @@ export class KTSelect extends KTComponent {
1283
1273
  */
1284
1274
  private _syncNativeSelectValue(): void {
1285
1275
  const selectedOptions = this.getSelectedOptions();
1276
+ const selectEl = this._element as HTMLSelectElement;
1286
1277
 
1287
1278
  if (this._config.multiple) {
1288
- // For multiple select, the selected options are marked via option.selected
1289
- // The native select's value property will return the first selected option's value
1290
- // FormData will include all selected values automatically
1279
+ // For multiple select, set each native option's selected from internal state
1280
+ const selectedSet = new Set(selectedOptions);
1281
+ Array.from(selectEl.options).forEach((option) => {
1282
+ option.selected = selectedSet.has(option.value);
1283
+ });
1291
1284
  } else {
1292
1285
  // For single select, set the value attribute explicitly
1293
1286
  const selectedValue =
1294
1287
  selectedOptions.length > 0 ? selectedOptions[0] : '';
1295
- (this._element as HTMLSelectElement).value = selectedValue;
1288
+ selectEl.value = selectedValue;
1296
1289
  }
1297
1290
  }
1298
1291
 
@@ -1371,6 +1364,7 @@ export class KTSelect extends KTComponent {
1371
1364
  * Update CSS classes for selected options
1372
1365
  */
1373
1366
  private _updateSelectedOptionClass(): void {
1367
+ if (!this._dropdownContentElement) return;
1374
1368
  const allOptions = this._dropdownContentElement.querySelectorAll(
1375
1369
  `[data-kt-select-option]`,
1376
1370
  );
@@ -1379,7 +1373,6 @@ export class KTSelect extends KTComponent {
1379
1373
  typeof this._config.maxSelections === 'number' &&
1380
1374
  selectedValues.length >= this._config.maxSelections;
1381
1375
 
1382
-
1383
1376
  allOptions.forEach((option) => {
1384
1377
  const optionValue = option.getAttribute('data-value');
1385
1378
  if (!optionValue) return;
@@ -1502,6 +1495,8 @@ export class KTSelect extends KTComponent {
1502
1495
  public setSelectedOptions(options: HTMLOptionElement[]) {
1503
1496
  const values = Array.from(options).map((option) => option.value);
1504
1497
  this._state.setSelectedOptions(values);
1498
+ this.updateSelectedOptionDisplay();
1499
+ this._updateSelectedOptionClass();
1505
1500
  }
1506
1501
 
1507
1502
  /**
@@ -1791,8 +1786,7 @@ export class KTSelect extends KTComponent {
1791
1786
  // Check if we should close based on closeOnEnter config
1792
1787
  // closeOnEnter only applies to Enter key selections, but for backward compatibility,
1793
1788
  // we'll respect it for all selections when explicitly set to false
1794
- const shouldClose =
1795
- this._config.closeOnEnter !== false; // Default to true
1789
+ const shouldClose = this._config.closeOnEnter !== false; // Default to true
1796
1790
  if (shouldClose) {
1797
1791
  this.closeDropdown();
1798
1792
  } else {
@@ -1954,7 +1948,6 @@ export class KTSelect extends KTComponent {
1954
1948
  availableValues.includes(value),
1955
1949
  );
1956
1950
 
1957
-
1958
1951
  // Add new options from remote data and restore selection state
1959
1952
  items.forEach((item) => {
1960
1953
  const option = document.createElement('option');
@@ -2049,7 +2042,6 @@ export class KTSelect extends KTComponent {
2049
2042
  availableValues.includes(value),
2050
2043
  );
2051
2044
 
2052
-
2053
2045
  // Mark preserved selections on new options
2054
2046
  validSelections.forEach((value) => {
2055
2047
  const option = Array.from(
@@ -2126,7 +2118,6 @@ export class KTSelect extends KTComponent {
2126
2118
  availableValues.includes(value),
2127
2119
  );
2128
2120
 
2129
-
2130
2121
  // Add new options and restore selection state
2131
2122
  items.forEach((item) => {
2132
2123
  const option = document.createElement('option');
@@ -2161,7 +2152,9 @@ export class KTSelect extends KTComponent {
2161
2152
  this._fireEvent('refreshError');
2162
2153
  });
2163
2154
  } else {
2164
- // For static selects, just sync visual state
2155
+ // For static selects, bail out if called before init (e.g. right after getOrCreateInstance)
2156
+ if (!this._dropdownContentElement) return;
2157
+ // Sync visual state
2165
2158
  this._syncSelectionFromNative();
2166
2159
 
2167
2160
  // Reapply ARIA attributes
@@ -2352,7 +2345,6 @@ export class KTSelect extends KTComponent {
2352
2345
  this._searchModule.refreshAfterSearch();
2353
2346
  }
2354
2347
  this.updateSelectAllButtonState();
2355
-
2356
2348
  }
2357
2349
 
2358
2350
  /**
@@ -2411,7 +2403,6 @@ export class KTSelect extends KTComponent {
2411
2403
 
2412
2404
  this._element.appendChild(optionElement);
2413
2405
  });
2414
-
2415
2406
  }
2416
2407
 
2417
2408
  /**
@@ -2457,6 +2448,15 @@ export class KTSelect extends KTComponent {
2457
2448
  * Centralized keyboard event handler for all select modes
2458
2449
  */
2459
2450
  private _handleKeyboardEvent(event: KeyboardEvent) {
2451
+ // When search is enabled and focus is on the search input, let the search module be the sole
2452
+ // handler for Enter (and Space). This avoids the select's FocusManager from selecting the wrong option.
2453
+ if (
2454
+ this._searchInputElement &&
2455
+ event.target === this._searchInputElement &&
2456
+ (event.key === 'Enter' || event.key === ' ')
2457
+ ) {
2458
+ return;
2459
+ }
2460
2460
  // If the event target is the search input and the event was already handled (defaultPrevented),
2461
2461
  // then return early to avoid duplicate processing by this broader handler.
2462
2462
  if (event.target === this._searchInputElement && event.defaultPrevented) {
@@ -25,7 +25,6 @@ export class KTSelectTags {
25
25
  this._config = select.getConfig();
26
26
  this._valueDisplayElement = select.getValueDisplayElement();
27
27
  this._eventManager = new EventManager();
28
-
29
28
  }
30
29
 
31
30
  /**
@@ -156,7 +156,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
156
156
  dropdown: (
157
157
  config: KTSelectConfigInterface & { zindex?: number; content?: string },
158
158
  ) => {
159
- let template = getTemplateStrings(config).dropdown;
159
+ const template = getTemplateStrings(config).dropdown;
160
160
  // If a custom dropdownTemplate is provided, it's responsible for its own content.
161
161
  // Otherwise, the base template is used, and content is appended later.
162
162
  if (config.dropdownTemplate) {
@@ -200,7 +200,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
200
200
  * Renders the load more button for pagination
201
201
  */
202
202
  loadMore: (config: KTSelectConfigInterface): HTMLElement => {
203
- let html = getTemplateStrings(config)
203
+ const html = getTemplateStrings(config)
204
204
  .loadMore // .replace('{{loadMoreText}}', config.loadMoreText || 'Load more...') // Content is no longer in template string
205
205
  .replace('{{class}}', config.loadMoreClass || '');
206
206
  const element = stringToElement(html);
@@ -239,7 +239,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
239
239
  * Renders the display element (trigger) for the select
240
240
  */
241
241
  display: (config: KTSelectConfigInterface): HTMLElement => {
242
- let html = getTemplateStrings(config)
242
+ const html = getTemplateStrings(config)
243
243
  .display.replace('{{tabindex}}', config.disabled ? '-1' : '0')
244
244
  .replace('{{label}}', config.label || config.placeholder || 'Select...')
245
245
  .replace('{{disabled}}', config.disabled ? 'aria-disabled="true"' : '')
@@ -335,7 +335,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
335
335
  * Renders the search input
336
336
  */
337
337
  search: (config: KTSelectConfigInterface): HTMLElement => {
338
- let html = getTemplateStrings(config)
338
+ const html = getTemplateStrings(config)
339
339
  .search.replace(
340
340
  '{{searchPlaceholder}}',
341
341
  config.searchPlaceholder || 'Search...',
@@ -348,7 +348,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
348
348
  * Renders the no results message
349
349
  */
350
350
  searchEmpty: (config: KTSelectConfigInterface): HTMLElement => {
351
- let html = getTemplateStrings(config).searchEmpty.replace(
351
+ const html = getTemplateStrings(config).searchEmpty.replace(
352
352
  '{{class}}',
353
353
  config.searchEmptyClass || '',
354
354
  );
@@ -376,7 +376,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
376
376
  config: KTSelectConfigInterface,
377
377
  loadingMessage: string,
378
378
  ): HTMLElement => {
379
- let html = getTemplateStrings(config).loading.replace(
379
+ const html = getTemplateStrings(config).loading.replace(
380
380
  '{{class}}',
381
381
  config.loadingClass || '',
382
382
  );
@@ -392,7 +392,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
392
392
  option: HTMLOptionElement,
393
393
  config: KTSelectConfigInterface,
394
394
  ): HTMLElement => {
395
- let template = getTemplateStrings(config).tag;
395
+ const template = getTemplateStrings(config).tag;
396
396
  let preparedContent =
397
397
  option.textContent || option.innerText || option.value || ''; // Default content is the option's text
398
398
 
@@ -444,7 +444,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
444
444
  * Renders the placeholder for the select
445
445
  */
446
446
  placeholder: (config: KTSelectConfigInterface): HTMLElement => {
447
- let html = getTemplateStrings(config).placeholder.replace(
447
+ const html = getTemplateStrings(config).placeholder.replace(
448
448
  '{{class}}',
449
449
  config.placeholderClass || '',
450
450
  );
@@ -227,7 +227,7 @@ export class FocusManager {
227
227
  this._focusedOptionIndex === null
228
228
  ? 0
229
229
  : (this._focusedOptionIndex + 1) % options.length;
230
- let startIdx = idx;
230
+ const startIdx = idx;
231
231
  do {
232
232
  const option = options[idx];
233
233
  if (
@@ -255,7 +255,7 @@ export class FocusManager {
255
255
  this._focusedOptionIndex === null
256
256
  ? options.length - 1
257
257
  : (this._focusedOptionIndex - 1 + options.length) % options.length;
258
- let startIdx = idx;
258
+ const startIdx = idx;
259
259
  do {
260
260
  const option = options[idx];
261
261
  if (
@@ -367,6 +367,19 @@ export class FocusManager {
367
367
  return options[this._focusedOptionIndex];
368
368
  }
369
369
 
370
+ // Fallback: DOM may have focus class applied (e.g. by arrow keys from search input)
371
+ // while _focusedOptionIndex is out of sync. Use the option that has the focus class.
372
+ const focusedEl = this._element.querySelector(
373
+ `${this._optionsSelector}.${this._focusClass}`,
374
+ ) as HTMLElement | null;
375
+ if (focusedEl && !focusedEl.classList.contains('hidden') && focusedEl.style.display !== 'none') {
376
+ const idx = options.indexOf(focusedEl);
377
+ if (idx >= 0) {
378
+ this._focusedOptionIndex = idx;
379
+ return focusedEl;
380
+ }
381
+ }
382
+
370
383
  return null;
371
384
  }
372
385
 
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Tests for KTSticky component (PR #107: release delay, active/release classes, debounce)
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
+ import * as KTDomModule from '../../../helpers/dom';
7
+ import { KTSticky } from '../sticky';
8
+ import { waitFor } from '../../datatable/__tests__/setup';
9
+
10
+ describe('KTSticky', () => {
11
+ let stickyEl: HTMLElement;
12
+ let wrapper: HTMLElement;
13
+ let scrollTop = 0;
14
+
15
+ beforeEach(() => {
16
+ document.body.innerHTML = '';
17
+ scrollTop = 0;
18
+ vi.spyOn(KTDomModule.default, 'getScrollTop').mockImplementation(() => scrollTop);
19
+ vi.spyOn(KTDomModule.default, 'getViewPort').mockReturnValue({ height: 800, width: 1024 });
20
+
21
+ wrapper = document.createElement('div');
22
+ wrapper.setAttribute('data-kt-sticky-wrapper', 'true');
23
+ wrapper.style.height = '60px';
24
+
25
+ stickyEl = document.createElement('div');
26
+ stickyEl.setAttribute('data-kt-sticky', 'true');
27
+ stickyEl.setAttribute('data-kt-sticky-name', 'test');
28
+ stickyEl.setAttribute('data-kt-sticky-target', 'body');
29
+ stickyEl.setAttribute('data-kt-sticky-offset', '100');
30
+ stickyEl.setAttribute('data-kt-sticky-zindex', '10'); // required for _enable() to set position: fixed
31
+ stickyEl.style.height = '60px';
32
+ wrapper.appendChild(stickyEl);
33
+ document.body.appendChild(wrapper);
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.useRealTimers();
38
+ vi.restoreAllMocks();
39
+ document.body.innerHTML = '';
40
+ });
41
+
42
+ describe('PR #107: data-kt-sticky-release-delay', () => {
43
+ it('reads releaseDelay from data-kt-sticky-release-delay attribute', () => {
44
+ stickyEl.setAttribute('data-kt-sticky-release-delay', '300');
45
+ const instance = new KTSticky(stickyEl);
46
+ expect(instance.getOption('releaseDelay')).toBe(300);
47
+ instance.dispose();
48
+ });
49
+
50
+ it('delays clearing inline styles when sticky is released (smooth exit)', async () => {
51
+ vi.useFakeTimers();
52
+ stickyEl.setAttribute('data-kt-sticky-release-delay', '250');
53
+ const instance = new KTSticky(stickyEl);
54
+
55
+ // Scroll past offset -> sticky becomes active (sync, no debounce when not active)
56
+ scrollTop = 150;
57
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
58
+ vi.advanceTimersByTime(0);
59
+ expect(stickyEl.classList.contains('active')).toBe(true);
60
+ expect(stickyEl.style.position).toBe('fixed');
61
+
62
+ // Scroll back up -> release (when active, debounce 200ms then _process)
63
+ scrollTop = 50;
64
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
65
+ vi.advanceTimersByTime(250); // debounce 200ms + a bit
66
+ expect(stickyEl.classList.contains('release')).toBe(true);
67
+ expect(stickyEl.style.position).toBe('fixed'); // not cleared yet (delay 250ms)
68
+
69
+ vi.advanceTimersByTime(250); // release delay
70
+ expect(stickyEl.style.position).toBe('');
71
+ expect(stickyEl.style.zIndex).toBe('');
72
+
73
+ vi.useRealTimers();
74
+ instance.dispose();
75
+ });
76
+
77
+ it('with no releaseDelay, inline styles are cleared immediately on release', async () => {
78
+ stickyEl.removeAttribute('data-kt-sticky-release-delay');
79
+ const instance = new KTSticky(stickyEl);
80
+
81
+ scrollTop = 150;
82
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
83
+ await waitFor(50);
84
+ expect(stickyEl.style.position).toBe('fixed');
85
+
86
+ scrollTop = 50;
87
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
88
+ await waitFor(250); // debounce 200ms
89
+ expect(stickyEl.style.position).toBe('');
90
+ instance.dispose();
91
+ });
92
+ });
93
+
94
+ describe('PR #107: data-kt-sticky-active-class and data-kt-sticky-release-class', () => {
95
+ it('applies activeClass when sticky is active and releaseClass when released', async () => {
96
+ stickyEl.setAttribute('data-kt-sticky-active-class', 'sticky-on');
97
+ stickyEl.setAttribute('data-kt-sticky-release-class', 'sticky-off');
98
+ const instance = new KTSticky(stickyEl);
99
+
100
+ scrollTop = 150;
101
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
102
+ await waitFor(50);
103
+ expect(stickyEl.classList.contains('sticky-on')).toBe(true);
104
+ expect(stickyEl.classList.contains('sticky-off')).toBe(false);
105
+
106
+ scrollTop = 50;
107
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
108
+ await waitFor(250);
109
+ expect(stickyEl.classList.contains('sticky-on')).toBe(false);
110
+ expect(stickyEl.classList.contains('sticky-off')).toBe(true);
111
+
112
+ instance.dispose();
113
+ });
114
+
115
+ it('falls back to data-kt-sticky-class when activeClass is not set', async () => {
116
+ stickyEl.setAttribute('data-kt-sticky-class', 'legacy-sticky');
117
+ const instance = new KTSticky(stickyEl);
118
+
119
+ scrollTop = 150;
120
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
121
+ await waitFor(50);
122
+ expect(stickyEl.classList.contains('legacy-sticky')).toBe(true);
123
+ instance.dispose();
124
+ });
125
+ });
126
+
127
+ describe('debounced scroll (no flicker on rapid scroll)', () => {
128
+ it('when active, scroll handler debounces so _process runs after delay', async () => {
129
+ vi.useFakeTimers();
130
+ const instance = new KTSticky(stickyEl);
131
+ scrollTop = 150;
132
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
133
+ vi.advanceTimersByTime(0);
134
+ expect(stickyEl.classList.contains('active')).toBe(true);
135
+
136
+ // Rapid scroll back: multiple scroll events, only one _process after debounce
137
+ scrollTop = 50;
138
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
139
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
140
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
141
+ expect(stickyEl.classList.contains('release')).toBe(false); // not yet, debounce pending
142
+ vi.advanceTimersByTime(250);
143
+ expect(stickyEl.classList.contains('release')).toBe(true);
144
+
145
+ vi.useRealTimers();
146
+ instance.dispose();
147
+ });
148
+ });
149
+
150
+ describe('change event', () => {
151
+ it('fires change event when sticky becomes active and when released', async () => {
152
+ const instance = new KTSticky(stickyEl);
153
+ const changes: { active: boolean }[] = [];
154
+ instance.on('change', (payload: { active: boolean }) => changes.push(payload));
155
+
156
+ scrollTop = 150;
157
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
158
+ await waitFor(50);
159
+ expect(changes).toContainEqual({ active: true });
160
+
161
+ scrollTop = 50;
162
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
163
+ await waitFor(250);
164
+ expect(changes).toContainEqual({ active: false });
165
+
166
+ instance.dispose();
167
+ });
168
+ });
169
+
170
+ describe('dispose', () => {
171
+ it('clears release-delay timeout so _resetStyles does not run after dispose', async () => {
172
+ vi.useFakeTimers();
173
+ stickyEl.setAttribute('data-kt-sticky-release-delay', '500');
174
+ const instance = new KTSticky(stickyEl);
175
+
176
+ scrollTop = 150;
177
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
178
+ vi.advanceTimersByTime(0);
179
+
180
+ scrollTop = 50;
181
+ window.dispatchEvent(new Event('scroll', { bubbles: true }));
182
+ vi.advanceTimersByTime(250);
183
+ expect(stickyEl.style.position).toBe('fixed');
184
+
185
+ instance.dispose();
186
+ vi.advanceTimersByTime(600);
187
+ // If timeout weren't cleared, _resetStyles would have run; element is disposed so we just ensure no throw
188
+ vi.useRealTimers();
189
+ });
190
+ });
191
+
192
+ describe('getInstance / getOrCreateInstance', () => {
193
+ it('getInstance returns null for element without data-kt-sticky', () => {
194
+ stickyEl.removeAttribute('data-kt-sticky');
195
+ expect(KTSticky.getInstance(stickyEl)).toBeNull();
196
+ });
197
+
198
+ it('getOrCreateInstance creates instance and returns it', () => {
199
+ const instance = KTSticky.getOrCreateInstance(stickyEl);
200
+ expect(instance).toBeInstanceOf(KTSticky);
201
+ expect(KTSticky.getInstance(stickyEl)).toBe(instance);
202
+ instance.dispose();
203
+ });
204
+ });
205
+ });