diff --git a/.changeset/tabler-components.md b/.changeset/tabler-components.md new file mode 100644 index 000000000..ac7e4897c --- /dev/null +++ b/.changeset/tabler-components.md @@ -0,0 +1,6 @@ +--- +"@tabler/core": minor +"@tabler/docs": patch +--- + +Added `SwitchIcon`, `CountUp`, `InputMask` and `Sortable` components to `tabler.js`, with countUp.js bundled. diff --git a/core/js/src/countup.ts b/core/js/src/countup.ts index c6519ece7..9adf07c80 100644 --- a/core/js/src/countup.ts +++ b/core/js/src/countup.ts @@ -1,33 +1,139 @@ -// js-docs-start countup-init -const countupElements: NodeListOf = document.querySelectorAll('[data-countup]') +/** + * -------------------------------------------------------------------------- + * Tabler countup.ts + * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) + * -------------------------------------------------------------------------- + */ -if (countupElements.length) { - countupElements.forEach(function (element: HTMLElement) { - let options: Record = {} - try { - const dataOptions = element.getAttribute('data-countup') ? JSON.parse(element.getAttribute('data-countup')!) : {} - options = Object.assign( - { - enableScrollSpy: true, - }, - dataOptions, - ) - } catch (error) { - // ignore invalid JSON +import { CountUp as CountUpPlugin, type CountUpOptions } from 'countup.js' + +import BaseComponent from './bootstrap/base-component' +import SelectorEngine from './bootstrap/dom/selector-engine' +import type { ElementSelector } from './bootstrap/types' + +// Mapped copy of the plugin's interface, so it satisfies the `Record` base config +type ComponentConfig = { [K in keyof CountUpOptions]: CountUpOptions[K] } & { + autoAnimate: boolean +} + +type ComponentConfigInput = Partial & Record + +/** + * 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 - // so formatted targets like "1,234", "1 234" or "$99.5" parse correctly. - const value = parseFloat((element.textContent ?? '').replace(/[^0-9.-]/g, '')) + // Strip thousands separators, currency symbols and other non-numeric + // characters so formatted targets like "1,234", "1 234" or "$99.5" parse. + const value = Number.parseFloat((this._element.textContent ?? '').replace(/[^0-9.-]/g, '')) - if (!Number.isNaN(value) && window.countUp && window.countUp.CountUp) { - const countUp = new window.countUp.CountUp(element, value, options) - // 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) { - countUp.start() + if (Number.isNaN(value)) { + return + } + + this._plugin = new CountUpPlugin(this._element, value, this._config) + + // 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, element?: HTMLElement): Record { + // `data-countup='{"duration":4}'` carries the options as JSON; a bare + // `data-countup` or invalid JSON means defaults. + let dataOptions: Record = {} + 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 + +export default CountUp diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index cc749baf0..22c30b437 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -12,17 +12,24 @@ declare module 'autosize' { export default autosize } -interface Window { - countUp?: { - CountUp: new ( - target: HTMLElement, - endVal: number, - options?: Record, - ) => { - error: boolean - start: () => void - } - } - IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => unknown - Sortable?: new (element: HTMLElement, options?: Record) => unknown +// IMask (https://imask.js.org) is loaded separately, not bundled +interface IMaskInstance { + value: string + unmaskedValue: string + updateValue(): void + updateOptions(options: Record): void + destroy(): void +} + +// SortableJS (https://sortablejs.github.io/Sortable/) is loaded separately, not bundled +interface SortableInstance { + option(name: string, value?: 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) => SortableInstance } diff --git a/core/js/src/input-mask.ts b/core/js/src/input-mask.ts index f51e95bd0..4713d456c 100644 --- a/core/js/src/input-mask.ts +++ b/core/js/src/input-mask.ts @@ -1,10 +1,122 @@ -// js-docs-start input-mask-init -const maskElementList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-mask]')) -maskElementList.map(function (maskEl: HTMLElement) { - window.IMask && - new window.IMask(maskEl, { - mask: maskEl.dataset.mask as string, - lazy: maskEl.dataset.maskVisible !== 'true', +/** + * -------------------------------------------------------------------------- + * Tabler input-mask.ts + * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) + * -------------------------------------------------------------------------- + */ + +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 & Record + +/** + * 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 = { + 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 { + 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, element?: HTMLElement): Record { + // `data-mask` holds the pattern; `data-mask-visible="true"` shows it + // before typing, which is IMask's `lazy: false`. + const dataOptions: Record = {} + 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 + +export default InputMask diff --git a/core/js/src/sortable.ts b/core/js/src/sortable.ts index 598d71fd4..298e10cd4 100644 --- a/core/js/src/sortable.ts +++ b/core/js/src/sortable.ts @@ -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 = document.querySelectorAll('[data-sortable]') +import BaseComponent from './bootstrap/base-component' +import SelectorEngine from './bootstrap/dom/selector-engine' +import type { ElementSelector } from './bootstrap/types' -if (sortableElements.length) { - sortableElements.forEach(function (element: HTMLElement) { - let options: Record = {} +type ComponentConfig = Record - try { - const rawOptions = element.getAttribute('data-sortable') - options = rawOptions ? JSON.parse(rawOptions) : {} - } catch (e) { - // ignore invalid JSON +type ComponentConfigInput = Record + +/** + * Constants + */ + +const NAME = 'sortable' +const DATA_ATTRIBUTE = `data-${NAME}` + +const SELECTOR_DATA_SORTABLE = `[${DATA_ATTRIBUTE}]` + +const Default: ComponentConfig = {} + +const DefaultType: Record = {} + +/** + * 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) { - // eslint-disable-next-line no-new - new window.Sortable(element, options) + this._sortable = new window.Sortable(this._element, this._config) + } + + // Getters + static get Default(): ComponentConfig { + return Default + } + + static get DefaultType(): Record { + 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, element?: HTMLElement): Record { + // `data-sortable='{"animation":150}'` carries the options as JSON; a bare + // `data-sortable` or invalid JSON means defaults. + let dataOptions: Record = {} + 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 diff --git a/core/js/src/switch-icon.ts b/core/js/src/switch-icon.ts index 0ebb91610..551a636f4 100644 --- a/core/js/src/switch-icon.ts +++ b/core/js/src/switch-icon.ts @@ -1,11 +1,102 @@ -// js-docs-start switch-icon-init -const switchesTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="switch-icon"]')) -switchesTriggerList.map(function (switchTriggerEl: HTMLElement) { - switchTriggerEl.addEventListener('click', (e: MouseEvent) => { - e.stopPropagation() +/** + * -------------------------------------------------------------------------- + * Tabler switch-icon.ts + * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) + * -------------------------------------------------------------------------- + */ - const active = switchTriggerEl.classList.toggle('active') - switchTriggerEl.setAttribute('aria-pressed', active ? 'true' : 'false') - }) -}) +import BaseComponent from './bootstrap/base-component' +import EventHandler from './bootstrap/dom/event-handler' +import SelectorEngine from './bootstrap/dom/selector-engine' +import type { ElementSelector } from './bootstrap/types' + +type ComponentConfig = Record + +type ComponentConfigInput = Record + +/** + * 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 = {} + +/** + * 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 { + 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 + +export default SwitchIcon diff --git a/core/js/tabler.ts b/core/js/tabler.ts index 794ce517d..1572eda42 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -15,3 +15,7 @@ export * from './src/bootstrap' // Tabler's own components 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' diff --git a/core/js/tests/unit/countup.spec.ts b/core/js/tests/unit/countup.spec.ts new file mode 100644 index 000000000..63aefe51f --- /dev/null +++ b/core/js/tests/unit/countup.spec.ts @@ -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) { + 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> => plugin.mock.instances[plugin.mock.instances.length - 1] as unknown as Record> + +describe('CountUp', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + vi.clearAllMocks() + fixtureEl.innerHTML = '

