Fix JS audit findings: auto-init robustness, storage guards, Datepicker, OTP and config edge cases (#3208)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-25 14:30:59 +02:00
1 parent f7c3956667
commit e3da90ecb6
46 files changed
+758 -96

No files matched your search

+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
@@ -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`.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed an open dropdown with a disabled toggle being closed by outside clicks, matching Bootstrap.
+5
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed Bootstrap components rejecting a jQuery collection passed as the element, `container`, `parent` or `target`.
+5
View File
@@ -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`.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed Clipboard `target`, Strength `input` and Datepicker `positionElement` selectors failing on ids like `#user:1`.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed sidebar toggles not syncing when `tabler.js` loads after `DOMContentLoaded` and errors on synthetic clicks.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed a partial `messages` or `weights` config on the Strength meter dropping the default entries.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed SwitchIcon throwing and staying in the loading state when a `wait()` promise settles after `dispose()`.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `data-bs-delay` on tooltips and popovers being overridden by the default delay.
+7 -2
View File
@@ -100,11 +100,16 @@ class Autosize extends BaseComponent {
// position is put back afterwards. // position is put back afterwards.
const { scrollTop } = document.documentElement const { scrollTop } = document.documentElement
const style = getComputedStyle(element) 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 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' element.style.height = 'auto'
const height = `${element.scrollHeight + border}px` const height = `${element.scrollHeight + extra}px`
element.style.height = height element.style.height = height
// Past max-height the content no longer fits, so let the field scroll. // Past max-height the content no longer fits, so let the field scroll.
+3 -1
View File
@@ -58,7 +58,9 @@ const CLASS_NAME_DROPUP_CENTER = 'dropup-center'
const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-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 = '[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_MENU = '.dropdown-menu'
const SELECTOR_NAVBAR = '.navbar' const SELECTOR_NAVBAR = '.navbar'
const SELECTOR_NAVBAR_NAV = '.navbar-nav' const SELECTOR_NAVBAR_NAV = '.navbar-nav'
+14 -1
View File
@@ -62,7 +62,18 @@ const triggerTransitionEnd = (element: HTMLElement): void => {
element.dispatchEvent(new Event(TRANSITION_END)) 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 => { const isElement = (object: unknown): object is HTMLElement => {
object = unwrapJQuery(object)
if (!object || typeof object !== 'object') { if (!object || typeof object !== 'object') {
return false return false
} }
@@ -71,6 +82,8 @@ const isElement = (object: unknown): object is HTMLElement => {
} }
const getElement = (object: unknown): HTMLElement | null => { const getElement = (object: unknown): HTMLElement | null => {
object = unwrapJQuery(object)
if (isElement(object)) { if (isElement(object)) {
return object return object
} }
@@ -159,7 +172,7 @@ const reflow = (element: HTMLElement): void => {
const isRTL = (): boolean => document.documentElement.dir === 'rtl' const isRTL = (): boolean => document.documentElement.dir === 'rtl'
const getjQuery = (): JQueryStaticLike | null => { 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 return window.jQuery
} }
+26 -2
View File
@@ -9,6 +9,7 @@ import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions' import { initAll } from './bootstrap/util/component-functions'
import { getElement } from './bootstrap/util/index'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type ComponentConfig = { 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_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
const SELECTOR_LABEL = `.${NAME}-label` const SELECTOR_LABEL = `.${NAME}-label`
const SELECTOR_FEEDBACK = `.${NAME}-feedback` const SELECTOR_FEEDBACK = `.${NAME}-feedback`
const ATTRIBUTE_TEXT = 'data-bs-text'
const ATTRIBUTE_TEXT_ALIAS = 'data-tblr-text'
const Default: ComponentConfig = { const Default: ComponentConfig = {
target: null, target: null,
@@ -130,12 +133,18 @@ class Clipboard extends BaseComponent {
return String(text) return String(text)
} }
const source = target ? SelectorEngine.findOne(target) : null const source = target ? getElement(target) : null
if (!source) { if (!source) {
return '' 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 // Public
@@ -158,12 +167,27 @@ class Clipboard extends BaseComponent {
EventHandler.trigger(this._element, EVENT_COPIED) 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 { dispose(): void {
window.clearTimeout(this._timeout) window.clearTimeout(this._timeout)
super.dispose() super.dispose()
} }
// Private // Private
_mergeConfigObj(config?: Record<string, unknown>, element?: HTMLElement): Record<string, unknown> {
// 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<string, unknown> = raw === null || raw === undefined ? {} : { text: raw }
return super._mergeConfigObj({ ...dataOptions, ...config }, element)
}
_showCopied(): void { _showCopied(): void {
window.clearTimeout(this._timeout) window.clearTimeout(this._timeout)
this._toggleCopied(true) this._toggleCopied(true)
+7 -1
View File
@@ -200,6 +200,8 @@ class CountUp extends BaseComponent {
reset(): void { reset(): void {
this._stop() this._stop()
// Lets the observer start the animation again when the element comes into view.
this._started = false
this._value = this._config.startVal this._value = this._config.startVal
this._print(this._value) this._print(this._value)
} }
@@ -239,7 +241,11 @@ class CountUp extends BaseComponent {
if (raw) { if (raw) {
try { 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<string, unknown>
}
} catch { } catch {
// ignore invalid JSON // ignore invalid JSON
} }
+62 -14
View File
@@ -10,7 +10,7 @@ import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions' 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' import type { ElementSelector } from './bootstrap/types'
/** /**
@@ -92,8 +92,8 @@ const Default: ComponentConfig = {
const DefaultType: Record<keyof ComponentConfig, string> = { const DefaultType: Record<keyof ComponentConfig, string> = {
datepickerTheme: '(null|string)', datepickerTheme: '(null|string)',
dateMin: '(null|string|number|object)', dateMin: '(null|string|number|object|date)',
dateMax: '(null|string|number|object)', dateMax: '(null|string|number|object|date)',
dateFormat: '(null|object|function)', dateFormat: '(null|object|function)',
displayElement: '(null|string|element|boolean)', displayElement: '(null|string|element|boolean)',
displayMonthsCount: 'number', displayMonthsCount: 'number',
@@ -136,6 +136,7 @@ class Datepicker extends BaseComponent {
_isSilent = false _isSilent = false
_skipPluginShow = false _skipPluginShow = false
_resolveShown: (() => void) | null = null _resolveShown: (() => void) | null = null
_hideTimeout = 0
_isInput = false _isInput = false
_isInline = false _isInline = false
_boundInput: HTMLInputElement | null = null _boundInput: HTMLInputElement | null = null
@@ -243,13 +244,34 @@ class Datepicker extends BaseComponent {
} }
dispose(): void { dispose(): void {
window.clearTimeout(this._hideTimeout)
this._themeObserver?.disconnect() this._themeObserver?.disconnect()
if (this._onFocusIn) { if (this._onFocusIn) {
EventHandler.off(document, EVENT_FOCUSIN, 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() super.dispose()
} }
@@ -260,8 +282,9 @@ class Datepicker extends BaseComponent {
} }
setSelectedDates(dates: DatesArr): void { setSelectedDates(dates: DatesArr): void {
this._selectedDates = dates.map(String) this._selectedDates = dates.map((date) => this._toIsoDate(date))
this._calendar?.set({ selectedDates: dates }) this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) })
this._writeSelection(this._selectedDates)
} }
// Private // Private
@@ -334,7 +357,7 @@ class Datepicker extends BaseComponent {
let { positionElement } = this._config let { positionElement } = this._config
if (typeof positionElement === 'string') { 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 // 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 const { displayElement } = this._config
if (typeof displayElement === 'string') { if (typeof displayElement === 'string') {
return SelectorEngine.findOne(displayElement) return getElement(displayElement)
} }
// For buttons/non-inputs (not inline), look for a display child // For buttons/non-inputs (not inline), look for a display child
@@ -554,9 +577,9 @@ class Datepicker extends BaseComponent {
const selectedDates = [...self.context.selectedDates] const selectedDates = [...self.context.selectedDates]
this._selectedDates = selectedDates this._selectedDates = selectedDates
if (selectedDates.length > 0) { // Written even when empty: clicking the picked day again deselects it, and
this._writeSelection(selectedDates) // the field must not keep submitting the old date.
} this._writeSelection(selectedDates)
const args: ChangeEventArgs = { dates: selectedDates, event } const args: ChangeEventArgs = { dates: selectedDates, event }
EventHandler.trigger(this._element, EVENT_CHANGE, args) 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) const shouldHide = (this._config.selectionMode === 'single' && selectedDates.length > 0) || (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2)
if (shouldHide) { 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 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 year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
this._selectedDates = [`${year}-${month}-${day}`] return `${year}-${month}-${day}`
this._calendar?.set({ selectedDates: this._selectedDates }) }
_monthOf(value: string | undefined): Pick<Options, 'selectedMonth' | 'selectedYear'> {
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() }
} }
} }
+10 -6
View File
@@ -1,11 +1,15 @@
import { Dropdown } from './bootstrap' import { Dropdown } from './bootstrap'
import Manipulator from './bootstrap/dom/manipulator'
// js-docs-start dropdown-init // js-docs-start dropdown-init
const dropdownTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="dropdown"]')) for (const dropdownTriggerEl of document.querySelectorAll<HTMLElement>('[data-bs-toggle="dropdown"], [data-tblr-toggle="dropdown"]')) {
dropdownTriggerList.map(function (dropdownTriggerEl: HTMLElement) { try {
const options = { const options = {
boundary: dropdownTriggerEl.getAttribute('data-bs-boundary') === 'viewport' ? document.documentElement : ('clippingParents' as const), 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 // js-docs-end dropdown-init
+1 -1
View File
@@ -7,7 +7,7 @@ import type { JQueryStaticLike } from './bootstrap/types'
declare global { declare global {
interface Window { interface Window {
VanillaCalendarPro?: typeof import('vanilla-calendar-pro') 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<string, unknown>) => import('./input-mask').IMaskInstance
Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => import('./sortable').SortableInstance Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => import('./sortable').SortableInstance
jQuery?: JQueryStaticLike jQuery?: JQueryStaticLike
} }
+7 -7
View File
@@ -19,7 +19,8 @@ interface IMaskInstance {
} }
type ComponentConfig = { type ComponentConfig = {
mask: string /** an IMask mask: a pattern string, `Number`, `Date`, a RegExp or a mask object */
mask: unknown
lazy: boolean lazy: boolean
} }
@@ -42,7 +43,7 @@ const Default: ComponentConfig = {
} }
const DefaultType: Record<keyof ComponentConfig, string> = { const DefaultType: Record<keyof ComponentConfig, string> = {
mask: 'string', mask: '(string|function|regexp|object|array)',
lazy: 'boolean', lazy: 'boolean',
} }
@@ -65,10 +66,8 @@ class InputMask extends BaseComponent {
return return
} }
this._mask = new window.IMask(this._element, { // Every option from the config reaches IMask, not only the two typed ones.
mask: this._config.mask, this._mask = new window.IMask(this._element, { ...this._config })
lazy: this._config.lazy,
})
} }
// Getters // Getters
@@ -110,8 +109,9 @@ class InputMask extends BaseComponent {
dataOptions.mask = mask dataOptions.mask = mask
} }
// A bare `data-mask-visible` means visible, like `="true"`.
if (element?.hasAttribute(ATTRIBUTE_VISIBLE)) { 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) return super._mergeConfigObj({ ...dataOptions, ...config }, element)
+38 -15
View File
@@ -57,6 +57,9 @@ const TYPES: Record<OtpInputType, { inputmode: string; pattern: string; filter:
alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g }, alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g },
} }
// Upper bound for the slot count, whatever the config asks for.
const MAX_LENGTH = 32
const Default: ComponentConfig = { const Default: ComponentConfig = {
groups: null, groups: null,
length: null, length: null,
@@ -87,6 +90,7 @@ class OtpInput extends BaseComponent {
_input!: HTMLInputElement _input!: HTMLInputElement
_type!: (typeof TYPES)[OtpInputType] _type!: (typeof TYPES)[OtpInputType]
_length = 0 _length = 0
_wasComplete = false
_slots: HTMLElement[] = [] _slots: HTMLElement[] = []
_slotsContainer: HTMLElement | null = null _slotsContainer: HTMLElement | null = null
// Tracks a tap so focus (fired natively by the browser) can respect the // Tracks a tap so focus (fired natively by the browser) can respect the
@@ -125,6 +129,7 @@ class OtpInput extends BaseComponent {
this._renderSlots() this._renderSlots()
this._addEventListeners() this._addEventListeners()
this._render() this._render()
this._wasComplete = this._input.value.length === this._length
} }
// Getters // Getters
@@ -186,18 +191,19 @@ class OtpInput extends BaseComponent {
// Private // Private
_resolveLength(): number { _resolveLength(): number {
if (this._config.length) { const candidates: unknown[] = [this._config.length, Number.parseInt(this._input.getAttribute('maxlength') ?? '', 10)]
return this._config.length
}
const maxLength = Number.parseInt(this._input.getAttribute('maxlength') ?? '', 10)
if (!Number.isNaN(maxLength) && maxLength > 0) {
return maxLength
}
const { groups } = this._config const { groups } = this._config
if (groups && groups.length > 0) { if (Array.isArray(groups) && groups.length > 0) {
return groups.reduce((sum, group) => sum + group, 0) candidates.push(groups.reduce<number>((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 return 6
@@ -208,7 +214,8 @@ class OtpInput extends BaseComponent {
// A single text field backs the whole control so screen readers, password // A single text field backs the whole control so screen readers, password
// managers, and SMS autofill treat it like any other input. // 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' input.type = 'text'
} }
@@ -328,6 +335,8 @@ class OtpInput extends BaseComponent {
if (inputType === 'deleteContentBackward') { if (inputType === 'deleteContentBackward') {
event.preventDefault() event.preventDefault()
const before = this._input.value
const start = this._input.selectionStart ?? 0 const start = this._input.selectionStart ?? 0
const end = this._input.selectionEnd ?? start const end = this._input.selectionEnd ?? start
const chars = [...this._input.value] const chars = [...this._input.value]
@@ -342,13 +351,15 @@ class OtpInput extends BaseComponent {
this._selectSlot(start - 1) this._selectSlot(start - 1)
} }
this._afterValueChange() this._afterValueChange(before)
return return
} }
if (inputType === 'deleteContentForward') { if (inputType === 'deleteContentForward') {
event.preventDefault() event.preventDefault()
const before = this._input.value
const start = this._input.selectionStart ?? 0 const start = this._input.selectionStart ?? 0
const end = this._input.selectionEnd ?? start const end = this._input.selectionEnd ?? start
const chars = [...this._input.value] const chars = [...this._input.value]
@@ -362,7 +373,7 @@ class OtpInput extends BaseComponent {
} }
this._selectSlot(start) this._selectSlot(start)
this._afterValueChange() this._afterValueChange(before)
} }
} }
@@ -407,8 +418,15 @@ class OtpInput extends BaseComponent {
return null 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() this._render()
if (before !== undefined && before === this._input.value) {
return
}
EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value }) EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
this._checkComplete() 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 { _checkComplete(): void {
const { value } = this._input 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 }) EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
} }
this._wasComplete = complete
} }
} }
+11 -8
View File
@@ -1,13 +1,16 @@
import { Popover } from './bootstrap' import { Popover } from './bootstrap'
import Manipulator from './bootstrap/dom/manipulator'
// js-docs-start popover-init // js-docs-start popover-init
const popoverTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="popover"]')) for (const popoverTriggerEl of document.querySelectorAll<HTMLElement>('[data-bs-toggle="popover"], [data-tblr-toggle="popover"]')) {
popoverTriggerList.map(function (popoverTriggerEl: HTMLElement) { try {
const options = { const options = {
delay: { show: 50, hide: 50 }, delay: Manipulator.getDataAttribute(popoverTriggerEl, 'delay') ?? { show: 50, hide: 50 },
html: popoverTriggerEl.getAttribute('data-bs-html') === 'true', placement: Manipulator.getDataAttribute(popoverTriggerEl, 'placement') ?? 'auto',
placement: popoverTriggerEl.getAttribute('data-bs-placement') ?? 'auto', }
Popover.getOrCreateInstance(popoverTriggerEl, options)
} catch (error) {
console.error(error)
} }
return new Popover(popoverTriggerEl, options) }
})
// js-docs-end popover-init // js-docs-end popover-init
+14 -6
View File
@@ -1,11 +1,12 @@
import { Dropdown } from './bootstrap' import { Dropdown } from './bootstrap'
import { onDOMContentLoaded } from './bootstrap/util/index'
/* /*
Folded sidebar toggle Folded sidebar toggle
*/ */
// js-docs-start sidebar-folded-toggle // js-docs-start sidebar-folded-toggle
const syncSidebarToggles = (folded: boolean): void => { 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)) toggle.setAttribute('aria-pressed', String(folded))
} }
} }
@@ -28,8 +29,9 @@ document.addEventListener('click', (event: MouseEvent) => {
return return
} }
const target = event.target as Element // A synthetic click dispatched on the document has no Element target.
if (target.closest('.navbar-vertical .dropdown-menu')) { const target = event.target
if (!(target instanceof Element) || target.closest('.navbar-vertical .dropdown-menu')) {
return return
} }
@@ -60,7 +62,8 @@ document.addEventListener(
// The folded state may come pre-render from localStorage (tabler-theme.js), so // 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 // 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. // 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') const folded = (document.documentElement.getAttribute('data-bs-sidebar') ?? '').startsWith('folded')
syncSidebarToggles(folded) syncSidebarToggles(folded)
@@ -73,7 +76,7 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
document.addEventListener('click', (event: MouseEvent) => { 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) { if (!trigger) {
return return
} }
@@ -89,7 +92,12 @@ document.addEventListener('click', (event: MouseEvent) => {
html.removeAttribute('data-bs-sidebar') 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) syncSidebarToggles(willFold)
// Close open sidebar dropdowns so an inline submenu doesn't turn into a // Close open sidebar dropdowns so an inline submenu doesn't turn into a
+5 -1
View File
@@ -101,7 +101,11 @@ class Sortable extends BaseComponent {
if (raw) { if (raw) {
try { 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<string, unknown>
}
} catch { } catch {
// ignore invalid JSON // ignore invalid JSON
} }
+11 -1
View File
@@ -9,6 +9,7 @@ import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions' import { initAll } from './bootstrap/util/component-functions'
import { getElement } from './bootstrap/util/index'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong' type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong'
@@ -164,10 +165,19 @@ class Strength extends BaseComponent {
} }
// Private // 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 { _getInput(): HTMLInputElement | null {
const { input } = this._config const { input } = this._config
if (input) { 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. // Without an explicit selector only the meter's own parent is searched.
+15
View File
@@ -138,8 +138,19 @@ class SwitchIcon extends BaseComponent {
.finally(() => this.#setLoading(false)) .finally(() => this.#setLoading(false))
} }
dispose(): void {
// A wait() still pending must not leave the spinner on the button.
this.#setLoading(false)
super.dispose()
}
// Private // Private
#setActive(active: boolean): void { #setActive(active: boolean): void {
// A pending wait() may settle after dispose().
if (!this._element) {
return
}
this.#iconElement.classList.toggle(CLASS_NAME_ACTIVE, active) this.#iconElement.classList.toggle(CLASS_NAME_ACTIVE, active)
this._element.setAttribute('aria-pressed', String(active)) this._element.setAttribute('aria-pressed', String(active))
@@ -147,6 +158,10 @@ class SwitchIcon extends BaseComponent {
} }
#setLoading(loading: boolean): void { #setLoading(loading: boolean): void {
if (!this._element) {
return
}
this.#iconElement.classList.toggle(CLASS_NAME_LOADING, loading) this.#iconElement.classList.toggle(CLASS_NAME_LOADING, loading)
if (loading) { if (loading) {
this._element.setAttribute('aria-busy', 'true') this._element.setAttribute('aria-busy', 'true')
+15 -6
View File
@@ -4,13 +4,22 @@ import { Tab } from './bootstrap'
export const EnableActivationTabsFromLocationHash = (): void => { export const EnableActivationTabsFromLocationHash = (): void => {
const locationHash: string = window.location.hash const locationHash: string = window.location.hash
if (locationHash) { if (!locationHash) {
const tabsList: HTMLAnchorElement[] = [].slice.call(document.querySelectorAll<HTMLAnchorElement>('[data-bs-toggle="tab"]')) return
const matchedTabs = tabsList.filter((tab: HTMLAnchorElement) => tab.hash === locationHash) }
matchedTabs.map((tab: HTMLAnchorElement) => { for (const tab of document.querySelectorAll<HTMLAnchorElement>('[data-bs-toggle="tab"], [data-tblr-toggle="tab"]')) {
new Tab(tab).show() 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<typeof Tab>).show()
} catch (error) {
console.error(error)
}
} }
} }
+17 -12
View File
@@ -1,17 +1,22 @@
import { Toast } from './bootstrap' import { Toast } from './bootstrap'
import SelectorEngine from './bootstrap/dom/selector-engine'
// js-docs-start toast-init // js-docs-start toast-init
const toastsTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="toast"]')) for (const toastTriggerEl of document.querySelectorAll<HTMLElement>('[data-bs-toggle="toast"], [data-tblr-toggle="toast"]')) {
toastsTriggerList.map(function (toastTriggerEl: HTMLElement) { try {
const target = toastTriggerEl.getAttribute('data-bs-target') // A trigger whose target is missing is left alone instead of getting a
if (target === null) { // click handler that throws.
return const toastEl = SelectorEngine.getElementFromSelector(toastTriggerEl)
if (!toastEl) {
continue
}
const toast = Toast.getOrCreateInstance(toastEl) as InstanceType<typeof Toast>
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 // js-docs-end toast-init
+14 -8
View File
@@ -1,13 +1,19 @@
import { Tooltip } from './bootstrap' import { Tooltip } from './bootstrap'
import Manipulator from './bootstrap/dom/manipulator'
// js-docs-start tooltip-init // js-docs-start tooltip-init
const tooltipTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"]')) // One element with a broken config must not stop the rest of the bundle, so
tooltipTriggerList.map(function (tooltipTriggerEl: HTMLElement) { // every instance is created on its own. `data-bs-delay` on the element wins
const options = { // over the shorter default delay.
delay: { show: 50, hide: 50 }, for (const tooltipTriggerEl of document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"], [data-tblr-toggle="tooltip"]')) {
html: tooltipTriggerEl.getAttribute('data-bs-html') === 'true', try {
placement: tooltipTriggerEl.getAttribute('data-bs-placement') ?? 'auto', 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 // js-docs-end tooltip-init
+28 -4
View File
@@ -9,16 +9,40 @@ const params = new URLSearchParams(window.location.search)
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)') const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
// A setting value is a short token: it ends up as a `data-bs-*` attribute on
// <html> 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) { for (const key in themeDefaults) {
const param = params.get(key) const param = params.get(key)
let selectedValue: string let selectedValue: string
if (!!param) { if (param && VALUE_PATTERN.test(param)) {
localStorage.setItem('tabler-' + key, param) storage.set('tabler-' + key, param)
selectedValue = param selectedValue = param
} else { } else {
// A stored choice wins; otherwise a server-rendered attribute is the starting value. // 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) const serverValue = document.documentElement.getAttribute('data-bs-' + key)
selectedValue = storedTheme ?? serverValue ?? themeDefaults[key as ThemeKey] selectedValue = storedTheme ?? serverValue ?? themeDefaults[key as ThemeKey]
} }
@@ -36,7 +60,7 @@ for (const key in themeDefaults) {
prefersDark.addEventListener('change', (event) => { prefersDark.addEventListener('change', (event) => {
// No stored choice means the default, which is auto. // 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) { if (event.matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark') document.documentElement.setAttribute('data-bs-theme', 'dark')
} else { } else {
+51
View File
@@ -157,4 +157,55 @@ describe('Clipboard', () => {
expect(hidden(feedback())).toBe(false) expect(hidden(feedback())).toBe(false)
}) })
}) })
describe('text sources', () => {
it('copies data-bs-text as written', async () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="clipboard" data-bs-text="007"></button>'
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 = '<button type="button" data-bs-toggle="clipboard" data-bs-text=\'{"a":1}\'></button>'
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 = '<div id="addr">Line 1<br>Line 2 <span style="display:none">(copy)</span></div><button type="button" data-bs-toggle="clipboard" data-bs-target="#addr"></button>'
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 = '<input id="key:1" value="x"><button type="button" data-bs-toggle="clipboard" data-bs-target="#key:1"></button>'
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)
})
})
}) })
+20
View File
@@ -359,4 +359,24 @@ describe('CountUp', () => {
expect(() => new CountUp(element())).toThrow(TypeError) expect(() => new CountUp(element())).toThrow(TypeError)
}) })
}) })
describe('robustness', () => {
it('ignores a data-countup value that is not an object', () => {
fixtureEl.innerHTML = '<h1 data-countup="null">30</h1>'
expect(() => new CountUp(element())).not.toThrow()
fixtureEl.innerHTML = '<h1 data-countup="[1,2]">30</h1>'
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)
})
})
}) })
+73
View File
@@ -307,4 +307,77 @@ describe('Datepicker', () => {
expect(seen).toEqual(['shown']) expect(seen).toEqual(['shown'])
}) })
}) })
describe('audit fixes', () => {
it('accepts a Date for dateMin and dateMax', () => {
fixtureEl.innerHTML = '<input type="text">'
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 = '<input type="text" id="picker">'
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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"><input type="hidden" name="d"></div>'
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 = '<input type="text">'
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 = '<input type="text" value="1990-06-20">'
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 = '<input type="text">'
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 = '<input type="text">'
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))
})
})
}) })
+22
View File
@@ -101,4 +101,26 @@ describe('InputMask', () => {
expect(plugin).toHaveBeenCalledTimes(1) expect(plugin).toHaveBeenCalledTimes(1)
}) })
}) })
describe('config passthrough', () => {
it('hands every config option to IMask', () => {
fixtureEl.innerHTML = '<input type="text">'
new InputMask(input(), { mask: '000', placeholderChar: '#' })
expect(plugin).toHaveBeenCalledWith(input(), { mask: '000', lazy: true, placeholderChar: '#' })
})
it('accepts a non-string mask', () => {
fixtureEl.innerHTML = '<input type="text">'
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 = '<input type="text" data-mask="00" data-mask-visible>'
new InputMask(input())
expect(plugin).toHaveBeenCalledWith(input(), { mask: '00', lazy: false })
})
})
}) })
+54
View File
@@ -380,4 +380,58 @@ describe('OtpInput', () => {
expect(event.defaultPrevented).toBe(false) 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 = '<div class="otp" data-bs-toggle="otp" data-bs-length="1000000"><input type="text" /></div>'
new OtpInput(otp())
expect(fixtureEl.querySelectorAll('.otp-slot').length).toBe(32)
})
it('turns an email input into a text input', () => {
fixtureEl.innerHTML = '<div class="otp" data-bs-toggle="otp"><input type="email" /></div>'
new OtpInput(otp())
expect(input().type).toBe('text')
expect(() => input().setSelectionRange(0, 0)).not.toThrow()
})
})
}) })
+35
View File
@@ -115,4 +115,39 @@ describe('sidebar folded toggle', () => {
expect(document.documentElement.hasAttribute('data-bs-sidebar')).toBe(false) expect(document.documentElement.hasAttribute('data-bs-sidebar')).toBe(false)
expect(localStorage.getItem('tabler-sidebar')).toBeNull() 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 = '<button type="button" data-tblr-toggle="sidebar-folded" aria-pressed="false">Toggle</button>'
trigger().click()
expect(document.documentElement.getAttribute('data-bs-sidebar')).toBe('folded-hover')
expect(trigger().getAttribute('aria-pressed')).toBe('true')
})
})
}) })
+20
View File
@@ -236,4 +236,24 @@ describe('Strength', () => {
expect(Strength.getInstance(meter())).toBeNull() 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)
})
})
}) })
+18
View File
@@ -220,4 +220,22 @@ describe('SwitchIcon', () => {
expect(SwitchIcon.getInstance(button())).toBeNull() 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<void>((resolve) => (settle = resolve)))
})
instance.toggle()
const element = button()
instance.dispose()
settle()
await tick()
expect(element.classList.contains('switch-icon-loading')).toBe(false)
})
})
}) })
+32
View File
@@ -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 = '<div class="card-header"><a data-bs-toggle="tab" href="#lonely">Lonely</a></div><div id="lonely"></div>'
history.replaceState(null, '', '#lonely')
expect(() => EnableActivationTabsFromLocationHash()).not.toThrow()
})
it('activates the tab matching the hash, also from the data-tblr-toggle alias', () => {
fixtureEl.innerHTML = '<ul class="nav"><li class="nav-item"><a class="nav-link" data-tblr-toggle="tab" href="#one">One</a></li></ul><div id="one" class="tab-pane"></div>'
history.replaceState(null, '', '#one')
EnableActivationTabsFromLocationHash()
expect(fixtureEl.querySelector('[href="#one"]')!.classList.contains('active')).toBe(true)
})
})
+37
View File
@@ -97,4 +97,41 @@ describe('tabler-theme', () => {
expect(['light', 'dark']).toContain(attribute('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()
}
})
})
}) })
+11
View File
@@ -475,4 +475,15 @@ describe('util/index', () => {
expect(getNextActiveElement(list, 'z', false, true)).toBe('d') 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()
})
})
}) })