@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.
- package/dist/ktui.js +11232 -11095
- package/dist/ktui.min.js +1 -1
- package/dist/ktui.min.js.map +1 -1
- package/dist/styles.css +33 -27
- package/lib/cjs/components/collapse/collapse.js +0 -2
- package/lib/cjs/components/collapse/collapse.js.map +1 -1
- package/lib/cjs/components/component.js +3 -1
- package/lib/cjs/components/component.js.map +1 -1
- package/lib/cjs/components/datatable/datatable-sort.js +1 -2
- package/lib/cjs/components/datatable/datatable-sort.js.map +1 -1
- package/lib/cjs/components/datatable/datatable.js +45 -23
- package/lib/cjs/components/datatable/datatable.js.map +1 -1
- package/lib/cjs/components/drawer/drawer.js +21 -9
- package/lib/cjs/components/drawer/drawer.js.map +1 -1
- package/lib/cjs/components/dropdown/dropdown.js.map +1 -1
- package/lib/cjs/components/scrollto/scrollto.js +0 -2
- package/lib/cjs/components/scrollto/scrollto.js.map +1 -1
- package/lib/cjs/components/select/combobox.js.map +1 -1
- package/lib/cjs/components/select/dropdown.js.map +1 -1
- package/lib/cjs/components/select/remote.js.map +1 -1
- package/lib/cjs/components/select/search.js +9 -5
- package/lib/cjs/components/select/search.js.map +1 -1
- package/lib/cjs/components/select/select.js +22 -5
- package/lib/cjs/components/select/select.js.map +1 -1
- package/lib/cjs/components/select/tags.js.map +1 -1
- package/lib/cjs/components/select/templates.js.map +1 -1
- package/lib/cjs/components/select/utils.js +10 -0
- package/lib/cjs/components/select/utils.js.map +1 -1
- package/lib/cjs/components/sticky/sticky.js +104 -24
- package/lib/cjs/components/sticky/sticky.js.map +1 -1
- package/lib/cjs/components/theme-switch/theme-switch.js +0 -2
- package/lib/cjs/components/theme-switch/theme-switch.js.map +1 -1
- package/lib/cjs/components/toast/toast.js +1 -2
- package/lib/cjs/components/toast/toast.js.map +1 -1
- package/lib/cjs/helpers/dom.js +0 -2
- package/lib/cjs/helpers/dom.js.map +1 -1
- package/lib/esm/components/collapse/collapse.js +0 -2
- package/lib/esm/components/collapse/collapse.js.map +1 -1
- package/lib/esm/components/component.js +3 -1
- package/lib/esm/components/component.js.map +1 -1
- package/lib/esm/components/datatable/datatable-sort.js +1 -2
- package/lib/esm/components/datatable/datatable-sort.js.map +1 -1
- package/lib/esm/components/datatable/datatable.js +45 -23
- package/lib/esm/components/datatable/datatable.js.map +1 -1
- package/lib/esm/components/drawer/drawer.js +21 -9
- package/lib/esm/components/drawer/drawer.js.map +1 -1
- package/lib/esm/components/dropdown/dropdown.js.map +1 -1
- package/lib/esm/components/scrollto/scrollto.js +0 -2
- package/lib/esm/components/scrollto/scrollto.js.map +1 -1
- package/lib/esm/components/select/combobox.js.map +1 -1
- package/lib/esm/components/select/dropdown.js.map +1 -1
- package/lib/esm/components/select/remote.js.map +1 -1
- package/lib/esm/components/select/search.js +9 -5
- package/lib/esm/components/select/search.js.map +1 -1
- package/lib/esm/components/select/select.js +22 -5
- package/lib/esm/components/select/select.js.map +1 -1
- package/lib/esm/components/select/tags.js.map +1 -1
- package/lib/esm/components/select/templates.js.map +1 -1
- package/lib/esm/components/select/utils.js +10 -0
- package/lib/esm/components/select/utils.js.map +1 -1
- package/lib/esm/components/sticky/sticky.js +104 -24
- package/lib/esm/components/sticky/sticky.js.map +1 -1
- package/lib/esm/components/theme-switch/theme-switch.js +0 -2
- package/lib/esm/components/theme-switch/theme-switch.js.map +1 -1
- package/lib/esm/components/toast/toast.js +1 -2
- package/lib/esm/components/toast/toast.js.map +1 -1
- package/lib/esm/helpers/dom.js +0 -2
- package/lib/esm/helpers/dom.js.map +1 -1
- package/package.json +14 -7
- package/src/components/collapse/collapse.ts +0 -3
- package/src/components/component.ts +5 -5
- package/src/components/datatable/__tests__/currency-sort.test.ts +108 -0
- package/src/components/datatable/__tests__/multi-row-headers.test.ts +121 -0
- package/src/components/datatable/__tests__/pagination-reset.test.ts +13 -5
- package/src/components/datatable/__tests__/race-conditions.test.ts +138 -78
- package/src/components/datatable/__tests__/setup.ts +9 -4
- package/src/components/datatable/datatable-sort.ts +12 -16
- package/src/components/datatable/datatable.css +4 -4
- package/src/components/datatable/datatable.ts +56 -26
- package/src/components/datatable/types.ts +3 -1
- package/src/components/drawer/drawer.ts +61 -24
- package/src/components/dropdown/dropdown.ts +3 -1
- package/src/components/scrollto/scrollto.ts +0 -3
- package/src/components/select/__tests__/ux-behaviors.test.ts +274 -8
- package/src/components/select/combobox.ts +0 -1
- package/src/components/select/dropdown.ts +0 -2
- package/src/components/select/remote.ts +1 -6
- package/src/components/select/search.ts +14 -7
- package/src/components/select/select.ts +29 -29
- package/src/components/select/tags.ts +0 -1
- package/src/components/select/templates.ts +8 -8
- package/src/components/select/utils.ts +15 -2
- package/src/components/sticky/__tests__/sticky.test.ts +205 -0
- package/src/components/sticky/sticky.ts +119 -21
- package/src/components/sticky/types.ts +3 -0
- package/src/components/theme-switch/theme-switch.ts +0 -3
- package/src/components/toast/toast.ts +3 -2
- 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(
|
|
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,
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
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,
|
|
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) {
|
|
@@ -156,7 +156,7 @@ export const defaultTemplates: KTSelectTemplateInterface = {
|
|
|
156
156
|
dropdown: (
|
|
157
157
|
config: KTSelectConfigInterface & { zindex?: number; content?: string },
|
|
158
158
|
) => {
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|