30000

' + }) + + 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 = '

300

' + + 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 = '

300

' + + new CountUp(element(), { duration: 6 }) + + expect(plugin.mock.calls[0][2]).toMatchObject({ duration: 6 }) + }) + + it('should ignore invalid JSON', () => { + fixtureEl.innerHTML = '

300

' + + expect(() => new CountUp(element())).not.toThrow() + expect(plugin).toHaveBeenCalledTimes(1) + }) + + it('should parse formatted numbers', () => { + fixtureEl.innerHTML = '

$1,234.5

' + + 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 = '

soon

' + + 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) + }) + }) +}) diff --git a/core/js/tests/unit/input-mask.spec.ts b/core/js/tests/unit/input-mask.spec.ts new file mode 100644 index 000000000..a1cca6fc2 --- /dev/null +++ b/core/js/tests/unit/input-mask.spec.ts @@ -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 + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + plugin = vi.fn(function (this: Record) { + this.updateValue = vi.fn() + this.destroy = vi.fn() + }) + window.IMask = plugin as unknown as Window['IMask'] + fixtureEl.innerHTML = '' + }) + + afterEach(() => { + clearFixture() + delete window.IMask + }) + + const input = (): HTMLInputElement => fixtureEl.querySelector('input')! + const lastInstance = (): Record> => plugin.mock.instances[plugin.mock.instances.length - 1] as Record> + + 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 = '' + + 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 = '' + + 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) + }) + }) +}) diff --git a/core/js/tests/unit/sortable.spec.ts b/core/js/tests/unit/sortable.spec.ts new file mode 100644 index 000000000..58cda1fad --- /dev/null +++ b/core/js/tests/unit/sortable.spec.ts @@ -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 + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + plugin = vi.fn(function (this: Record) { + 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 = '
  • a
  • b
