diff --git a/.changeset/autosize-measure.md b/.changeset/autosize-measure.md new file mode 100644 index 000000000..545d586f7 --- /dev/null +++ b/.changeset/autosize-measure.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Autosize adding a phantom line after shrinking past `max-height` and wrong heights on `content-box` textareas. diff --git a/.changeset/clipboard-text-source.md b/.changeset/clipboard-text-source.md new file mode 100644 index 000000000..6610e9777 --- /dev/null +++ b/.changeset/clipboard-text-source.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Clipboard reading `data-bs-text` as a number or JSON, copying hidden text, and added `reset()` for a `delay` of 0. diff --git a/.changeset/countup-reset-and-json.md b/.changeset/countup-reset-and-json.md new file mode 100644 index 000000000..0865592ea --- /dev/null +++ b/.changeset/countup-reset-and-json.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed CountUp `reset()` blocking a later viewport start and `data-countup` or `data-sortable` JSON that is not an object. diff --git a/.changeset/datepicker-dispose-and-selection.md b/.changeset/datepicker-dispose-and-selection.md new file mode 100644 index 000000000..32c344fb7 --- /dev/null +++ b/.changeset/datepicker-dispose-and-selection.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Datepicker `dispose()` swapping the input for a stale clone, a hide timer after dispose and `Date` values for `dateMin`. diff --git a/.changeset/datepicker-write-selection.md b/.changeset/datepicker-write-selection.md new file mode 100644 index 000000000..1ac8bcd49 --- /dev/null +++ b/.changeset/datepicker-write-selection.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Datepicker keeping a deselected date in the field, `setSelectedDates()` not writing it and not opening on the value's month. diff --git a/.changeset/dropdown-disabled-shown.md b/.changeset/dropdown-disabled-shown.md new file mode 100644 index 000000000..a06974c9f --- /dev/null +++ b/.changeset/dropdown-disabled-shown.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed an open dropdown with a disabled toggle being closed by outside clicks, matching Bootstrap. diff --git a/.changeset/input-mask-passthrough.md b/.changeset/input-mask-passthrough.md new file mode 100644 index 000000000..f8dfeabe4 --- /dev/null +++ b/.changeset/input-mask-passthrough.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed InputMask dropping options other than `mask` and `lazy`, rejecting non-string masks and a bare `data-mask-visible`. diff --git a/.changeset/jquery-collection-args.md b/.changeset/jquery-collection-args.md new file mode 100644 index 000000000..67c995099 --- /dev/null +++ b/.changeset/jquery-collection-args.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Bootstrap components rejecting a jQuery collection passed as the element, `container`, `parent` or `target`. diff --git a/.changeset/js-auto-init-robustness.md b/.changeset/js-auto-init-robustness.md new file mode 100644 index 000000000..84e6a8f99 --- /dev/null +++ b/.changeset/js-auto-init-robustness.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed tooltip, popover, dropdown, toast and tab auto-init stopping the bundle on one bad element and ignoring `data-tblr-toggle`. diff --git a/.changeset/otp-events-and-length.md b/.changeset/otp-events-and-length.md new file mode 100644 index 000000000..95930611d --- /dev/null +++ b/.changeset/otp-events-and-length.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed OTP input firing `complete` on every edit of a full value, events on no-op keys, invalid `length` and `type="email"` fields. diff --git a/.changeset/selector-escape-config.md b/.changeset/selector-escape-config.md new file mode 100644 index 000000000..1793789e7 --- /dev/null +++ b/.changeset/selector-escape-config.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Clipboard `target`, Strength `input` and Datepicker `positionElement` selectors failing on ids like `#user:1`. diff --git a/.changeset/sidebar-late-load.md b/.changeset/sidebar-late-load.md new file mode 100644 index 000000000..907ba50a6 --- /dev/null +++ b/.changeset/sidebar-late-load.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed sidebar toggles not syncing when `tabler.js` loads after `DOMContentLoaded` and errors on synthetic clicks. diff --git a/.changeset/strength-partial-config.md b/.changeset/strength-partial-config.md new file mode 100644 index 000000000..6d8b8ef52 --- /dev/null +++ b/.changeset/strength-partial-config.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed a partial `messages` or `weights` config on the Strength meter dropping the default entries. diff --git a/.changeset/switch-icon-dispose.md b/.changeset/switch-icon-dispose.md new file mode 100644 index 000000000..e135dc72c --- /dev/null +++ b/.changeset/switch-icon-dispose.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed SwitchIcon throwing and staying in the loading state when a `wait()` promise settles after `dispose()`. diff --git a/.changeset/theme-storage-guard.md b/.changeset/theme-storage-guard.md new file mode 100644 index 000000000..0e45da4bf --- /dev/null +++ b/.changeset/theme-storage-guard.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed the theme switcher and sidebar toggle failing when `localStorage` is blocked, and ignored unsafe `?theme=` URL values. diff --git a/.changeset/tooltip-data-delay.md b/.changeset/tooltip-data-delay.md new file mode 100644 index 000000000..02fc2bc38 --- /dev/null +++ b/.changeset/tooltip-data-delay.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed `data-bs-delay` on tooltips and popovers being overridden by the default delay. diff --git a/core/js/src/autosize.ts b/core/js/src/autosize.ts index 7b5fbf2e1..ddf62e4f2 100644 --- a/core/js/src/autosize.ts +++ b/core/js/src/autosize.ts @@ -100,11 +100,16 @@ class Autosize extends BaseComponent { // position is put back afterwards. const { scrollTop } = document.documentElement const style = getComputedStyle(element) - const border = style.boxSizing === 'border-box' ? Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth) : 0 + // scrollHeight is the padding box: a border-box field adds its borders, a + // content-box field drops its padding. + const extra = style.boxSizing === 'border-box' ? Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth) : -(Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)) const before = element.style.height + // Measured without a scrollbar: one left over from the previous size + // would wrap the text and add a phantom line. + element.style.overflowY = 'hidden' element.style.height = 'auto' - const height = `${element.scrollHeight + border}px` + const height = `${element.scrollHeight + extra}px` element.style.height = height // Past max-height the content no longer fits, so let the field scroll. diff --git a/core/js/src/bootstrap/dropdown.ts b/core/js/src/bootstrap/dropdown.ts index 23e763c52..627e2944b 100644 --- a/core/js/src/bootstrap/dropdown.ts +++ b/core/js/src/bootstrap/dropdown.ts @@ -58,7 +58,9 @@ const CLASS_NAME_DROPUP_CENTER = 'dropup-center' const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled), [data-tblr-toggle="dropdown"]:not(.disabled):not(:disabled)' -const SELECTOR_DATA_TOGGLE_SHOWN = `.${CLASS_NAME_SHOW}[data-bs-toggle="dropdown"], .${CLASS_NAME_SHOW}[data-tblr-toggle="dropdown"]` +const SELECTOR_DATA_TOGGLE_SHOWN = SELECTOR_DATA_TOGGLE.split(', ') + .map((selector) => `${selector}.${CLASS_NAME_SHOW}`) + .join(', ') const SELECTOR_MENU = '.dropdown-menu' const SELECTOR_NAVBAR = '.navbar' const SELECTOR_NAVBAR_NAV = '.navbar-nav' diff --git a/core/js/src/bootstrap/util/index.ts b/core/js/src/bootstrap/util/index.ts index dfedec2a1..0a388ce74 100644 --- a/core/js/src/bootstrap/util/index.ts +++ b/core/js/src/bootstrap/util/index.ts @@ -62,7 +62,18 @@ const triggerTransitionEnd = (element: HTMLElement): void => { element.dispatchEvent(new Event(TRANSITION_END)) } +// A jQuery collection stands in for its first element, as in upstream Bootstrap. +const unwrapJQuery = (object: unknown): unknown => { + if (object && typeof object === 'object' && typeof (object as { jquery?: unknown }).jquery !== 'undefined') { + return (object as { 0?: unknown })[0] + } + + return object +} + const isElement = (object: unknown): object is HTMLElement => { + object = unwrapJQuery(object) + if (!object || typeof object !== 'object') { return false } @@ -71,6 +82,8 @@ const isElement = (object: unknown): object is HTMLElement => { } const getElement = (object: unknown): HTMLElement | null => { + object = unwrapJQuery(object) + if (isElement(object)) { return object } @@ -159,7 +172,7 @@ const reflow = (element: HTMLElement): void => { const isRTL = (): boolean => document.documentElement.dir === 'rtl' const getjQuery = (): JQueryStaticLike | null => { - if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) { + if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery') && !document.body.hasAttribute('data-tblr-no-jquery')) { return window.jQuery } diff --git a/core/js/src/clipboard.ts b/core/js/src/clipboard.ts index d1ae1cf76..714bc237b 100644 --- a/core/js/src/clipboard.ts +++ b/core/js/src/clipboard.ts @@ -9,6 +9,7 @@ import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions' +import { getElement } from './bootstrap/util/index' import type { ElementSelector } from './bootstrap/types' type ComponentConfig = { @@ -38,6 +39,8 @@ const CLASS_NAME_COPIED = 'copied' const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` const SELECTOR_LABEL = `.${NAME}-label` const SELECTOR_FEEDBACK = `.${NAME}-feedback` +const ATTRIBUTE_TEXT = 'data-bs-text' +const ATTRIBUTE_TEXT_ALIAS = 'data-tblr-text' const Default: ComponentConfig = { target: null, @@ -130,12 +133,18 @@ class Clipboard extends BaseComponent { return String(text) } - const source = target ? SelectorEngine.findOne(target) : null + const source = target ? getElement(target) : null if (!source) { return '' } - return source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement ? source.value : (source.textContent ?? '').trim() + if (source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement) { + return source.value + } + + // innerText keeps line breaks and skips hidden text such as a + // visually-hidden label; textContent is the fallback for SVG. + return (source instanceof HTMLElement ? source.innerText : ((source as Element).textContent ?? '')).trim() } // Public @@ -158,12 +167,27 @@ class Clipboard extends BaseComponent { EventHandler.trigger(this._element, EVENT_COPIED) } + /** Ends the copied state, for a `delay` of 0 or an early reset. */ + reset(): void { + window.clearTimeout(this._timeout) + this._toggleCopied(false) + } + dispose(): void { window.clearTimeout(this._timeout) super.dispose() } // Private + _mergeConfigObj(config?: Record, element?: HTMLElement): Record { + // The text is copied as written: `data-bs-text="007"` or `"true"` must not + // be read as a number or a boolean the way other data attributes are. + const raw = element?.getAttribute(ATTRIBUTE_TEXT) ?? element?.getAttribute(ATTRIBUTE_TEXT_ALIAS) + const dataOptions: Record = raw === null || raw === undefined ? {} : { text: raw } + + return super._mergeConfigObj({ ...dataOptions, ...config }, element) + } + _showCopied(): void { window.clearTimeout(this._timeout) this._toggleCopied(true) diff --git a/core/js/src/countup.ts b/core/js/src/countup.ts index ed9289518..a980b350c 100644 --- a/core/js/src/countup.ts +++ b/core/js/src/countup.ts @@ -200,6 +200,8 @@ class CountUp extends BaseComponent { reset(): void { this._stop() + // Lets the observer start the animation again when the element comes into view. + this._started = false this._value = this._config.startVal this._print(this._value) } @@ -239,7 +241,11 @@ class CountUp extends BaseComponent { if (raw) { try { - dataOptions = JSON.parse(raw) + const parsed: unknown = JSON.parse(raw) + // Only an object carries options; `null`, a number or an array is ignored. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + dataOptions = parsed as Record + } } catch { // ignore invalid JSON } diff --git a/core/js/src/datepicker.ts b/core/js/src/datepicker.ts index 70ebe863f..68efe0b7b 100644 --- a/core/js/src/datepicker.ts +++ b/core/js/src/datepicker.ts @@ -10,7 +10,7 @@ import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions' -import { isDisabled } from './bootstrap/util/index' +import { getElement, isDisabled } from './bootstrap/util/index' import type { ElementSelector } from './bootstrap/types' /** @@ -92,8 +92,8 @@ const Default: ComponentConfig = { const DefaultType: Record = { datepickerTheme: '(null|string)', - dateMin: '(null|string|number|object)', - dateMax: '(null|string|number|object)', + dateMin: '(null|string|number|object|date)', + dateMax: '(null|string|number|object|date)', dateFormat: '(null|object|function)', displayElement: '(null|string|element|boolean)', displayMonthsCount: 'number', @@ -136,6 +136,7 @@ class Datepicker extends BaseComponent { _isSilent = false _skipPluginShow = false _resolveShown: (() => void) | null = null + _hideTimeout = 0 _isInput = false _isInline = false _boundInput: HTMLInputElement | null = null @@ -243,13 +244,34 @@ class Datepicker extends BaseComponent { } dispose(): void { + window.clearTimeout(this._hideTimeout) this._themeObserver?.disconnect() if (this._onFocusIn) { EventHandler.off(document, EVENT_FOCUSIN, this._onFocusIn) } - this._calendar?.destroy() + if (this._calendar) { + this._calendar.destroy() + + // The plugin swaps the element for a clone taken at init, which has the + // value and listeners from back then. The live element goes back in; + // an inline calendar keeps the clone (its original children), so only + // the bound input moves back into it. + const clone = this._calendar.context.mainElement as HTMLElement | undefined + if (clone && clone !== this._element && clone.isConnected) { + if (this._isInput) { + clone.replaceWith(this._element) + } else if (this._boundInput) { + const copy = SelectorEngine.findOne(SELECTOR_BOUND_INPUT, clone) + if (copy) { + copy.replaceWith(this._boundInput) + } else { + clone.append(this._boundInput) + } + } + } + } super.dispose() } @@ -260,8 +282,9 @@ class Datepicker extends BaseComponent { } setSelectedDates(dates: DatesArr): void { - this._selectedDates = dates.map(String) - this._calendar?.set({ selectedDates: dates }) + this._selectedDates = dates.map((date) => this._toIsoDate(date)) + this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) }) + this._writeSelection(this._selectedDates) } // Private @@ -334,7 +357,7 @@ class Datepicker extends BaseComponent { let { positionElement } = this._config if (typeof positionElement === 'string') { - positionElement = SelectorEngine.findOne(positionElement) + positionElement = getElement(positionElement) } // Use the input's wrapper when it sits in an icon or group wrapper @@ -349,7 +372,7 @@ class Datepicker extends BaseComponent { const { displayElement } = this._config if (typeof displayElement === 'string') { - return SelectorEngine.findOne(displayElement) + return getElement(displayElement) } // For buttons/non-inputs (not inline), look for a display child @@ -554,9 +577,9 @@ class Datepicker extends BaseComponent { const selectedDates = [...self.context.selectedDates] this._selectedDates = selectedDates - if (selectedDates.length > 0) { - this._writeSelection(selectedDates) - } + // Written even when empty: clicking the picked day again deselects it, and + // the field must not keep submitting the old date. + this._writeSelection(selectedDates) const args: ChangeEventArgs = { dates: selectedDates, event } EventHandler.trigger(this._element, EVENT_CHANGE, args) @@ -572,7 +595,8 @@ class Datepicker extends BaseComponent { const shouldHide = (this._config.selectionMode === 'single' && selectedDates.length > 0) || (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2) if (shouldHide) { - setTimeout(() => this.hide(), HIDE_DELAY) + window.clearTimeout(this._hideTimeout) + this._hideTimeout = window.setTimeout(() => this.hide(), HIDE_DELAY) } } @@ -627,11 +651,35 @@ class Datepicker extends BaseComponent { return } + this._selectedDates = [this._toIsoDate(date)] + // The calendar opens on the month of the value, not on today. + this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) }) + } + + _toIsoDate(value: DateAny | string): string { + if (typeof value === 'string' && DATE_PATTERN.test(value)) { + return value + } + + const date = value instanceof Date ? value : typeof value === 'string' && DATE_PATTERN.test(value) ? this._parseDate(value) : new Date(value) + if (Number.isNaN(date.getTime())) { + return String(value) + } + const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') - this._selectedDates = [`${year}-${month}-${day}`] - this._calendar?.set({ selectedDates: this._selectedDates }) + return `${year}-${month}-${day}` + } + + _monthOf(value: string | undefined): Pick { + if (!value || !DATE_PATTERN.test(value)) { + return {} + } + + const date = this._parseDate(value) + // `getMonth()` is always 0-11, which TypeScript cannot narrow on its own + return { selectedMonth: date.getMonth() as Range<12>, selectedYear: date.getFullYear() } } } diff --git a/core/js/src/dropdown.ts b/core/js/src/dropdown.ts index 0df65a649..104c2a896 100644 --- a/core/js/src/dropdown.ts +++ b/core/js/src/dropdown.ts @@ -1,11 +1,15 @@ import { Dropdown } from './bootstrap' +import Manipulator from './bootstrap/dom/manipulator' // js-docs-start dropdown-init -const dropdownTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="dropdown"]')) -dropdownTriggerList.map(function (dropdownTriggerEl: HTMLElement) { - const options = { - boundary: dropdownTriggerEl.getAttribute('data-bs-boundary') === 'viewport' ? document.documentElement : ('clippingParents' as const), +for (const dropdownTriggerEl of document.querySelectorAll('[data-bs-toggle="dropdown"], [data-tblr-toggle="dropdown"]')) { + try { + const options = { + boundary: Manipulator.getDataAttribute(dropdownTriggerEl, 'boundary') === 'viewport' ? document.documentElement : ('clippingParents' as const), + } + Dropdown.getOrCreateInstance(dropdownTriggerEl, options) + } catch (error) { + console.error(error) } - return new Dropdown(dropdownTriggerEl, options) -}) +} // js-docs-end dropdown-init diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index 3eccab382..67e3bd895 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -7,7 +7,7 @@ import type { JQueryStaticLike } from './bootstrap/types' declare global { interface Window { VanillaCalendarPro?: typeof import('vanilla-calendar-pro') - IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => import('./input-mask').IMaskInstance + IMask?: new (element: HTMLElement, options: { mask: unknown; lazy?: boolean } & Record) => import('./input-mask').IMaskInstance Sortable?: new (element: HTMLElement, options?: Record) => import('./sortable').SortableInstance jQuery?: JQueryStaticLike } diff --git a/core/js/src/input-mask.ts b/core/js/src/input-mask.ts index 3fc777efb..c7edbf019 100644 --- a/core/js/src/input-mask.ts +++ b/core/js/src/input-mask.ts @@ -19,7 +19,8 @@ interface IMaskInstance { } type ComponentConfig = { - mask: string + /** an IMask mask: a pattern string, `Number`, `Date`, a RegExp or a mask object */ + mask: unknown lazy: boolean } @@ -42,7 +43,7 @@ const Default: ComponentConfig = { } const DefaultType: Record = { - mask: 'string', + mask: '(string|function|regexp|object|array)', lazy: 'boolean', } @@ -65,10 +66,8 @@ class InputMask extends BaseComponent { return } - this._mask = new window.IMask(this._element, { - mask: this._config.mask, - lazy: this._config.lazy, - }) + // Every option from the config reaches IMask, not only the two typed ones. + this._mask = new window.IMask(this._element, { ...this._config }) } // Getters @@ -110,8 +109,9 @@ class InputMask extends BaseComponent { dataOptions.mask = mask } + // A bare `data-mask-visible` means visible, like `="true"`. if (element?.hasAttribute(ATTRIBUTE_VISIBLE)) { - dataOptions.lazy = element.getAttribute(ATTRIBUTE_VISIBLE) !== 'true' + dataOptions.lazy = element.getAttribute(ATTRIBUTE_VISIBLE) === 'false' } return super._mergeConfigObj({ ...dataOptions, ...config }, element) diff --git a/core/js/src/otp-input.ts b/core/js/src/otp-input.ts index 6ebc82227..8bf48ef97 100644 --- a/core/js/src/otp-input.ts +++ b/core/js/src/otp-input.ts @@ -57,6 +57,9 @@ const TYPES: Record 0) { - return maxLength - } + const candidates: unknown[] = [this._config.length, Number.parseInt(this._input.getAttribute('maxlength') ?? '', 10)] const { groups } = this._config - if (groups && groups.length > 0) { - return groups.reduce((sum, group) => sum + group, 0) + if (Array.isArray(groups) && groups.length > 0) { + candidates.push(groups.reduce((sum, group) => sum + Number(group), 0)) + } + + // The first usable value wins; a negative, fractional, or huge length + // would render no slots or flood the DOM. + for (const candidate of candidates) { + if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) { + return Math.min(candidate, MAX_LENGTH) + } } return 6 @@ -208,7 +214,8 @@ class OtpInput extends BaseComponent { // A single text field backs the whole control so screen readers, password // managers, and SMS autofill treat it like any other input. - if (input.type === 'number' || input.type === 'password') { + // Only text-like types support the selection API the slots rely on. + if (input.type !== 'text' && input.type !== 'tel') { input.type = 'text' } @@ -328,6 +335,8 @@ class OtpInput extends BaseComponent { if (inputType === 'deleteContentBackward') { event.preventDefault() + const before = this._input.value + const start = this._input.selectionStart ?? 0 const end = this._input.selectionEnd ?? start const chars = [...this._input.value] @@ -342,13 +351,15 @@ class OtpInput extends BaseComponent { this._selectSlot(start - 1) } - this._afterValueChange() + this._afterValueChange(before) return } if (inputType === 'deleteContentForward') { event.preventDefault() + const before = this._input.value + const start = this._input.selectionStart ?? 0 const end = this._input.selectionEnd ?? start const chars = [...this._input.value] @@ -362,7 +373,7 @@ class OtpInput extends BaseComponent { } this._selectSlot(start) - this._afterValueChange() + this._afterValueChange(before) } } @@ -407,8 +418,15 @@ class OtpInput extends BaseComponent { return null } - _afterValueChange(): void { + // `before` is the value ahead of the edit: a Backspace in the first slot or + // a Delete past the last one changes nothing and fires no event. + _afterValueChange(before?: string): void { this._render() + + if (before !== undefined && before === this._input.value) { + return + } + EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value }) this._checkComplete() } @@ -442,11 +460,16 @@ class OtpInput extends BaseComponent { } } + // Fires once when the value becomes full, not on every edit of a full value. _checkComplete(): void { const { value } = this._input - if (value.length === this._length) { + const complete = value.length === this._length + + if (complete && !this._wasComplete) { EventHandler.trigger(this._element, EVENT_COMPLETE, { value }) } + + this._wasComplete = complete } } diff --git a/core/js/src/popover.ts b/core/js/src/popover.ts index d0d1dc071..a3bd66f05 100644 --- a/core/js/src/popover.ts +++ b/core/js/src/popover.ts @@ -1,13 +1,16 @@ import { Popover } from './bootstrap' +import Manipulator from './bootstrap/dom/manipulator' // js-docs-start popover-init -const popoverTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')) -popoverTriggerList.map(function (popoverTriggerEl: HTMLElement) { - const options = { - delay: { show: 50, hide: 50 }, - html: popoverTriggerEl.getAttribute('data-bs-html') === 'true', - placement: popoverTriggerEl.getAttribute('data-bs-placement') ?? 'auto', +for (const popoverTriggerEl of document.querySelectorAll('[data-bs-toggle="popover"], [data-tblr-toggle="popover"]')) { + try { + const options = { + delay: Manipulator.getDataAttribute(popoverTriggerEl, 'delay') ?? { show: 50, hide: 50 }, + placement: Manipulator.getDataAttribute(popoverTriggerEl, 'placement') ?? 'auto', + } + Popover.getOrCreateInstance(popoverTriggerEl, options) + } catch (error) { + console.error(error) } - return new Popover(popoverTriggerEl, options) -}) +} // js-docs-end popover-init diff --git a/core/js/src/sidebar.ts b/core/js/src/sidebar.ts index eea8d27b2..3bd6da1d3 100644 --- a/core/js/src/sidebar.ts +++ b/core/js/src/sidebar.ts @@ -1,11 +1,12 @@ import { Dropdown } from './bootstrap' +import { onDOMContentLoaded } from './bootstrap/util/index' /* Folded sidebar toggle */ // js-docs-start sidebar-folded-toggle const syncSidebarToggles = (folded: boolean): void => { - for (const toggle of document.querySelectorAll('[data-bs-toggle="sidebar-folded"]')) { + for (const toggle of document.querySelectorAll('[data-bs-toggle="sidebar-folded"], [data-tblr-toggle="sidebar-folded"]')) { toggle.setAttribute('aria-pressed', String(folded)) } } @@ -28,8 +29,9 @@ document.addEventListener('click', (event: MouseEvent) => { return } - const target = event.target as Element - if (target.closest('.navbar-vertical .dropdown-menu')) { + // A synthetic click dispatched on the document has no Element target. + const target = event.target + if (!(target instanceof Element) || target.closest('.navbar-vertical .dropdown-menu')) { return } @@ -60,7 +62,8 @@ document.addEventListener( // The folded state may come pre-render from localStorage (tabler-theme.js), so // align the toggle buttons with it once the DOM is available. Server-rendered // open submenus are also closed, so they don't hang as open flyouts. -document.addEventListener('DOMContentLoaded', () => { +// onDOMContentLoaded runs at once when the script loads after the event. +onDOMContentLoaded(() => { const folded = (document.documentElement.getAttribute('data-bs-sidebar') ?? '').startsWith('folded') syncSidebarToggles(folded) @@ -73,7 +76,7 @@ document.addEventListener('DOMContentLoaded', () => { }) document.addEventListener('click', (event: MouseEvent) => { - const trigger = (event.target as Element).closest('[data-bs-toggle="sidebar-folded"]') + const trigger = event.target instanceof Element ? event.target.closest('[data-bs-toggle="sidebar-folded"], [data-tblr-toggle="sidebar-folded"]') : null if (!trigger) { return } @@ -89,7 +92,12 @@ document.addEventListener('click', (event: MouseEvent) => { html.removeAttribute('data-bs-sidebar') } - localStorage.setItem('tabler-sidebar', willFold ? 'folded-hover' : 'default') + // Storage may be blocked by the browser; the fold still applies to this page. + try { + localStorage.setItem('tabler-sidebar', willFold ? 'folded-hover' : 'default') + } catch { + // ignore + } syncSidebarToggles(willFold) // Close open sidebar dropdowns so an inline submenu doesn't turn into a diff --git a/core/js/src/sortable.ts b/core/js/src/sortable.ts index cc82e2483..66be2d8a6 100644 --- a/core/js/src/sortable.ts +++ b/core/js/src/sortable.ts @@ -101,7 +101,11 @@ class Sortable extends BaseComponent { if (raw) { try { - dataOptions = JSON.parse(raw) + const parsed: unknown = JSON.parse(raw) + // Only an object carries options; `null`, a number or an array is ignored. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + dataOptions = parsed as Record + } } catch { // ignore invalid JSON } diff --git a/core/js/src/strength.ts b/core/js/src/strength.ts index 09527364b..a2fe2ce04 100644 --- a/core/js/src/strength.ts +++ b/core/js/src/strength.ts @@ -9,6 +9,7 @@ import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions' +import { getElement } from './bootstrap/util/index' import type { ElementSelector } from './bootstrap/types' type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong' @@ -164,10 +165,19 @@ class Strength extends BaseComponent { } // Private + _configAfterMerge(config: ComponentConfig): ComponentConfig { + // A partial `messages` or `weights` overrides only the keys it names. + return { + ...config, + messages: { ...Default.messages, ...config.messages }, + weights: { ...Default.weights, ...config.weights }, + } + } + _getInput(): HTMLInputElement | null { const { input } = this._config if (input) { - return SelectorEngine.findOne(input) as HTMLInputElement | null + return getElement(input) as HTMLInputElement | null } // Without an explicit selector only the meter's own parent is searched. diff --git a/core/js/src/switch-icon.ts b/core/js/src/switch-icon.ts index 4960e65d1..9da665ea5 100644 --- a/core/js/src/switch-icon.ts +++ b/core/js/src/switch-icon.ts @@ -138,8 +138,19 @@ class SwitchIcon extends BaseComponent { .finally(() => this.#setLoading(false)) } + dispose(): void { + // A wait() still pending must not leave the spinner on the button. + this.#setLoading(false) + super.dispose() + } + // Private #setActive(active: boolean): void { + // A pending wait() may settle after dispose(). + if (!this._element) { + return + } + this.#iconElement.classList.toggle(CLASS_NAME_ACTIVE, active) this._element.setAttribute('aria-pressed', String(active)) @@ -147,6 +158,10 @@ class SwitchIcon extends BaseComponent { } #setLoading(loading: boolean): void { + if (!this._element) { + return + } + this.#iconElement.classList.toggle(CLASS_NAME_LOADING, loading) if (loading) { this._element.setAttribute('aria-busy', 'true') diff --git a/core/js/src/tab.ts b/core/js/src/tab.ts index 7355ec77a..380d84176 100644 --- a/core/js/src/tab.ts +++ b/core/js/src/tab.ts @@ -4,13 +4,22 @@ import { Tab } from './bootstrap' export const EnableActivationTabsFromLocationHash = (): void => { const locationHash: string = window.location.hash - if (locationHash) { - const tabsList: HTMLAnchorElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="tab"]')) - const matchedTabs = tabsList.filter((tab: HTMLAnchorElement) => tab.hash === locationHash) + if (!locationHash) { + return + } - matchedTabs.map((tab: HTMLAnchorElement) => { - new Tab(tab).show() - }) + for (const tab of document.querySelectorAll('[data-bs-toggle="tab"], [data-tblr-toggle="tab"]')) { + if (tab.hash !== locationHash) { + continue + } + + // A tab link outside a .nav/.list-group has no parent to switch in; + // show() would throw and stop the rest of the bundle. + try { + ;(Tab.getOrCreateInstance(tab) as InstanceType).show() + } catch (error) { + console.error(error) + } } } diff --git a/core/js/src/toast.ts b/core/js/src/toast.ts index 9f700fccd..77a196342 100644 --- a/core/js/src/toast.ts +++ b/core/js/src/toast.ts @@ -1,17 +1,22 @@ import { Toast } from './bootstrap' +import SelectorEngine from './bootstrap/dom/selector-engine' // js-docs-start toast-init -const toastsTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="toast"]')) -toastsTriggerList.map(function (toastTriggerEl: HTMLElement) { - const target = toastTriggerEl.getAttribute('data-bs-target') - if (target === null) { - return +for (const toastTriggerEl of document.querySelectorAll('[data-bs-toggle="toast"], [data-tblr-toggle="toast"]')) { + try { + // A trigger whose target is missing is left alone instead of getting a + // click handler that throws. + const toastEl = SelectorEngine.getElementFromSelector(toastTriggerEl) + if (!toastEl) { + continue + } + + const toast = Toast.getOrCreateInstance(toastEl) as InstanceType + toastTriggerEl.addEventListener('click', () => { + toast.show() + }) + } catch (error) { + console.error(error) } - - const toastEl = new Toast(target) - - toastTriggerEl.addEventListener('click', () => { - toastEl.show() - }) -}) +} // js-docs-end toast-init diff --git a/core/js/src/tooltip.ts b/core/js/src/tooltip.ts index f394f4daf..d0345a67b 100644 --- a/core/js/src/tooltip.ts +++ b/core/js/src/tooltip.ts @@ -1,13 +1,19 @@ import { Tooltip } from './bootstrap' +import Manipulator from './bootstrap/dom/manipulator' // js-docs-start tooltip-init -const tooltipTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) -tooltipTriggerList.map(function (tooltipTriggerEl: HTMLElement) { - const options = { - delay: { show: 50, hide: 50 }, - html: tooltipTriggerEl.getAttribute('data-bs-html') === 'true', - placement: tooltipTriggerEl.getAttribute('data-bs-placement') ?? 'auto', +// One element with a broken config must not stop the rest of the bundle, so +// every instance is created on its own. `data-bs-delay` on the element wins +// over the shorter default delay. +for (const tooltipTriggerEl of document.querySelectorAll('[data-bs-toggle="tooltip"], [data-tblr-toggle="tooltip"]')) { + try { + const options = { + delay: Manipulator.getDataAttribute(tooltipTriggerEl, 'delay') ?? { show: 50, hide: 50 }, + placement: Manipulator.getDataAttribute(tooltipTriggerEl, 'placement') ?? 'auto', + } + Tooltip.getOrCreateInstance(tooltipTriggerEl, options) + } catch (error) { + console.error(error) } - return new Tooltip(tooltipTriggerEl, options) -}) +} // js-docs-end tooltip-init diff --git a/core/js/tabler-theme.ts b/core/js/tabler-theme.ts index 4e9da2eec..9743b2e2e 100644 --- a/core/js/tabler-theme.ts +++ b/core/js/tabler-theme.ts @@ -9,16 +9,40 @@ const params = new URLSearchParams(window.location.search) const prefersDark = window.matchMedia('(prefers-color-scheme: dark)') +// A setting value is a short token: it ends up as a `data-bs-*` attribute on +// and in localStorage, so anything else from the URL is ignored. +const VALUE_PATTERN = /^[a-z0-9-]{1,32}$/ + +// Accessing localStorage throws when site data is blocked (Chrome "block all +// cookies", Safari with storage disabled, partitioned third-party frames), so +// every access is guarded and the switcher falls back to the page defaults. +const storage = { + get(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } + }, + set(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + // ignore: the choice applies to this page only + } + }, +} + for (const key in themeDefaults) { const param = params.get(key) let selectedValue: string - if (!!param) { - localStorage.setItem('tabler-' + key, param) + if (param && VALUE_PATTERN.test(param)) { + storage.set('tabler-' + key, param) selectedValue = param } else { // A stored choice wins; otherwise a server-rendered attribute is the starting value. - const storedTheme = localStorage.getItem('tabler-' + key) + const storedTheme = storage.get('tabler-' + key) const serverValue = document.documentElement.getAttribute('data-bs-' + key) selectedValue = storedTheme ?? serverValue ?? themeDefaults[key as ThemeKey] } @@ -36,7 +60,7 @@ for (const key in themeDefaults) { prefersDark.addEventListener('change', (event) => { // No stored choice means the default, which is auto. - if ((localStorage.getItem('tabler-theme') ?? 'auto') === 'auto') { + if ((storage.get('tabler-theme') ?? 'auto') === 'auto') { if (event.matches) { document.documentElement.setAttribute('data-bs-theme', 'dark') } else { diff --git a/core/js/tests/unit/clipboard.spec.ts b/core/js/tests/unit/clipboard.spec.ts index 905acbba2..54487d71d 100644 --- a/core/js/tests/unit/clipboard.spec.ts +++ b/core/js/tests/unit/clipboard.spec.ts @@ -157,4 +157,55 @@ describe('Clipboard', () => { expect(hidden(feedback())).toBe(false) }) }) + + describe('text sources', () => { + it('copies data-bs-text as written', async () => { + fixtureEl.innerHTML = '' + const instance = new Clipboard(trigger()) + + await instance.copy() + + expect(written).toEqual(['007']) + }) + + it('copies data-bs-text that looks like JSON as written', async () => { + fixtureEl.innerHTML = '' + const instance = new Clipboard(trigger()) + + await instance.copy() + + expect(written).toEqual(['{"a":1}']) + }) + + it('skips hidden text and keeps line breaks from an element target', async () => { + fixtureEl.innerHTML = '
Line 1
Line 2 (copy)
' + const instance = new Clipboard(trigger()) + + await instance.copy() + + expect(written).toEqual(['Line 1\nLine 2']) + }) + + it('finds a target whose id needs escaping', async () => { + fixtureEl.innerHTML = '' + const instance = new Clipboard(trigger()) + + await instance.copy() + + expect(written).toEqual(['x']) + }) + }) + + describe('reset', () => { + it('ends the copied state kept by delay 0', async () => { + const instance = new Clipboard(trigger(), { delay: 0 }) + await instance.copy() + expect(hidden(feedback())).toBe(false) + + instance.reset() + + expect(hidden(feedback())).toBe(true) + expect(hidden(label())).toBe(false) + }) + }) }) diff --git a/core/js/tests/unit/countup.spec.ts b/core/js/tests/unit/countup.spec.ts index d6f087d8f..16aa5b5de 100644 --- a/core/js/tests/unit/countup.spec.ts +++ b/core/js/tests/unit/countup.spec.ts @@ -359,4 +359,24 @@ describe('CountUp', () => { expect(() => new CountUp(element())).toThrow(TypeError) }) }) + + describe('robustness', () => { + it('ignores a data-countup value that is not an object', () => { + fixtureEl.innerHTML = '

