mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add loading state and toggle event to switch icon (#3049)
This commit is contained in:
1 parent
a744b8407f
commit
0776b88863
11 files changed
+628
-34
No files matched your search
@@ -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.
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -58,6 +58,9 @@
|
||||
--spacer-#{$name}: #{$value};
|
||||
}
|
||||
|
||||
/** Icons */
|
||||
--icon-size: #{$icon-size};
|
||||
|
||||
/** Font sizes */
|
||||
@each $name, $value in $font-weights {
|
||||
--font-weight-#{$name}: #{$value};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user