From 0776b88863690c9740d7d33988a8b5df66aad6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:35:36 +0200 Subject: [PATCH] Add loading state and toggle event to switch icon (#3049) --- .changeset/switch-icon-loading.md | 7 + core/js/src/switch-icon.ts | 72 ++++++++- core/js/tests/unit/switch-icon.spec.ts | 132 +++++++++++++++- core/js/tests/visual/switch-icon.html | 46 ++++++ core/scss/_props.scss | 3 + core/scss/_variables.scss | 11 ++ core/scss/ui/_switch-icon.scss | 72 +++++++-- docs/content/ui/components/switch-icon.mdx | 86 +++++++++- preview/pages/switch-icons.astro | 174 +++++++++++++++++++++ shared/data/menu.json | 4 + shared/ui/SwitchIcon.astro | 55 +++++-- 11 files changed, 628 insertions(+), 34 deletions(-) create mode 100644 .changeset/switch-icon-loading.md create mode 100644 core/js/tests/visual/switch-icon.html create mode 100644 preview/pages/switch-icons.astro diff --git a/.changeset/switch-icon-loading.md b/.changeset/switch-icon-loading.md new file mode 100644 index 000000000..bc6e9f6b5 --- /dev/null +++ b/.changeset/switch-icon-loading.md @@ -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. diff --git a/core/js/src/switch-icon.ts b/core/js/src/switch-icon.ts index 551a636f4..2abbd411f 100644 --- a/core/js/src/switch-icon.ts +++ b/core/js/src/switch-icon.ts @@ -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 = {} * 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 | undefined + const toggleEvent = EventHandler.trigger(this._element, EVENT_TOGGLE, { + active, + wait: (promise: Promise) => { + 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') + } + } } /** diff --git a/core/js/tests/unit/switch-icon.spec.ts b/core/js/tests/unit/switch-icon.spec.ts index 9aa863447..115bd4d57 100644 --- a/core/js/tests/unit/switch-icon.spec.ts +++ b/core/js/tests/unit/switch-icon.spec.ts @@ -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) => 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((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 = '' + const instance = new SwitchIcon(button()) + + instance.toggle() + + expect(instance.isActive).toBe(false) + }) + }) + + describe('wrapping button', () => { + beforeEach(() => { + fixtureEl.innerHTML = '' + }) + + 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((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 = '' + 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() diff --git a/core/js/tests/visual/switch-icon.html b/core/js/tests/visual/switch-icon.html new file mode 100644 index 000000000..753b7aa50 --- /dev/null +++ b/core/js/tests/visual/switch-icon.html @@ -0,0 +1,46 @@ + + + + + + + Switch icon + + +
+

Switch icon Tabler Visual Test

+ +

Plain toggle, a toggle that waits 1.5 s for a fake request, one whose request fails, and one rendered loading.

+ + + + + + + + +
+ + + + + diff --git a/core/scss/_props.scss b/core/scss/_props.scss index 840549062..393ea6e8c 100644 --- a/core/scss/_props.scss +++ b/core/scss/_props.scss @@ -58,6 +58,9 @@ --spacer-#{$name}: #{$value}; } + /** Icons */ + --icon-size: #{$icon-size}; + /** Font sizes */ @each $name, $value in $font-weights { --font-weight-#{$name}: #{$value}; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 91e46f487..d9d427d03 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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; diff --git a/core/scss/ui/_switch-icon.scss b/core/scss/ui/_switch-icon.scss index 593e64fea..d75c70582 100644 --- a/core/scss/ui/_switch-icon.scss +++ b/core/scss/ui/_switch-icon.scss @@ -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 diff --git a/docs/content/ui/components/switch-icon.mdx b/docs/content/ui/components/switch-icon.mdx index c6836d08e..16e0689da 100644 --- a/docs/content/ui/components/switch-icon.mdx +++ b/docs/content/ui/components/switch-icon.mdx @@ -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 +## 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. + + +
+ + + +
+
+ +## 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. + + + + + + +## 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 + +``` + +```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: + + diff --git a/preview/pages/switch-icons.astro b/preview/pages/switch-icons.astro new file mode 100644 index 000000000..93e0efba2 --- /dev/null +++ b/preview/pages/switch-icons.astro @@ -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 }, +] +--- + + + + +
+
+ + + Basic + A SwitchIcon swaps the outline icon for the filled one in activeColor. + + {icons.map((item) => )} + + + +
+ +
+ + + Animations + Pick a transition with variant: fade, scale, flip or one of the slides. + + {variants.map((variant) => )} + + + +
+ +
+ + + Two icons + Set activeIcon to switch to a different icon instead of the filled variant. + + {pairs.map((pair) => )} + + + +
+ +
+ + + In an action button + Add action to wrap the switch in a btn-action, which then carries the toggle, hover and focus. +
+ {icons.map((item) => )} +
+
+
+
+ +
+ + + Loading + Add loading to show a spinner in place of the icons; clicks are ignored. + + + + + + + +
+ +
+ + + Wait for a request + Pass a promise to event.wait() and the switch waits for it. The last one fails and stays as it was. + + + + + + + + + + + + + +
+ +
+ + + In a list + The event bubbles, so one listener on the list keeps the count in sync. +
+ { + repos.map((repo) => ( +
+
+
{repo.name}
+
{repo.description}
+
+ +
+ )) + } +
+
+ {repos.filter((repo) => repo.starred).length} starred +
+ + + + + +
+
+
+
+
diff --git a/shared/data/menu.json b/shared/data/menu.json index 549378ad7..c54af9ad1 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -241,6 +241,10 @@ "url": "tabs.html", "title": "Tabs" }, + "switch-icons": { + "url": "switch-icons.html", + "title": "Switch icons" + }, "tags": { "url": "tags.html", "title": "Tags" diff --git a/shared/ui/SwitchIcon.astro b/shared/ui/SwitchIcon.astro index 9bef8bf15..555025eb9 100644 --- a/shared/ui/SwitchIcon.astro +++ b/shared/ui/SwitchIcon.astro @@ -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 = { '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 } --- - +{ + action ? ( + + ) : ( + + ) +}