30

' + expect(() => new CountUp(element())).not.toThrow() + + fixtureEl.innerHTML = '

30

' + expect(() => new CountUp(element())).not.toThrow() + }) + + it('lets the animation start again after reset', () => { + const instance = quick() + instance.update(40) + expect(instance._started).toBe(true) + + instance.reset() + + expect(instance._started).toBe(false) + }) + }) }) diff --git a/core/js/tests/unit/datepicker.spec.ts b/core/js/tests/unit/datepicker.spec.ts index 2a4a0ad82..488222695 100644 --- a/core/js/tests/unit/datepicker.spec.ts +++ b/core/js/tests/unit/datepicker.spec.ts @@ -307,4 +307,77 @@ describe('Datepicker', () => { expect(seen).toEqual(['shown']) }) }) + + describe('audit fixes', () => { + it('accepts a Date for dateMin and dateMax', () => { + fixtureEl.innerHTML = '' + expect(() => new Datepicker(input(), { dateMin: new Date(2024, 0, 1), dateMax: new Date(2024, 11, 31) })).not.toThrow() + }) + + it('keeps the live input in the DOM after dispose', () => { + fixtureEl.innerHTML = '' + const original = input() + const instance = new Datepicker(original) + instance.setSelectedDates(['2024-06-20']) + + instance.dispose() + + expect(document.querySelector('#picker')).toBe(original) + expect(original.value).not.toBe('') + }) + + it('keeps a single bound input for an inline calendar after dispose', () => { + fixtureEl.innerHTML = '
' + const instance = new Datepicker(fixtureEl.firstElementChild as HTMLElement) + instance.setSelectedDates(['2024-06-20']) + + instance.dispose() + + const bound = fixtureEl.querySelectorAll('input[name="d"]') + expect(bound.length).toBe(1) + expect((bound[0] as HTMLInputElement).value).toBe('2024-06-20') + }) + + it('writes the field and opens on the month of setSelectedDates', () => { + fixtureEl.innerHTML = '' + const instance = new Datepicker(input()) + + instance.setSelectedDates([new Date(1990, 5, 20)]) + + expect(instance.getSelectedDates()).toEqual(['1990-06-20']) + expect(input().value).not.toBe('') + expect(instance.calendar!.context.selectedMonth).toBe(5) + expect(instance.calendar!.context.selectedYear).toBe(1990) + }) + + it('opens on the month of an initial value', () => { + fixtureEl.innerHTML = '' + const instance = new Datepicker(input()) + + expect(instance.calendar!.context.selectedMonth).toBe(5) + expect(instance.calendar!.context.selectedYear).toBe(1990) + }) + + it('clears the field when the picked date is deselected', () => { + fixtureEl.innerHTML = '' + const instance = new Datepicker(input()) + instance.setSelectedDates(['2024-06-20']) + expect(input().value).not.toBe('') + + instance.calendar!.context.selectedDates = [] + instance._handleDateClick(instance.calendar!, new MouseEvent('click')) + + expect(input().value).toBe('') + }) + + it('does not run the hide timer after dispose', async () => { + fixtureEl.innerHTML = '' + const instance = new Datepicker(input()) + instance.calendar!.context.selectedDates = ['2024-06-20'] + instance._handleDateClick(instance.calendar!, new MouseEvent('click')) + + instance.dispose() + await new Promise((resolve) => setTimeout(resolve, 150)) + }) + }) }) diff --git a/core/js/tests/unit/input-mask.spec.ts b/core/js/tests/unit/input-mask.spec.ts index a1cca6fc2..fa1320c16 100644 --- a/core/js/tests/unit/input-mask.spec.ts +++ b/core/js/tests/unit/input-mask.spec.ts @@ -101,4 +101,26 @@ describe('InputMask', () => { expect(plugin).toHaveBeenCalledTimes(1) }) }) + + describe('config passthrough', () => { + it('hands every config option to IMask', () => { + fixtureEl.innerHTML = '' + new InputMask(input(), { mask: '000', placeholderChar: '#' }) + + expect(plugin).toHaveBeenCalledWith(input(), { mask: '000', lazy: true, placeholderChar: '#' }) + }) + + it('accepts a non-string mask', () => { + fixtureEl.innerHTML = '' + expect(() => new InputMask(input(), { mask: Number })).not.toThrow() + expect(() => new InputMask(fixtureEl.appendChild(document.createElement('input')), { mask: /^\d+$/ })).not.toThrow() + }) + + it('treats a bare data-mask-visible as visible', () => { + fixtureEl.innerHTML = '' + new InputMask(input()) + + expect(plugin).toHaveBeenCalledWith(input(), { mask: '00', lazy: false }) + }) + }) }) diff --git a/core/js/tests/unit/otp-input.spec.ts b/core/js/tests/unit/otp-input.spec.ts index 61260b10b..e1057fe8b 100644 --- a/core/js/tests/unit/otp-input.spec.ts +++ b/core/js/tests/unit/otp-input.spec.ts @@ -380,4 +380,58 @@ describe('OtpInput', () => { expect(event.defaultPrevented).toBe(false) }) }) + + describe('audit fixes', () => { + it('fires complete once when the value becomes full', () => { + new OtpInput(otp()) + const spy = vi.fn() + otp().addEventListener('complete.bs.otpInput', spy) + + typeInto(input(), '123456') + input().setSelectionRange(5, 6) + beforeInput(input(), { inputType: 'insertText', data: '9' }) + + expect(spy).toHaveBeenCalledTimes(1) + + input().setSelectionRange(5, 6) + beforeInput(input(), { inputType: 'deleteContentBackward' }) + input().setSelectionRange(5, 5) + beforeInput(input(), { inputType: 'insertText', data: '7' }) + + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('fires no input event for a Delete past the end or a Backspace at the start', () => { + new OtpInput(otp()) + const spy = vi.fn() + otp().addEventListener('input.bs.otpInput', spy) + typeInto(input(), '12') + spy.mockClear() + + input().setSelectionRange(2, 2) + beforeInput(input(), { inputType: 'deleteContentForward' }) + input().setSelectionRange(0, 0) + beforeInput(input(), { inputType: 'deleteContentBackward' }) + + expect(spy).not.toHaveBeenCalled() + }) + + it('clamps an unusable length to a sane slot count', () => { + otp().setAttribute('data-bs-length', '-3') + new OtpInput(otp()) + expect(fixtureEl.querySelectorAll('.otp-slot').length).toBe(6) + + fixtureEl.innerHTML = '
' + new OtpInput(otp()) + expect(fixtureEl.querySelectorAll('.otp-slot').length).toBe(32) + }) + + it('turns an email input into a text input', () => { + fixtureEl.innerHTML = '
' + new OtpInput(otp()) + + expect(input().type).toBe('text') + expect(() => input().setSelectionRange(0, 0)).not.toThrow() + }) + }) }) diff --git a/core/js/tests/unit/sidebar.spec.ts b/core/js/tests/unit/sidebar.spec.ts index a27443129..35a5ccef0 100644 --- a/core/js/tests/unit/sidebar.spec.ts +++ b/core/js/tests/unit/sidebar.spec.ts @@ -115,4 +115,39 @@ describe('sidebar folded toggle', () => { expect(document.documentElement.hasAttribute('data-bs-sidebar')).toBe(false) expect(localStorage.getItem('tabler-sidebar')).toBeNull() }) + + describe('robustness', () => { + it('still syncs the toggle when storage is blocked', () => { + const original = Object.getOwnPropertyDescriptor(window, 'localStorage')! + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new DOMException('blocked', 'SecurityError') + }, + }) + + try { + expect(() => trigger().click()).not.toThrow() + expect(document.documentElement.getAttribute('data-bs-sidebar')).toBe('folded-hover') + expect(trigger().getAttribute('aria-pressed')).toBe('true') + } finally { + Object.defineProperty(window, 'localStorage', original) + } + }) + + it('ignores a synthetic click without an element target', () => { + document.documentElement.setAttribute('data-bs-sidebar', 'folded') + + expect(() => document.dispatchEvent(new MouseEvent('click', { bubbles: true }))).not.toThrow() + }) + + it('reads the data-tblr-toggle alias', () => { + fixtureEl.innerHTML = '' + + trigger().click() + + expect(document.documentElement.getAttribute('data-bs-sidebar')).toBe('folded-hover') + expect(trigger().getAttribute('aria-pressed')).toBe('true') + }) + }) }) diff --git a/core/js/tests/unit/strength.spec.ts b/core/js/tests/unit/strength.spec.ts index c5014f56c..9acb3c78b 100644 --- a/core/js/tests/unit/strength.spec.ts +++ b/core/js/tests/unit/strength.spec.ts @@ -236,4 +236,24 @@ describe('Strength', () => { expect(Strength.getInstance(meter())).toBeNull() }) }) + + describe('partial config', () => { + it('merges a partial messages map with the defaults', () => { + new Strength(meter(), { messages: { weak: 'Słabe' } }) + + type('a') + expect(text().textContent).toBe('Słabe') + + type('Abcdefgh1!xyz') + expect(text().textContent).not.toBe('') + expect(text().textContent).not.toBe('Słabe') + }) + + it('merges a partial weights map with the defaults', () => { + const instance = new Strength(meter(), { weights: { special: 3 } }) + + expect(instance._config.weights.lowercase).toBe(1) + expect(instance._config.weights.special).toBe(3) + }) + }) }) diff --git a/core/js/tests/unit/switch-icon.spec.ts b/core/js/tests/unit/switch-icon.spec.ts index 115bd4d57..57368a723 100644 --- a/core/js/tests/unit/switch-icon.spec.ts +++ b/core/js/tests/unit/switch-icon.spec.ts @@ -220,4 +220,22 @@ describe('SwitchIcon', () => { expect(SwitchIcon.getInstance(button())).toBeNull() }) }) + + describe('dispose while waiting', () => { + it('ignores a wait() that settles after dispose', async () => { + const instance = new SwitchIcon(button()) + let settle!: () => void + button().addEventListener('toggle.bs.switch-icon', (event) => { + ;(event as ToggleEvent).wait(new Promise((resolve) => (settle = resolve))) + }) + instance.toggle() + const element = button() + + instance.dispose() + settle() + await tick() + + expect(element.classList.contains('switch-icon-loading')).toBe(false) + }) + }) }) diff --git a/core/js/tests/unit/tab-init.spec.ts b/core/js/tests/unit/tab-init.spec.ts new file mode 100644 index 000000000..f1b87ca06 --- /dev/null +++ b/core/js/tests/unit/tab-init.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import { EnableActivationTabsFromLocationHash } from '../../src/tab' + +describe('tab init from location hash', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + history.replaceState(null, '', window.location.pathname) + }) + + it('does not throw for a tab link outside a nav', () => { + fixtureEl.innerHTML = '
' + history.replaceState(null, '', '#lonely') + + expect(() => EnableActivationTabsFromLocationHash()).not.toThrow() + }) + + it('activates the tab matching the hash, also from the data-tblr-toggle alias', () => { + fixtureEl.innerHTML = '
' + history.replaceState(null, '', '#one') + + EnableActivationTabsFromLocationHash() + + expect(fixtureEl.querySelector('[href="#one"]')!.classList.contains('active')).toBe(true) + }) +}) diff --git a/core/js/tests/unit/tabler-theme.spec.ts b/core/js/tests/unit/tabler-theme.spec.ts index 5f550d18c..6dc23e0f3 100644 --- a/core/js/tests/unit/tabler-theme.spec.ts +++ b/core/js/tests/unit/tabler-theme.spec.ts @@ -97,4 +97,41 @@ describe('tabler-theme', () => { expect(['light', 'dark']).toContain(attribute('theme')) }) + + describe('robustness', () => { + const blockStorage = (): (() => void) => { + const original = Object.getOwnPropertyDescriptor(window, 'localStorage')! + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new DOMException('blocked', 'SecurityError') + }, + }) + return () => Object.defineProperty(window, 'localStorage', original) + } + + it('ignores a URL parameter that is not a plain token', async () => { + history.replaceState(null, '', '?layout=fluid%20%22onload%3D%22x&navbar=sticky') + + await runThemeScript() + + expect(attribute('layout')).toBeNull() + expect(localStorage.getItem('tabler-layout')).toBeNull() + expect(attribute('navbar')).toBe('sticky') + }) + + it('falls back to the server attribute when storage is blocked', async () => { + document.documentElement.setAttribute('data-bs-layout', 'boxed') + history.replaceState(null, '', '?navbar=sticky') + const restore = blockStorage() + + try { + await expect(runThemeScript()).resolves.toBeUndefined() + expect(attribute('layout')).toBe('boxed') + expect(attribute('navbar')).toBe('sticky') + } finally { + restore() + } + }) + }) }) diff --git a/core/js/tests/unit/util/index.spec.ts b/core/js/tests/unit/util/index.spec.ts index 467ca48b8..dedc01720 100644 --- a/core/js/tests/unit/util/index.spec.ts +++ b/core/js/tests/unit/util/index.spec.ts @@ -475,4 +475,15 @@ describe('util/index', () => { expect(getNextActiveElement(list, 'z', false, true)).toBe('d') }) }) + + describe('jQuery collections', () => { + it('unwraps a jQuery-like collection in isElement and getElement', () => { + const div = document.createElement('div') + const collection = { 0: div, length: 1, jquery: '3.7.1' } + + expect(isElement(collection)).toBe(true) + expect(getElement(collection)).toBe(div) + expect(getElement({ length: 0, jquery: '3.7.1' })).toBeNull() + }) + }) })