mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Fix JS audit findings: auto-init robustness, storage guards, Datepicker, OTP and config edge cases (#3208)
This commit is contained in:
1 parent
f7c3956667
commit
e3da90ecb6
46 files changed
+758
-96
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed an open dropdown with a disabled toggle being closed by outside clicks, matching Bootstrap.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed Bootstrap components rejecting a jQuery collection passed as the element, `container`, `parent` or `target`.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed Clipboard `target`, Strength `input` and Datepicker `positionElement` selectors failing on ids like `#user:1`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed sidebar toggles not syncing when `tabler.js` loads after `DOMContentLoaded` and errors on synthetic clicks.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed a partial `messages` or `weights` config on the Strength meter dropping the default entries.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed SwitchIcon throwing and staying in the loading state when a `wait()` promise settles after `dispose()`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed `data-bs-delay` on tooltips and popovers being overridden by the default delay.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
Vendored
+1
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user