diff --git a/.changeset/toggler-plugin.md b/.changeset/toggler-plugin.md new file mode 100644 index 000000000..fe362f117 --- /dev/null +++ b/.changeset/toggler-plugin.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added the `Toggler` plugin, which toggles a class or an attribute on click with `data-bs-toggle="toggler"`. diff --git a/core/js/src/bootstrap.ts b/core/js/src/bootstrap.ts index 26f9973c9..abf97c8db 100644 --- a/core/js/src/bootstrap.ts +++ b/core/js/src/bootstrap.ts @@ -11,6 +11,7 @@ export { default as Popover } from './bootstrap/popover' export { default as ScrollSpy } from './bootstrap/scrollspy' export { default as Tab } from './bootstrap/tab' export { default as Toast } from './bootstrap/toast' +export { default as Toggler } from './bootstrap/toggler' export { default as Tooltip } from './bootstrap/tooltip' import Alert from './bootstrap/alert' @@ -24,6 +25,7 @@ import Popover from './bootstrap/popover' import ScrollSpy from './bootstrap/scrollspy' import Tab from './bootstrap/tab' import Toast from './bootstrap/toast' +import Toggler from './bootstrap/toggler' import Tooltip from './bootstrap/tooltip' export const bootstrap = { @@ -38,5 +40,6 @@ export const bootstrap = { ScrollSpy, Tab, Toast, + Toggler, Tooltip, } diff --git a/core/js/src/bootstrap/toggler.ts b/core/js/src/bootstrap/toggler.ts new file mode 100644 index 000000000..0e8653a41 --- /dev/null +++ b/core/js/src/bootstrap/toggler.ts @@ -0,0 +1,135 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap toggler.ts + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component' +import EventHandler from './dom/event-handler.js' +import { eventActionOnPlugin } from './util/component-functions' +import { defineJQueryPlugin } from './util/index.js' +import type { JQueryCollectionLike } from './types' + +type TogglerConfig = { + attribute: string + value: string | number | boolean | null +} + +type TogglerConfigInput = Partial & Record + +/** + * Constants + */ + +const NAME = 'toggler' +const DATA_KEY = 'bs.toggler' +const EVENT_KEY = `.${DATA_KEY}` + +const EVENT_TOGGLE = `toggle${EVENT_KEY}` +const EVENT_TOGGLED = `toggled${EVENT_KEY}` +const EVENT_CLICK = 'click' + +const SELECTOR_DATA_TOGGLE = ':is([data-bs-toggle="toggler"], [data-tblr-toggle="toggler"])' + +const DefaultType: Record = { + attribute: 'string', + value: '(string|number|boolean|null)', +} + +const Default: TogglerConfig = { + attribute: 'class', + value: null, +} + +/** + * Class definition + */ + +class Toggler extends BaseComponent { + declare _element: HTMLElement + declare _config: TogglerConfig + + constructor(element?: Element | string | null, config?: TogglerConfigInput | null) { + super(element as string | HTMLElement, (config ?? undefined) as Record) + } + + // Getters + static get Default(): TogglerConfig { + return Default + } + + static get DefaultType(): Record { + return DefaultType + } + + static get NAME(): string { + return NAME + } + + // Public + toggle(): void { + const toggleEvent = EventHandler.trigger(this._element, EVENT_TOGGLE) + + if (toggleEvent?.defaultPrevented) { + return + } + + this._execute() + + EventHandler.trigger(this._element, EVENT_TOGGLED) + } + + // Private + _execute(): void { + const { attribute, value } = this._config + + if (attribute === 'id') { + return // You have to be kidding + } + + // Nothing to toggle without a value (e.g. missing `data-bs-value`) + if (value === null || value === undefined) { + return + } + + if (attribute === 'class') { + this._element.classList.toggle(String(value)) + return + } + + // Compare as strings since getAttribute() always returns a string + if (this._element.getAttribute(attribute) === String(value)) { + this._element.removeAttribute(attribute) + return + } + + this._element.setAttribute(attribute, String(value)) + } + + // Static + static jQueryInterface(this: JQueryCollectionLike, config?: unknown): unknown { + return this.each(function (this: HTMLElement) { + const data = Toggler.getOrCreateInstance(this) as Toggler + + if (config === 'toggle') { + data.toggle() + } + }) + } +} + +/** + * Data API implementation + */ + +eventActionOnPlugin(Toggler, EVENT_CLICK, SELECTOR_DATA_TOGGLE, 'toggle') + +/** + * jQuery + */ + +defineJQueryPlugin(Toggler) + +export default Toggler +export type { TogglerConfig } diff --git a/core/js/tests/unit/toggler.spec.ts b/core/js/tests/unit/toggler.spec.ts new file mode 100644 index 000000000..b296314e4 --- /dev/null +++ b/core/js/tests/unit/toggler.spec.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' +import Toggler from '../../src/bootstrap/toggler' +import { clearFixture, getFixture } from '../helpers/fixture' + +describe('Toggler', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(typeof Toggler.VERSION).toBe('string') + }) + }) + + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const togglerBySelector = new Toggler('[data-bs-toggle="toggler"]') + expect(togglerBySelector._element).toEqual(togglerEl) + + const togglerByElement = new Toggler(togglerEl) + expect(togglerByElement._element).toEqual(togglerEl) + }) + }) + + describe('toggle', () => { + it('should toggle class on the element', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + toggler.toggle() + expect(togglerEl.classList.contains('bg-warning')).toBe(true) + + toggler.toggle() + expect(togglerEl.classList.contains('bg-warning')).toBe(false) + }) + + it('should toggle attribute on the element', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + toggler.toggle() + expect(togglerEl.getAttribute('hidden')).toEqual('true') + + toggler.toggle() + expect(togglerEl.hasAttribute('hidden')).toBe(false) + }) + + it('should not throw and should be a no-op when no value is provided', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + const classNameBefore = togglerEl.className + + expect(() => toggler.toggle()).not.toThrow() + expect(togglerEl.className).toEqual(classNameBefore) + }) + + it('should not toggle id attribute', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + toggler.toggle() + expect(togglerEl.getAttribute('id')).toBeNull() + }) + + it('should trigger events', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + const toggleSpy = vi.fn() + const toggledSpy = vi.fn() + + togglerEl.addEventListener('toggle.bs.toggler', toggleSpy) + togglerEl.addEventListener('toggled.bs.toggler', toggledSpy) + + toggler.toggle() + + expect(toggleSpy).toHaveBeenCalled() + expect(toggledSpy).toHaveBeenCalled() + }) + + it('should not trigger toggled event if toggle is prevented', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + const toggledSpy = vi.fn() + + togglerEl.addEventListener('toggle.bs.toggler', (event) => { + event.preventDefault() + }) + togglerEl.addEventListener('toggled.bs.toggler', toggledSpy) + + toggler.toggle() + + expect(toggledSpy).not.toHaveBeenCalled() + }) + }) + + describe('dispose', () => { + it('should dispose a toggler', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + expect(Toggler.getInstance(togglerEl)).not.toBeNull() + + toggler.dispose() + + expect(Toggler.getInstance(togglerEl)).toBeNull() + }) + }) + + describe('getInstance', () => { + it('should return null if there is no instance', () => { + expect(Toggler.getInstance(fixtureEl)).toBeNull() + }) + + it('should return this instance', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + expect(Toggler.getInstance(togglerEl)).toEqual(toggler) + expect(Toggler.getInstance(togglerEl)).toBeInstanceOf(Toggler) + }) + }) + + describe('getOrCreateInstance', () => { + it('should return toggler instance', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const toggler = new Toggler(togglerEl) + + expect(Toggler.getOrCreateInstance(togglerEl)).toEqual(toggler) + expect(Toggler.getInstance(togglerEl)).toEqual(Toggler.getOrCreateInstance(togglerEl, {})) + expect(Toggler.getOrCreateInstance(togglerEl)).toBeInstanceOf(Toggler) + }) + + it('should return new instance when there is no toggler instance', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + + expect(Toggler.getInstance(togglerEl)).toBeNull() + expect(Toggler.getOrCreateInstance(togglerEl)).toBeInstanceOf(Toggler) + }) + }) + + describe('data-api', () => { + it('should toggle class on click', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + + togglerEl.click() + expect(togglerEl.classList.contains('bg-warning')).toBe(true) + + togglerEl.click() + expect(togglerEl.classList.contains('bg-warning')).toBe(false) + }) + + it('should toggle attribute on click', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + + togglerEl.click() + expect(togglerEl.getAttribute('hidden')).toEqual('true') + + togglerEl.click() + expect(togglerEl.hasAttribute('hidden')).toBe(false) + }) + + it('should not toggle id attribute on click', () => { + fixtureEl.innerHTML = '
' + + const togglerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + + togglerEl.click() + expect(togglerEl.getAttribute('id')).toBeNull() + }) + + it('should toggle class on target element via data-bs-target with ID selector', () => { + fixtureEl.innerHTML = ['', '
'].join('') + + const triggerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const targetEl = fixtureEl.querySelector('#target-element')! + + triggerEl.click() + expect(targetEl.classList.contains('bg-info')).toBe(true) + + triggerEl.click() + expect(targetEl.classList.contains('bg-info')).toBe(false) + }) + + it('should toggle class on multiple target elements via data-bs-target with class selector', () => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + '
', + ].join('') + + const triggerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const targetEls = fixtureEl.querySelectorAll('.target-class') + + triggerEl.click() + expect(targetEls[0].classList.contains('bg-warning')).toBe(true) + expect(targetEls[1].classList.contains('bg-info')).toBe(true) + expect(targetEls[2].classList.contains('bg-danger')).toBe(true) + + triggerEl.click() + expect(targetEls[0].classList.contains('bg-warning')).toBe(false) + expect(targetEls[1].classList.contains('bg-info')).toBe(false) + expect(targetEls[2].classList.contains('bg-danger')).toBe(false) + }) + + it('should toggle attribute on target element via data-bs-target', () => { + fixtureEl.innerHTML = ['', '
'].join('') + + const triggerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const targetEl = fixtureEl.querySelector('#target-fieldset')! + + triggerEl.click() + expect(targetEl.getAttribute('disabled')).toEqual('disabled') + + triggerEl.click() + expect(targetEl.hasAttribute('disabled')).toBe(false) + }) + + it('should toggle target element via href attribute as fallback', () => { + fixtureEl.innerHTML = ['', '
'].join('') + + const triggerEl = fixtureEl.querySelector('[data-bs-toggle="toggler"]')! + const targetEl = fixtureEl.querySelector('#target-via-href')! + + triggerEl.click() + expect(targetEl.classList.contains('active')).toBe(true) + + triggerEl.click() + expect(targetEl.classList.contains('active')).toBe(false) + }) + it('should work with the data-tblr- prefix', () => { + fixtureEl.innerHTML = ['', '
'].join('') + + const triggerEl = fixtureEl.querySelector('[data-tblr-toggle="toggler"]')! + const targetEl = fixtureEl.querySelector('#target-tblr')! + + triggerEl.click() + expect(targetEl.classList.contains('active')).toBe(true) + }) + + it('should not toggle when the trigger is disabled', () => { + fixtureEl.innerHTML = ['', ''].join('') + + const buttonEl = fixtureEl.querySelector('button')! + const linkEl = fixtureEl.querySelector('a')! + + buttonEl.click() + linkEl.click() + expect(buttonEl.classList.contains('active')).toBe(false) + expect(linkEl.classList.contains('active')).toBe(false) + }) + }) +}) diff --git a/core/package.json b/core/package.json index 37e580c94..753bbb42f 100644 --- a/core/package.json +++ b/core/package.json @@ -212,7 +212,7 @@ }, { "path": "./dist/js/tabler.js", - "maxSize": "58 kB" + "maxSize": "59 kB" }, { "path": "./dist/js/tabler.min.js", @@ -220,7 +220,7 @@ }, { "path": "./dist/js/tabler.esm.js", - "maxSize": "58 kB" + "maxSize": "59 kB" }, { "path": "./dist/js/tabler.esm.min.js", diff --git a/docs/content/ui/components/toggler.mdx b/docs/content/ui/components/toggler.mdx new file mode 100644 index 000000000..1f3ee29df --- /dev/null +++ b/docs/content/ui/components/toggler.mdx @@ -0,0 +1,105 @@ +--- +title: Toggler +summary: Toggler switches a class or an attribute on click. It needs only data attributes, so you do not have to write small one-off scripts. +description: Toggle a class or any attribute on click with data attributes only - no custom JavaScript needed. +added-in: '1.7.0' +related: [/ui/components/switch-icon, /ui/components/button] +source: core/js/src/bootstrap/toggler.ts +--- +import Example from '@components/Example.astro'; + +## Overview + +Add `data-bs-toggle="toggler"` to an element and put the class name in `data-bs-value`. Each click adds the class, and the next click removes it. + + + + + +## Usage + +### Remove an existing class + +If the element already has the class, the first click removes it. + + + + + +### Target element + +Use `data-bs-target` to change another element. Put `data-bs-value` on the target, not on the button. + + + +
The button toggles bg-primary-lt here.
+
+ +### Multiple elements + +A class selector in `data-bs-target` toggles every matching element. Each one can have its own value. + + + +
Toggles bg-blue-lt
+
Toggles bg-green-lt
+
+ +### Toggle an attribute + +Set `data-bs-attribute` to toggle an attribute instead of a class. The attribute gets the value from `data-bs-value`, and the next click removes the attribute. + + + +
Now you see me.
+
+ +You can use the same pattern to toggle `disabled` on a `fieldset` and lock all controls inside it. Every attribute works except `id`. + +### Data attributes + +| Attribute | Description | +| --- | --- | +| `data-bs-toggle="toggler"` | Turns the element into a trigger. | +| `data-bs-target` | Optional CSS selector of the element to change. Without it, the trigger changes itself. | +| `data-bs-attribute` | Attribute to toggle. Default is `class`. | +| `data-bs-value` | Class name or attribute value to toggle. Without it, nothing happens. | + +### JavaScript + +Toggler is made to avoid writing JavaScript, but you can also use it from code. + +```js +const toggler = tabler.Toggler.getOrCreateInstance('#my-element', { + attribute: 'class', + value: 'active', +}) + +toggler.toggle() +``` + +| Method | Description | +| --- | --- | +| `toggle()` | Toggles the class or the attribute. | +| `dispose()` | Removes the instance from the element. | +| `getInstance()` | Static. Returns the instance of an element, or `null`. | +| `getOrCreateInstance()` | Static. Returns the instance of an element, or creates one. | + +### Events + +| Event | Description | +| --- | --- | +| `toggle.bs.toggler` | Fires before the change. Call `event.preventDefault()` to stop it. | +| `toggled.bs.toggler` | Fires after the change. | + +```js +document.getElementById('my-element').addEventListener('toggled.bs.toggler', () => { + // do something +}) +``` + +## Accessibility + +- Use a ` + + + + +
+ + + Remove an existing class + When the element already has the class, the first click removes it. + + + +
+ +
+ + + Target element + Point data-bs-target at another element. The value lives on the target. + +
The button toggles bg-primary-lt here.
+
+
+
+ +
+ + + Multiple elements + A class selector toggles every match, each with its own value. + +
+ { + colors.map((color) => ( +
+ Toggles bg-{color} +
+ )) + } +
+
+
+
+ +
+ + + Toggle an attribute + Set data-bs-attribute to toggle an attribute instead, here hidden. + +
Now you see me.
+
+
+
+ +
+ + + Disable a form + Toggle disabled on a fieldset to lock every control inside. + +
+
+ + +
+ +
+
+
+
+ + diff --git a/shared/data/docs.json b/shared/data/docs.json index ba3a7ac42..0e2851d1e 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -326,6 +326,10 @@ "title": "Toast", "url": "/ui/components/toast" }, + { + "title": "Toggler", + "url": "/ui/components/toggler" + }, { "title": "Tooltip", "url": "/ui/components/tooltip" diff --git a/shared/data/menu.json b/shared/data/menu.json index f0df71c6d..53ca43fb5 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -261,6 +261,10 @@ "title": "Toasts", "url": "toasts.html" }, + "toggler": { + "title": "Toggler", + "url": "toggler.html" + }, "tracking": { "title": "Tracking", "url": "tracking.html"