' + }) + + afterEach(() => { + clearFixture() + delete window.Sortable + }) + + const list = (): HTMLElement => fixtureEl.querySelector('ul')! + const lastInstance = (): Record> => plugin.mock.instances[plugin.mock.instances.length - 1] as Record> + + 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 = '
  • a
' + + 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) + }) + }) +}) diff --git a/core/js/tests/unit/switch-icon.spec.ts b/core/js/tests/unit/switch-icon.spec.ts new file mode 100644 index 000000000..9aa863447 --- /dev/null +++ b/core/js/tests/unit/switch-icon.spec.ts @@ -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 = '
' + }) + + 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 = '' + + 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() + }) + }) +}) diff --git a/docs/content/ui/components/switch-icon.mdx b/docs/content/ui/components/switch-icon.mdx index d06e9b9d1..c6836d08e 100644 --- a/docs/content/ui/components/switch-icon.mdx +++ b/docs/content/ui/components/switch-icon.mdx @@ -101,13 +101,37 @@ You can also add a fancy animation to add variety to your button. See demo below ## 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: +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 -- 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. - 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. diff --git a/docs/content/ui/plugins/countup.mdx b/docs/content/ui/plugins/countup.mdx index 8eb3e4bed..3cfbbb69b 100644 --- a/docs/content/ui/plugins/countup.mdx +++ b/docs/content/ui/plugins/countup.mdx @@ -1,15 +1,11 @@ --- title: Countup 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. related: [/ui/plugins/chart] --- import Example from '@components/Example.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 @@ -23,23 +19,7 @@ Write the final number as the text of the element and add `data-countup`. Tabler ## Installation -Install countup.js with npm: - - - -Or include it from a CDN: - -`} /> - -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/). +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. ## Usage @@ -129,10 +109,34 @@ Grouping is on by default, so thousands get a separator. Set `"useGrouping": fal ## 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: +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 - 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. diff --git a/docs/content/ui/plugins/input-mask.mdx b/docs/content/ui/plugins/input-mask.mdx index 908dea8a3..5c6565211 100644 --- a/docs/content/ui/plugins/input-mask.mdx +++ b/docs/content/ui/plugins/input-mask.mdx @@ -60,10 +60,33 @@ If you need more examples of input masks, you can find them in the [IMask docume ## 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: +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 - 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`.