Rewrite switch-icon, countup, input-mask and sortable as components (#3046)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-13 01:46:04 +02:00
1 parent a13ebc3621
commit bd5c010595
14 files changed
+1007 -95

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/core": minor
"@tabler/docs": patch
---
Added `SwitchIcon`, `CountUp`, `InputMask` and `Sortable` components to `tabler.js`, with countUp.js bundled.
+131 -25
View File
@@ -1,33 +1,139 @@
// js-docs-start countup-init /**
const countupElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('[data-countup]') * --------------------------------------------------------------------------
* Tabler countup.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* --------------------------------------------------------------------------
*/
if (countupElements.length) { import { CountUp as CountUpPlugin, type CountUpOptions } from 'countup.js'
countupElements.forEach(function (element: HTMLElement) {
let options: Record<string, unknown> = {} import BaseComponent from './bootstrap/base-component'
try { import SelectorEngine from './bootstrap/dom/selector-engine'
const dataOptions = element.getAttribute('data-countup') ? JSON.parse(element.getAttribute('data-countup')!) : {} import type { ElementSelector } from './bootstrap/types'
options = Object.assign(
{ // Mapped copy of the plugin's interface, so it satisfies the `Record` base config
enableScrollSpy: true, type ComponentConfig = { [K in keyof CountUpOptions]: CountUpOptions[K] } & {
}, autoAnimate: boolean
dataOptions, }
)
} catch (error) { type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
// ignore invalid JSON
/**
* Constants
*/
const NAME = 'countup'
const DATA_ATTRIBUTE = `data-${NAME}`
const SELECTOR_DATA_COUNTUP = `[${DATA_ATTRIBUTE}]`
const Default: ComponentConfig = {
autoAnimate: true,
}
const DefaultType: Record<'autoAnimate', string> = {
autoAnimate: 'boolean',
}
/**
* Class definition
*
* Animates the number written inside the element with countUp.js
* (https://github.com/inorganik/countUp.js), bundled with `tabler.js`. Options
* come from the `data-countup` attribute as JSON, or from the config object.
*/
class CountUp extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_plugin: CountUpPlugin | null = null
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element) {
return
} }
// Strip thousands separators, currency symbols and other non-numeric characters // Strip thousands separators, currency symbols and other non-numeric
// so formatted targets like "1,234", "1 234" or "$99.5" parse correctly. // characters so formatted targets like "1,234", "1 234" or "$99.5" parse.
const value = parseFloat((element.textContent ?? '').replace(/[^0-9.-]/g, '')) const value = Number.parseFloat((this._element.textContent ?? '').replace(/[^0-9.-]/g, ''))
if (!Number.isNaN(value) && window.countUp && window.countUp.CountUp) { if (Number.isNaN(value)) {
const countUp = new window.countUp.CountUp(element, value, options) return
// When scrollSpy is enabled CountUp starts the animation itself once the }
// element scrolls into view, so only start manually when it's disabled.
if (!countUp.error && !options.enableScrollSpy) { this._plugin = new CountUpPlugin(this._element, value, this._config)
countUp.start()
// With `autoAnimate` the plugin starts itself once the element scrolls into
// view, so only start by hand when it is off.
if (!this._plugin.error && !this._config.autoAnimate && !this._config.enableScrollSpy) {
this._plugin.start()
}
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<'autoAnimate', string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
// Public
start(): void {
this._plugin?.start()
}
reset(): void {
this._plugin?.reset()
}
update(value: number | string): void {
this._plugin?.update(value)
}
pauseResume(): void {
this._plugin?.pauseResume()
}
dispose(): void {
this._plugin?.onDestroy()
super.dispose()
}
// Private
_mergeConfigObj(config?: Record<string, unknown>, element?: HTMLElement): Record<string, unknown> {
// `data-countup='{"duration":4}'` carries the options as JSON; a bare
// `data-countup` or invalid JSON means defaults.
let dataOptions: Record<string, unknown> = {}
const raw = element?.getAttribute(DATA_ATTRIBUTE)
if (raw) {
try {
dataOptions = JSON.parse(raw)
} catch {
// ignore invalid JSON
} }
} }
})
return super._mergeConfigObj({ ...dataOptions, ...config }, element)
}
}
/**
* Data API implementation
*/
// js-docs-start countup-init
for (const element of SelectorEngine.find(SELECTOR_DATA_COUNTUP)) {
CountUp.getOrCreateInstance(element)
} }
// js-docs-end countup-init // js-docs-end countup-init
export default CountUp
+20 -13
View File
@@ -12,17 +12,24 @@ declare module 'autosize' {
export default autosize export default autosize
} }
interface Window { // IMask (https://imask.js.org) is loaded separately, not bundled
countUp?: { interface IMaskInstance {
CountUp: new ( value: string
target: HTMLElement, unmaskedValue: string
endVal: number, updateValue(): void
options?: Record<string, unknown>, updateOptions(options: Record<string, unknown>): void
) => { destroy(): void
error: boolean }
start: () => void
} // SortableJS (https://sortablejs.github.io/Sortable/) is loaded separately, not bundled
} interface SortableInstance {
IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => unknown option(name: string, value?: unknown): unknown
Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => unknown toArray(): string[]
sort(order: string[], useAnimation?: boolean): void
destroy(): void
}
interface Window {
IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => IMaskInstance
Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => SortableInstance
} }
+120 -8
View File
@@ -1,10 +1,122 @@
// js-docs-start input-mask-init /**
const maskElementList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-mask]')) * --------------------------------------------------------------------------
maskElementList.map(function (maskEl: HTMLElement) { * Tabler input-mask.ts
window.IMask && * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
new window.IMask(maskEl, { * --------------------------------------------------------------------------
mask: maskEl.dataset.mask as string, */
lazy: maskEl.dataset.maskVisible !== 'true',
import BaseComponent from './bootstrap/base-component'
import SelectorEngine from './bootstrap/dom/selector-engine'
import type { ElementSelector } from './bootstrap/types'
type ComponentConfig = {
mask: string
lazy: boolean
}
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
/**
* Constants
*/
const NAME = 'input-mask'
const ATTRIBUTE_MASK = 'data-mask'
const ATTRIBUTE_VISIBLE = 'data-mask-visible'
const SELECTOR_DATA_MASK = `[${ATTRIBUTE_MASK}]`
const Default: ComponentConfig = {
mask: '',
lazy: true,
}
const DefaultType: Record<keyof ComponentConfig, string> = {
mask: 'string',
lazy: 'boolean',
}
/**
* Class definition
*
* Wraps the IMask plugin (https://imask.js.org), loaded separately as
* `window.IMask`. Without the plugin the component is inert.
*/
class InputMask extends BaseComponent {
declare _element: HTMLInputElement
declare _config: ComponentConfig
_mask: IMaskInstance | null = null
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element || !this._config.mask || !window.IMask) {
return
}
this._mask = new window.IMask(this._element, {
mask: this._config.mask,
lazy: this._config.lazy,
}) })
}) }
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
/** The IMask instance, for options the component does not expose. */
get mask(): IMaskInstance | null {
return this._mask
}
// Public
update(): void {
this._mask?.updateValue()
}
dispose(): void {
this._mask?.destroy()
super.dispose()
}
// Private
_mergeConfigObj(config?: Record<string, unknown>, element?: HTMLElement): Record<string, unknown> {
// `data-mask` holds the pattern; `data-mask-visible="true"` shows it
// before typing, which is IMask's `lazy: false`.
const dataOptions: Record<string, unknown> = {}
const mask = element?.getAttribute(ATTRIBUTE_MASK)
if (mask) {
dataOptions.mask = mask
}
if (element?.hasAttribute(ATTRIBUTE_VISIBLE)) {
dataOptions.lazy = element.getAttribute(ATTRIBUTE_VISIBLE) !== 'true'
}
return super._mergeConfigObj({ ...dataOptions, ...config }, element)
}
}
/**
* Data API implementation
*/
// js-docs-start input-mask-init
for (const element of SelectorEngine.find(SELECTOR_DATA_MASK)) {
InputMask.getOrCreateInstance(element)
}
// js-docs-end input-mask-init // js-docs-end input-mask-init
export default InputMask
+106 -15
View File
@@ -1,22 +1,113 @@
// Initializes Sortable on elements marked with [data-sortable] /**
// Allows options via JSON in data attribute: data-sortable='{"animation":150}' * --------------------------------------------------------------------------
* Tabler sortable.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* --------------------------------------------------------------------------
*/
const sortableElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('[data-sortable]') import BaseComponent from './bootstrap/base-component'
import SelectorEngine from './bootstrap/dom/selector-engine'
import type { ElementSelector } from './bootstrap/types'
if (sortableElements.length) { type ComponentConfig = Record<string, unknown>
sortableElements.forEach(function (element: HTMLElement) {
let options: Record<string, unknown> = {}
try { type ComponentConfigInput = Record<string, unknown>
const rawOptions = element.getAttribute('data-sortable')
options = rawOptions ? JSON.parse(rawOptions) : {} /**
} catch (e) { * Constants
// ignore invalid JSON */
const NAME = 'sortable'
const DATA_ATTRIBUTE = `data-${NAME}`
const SELECTOR_DATA_SORTABLE = `[${DATA_ATTRIBUTE}]`
const Default: ComponentConfig = {}
const DefaultType: Record<string, string> = {}
/**
* Class definition
*
* Wraps SortableJS (https://sortablejs.github.io/Sortable/), loaded separately
* as `window.Sortable`. Without the plugin the component is inert. Options come
* from the `data-sortable` attribute as JSON, or from the config object.
*/
class Sortable extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_sortable: SortableInstance | null = null
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element || !window.Sortable) {
return
} }
if (window.Sortable) { this._sortable = new window.Sortable(this._element, this._config)
// eslint-disable-next-line no-new }
new window.Sortable(element, options)
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<string, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
/** The SortableJS instance, for options the component does not expose. */
get sortable(): SortableInstance | null {
return this._sortable
}
// Public
toArray(): string[] {
return this._sortable?.toArray() ?? []
}
sort(order: string[], useAnimation?: boolean): void {
this._sortable?.sort(order, useAnimation)
}
dispose(): void {
this._sortable?.destroy()
super.dispose()
}
// Private
_mergeConfigObj(config?: Record<string, unknown>, element?: HTMLElement): Record<string, unknown> {
// `data-sortable='{"animation":150}'` carries the options as JSON; a bare
// `data-sortable` or invalid JSON means defaults.
let dataOptions: Record<string, unknown> = {}
const raw = element?.getAttribute(DATA_ATTRIBUTE)
if (raw) {
try {
dataOptions = JSON.parse(raw)
} catch {
// ignore invalid JSON
}
} }
})
return super._mergeConfigObj({ ...dataOptions, ...config }, element)
}
} }
/**
* Data API implementation
*/
// js-docs-start sortable-init
for (const element of SelectorEngine.find(SELECTOR_DATA_SORTABLE)) {
Sortable.getOrCreateInstance(element)
}
// js-docs-end sortable-init
export default Sortable
+100 -9
View File
@@ -1,11 +1,102 @@
// js-docs-start switch-icon-init /**
const switchesTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="switch-icon"]')) * --------------------------------------------------------------------------
switchesTriggerList.map(function (switchTriggerEl: HTMLElement) { * Tabler switch-icon.ts
switchTriggerEl.addEventListener('click', (e: MouseEvent) => { * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
e.stopPropagation() * --------------------------------------------------------------------------
*/
const active = switchTriggerEl.classList.toggle('active') import BaseComponent from './bootstrap/base-component'
switchTriggerEl.setAttribute('aria-pressed', active ? 'true' : 'false') import EventHandler from './bootstrap/dom/event-handler'
}) import SelectorEngine from './bootstrap/dom/selector-engine'
}) import type { ElementSelector } from './bootstrap/types'
type ComponentConfig = Record<string, never>
type ComponentConfigInput = Record<string, unknown>
/**
* Constants
*/
const NAME = 'switch-icon'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}`
const CLASS_NAME_ACTIVE = 'active'
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
const Default: ComponentConfig = {}
const DefaultType: Record<keyof ComponentConfig, string> = {}
/**
* Class definition
*
* A toggle button that swaps between two icons. The state is the `active`
* class, mirrored in `aria-pressed`.
*/
class SwitchIcon extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element) {
return
}
if (!this._element.hasAttribute('aria-pressed')) {
this._element.setAttribute('aria-pressed', String(this.isActive))
}
// The button usually sits inside a clickable row or card; the click must
// not reach it.
EventHandler.on(this._element, EVENT_CLICK, (event: Event) => {
event.stopPropagation()
this.toggle()
})
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
get isActive(): boolean {
return this._element.classList.contains(CLASS_NAME_ACTIVE)
}
// Public
toggle(force?: boolean): void {
const active = this._element.classList.toggle(CLASS_NAME_ACTIVE, force)
this._element.setAttribute('aria-pressed', String(active))
EventHandler.trigger(this._element, EVENT_CHANGE, { active })
}
}
/**
* Data API implementation
*/
// js-docs-start switch-icon-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
SwitchIcon.getOrCreateInstance(element)
}
// js-docs-end switch-icon-init // js-docs-end switch-icon-init
export default SwitchIcon
+4
View File
@@ -15,3 +15,7 @@ export * from './src/bootstrap'
// Tabler's own components // Tabler's own components
export { default as Autosize } from './src/autosize' export { default as Autosize } from './src/autosize'
export { default as CountUp } from './src/countup'
export { default as InputMask } from './src/input-mask'
export { default as Sortable } from './src/sortable'
export { default as SwitchIcon } from './src/switch-icon'
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { CountUp as CountUpPlugin } from 'countup.js'
import { clearFixture, getFixture } from '../helpers/fixture'
import CountUp from '../../src/countup'
vi.mock('countup.js', () => ({
CountUp: vi.fn(function (this: Record<string, unknown>) {
this.error = ''
this.start = vi.fn()
this.reset = vi.fn()
this.update = vi.fn()
this.pauseResume = vi.fn()
this.onDestroy = vi.fn()
}),
}))
const plugin = vi.mocked(CountUpPlugin)
const lastInstance = (): Record<string, ReturnType<typeof vi.fn>> => plugin.mock.instances[plugin.mock.instances.length - 1] as unknown as Record<string, ReturnType<typeof vi.fn>>
describe('CountUp', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
vi.clearAllMocks()
fixtureEl.innerHTML = '<h1 data-countup>30000</h1>'
})
afterEach(() => {
clearFixture()
})
const element = (): HTMLElement => fixtureEl.querySelector('h1')!
describe('NAME', () => {
it('should return plugin name', () => {
expect(CountUp.NAME).toBe('countup')
})
})
describe('constructor', () => {
it('should create the plugin with the parsed number and autoAnimate on', () => {
new CountUp(element())
expect(plugin).toHaveBeenCalledTimes(1)
expect(plugin.mock.calls[0][0]).toBe(element())
expect(plugin.mock.calls[0][1]).toBe(30000)
expect(plugin.mock.calls[0][2]).toMatchObject({ autoAnimate: true })
expect(lastInstance().start).not.toHaveBeenCalled()
})
it('should read options from the data-countup JSON', () => {
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":4,"suffix":"%"}\'>300</h1>'
const instance = new CountUp(element())
expect(plugin.mock.calls[0][2]).toMatchObject({ duration: 4, suffix: '%', autoAnimate: true })
expect(instance._config.duration).toBe(4)
})
it('should let the config object win over the attribute', () => {
fixtureEl.innerHTML = '<h1 data-countup=\'{"duration":4}\'>300</h1>'
new CountUp(element(), { duration: 6 })
expect(plugin.mock.calls[0][2]).toMatchObject({ duration: 6 })
})
it('should ignore invalid JSON', () => {
fixtureEl.innerHTML = '<h1 data-countup="{oops">300</h1>'
expect(() => new CountUp(element())).not.toThrow()
expect(plugin).toHaveBeenCalledTimes(1)
})
it('should parse formatted numbers', () => {
fixtureEl.innerHTML = '<h1 data-countup>$1,234.5</h1>'
new CountUp(element())
expect(plugin.mock.calls[0][1]).toBe(1234.5)
})
it('should not create the plugin when the text is not a number', () => {
fixtureEl.innerHTML = '<h1 data-countup>soon</h1>'
new CountUp(element())
expect(plugin).not.toHaveBeenCalled()
})
it('should start by hand when autoAnimate is off', () => {
new CountUp(element(), { autoAnimate: false })
expect(lastInstance().start).toHaveBeenCalledTimes(1)
})
})
describe('public API', () => {
it('should forward start, reset, update and pauseResume to the plugin', () => {
const instance = new CountUp(element())
instance.start()
instance.reset()
instance.update(42)
instance.pauseResume()
expect(lastInstance().start).toHaveBeenCalledTimes(1)
expect(lastInstance().reset).toHaveBeenCalledTimes(1)
expect(lastInstance().update).toHaveBeenCalledWith(42)
expect(lastInstance().pauseResume).toHaveBeenCalledTimes(1)
})
it('should destroy the plugin and remove the instance on dispose', () => {
const instance = new CountUp(element())
const el = element()
const destroy = lastInstance().onDestroy
instance.dispose()
expect(destroy).toHaveBeenCalledTimes(1)
expect(CountUp.getInstance(el)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should reuse the instance', () => {
const first = CountUp.getOrCreateInstance(element())
const second = CountUp.getOrCreateInstance(element())
expect(second).toBe(first)
expect(plugin).toHaveBeenCalledTimes(1)
})
})
})
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import InputMask from '../../src/input-mask'
describe('InputMask', () => {
let fixtureEl: HTMLElement
let plugin: ReturnType<typeof vi.fn>
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
plugin = vi.fn(function (this: Record<string, unknown>) {
this.updateValue = vi.fn()
this.destroy = vi.fn()
})
window.IMask = plugin as unknown as Window['IMask']
fixtureEl.innerHTML = '<input type="text" data-mask="00/00/0000" data-mask-visible="true">'
})
afterEach(() => {
clearFixture()
delete window.IMask
})
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
const lastInstance = (): Record<string, ReturnType<typeof vi.fn>> => plugin.mock.instances[plugin.mock.instances.length - 1] as Record<string, ReturnType<typeof vi.fn>>
describe('NAME', () => {
it('should return plugin name', () => {
expect(InputMask.NAME).toBe('input-mask')
})
})
describe('constructor', () => {
it('should create the mask from data-mask and data-mask-visible', () => {
const instance = new InputMask(input())
expect(plugin).toHaveBeenCalledWith(input(), { mask: '00/00/0000', lazy: false })
expect(instance.mask).toBe(plugin.mock.instances[0])
})
it('should default to a lazy mask', () => {
fixtureEl.innerHTML = '<input type="text" data-mask="(00) 0000-0000">'
new InputMask(input())
expect(plugin).toHaveBeenCalledWith(input(), { mask: '(00) 0000-0000', lazy: true })
})
it('should let the config object win over the attributes', () => {
new InputMask(input(), { mask: '0000', lazy: true })
expect(plugin).toHaveBeenCalledWith(input(), { mask: '0000', lazy: true })
})
it('should stay inert without a mask', () => {
fixtureEl.innerHTML = '<input type="text" data-mask>'
const instance = new InputMask(input())
expect(plugin).not.toHaveBeenCalled()
expect(instance.mask).toBeNull()
})
it('should stay inert when the plugin is not loaded', () => {
delete window.IMask
expect(() => new InputMask(input())).not.toThrow()
expect(InputMask.getInstance(input())).toBeInstanceOf(InputMask)
})
})
describe('public API', () => {
it('should forward update to the mask', () => {
const instance = new InputMask(input())
instance.update()
expect(lastInstance().updateValue).toHaveBeenCalledTimes(1)
})
it('should destroy the mask and remove the instance on dispose', () => {
const instance = new InputMask(input())
const element = input()
const destroy = lastInstance().destroy
instance.dispose()
expect(destroy).toHaveBeenCalledTimes(1)
expect(InputMask.getInstance(element)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should reuse the instance', () => {
const first = InputMask.getOrCreateInstance(input())
const second = InputMask.getOrCreateInstance(input())
expect(second).toBe(first)
expect(plugin).toHaveBeenCalledTimes(1)
})
})
})
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import Sortable from '../../src/sortable'
describe('Sortable', () => {
let fixtureEl: HTMLElement
let plugin: ReturnType<typeof vi.fn>
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
plugin = vi.fn(function (this: Record<string, unknown>) {
this.toArray = vi.fn(() => ['a', 'b'])
this.sort = vi.fn()
this.option = vi.fn()
this.destroy = vi.fn()
})
window.Sortable = plugin as unknown as Window['Sortable']
fixtureEl.innerHTML = '<ul data-sortable=\'{"animation":150}\'><li>a</li><li>b</li></ul>'
})
afterEach(() => {
clearFixture()
delete window.Sortable
})
const list = (): HTMLElement => fixtureEl.querySelector('ul')!
const lastInstance = (): Record<string, ReturnType<typeof vi.fn>> => plugin.mock.instances[plugin.mock.instances.length - 1] as Record<string, ReturnType<typeof vi.fn>>
describe('NAME', () => {
it('should return plugin name', () => {
expect(Sortable.NAME).toBe('sortable')
})
})
describe('constructor', () => {
it('should create the plugin with options from data-sortable', () => {
const instance = new Sortable(list())
expect(plugin).toHaveBeenCalledTimes(1)
expect(plugin.mock.calls[0][0]).toBe(list())
expect(plugin.mock.calls[0][1]).toMatchObject({ animation: 150 })
expect(instance.sortable).toBe(plugin.mock.instances[0])
})
it('should let the config object win over the attribute', () => {
new Sortable(list(), { animation: 0, group: 'shared' })
expect(plugin.mock.calls[0][1]).toMatchObject({ animation: 0, group: 'shared' })
})
it('should ignore invalid JSON', () => {
fixtureEl.innerHTML = '<ul data-sortable="{oops"><li>a</li></ul>'
expect(() => new Sortable(list())).not.toThrow()
expect(plugin).toHaveBeenCalledTimes(1)
})
it('should stay inert when the plugin is not loaded', () => {
delete window.Sortable
expect(() => new Sortable(list())).not.toThrow()
expect(Sortable.getInstance(list())).toBeInstanceOf(Sortable)
})
})
describe('public API', () => {
it('should forward toArray and sort to the plugin', () => {
const instance = new Sortable(list())
expect(instance.toArray()).toEqual(['a', 'b'])
instance.sort(['b', 'a'], true)
expect(lastInstance().sort).toHaveBeenCalledWith(['b', 'a'], true)
})
it('should destroy the plugin and remove the instance on dispose', () => {
const instance = new Sortable(list())
const element = list()
const destroy = lastInstance().destroy
instance.dispose()
expect(destroy).toHaveBeenCalledTimes(1)
expect(Sortable.getInstance(element)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should reuse the instance', () => {
const first = Sortable.getOrCreateInstance(list())
const second = Sortable.getOrCreateInstance(list())
expect(second).toBe(first)
expect(plugin).toHaveBeenCalledTimes(1)
})
})
})
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import SwitchIcon from '../../src/switch-icon'
describe('SwitchIcon', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div id="parent"><button type="button" class="switch-icon" data-bs-toggle="switch-icon" aria-pressed="false"></button></div>'
})
afterEach(() => {
clearFixture()
})
const button = (): HTMLButtonElement => fixtureEl.querySelector('button')!
describe('NAME', () => {
it('should return plugin name', () => {
expect(SwitchIcon.NAME).toBe('switch-icon')
})
})
describe('constructor', () => {
it('should set aria-pressed from the active class when missing', () => {
fixtureEl.innerHTML = '<button type="button" class="switch-icon active" data-bs-toggle="switch-icon"></button>'
new SwitchIcon(button())
expect(button().getAttribute('aria-pressed')).toBe('true')
})
it('should keep an existing aria-pressed value', () => {
new SwitchIcon(button())
expect(button().getAttribute('aria-pressed')).toBe('false')
})
})
describe('toggle', () => {
it('should toggle the active class and aria-pressed', () => {
const instance = new SwitchIcon(button())
instance.toggle()
expect(button().classList.contains('active')).toBe(true)
expect(button().getAttribute('aria-pressed')).toBe('true')
expect(instance.isActive).toBe(true)
instance.toggle()
expect(button().classList.contains('active')).toBe(false)
expect(button().getAttribute('aria-pressed')).toBe('false')
})
it('should force the state with an argument', () => {
const instance = new SwitchIcon(button())
instance.toggle(true)
instance.toggle(true)
expect(instance.isActive).toBe(true)
})
it('should trigger change.bs.switch-icon with the new state', () => {
const instance = new SwitchIcon(button())
const spy = vi.fn()
button().addEventListener('change.bs.switch-icon', spy)
instance.toggle()
expect(spy).toHaveBeenCalledTimes(1)
expect((spy.mock.calls[0][0] as Event & { active: boolean }).active).toBe(true)
})
})
describe('click', () => {
it('should toggle on click and stop propagation', () => {
new SwitchIcon(button())
const parentSpy = vi.fn()
fixtureEl.querySelector('#parent')!.addEventListener('click', parentSpy)
button().click()
expect(button().classList.contains('active')).toBe(true)
expect(parentSpy).not.toHaveBeenCalled()
})
it('should stop toggling after dispose', () => {
const instance = new SwitchIcon(button())
instance.dispose()
button().click()
expect(button().classList.contains('active')).toBe(false)
expect(SwitchIcon.getInstance(button())).toBeNull()
})
})
})
+26 -2
View File
@@ -101,13 +101,37 @@ You can also add a fancy animation to add variety to your button. See demo below
## JavaScript ## JavaScript
Tabler toggles the `active` class on every element with `data-bs-toggle="switch-icon"` on click. This is the code that runs: Tabler wraps the button in a `SwitchIcon` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-bs-toggle="switch-icon"`, and a click toggles the `active` class and `aria-pressed`. This is the code that runs:
<CodeDocs name="switch-icon-init" file="core/js/src/switch-icon.ts" /> <CodeDocs name="switch-icon-init" file="core/js/src/switch-icon.ts" />
Create the component yourself for a button added later, or change the state from code:
```js
const favorite = tabler.SwitchIcon.getOrCreateInstance(document.getElementById('favorite'));
favorite.toggle(); // flip
favorite.toggle(true); // force on
```
Every change fires `change.bs.switch-icon` on the button, with the new state in `event.active`:
```js
document.getElementById('favorite').addEventListener('change.bs.switch-icon', (event) => {
console.log(event.active ? 'added to favorites' : 'removed from favorites');
});
```
| Method | Description |
| --- | --- |
| `toggle(force)` | Flips the state, or sets it when `force` is `true` or `false`. Updates `aria-pressed` and fires `change.bs.switch-icon`. |
| `isActive` | Getter. `true` when the button has the `active` class. |
| `dispose()` | Removes the click handler and the component from the element. |
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
| `getOrCreateInstance(element)` | Static. Returns the component for the element and creates it when needed. |
## Accessibility ## Accessibility
- A switch icon is a toggle button. Give it `type="button"`, an `aria-label` saying what it toggles, and `aria-pressed` for the state - Tabler keeps `aria-pressed` in sync from the first click, but the initial value has to be in your markup. - A switch icon is a toggle button. Give it `type="button"`, an `aria-label` saying what it toggles, and `aria-pressed` for the state. Tabler keeps `aria-pressed` in sync, and fills it in from the `active` class when the markup leaves it out.
- The two icons are decoration. The state lives in `aria-pressed`, not in which icon is visible. - The two icons are decoration. The state lives in `aria-pressed`, not in which icon is visible.
- Colour alone should not carry the state. A filled red heart and an outlined gray one differ in shape too, which is what makes them readable. - Colour alone should not carry the state. A filled red heart and an outlined gray one differ in shape too, which is what makes them readable.
- The animations run on every toggle. Disable them under `prefers-reduced-motion` if you add your own. - The animations run on every toggle. Disable them under `prefers-reduced-motion` if you add your own.
+26 -22
View File
@@ -1,15 +1,11 @@
--- ---
title: Countup title: Countup
summary: A countup animates a number from zero to its final value when it scrolls into view. summary: A countup animates a number from zero to its final value when it scrolls into view.
docs-libs: [countup]
description: Animate numbers with the countup component, for statistics on dashboards and landing pages. description: Animate numbers with the countup component, for statistics on dashboards and landing pages.
related: [/ui/plugins/chart] related: [/ui/plugins/chart]
--- ---
import Example from '@components/Example.astro'; import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro'; import CodeDocs from '@components/CodeDocs.astro';
import TabsPackage from '@components/TabsPackage.astro';
import { Code } from 'astro:components';
import { site } from '@shared/lib/site.ts';
## Overview ## Overview
@@ -23,23 +19,7 @@ Write the final number as the text of the element and add `data-countup`. Tabler
## Installation ## Installation
Install countup.js with npm: The [countUp.js](https://inorganik.github.io/countUp.js/) library is bundled with `tabler.js`, so there is nothing extra to install or load. For options beyond the ones below, see its website.
<TabsPackage name="countup.js" />
Or include it from a CDN:
<Code lang="html" code={`<script src="${site.cdnUrl}/dist/libs/countup.js/dist/countUp.umd.js"></script>`} />
Tabler reads the library from the `countUp` global, so load the UMD build above before `tabler.js`. If you install with npm and bundle it yourself, assign it first:
```js
import * as countUp from 'countup.js';
window.countUp = countUp;
```
For options beyond the ones below, see the [countUp.js website](https://inorganik.github.io/countUp.js/).
## Usage ## Usage
@@ -129,10 +109,34 @@ Grouping is on by default, so thousands get a separator. Set `"useGrouping": fal
## JavaScript ## JavaScript
Tabler automatically initializes all elements with `data-countup` on page load. This is the code that runs: Tabler wraps the library in a `CountUp` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-countup`. This is the code that runs:
<CodeDocs name="countup-init" file="core/js/src/countup.ts" /> <CodeDocs name="countup-init" file="core/js/src/countup.ts" />
Create the component yourself for an element added later. Options go in the `data-countup` attribute, in the config object, or both; the object wins.
```js
const countup = new tabler.CountUp(document.getElementById('total'), { duration: 4 });
```
By default the animation starts when the element scrolls into view. Set `autoAnimate` to `false` to start it right away, or to control it yourself:
```js
const countup = tabler.CountUp.getOrCreateInstance(element, { autoAnimate: false });
countup.reset();
countup.start();
```
| Method | Description |
| --- | --- |
| `start()` | Starts the animation. |
| `reset()` | Sets the number back to the starting value, so the animation can run again. |
| `update(value)` | Animates to a new final value. |
| `pauseResume()` | Pauses a running animation, or resumes a paused one. |
| `dispose()` | Stops the animation and removes the component from the element. |
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
## Accessibility ## Accessibility
- Write the final number in the HTML, as the examples do. That is what a search engine indexes, what shows without JavaScript, and what a screen reader reads if it reaches the element before the animation starts. - Write the final number in the HTML, as the examples do. That is what a search engine indexes, what shows without JavaScript, and what a screen reader reads if it reaches the element before the animation starts.
+24 -1
View File
@@ -60,10 +60,33 @@ If you need more examples of input masks, you can find them in the [IMask docume
## JavaScript ## JavaScript
Tabler automatically initializes all elements with `data-mask` on page load. This is the code that runs: Tabler wraps the plugin in an `InputMask` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-mask`. This is the code that runs:
<CodeDocs name="input-mask-init" file="core/js/src/input-mask.ts" /> <CodeDocs name="input-mask-init" file="core/js/src/input-mask.ts" />
Create the component yourself for a field added later. The pattern comes from `data-mask`, or from the config object, which wins:
```js
const phone = new tabler.InputMask(document.getElementById('phone'), { mask: '(00) 0000-0000' });
```
`data-mask-visible="true"` shows the pattern before the user types. In the config object that is `lazy: false`.
The `mask` getter returns the IMask instance, so everything from the [IMask API](https://imask.js.org/guide.html#api) is available:
```js
const phone = tabler.InputMask.getOrCreateInstance(document.getElementById('phone'));
console.log(phone.mask.unmaskedValue);
```
| Method | Description |
| --- | --- |
| `update()` | Re-applies the mask after the value was changed from code. |
| `mask` | Getter. The IMask instance, or `null` when the library is not loaded. |
| `dispose()` | Removes the mask and the component from the element. |
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
## Accessibility ## Accessibility
- A mask changes what the field accepts as the user types, which is easy to lose track of without sight. Say the expected format in a hint tied to the field with `aria-describedby`. - A mask changes what the field accepts as the user types, which is easy to lose track of without sight. Say the expected format in a hint tied to the field with `aria-describedby`.