Add loading state and toggle event to switch icon (#3049)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-15 22:35:36 +02:00
1 parent a744b8407f
commit 0776b88863
11 files changed
+628 -34

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": patch
---
Added a `switch-icon-loading` state and a `tabler:switch-icon-toggle` event that waits for a promise before switching.
+69 -3
View File
@@ -23,10 +23,14 @@ const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_TOGGLE = `toggle${EVENT_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}`
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_DISABLED = 'disabled'
const CLASS_NAME_LOADING = 'switch-icon-loading'
const SELECTOR_SWITCH_ICON = '.switch-icon'
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
const Default: ComponentConfig = {}
@@ -37,13 +41,17 @@ 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, mirrored in `aria-pressed`. The element is the `.switch-icon` itself,
* or a button (e.g. `.btn-action`) wrapping one: the classes then go on the
* inner `.switch-icon` and the ARIA state stays on the button.
*/
class SwitchIcon extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
#iconElement!: HTMLElement
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
@@ -51,6 +59,8 @@ class SwitchIcon extends BaseComponent {
return
}
this.#iconElement = this._element.matches(SELECTOR_SWITCH_ICON) ? this._element : (SelectorEngine.findOne(SELECTOR_SWITCH_ICON, this._element) ?? this._element)
if (!this._element.hasAttribute('aria-pressed')) {
this._element.setAttribute('aria-pressed', String(this.isActive))
}
@@ -59,6 +69,12 @@ class SwitchIcon extends BaseComponent {
// not reach it.
EventHandler.on(this._element, EVENT_CLICK, (event: Event) => {
event.stopPropagation()
// `.disabled` only blocks the pointer; Enter and Space still fire a click
if (this._element.classList.contains(CLASS_NAME_DISABLED) || this._element.getAttribute('aria-disabled') === 'true') {
return
}
this.toggle()
})
}
@@ -77,16 +93,66 @@ class SwitchIcon extends BaseComponent {
}
get isActive(): boolean {
return this._element.classList.contains(CLASS_NAME_ACTIVE)
return this.#iconElement.classList.contains(CLASS_NAME_ACTIVE)
}
get isLoading(): boolean {
return this.#iconElement.classList.contains(CLASS_NAME_LOADING)
}
// Public
toggle(force?: boolean): void {
const active = this._element.classList.toggle(CLASS_NAME_ACTIVE, force)
if (this.isLoading) {
return
}
const active = force ?? !this.isActive
// A listener can cancel with preventDefault(), or hand over a promise with
// wait(): the button shows a spinner until it settles and switches only
// when it resolves.
let pending: Promise<unknown> | undefined
const toggleEvent = EventHandler.trigger(this._element, EVENT_TOGGLE, {
active,
wait: (promise: Promise<unknown>) => {
pending = promise
},
})
if (toggleEvent?.defaultPrevented) {
return
}
if (!pending) {
this.#setActive(active)
return
}
this.#setLoading(true)
pending
.then(
() => this.#setActive(active),
() => undefined,
)
.finally(() => this.#setLoading(false))
}
// Private
#setActive(active: boolean): void {
this.#iconElement.classList.toggle(CLASS_NAME_ACTIVE, active)
this._element.setAttribute('aria-pressed', String(active))
EventHandler.trigger(this._element, EVENT_CHANGE, { active })
}
#setLoading(loading: boolean): void {
this.#iconElement.classList.toggle(CLASS_NAME_LOADING, loading)
if (loading) {
this._element.setAttribute('aria-busy', 'true')
} else {
this._element.removeAttribute('aria-busy')
}
}
}
/**
+127 -5
View File
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vite
import { clearFixture, getFixture } from '../helpers/fixture'
import SwitchIcon from '../../src/switch-icon'
type ToggleEvent = Event & { active: boolean; wait: (promise: Promise<unknown>) => void }
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('SwitchIcon', () => {
let fixtureEl: HTMLElement
@@ -64,15 +68,120 @@ describe('SwitchIcon', () => {
expect(instance.isActive).toBe(true)
})
it('should trigger change.bs.switch-icon with the new state', () => {
it('should trigger toggle.bs.switch-icon before and change.bs.switch-icon after the change', () => {
const instance = new SwitchIcon(button())
const spy = vi.fn()
button().addEventListener('change.bs.switch-icon', spy)
const toggleSpy = vi.fn()
const changeSpy = vi.fn()
button().addEventListener('toggle.bs.switch-icon', toggleSpy)
button().addEventListener('change.bs.switch-icon', changeSpy)
instance.toggle()
expect(spy).toHaveBeenCalledTimes(1)
expect((spy.mock.calls[0][0] as Event & { active: boolean }).active).toBe(true)
expect(toggleSpy).toHaveBeenCalledTimes(1)
expect((toggleSpy.mock.calls[0][0] as ToggleEvent).active).toBe(true)
expect(changeSpy).toHaveBeenCalledTimes(1)
expect((changeSpy.mock.calls[0][0] as Event & { active: boolean }).active).toBe(true)
})
it('should not change when toggle.bs.switch-icon is prevented', () => {
const instance = new SwitchIcon(button())
const changeSpy = vi.fn()
button().addEventListener('toggle.bs.switch-icon', (event) => event.preventDefault())
button().addEventListener('change.bs.switch-icon', changeSpy)
instance.toggle()
expect(instance.isActive).toBe(false)
expect(button().getAttribute('aria-pressed')).toBe('false')
expect(changeSpy).not.toHaveBeenCalled()
})
})
describe('wait', () => {
it('should show the loading state until the promise resolves, then switch', async () => {
const instance = new SwitchIcon(button())
let resolve!: () => void
button().addEventListener('toggle.bs.switch-icon', (event) => {
;(event as ToggleEvent).wait(new Promise<void>((r) => (resolve = r)))
})
instance.toggle()
expect(instance.isLoading).toBe(true)
expect(button().classList.contains('switch-icon-loading')).toBe(true)
expect(button().getAttribute('aria-busy')).toBe('true')
expect(instance.isActive).toBe(false)
// a toggle while loading is ignored
instance.toggle()
expect(instance.isLoading).toBe(true)
resolve()
await tick()
expect(instance.isLoading).toBe(false)
expect(button().hasAttribute('aria-busy')).toBe(false)
expect(instance.isActive).toBe(true)
expect(button().getAttribute('aria-pressed')).toBe('true')
})
it('should keep the previous state when the promise rejects', async () => {
const instance = new SwitchIcon(button())
button().addEventListener('toggle.bs.switch-icon', (event) => {
;(event as ToggleEvent).wait(Promise.reject(new Error('nope')))
})
instance.toggle()
expect(instance.isLoading).toBe(true)
await tick()
expect(instance.isLoading).toBe(false)
expect(instance.isActive).toBe(false)
expect(button().getAttribute('aria-pressed')).toBe('false')
})
it('should ignore a toggle on a button rendered in the loading state', () => {
fixtureEl.innerHTML = '<button type="button" class="switch-icon switch-icon-loading" data-bs-toggle="switch-icon" aria-pressed="false"></button>'
const instance = new SwitchIcon(button())
instance.toggle()
expect(instance.isActive).toBe(false)
})
})
describe('wrapping button', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<button type="button" class="btn btn-action" data-bs-toggle="switch-icon" aria-pressed="false"><span class="switch-icon"></span></button>'
})
const icon = (): HTMLElement => fixtureEl.querySelector('.switch-icon')!
it('should toggle the inner switch-icon and keep aria-pressed on the button', () => {
const instance = new SwitchIcon(button())
instance.toggle()
expect(icon().classList.contains('active')).toBe(true)
expect(button().classList.contains('active')).toBe(false)
expect(button().getAttribute('aria-pressed')).toBe('true')
expect(instance.isActive).toBe(true)
})
it('should put the loading class on the inner switch-icon and aria-busy on the button', async () => {
const instance = new SwitchIcon(button())
let resolve!: () => void
button().addEventListener('toggle.bs.switch-icon', (event) => {
;(event as ToggleEvent).wait(new Promise<void>((r) => (resolve = r)))
})
instance.toggle()
expect(icon().classList.contains('switch-icon-loading')).toBe(true)
expect(button().classList.contains('switch-icon-loading')).toBe(false)
expect(button().getAttribute('aria-busy')).toBe('true')
resolve()
await tick()
expect(icon().classList.contains('active')).toBe(true)
expect(button().hasAttribute('aria-busy')).toBe(false)
})
})
@@ -88,6 +197,19 @@ describe('SwitchIcon', () => {
expect(parentSpy).not.toHaveBeenCalled()
})
it('should ignore clicks on a button with the disabled class or aria-disabled', () => {
fixtureEl.innerHTML = '<button type="button" class="switch-icon disabled" data-bs-toggle="switch-icon" aria-pressed="false"></button>'
new SwitchIcon(button())
button().click()
expect(button().classList.contains('active')).toBe(false)
button().classList.remove('disabled')
button().setAttribute('aria-disabled', 'true')
button().click()
expect(button().classList.contains('active')).toBe(false)
})
it('should stop toggling after dispose', () => {
const instance = new SwitchIcon(button())
instance.dispose()
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Switch icon</title>
</head>
<body>
<div class="container">
<h1>Switch icon <small>Tabler Visual Test</small></h1>
<p>Plain toggle, a toggle that waits 1.5 s for a fake request, one whose request fails, and one rendered loading.</p>
<button type="button" class="switch-icon switch-icon-scale" data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Star">
<span class="switch-icon-a text-secondary"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg></span>
<span class="switch-icon-b text-yellow"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-filled"><path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z" /></svg></span>
</button>
<button type="button" id="async" class="switch-icon switch-icon-scale" data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Star">
<span class="switch-icon-a text-secondary"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg></span>
<span class="switch-icon-b text-yellow"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-filled"><path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z" /></svg></span>
</button>
<button type="button" id="failing" class="switch-icon switch-icon-scale" data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Star">
<span class="switch-icon-a text-secondary"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg></span>
<span class="switch-icon-b text-yellow"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-filled"><path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z" /></svg></span>
</button>
<button type="button" class="switch-icon switch-icon-loading" data-bs-toggle="switch-icon" aria-pressed="false" aria-busy="true" aria-label="Star">
<span class="switch-icon-a text-secondary"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg></span>
<span class="switch-icon-b text-yellow"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-filled"><path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z" /></svg></span>
</button>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>
document.getElementById('async').addEventListener('toggle.bs.switch-icon', (event) => {
event.wait(new Promise((resolve) => setTimeout(resolve, 1500)))
})
document.getElementById('failing').addEventListener('toggle.bs.switch-icon', (event) => {
event.wait(new Promise((resolve, reject) => setTimeout(reject, 1500)))
})
</script>
</body>
</html>
+3
View File
@@ -58,6 +58,9 @@
--spacer-#{$name}: #{$value};
}
/** Icons */
--icon-size: #{$icon-size};
/** Font sizes */
@each $name, $value in $font-weights {
--font-weight-#{$name}: #{$value};
+11
View File
@@ -2165,6 +2165,17 @@ $legend-list-gap: 0.25rem 1rem !default;
$legend-list-gap-interactive: 0.25rem !default;
// scss-docs-end legend-variables
// Switch icon
// scss-docs-start switch-icon-variables
$switch-icon-size: var(--icon-size) !default;
// WCAG 2.5.8 minimum target size; the icon stays at $switch-icon-size, centered
$switch-icon-min-size: 1.5rem !default;
$switch-icon-disabled-opacity: $btn-disabled-opacity !default;
$switch-icon-loading-size: $spinner-width-sm !default;
$switch-icon-loading-border-width: $spinner-border-width-sm !default;
$switch-icon-loading-animation-speed: $spinner-animation-speed !default;
// scss-docs-end switch-icon-variables
// Offcanvas
// scss-docs-start offcanvas-variables
$offcanvas-padding-y: $modal-inner-padding !default;
+63 -9
View File
@@ -1,10 +1,18 @@
@use '../config' as *;
.switch-icon {
--switch-icon-size: #{$switch-icon-size};
--switch-icon-min-size: #{$switch-icon-min-size};
--switch-icon-disabled-opacity: #{$switch-icon-disabled-opacity};
--switch-icon-loading-size: #{$switch-icon-loading-size};
--switch-icon-loading-border-width: #{$switch-icon-loading-border-width};
--switch-icon-loading-animation-speed: #{$switch-icon-loading-animation-speed};
position: relative;
display: inline-block;
width: $icon-size;
height: $icon-size;
width: var(--switch-icon-size);
min-width: var(--switch-icon-min-size);
height: var(--switch-icon-size);
min-height: var(--switch-icon-min-size);
padding: 0;
line-height: 1;
vertical-align: bottom;
@@ -12,9 +20,15 @@
background: transparent;
border: 0;
&.disabled {
&.disabled,
&:disabled,
&[aria-disabled='true'] {
pointer-events: none;
opacity: $btn-disabled-opacity;
opacity: var(--switch-icon-disabled-opacity);
}
@media (prefers-reduced-motion: reduce) {
--switch-icon-loading-animation-speed: #{$switch-icon-loading-animation-speed * 2};
}
&:focus:not(:focus-visible) {
@@ -32,11 +46,15 @@
height: 100%;
}
// Both icons sit centered in the (possibly larger) button box
.switch-icon-a,
.switch-icon-b {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
width: var(--switch-icon-size);
height: var(--switch-icon-size);
margin: auto;
}
.switch-icon-a {
@@ -44,9 +62,6 @@
}
.switch-icon-b {
position: absolute;
inset-inline-start: 0;
top: 0;
opacity: 0;
}
@@ -59,6 +74,45 @@
opacity: 1;
}
}
// Loading state: both icons hidden, a spinner drawn in their place
&.switch-icon-loading {
pointer-events: none;
.switch-icon-a,
.switch-icon-b {
opacity: 0;
transition: none;
}
&::after {
position: absolute;
inset-inline-start: calc(50% - var(--switch-icon-loading-size) / 2);
top: calc(50% - var(--switch-icon-loading-size) / 2);
width: var(--switch-icon-loading-size);
height: var(--switch-icon-loading-size);
content: '';
border: var(--switch-icon-loading-border-width) var(--border-style) currentColor;
border-inline-end-color: transparent;
animation: spinner-border var(--switch-icon-loading-animation-speed) linear infinite;
@include border-radius(var(--border-radius-pill));
}
}
}
// Inside a button (e.g. .btn-action) the button is the target and carries hover and focus
.btn .switch-icon {
--switch-icon-min-size: var(--switch-icon-size);
vertical-align: middle;
pointer-events: none;
// the button's own icon sizing and margins would misplace the two stacked icons
.icon {
width: 100%;
min-width: 0;
height: 100%;
margin: 0;
}
}
// Fade variant
+82 -4
View File
@@ -13,6 +13,11 @@ classnames:
desc: Icon shown in the off state
- class: switch-icon-b
desc: Icon shown in the on state
state:
- class: active
desc: Switched on; Tabler toggles it on click
- class: switch-icon-loading
desc: Spinner instead of the icons, clicks ignored
behavior:
- class: switch-icon-fade
desc: Crossfades between the icons
@@ -99,6 +104,69 @@ You can also add a fancy animation to add variety to your button. See demo below
</button>
</Example>
## In an action button
A switch icon can sit inside a button, for example a `btn-action` in a table row or a card header. Put `data-bs-toggle="switch-icon"` and `aria-pressed` on the button and the `switch-icon` inside it: Tabler toggles the icon and keeps the state on the button, which also provides the hover, focus and target size.
<Example centered>
<div class="btn-actions">
<button type="button" class="btn btn-action" data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Like">
<span class="switch-icon switch-icon-scale">
<span class="switch-icon-a"><Icon name="heart" /></span>
<span class="switch-icon-b text-red"><Icon name="heart" filled /></span>
</span>
</button>
<button type="button" class="btn btn-action" data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Add to favorites">
<span class="switch-icon switch-icon-scale">
<span class="switch-icon-a"><Icon name="star" /></span>
<span class="switch-icon-b text-yellow"><Icon name="star" filled /></span>
</span>
</button>
<button type="button" class="btn btn-action" data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Bookmark">
<span class="switch-icon switch-icon-flip">
<span class="switch-icon-a"><Icon name="bookmark" /></span>
<span class="switch-icon-b text-primary"><Icon name="bookmark" filled /></span>
</span>
</button>
</div>
</Example>
## Loading state
Add `switch-icon-loading` to show a spinner in place of the icons, for example while a request is in flight. The button ignores clicks in this state. Set `aria-busy="true"` with it, so assistive technology knows the control is busy.
<Example centered>
<button type="button" class="switch-icon switch-icon-loading" data-bs-toggle="switch-icon" aria-pressed="false" aria-busy="true" aria-label="Add to favorites">
<span class="switch-icon-a text-secondary"><Icon name="star" /></span>
<span class="switch-icon-b text-yellow"><Icon name="star" filled /></span>
</button>
<button type="button" class="switch-icon switch-icon-loading" data-bs-toggle="switch-icon" aria-pressed="false" aria-busy="true" aria-label="Like">
<span class="switch-icon-a text-secondary"><Icon name="heart" /></span>
<span class="switch-icon-b text-red"><Icon name="heart" filled /></span>
</button>
</Example>
## Wait for a request
Every toggle fires `toggle.bs.switch-icon` on the button before the state changes. `event.active` is the state the button is about to switch to. Pass a promise to `event.wait()` and the button shows the loading state until it settles: it switches when the promise resolves and stays as it was when it rejects. Call `event.preventDefault()` to cancel the toggle.
```html
<button type="button" class="switch-icon" data-bs-toggle="switch-icon" data-id="42" aria-pressed="false" aria-label="Star">
<span class="switch-icon-a text-secondary">…</span>
<span class="switch-icon-b text-yellow">…</span>
</button>
```
```js
document.querySelectorAll('[data-bs-toggle="switch-icon"]').forEach((button) => {
button.addEventListener('toggle.bs.switch-icon', (event) => {
event.wait(fetch(`/api/items/${button.dataset.id}/star`, { method: event.active ? 'PUT' : 'DELETE' }));
});
});
```
The event bubbles, so one listener on a list can serve all of its buttons.
## JavaScript
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:
@@ -113,7 +181,7 @@ 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`:
Every toggle fires `toggle.bs.switch-icon` before the change (cancelable, see [Wait for a request](#wait-for-a-request)) and `change.bs.switch-icon` after it, both with the new state in `event.active`:
```js
document.getElementById('favorite').addEventListener('change.bs.switch-icon', (event) => {
@@ -123,8 +191,9 @@ document.getElementById('favorite').addEventListener('change.bs.switch-icon', (e
| 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. |
| `toggle(force)` | Flips the state, or sets it when `force` is `true` or `false`. Fires `toggle.bs.switch-icon`, then updates `aria-pressed` and fires `change.bs.switch-icon`. Does nothing while loading. |
| `isActive` | Getter. `true` when the switch icon has the `active` class. |
| `isLoading` | Getter. `true` while a promise passed to `event.wait()` is pending. |
| `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. |
@@ -134,4 +203,13 @@ document.getElementById('favorite').addEventListener('change.bs.switch-icon', (e
- 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.
- The button box is at least 24×24px (`$switch-icon-min-size`) with the icon centered inside, to meet the WCAG target size. Set `--tblr-switch-icon-min-size` to change it.
- To disable a switch icon use the `disabled` attribute, or `aria-disabled="true"` when it has to stay focusable. Tabler ignores clicks on both, and on the `disabled` class, which alone would still let Enter and Space toggle it.
- While a request is pending, the button carries `aria-busy="true"` and ignores clicks, so a double click cannot send the request twice.
- The spinner slows down under `prefers-reduced-motion`, and the icon transitions are switched off there.
## Variables
Use these SCSS variables to customize switch icons. The default values are:
<CodeDocs name="switch-icon-variables" file="core/scss/_variables.scss" />
+174
View File
@@ -0,0 +1,174 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import CaptureScript from '@shared/components/CaptureScript.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro'
import SwitchIcon from '@ui/SwitchIcon.astro'
import ButtonList from '@ui/ButtonList.astro'
const icons = [
{ icon: 'heart', color: 'red', label: 'Like' },
{ icon: 'star', color: 'yellow', label: 'Add to favorites' },
{ icon: 'bookmark', color: 'primary', label: 'Bookmark' },
{ icon: 'bell', color: 'orange', label: 'Notify me' },
{ icon: 'thumb-up', color: 'blue', label: 'Vote up' },
]
/** core/scss/ui/_switch-icon.scss `.switch-icon-{variant}` */
const variants = ['fade', 'scale', 'flip', 'slide-up', 'slide-down', 'slide-start', 'slide-end'] as const
const pairs = [
{ a: 'check', b: 'x', color: 'red', label: 'Toggle' },
{ a: 'player-play', b: 'player-pause', color: 'primary', label: 'Play' },
{ a: 'volume', b: 'volume-off', color: 'secondary', label: 'Mute' },
{ a: 'eye', b: 'eye-off', color: 'secondary', label: 'Hide' },
{ a: 'lock-open', b: 'lock', color: 'green', label: 'Lock' },
]
const repos = [
{ name: 'tabler/tabler', description: 'Dashboard UI kit', starred: true },
{ name: 'tabler/tabler-icons', description: '5900+ free SVG icons', starred: true },
{ name: 'tabler/tabler-react', description: 'React components', starred: false },
{ name: 'tabler/tabler-emails', description: 'Responsive email templates', starred: false },
]
---
<DefaultLayout title="Switch icons" pageMenu="base.switch-icons" description="Switch icons animate the change between two icons, for likes, favorites and other on/off actions.">
<DocsLink slot="page-header-actions" path="/ui/components/switch-icon" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>A <code>SwitchIcon</code> swaps the outline icon for the filled one in <code>activeColor</code>.</CardSubtitle>
<ButtonList>
{icons.map((item) => <SwitchIcon icon={item.icon} activeColor={item.color} ariaLabel={item.label} />)}
</ButtonList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Animations</CardTitle>
<CardSubtitle>Pick a transition with <code>variant</code>: fade, scale, flip or one of the slides.</CardSubtitle>
<ButtonList>
{variants.map((variant) => <SwitchIcon icon="heart" activeColor="red" variant={variant} ariaLabel={`Like (${variant})`} />)}
</ButtonList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Two icons</CardTitle>
<CardSubtitle>Set <code>activeIcon</code> to switch to a different icon instead of the filled variant.</CardSubtitle>
<ButtonList>
{pairs.map((pair) => <SwitchIcon icon={pair.a} activeIcon={pair.b} activeColor={pair.color} variant="flip" ariaLabel={pair.label} />)}
</ButtonList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>In an action button</CardTitle>
<CardSubtitle>Add <code>action</code> to wrap the switch in a <code>btn-action</code>, which then carries the toggle, hover and focus.</CardSubtitle>
<div class="btn-actions">
{icons.map((item) => <SwitchIcon action icon={item.icon} activeColor={item.color} variant="scale" ariaLabel={item.label} />)}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Loading</CardTitle>
<CardSubtitle>Add <code>loading</code> to show a spinner in place of the icons; clicks are ignored.</CardSubtitle>
<ButtonList>
<SwitchIcon icon="star" activeColor="yellow" loading ariaLabel="Add to favorites" />
<SwitchIcon icon="heart" activeColor="red" loading ariaLabel="Like" />
<SwitchIcon icon="bookmark" activeColor="primary" loading ariaLabel="Bookmark" />
</ButtonList>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Wait for a request</CardTitle>
<CardSubtitle>Pass a promise to <code>event.wait()</code> and the switch waits for it. The last one fails and stays as it was.</CardSubtitle>
<ButtonList>
<SwitchIcon icon="star" activeColor="yellow" variant="scale" data-request="ok" ariaLabel="Add to favorites" />
<SwitchIcon icon="heart" activeColor="red" variant="scale" data-request="ok" ariaLabel="Like" />
<SwitchIcon icon="bookmark" activeColor="primary" variant="scale" data-request="ok" ariaLabel="Bookmark" />
<SwitchIcon icon="bell" activeColor="orange" variant="scale" data-request="fail" ariaLabel="Notify me" />
</ButtonList>
<CaptureScript>
<!-- BEGIN SCRIPT OF SWITCH ICON REQUEST -->
<script is:inline>
/*
Simulates a request behind a switch icon. The button shows a spinner while the
promise is pending, switches when it resolves and stays as it was when it rejects.
*/
document.querySelectorAll('[data-request]').forEach((button) => {
button.addEventListener('toggle.bs.switch-icon', (event) => {
const fails = button.dataset.request === 'fail'
event.wait(new Promise((resolve, reject) => setTimeout(fails ? reject : resolve, 1500)))
})
})
</script>
<!-- END SCRIPT OF SWITCH ICON REQUEST -->
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>In a list</CardTitle>
<CardSubtitle>The event bubbles, so one listener on the list keeps the count in sync.</CardSubtitle>
<div class="list-group list-group-flush" id="starred-repos">
{
repos.map((repo) => (
<div class="list-group-item d-flex align-items-center gap-3 px-0">
<div class="flex-fill">
<div class="fw-medium">{repo.name}</div>
<div class="text-secondary">{repo.description}</div>
</div>
<SwitchIcon icon="star" activeColor="yellow" variant="scale" active={repo.starred} ariaLabel={`Star ${repo.name}`} />
</div>
))
}
</div>
<div class="text-secondary mt-3">
<span id="starred-count">{repos.filter((repo) => repo.starred).length}</span> starred
</div>
<CaptureScript>
<!-- BEGIN SCRIPT OF STARRED COUNT -->
<script is:inline>
/*
Counts the active switches in the list after every change.
*/
const list = document.getElementById('starred-repos')
list.addEventListener('change.bs.switch-icon', () => {
document.getElementById('starred-count').textContent = list.querySelectorAll('.switch-icon.active').length
})
</script>
<!-- END SCRIPT OF STARRED COUNT -->
</CaptureScript>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+4
View File
@@ -241,6 +241,10 @@
"url": "tabs.html",
"title": "Tabs"
},
"switch-icons": {
"url": "switch-icons.html",
"title": "Switch icons"
},
"tags": {
"url": "tags.html",
"title": "Tags"
+42 -13
View File
@@ -6,27 +6,56 @@ type SwitchIconVariant = 'fade' | 'scale' | 'flip' | 'slide-up' | 'slide-down' |
interface Props {
icon?: string
/** icon shown in the on state; defaults to the filled variant of `icon` */
activeIcon?: string | undefined
/** text-{activeColor} on the switched-on icon */
activeColor?: string
variant?: SwitchIconVariant | undefined
class?: string
/** initial state: active + aria-pressed="true" */
active?: boolean | undefined
/** switch-icon-loading + aria-busy: spinner instead of the icons */
loading?: boolean | undefined
/** wraps the switch in a `btn btn-action`, which then carries the toggle, hover and focus */
action?: boolean | undefined
/** accessible name of the toggle; defaults to a label matching the icon */
ariaLabel?: string | undefined
class?: string | undefined
/** remaining attributes (id, data-*, aria-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { icon = 'heart', activeColor = 'red', variant, class: className, ...rest } = Astro.props
const { icon = 'heart', activeIcon, activeColor = 'red', variant, active, loading, action, ariaLabel, class: className, ...rest } = Astro.props
// star/heart force the filled variant; otherwise no extra class.
const resolvedIconBClass = icon === 'star' || icon === 'heart' ? 'icon-filled' : undefined
const defaultLabels: Record<string, string> = { 'heart': 'Like', 'star': 'Add to favorites', 'bookmark': 'Bookmark', 'bell': 'Notify me', 'thumb-up': 'Vote up' }
const label = ariaLabel ?? defaultLabels[icon] ?? 'Toggle'
const buttonClass = ['switch-icon', variant && `switch-icon-${variant}`, className]
// Without an activeIcon, star/heart force the filled variant; otherwise no extra class.
const resolvedIconBClass = !activeIcon && (icon === 'star' || icon === 'heart') ? 'icon-filled' : undefined
const switchClass = ['switch-icon', variant && `switch-icon-${variant}`, active && 'active', loading && 'switch-icon-loading']
const rootAttrs = { 'data-bs-toggle': 'switch-icon', 'aria-pressed': active ? ('true' as const) : ('false' as const), 'aria-busy': loading ? ('true' as const) : undefined, 'aria-label': label, ...rest }
---
<button type="button" class:list={buttonClass} data-bs-toggle="switch-icon" aria-pressed="false" aria-label="Favorite" {...rest}>
<span class="switch-icon-a text-muted">
<Icon name={icon} />
</span>
<span class={`switch-icon-b text-${activeColor}`}>
<Icon name={icon} class={resolvedIconBClass} />
</span>
</button>
{
action ? (
<button type="button" class:list={['btn btn-action', className]} {...rootAttrs}>
<span class:list={switchClass}>
<span class="switch-icon-a">
<Icon name={icon} />
</span>
<span class={`switch-icon-b text-${activeColor}`}>
<Icon name={activeIcon ?? icon} class={resolvedIconBClass} />
</span>
</span>
</button>
) : (
<button type="button" class:list={[...switchClass, className]} {...rootAttrs}>
<span class="switch-icon-a text-muted">
<Icon name={icon} />
</span>
<span class={`switch-icon-b text-${activeColor}`}>
<Icon name={activeIcon ?? icon} class={resolvedIconBClass} />
</span>
</button>
)
}