diff --git a/packages/craftcms-cp/src/components/switch-button/switch-button.ts b/packages/craftcms-cp/src/components/switch-button/switch-button.ts index 45a00b9f46d..02a32dcd948 100644 --- a/packages/craftcms-cp/src/components/switch-button/switch-button.ts +++ b/packages/craftcms-cp/src/components/switch-button/switch-button.ts @@ -1,7 +1,27 @@ import {LionSwitchButton} from '@lion/ui/switch.js'; -import {css} from 'lit'; +import {css, type PropertyValues} from 'lit'; +import {property} from 'lit/decorators.js'; export default class CraftSwitchButton extends LionSwitchButton { + /** + * Display-only mixed state (thumb centered, `aria-checked="mixed"`). + * State transitions are managed by `craft-switch`. + */ + @property({type: Boolean, reflect: true}) indeterminate = false; + + override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if ( + changedProperties.has('indeterminate') || + changedProperties.has('checked') + ) { + this.setAttribute( + 'aria-checked', + this.checked ? 'true' : this.indeterminate ? 'mixed' : 'false' + ); + } + } + static override get styles() { return [ ...super.styles, @@ -48,6 +68,11 @@ export default class CraftSwitchButton extends LionSwitchButton { inset-inline-end: auto; } + :host([indeterminate]:not([checked])) .switch-button__thumb { + inset-inline-start: calc(50% - (var(--c-switch-thumb-height) / 2)); + inset-inline-end: auto; + } + :host([checked]) .switch-button__track { background-color: var(--c-color-static-success-fill); } diff --git a/packages/craftcms-cp/src/components/switch/switch.stories.ts b/packages/craftcms-cp/src/components/switch/switch.stories.ts index 9dc2052f892..325e42772ac 100644 --- a/packages/craftcms-cp/src/components/switch/switch.stories.ts +++ b/packages/craftcms-cp/src/components/switch/switch.stories.ts @@ -1,6 +1,7 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import {html} from 'lit'; +import {ifDefined} from 'lit/directives/if-defined.js'; import './switch.js'; @@ -14,14 +15,31 @@ const meta = { disabled: false, checked: false, labelSrOnly: false, + onLabel: undefined, + offLabel: undefined, }, argTypes: { size: { control: 'select', options: ['small', 'medium'], }, + onLabel: { + control: 'text', + }, + offLabel: { + control: 'text', + }, }, - render: ({label, helpText, size, disabled, checked, labelSrOnly}) => { + render: ({ + label, + helpText, + size, + disabled, + checked, + labelSrOnly, + onLabel, + offLabel, + }) => { return html``; }, } satisfies Meta; @@ -79,3 +99,16 @@ export const LabelSrOnly: Story = { labelSrOnly: true, }, }; + +export const MultipleLabels: Story = { + args: { + onLabel: 'On', + offLabel: 'Off', + }, +}; + +export const Indeterminate: Story = { + render: () => html` + + `, +}; diff --git a/packages/craftcms-cp/src/components/switch/switch.styles.ts b/packages/craftcms-cp/src/components/switch/switch.styles.ts index f5fdb99cc70..06b4eb5b3c0 100644 --- a/packages/craftcms-cp/src/components/switch/switch.styles.ts +++ b/packages/craftcms-cp/src/components/switch/switch.styles.ts @@ -6,9 +6,33 @@ export default css` .input-group { display: inline-flex; + align-items: center; } ::slotted(label) { font-weight: bold; } + + .state-label { + cursor: pointer; + user-select: none; + } + + .state-label[data-state='off'] { + margin-inline-end: var(--c-spacing-sm); + } + + .state-label[data-state='on'] { + margin-inline-start: var(--c-spacing-sm); + } + + ::slotted([slot='state-description']) { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } `; diff --git a/packages/craftcms-cp/src/components/switch/switch.test.ts b/packages/craftcms-cp/src/components/switch/switch.test.ts new file mode 100644 index 00000000000..e1348274c90 --- /dev/null +++ b/packages/craftcms-cp/src/components/switch/switch.test.ts @@ -0,0 +1,579 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftSwitch from './switch.js'; +import './switch.js'; + +async function createSwitch( + attrs: Record = {} +): Promise { + const element = document.createElement('craft-switch') as CraftSwitch; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + // Wait one more cycle for aria attribute reflection onto the switch button. + await element.updateComplete; + return element; +} + +function switchButton(element: CraftSwitch): HTMLElement | null { + return element.querySelector('[slot="input"]'); +} + +function stateDescription(element: CraftSwitch): HTMLElement | null { + return element.querySelector('[slot="state-description"]'); +} + +function stateLabels(element: CraftSwitch): HTMLElement[] { + return Array.from(element.shadowRoot?.querySelectorAll('.state-label') ?? []); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-switch without on/off labels', () => { + it('renders no state labels', async () => { + const element = await createSwitch({label: 'Enable feature'}); + expect(stateLabels(element)).toHaveLength(0); + }); + + it('keeps the state description empty and unlinked', async () => { + const element = await createSwitch({label: 'Enable feature'}); + const description = stateDescription(element); + expect(description?.textContent).toBe(''); + const describedBy = + switchButton(element)?.getAttribute('aria-describedby') ?? ''; + expect(describedBy).not.toContain('state-description'); + }); +}); + +describe('craft-switch with on/off labels', () => { + it('renders the off label before the control and the on label after it', async () => { + const element = await createSwitch({ + label: 'Enable feature', + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + const before = element.shadowRoot?.querySelector('.input-group__before'); + const after = element.shadowRoot?.querySelector('.input-group__after'); + + const offLabel = before?.querySelector('.state-label'); + expect(offLabel?.textContent?.trim()).toBe('Disabled'); + expect(offLabel?.getAttribute('data-state')).toBe('off'); + + const onLabel = after?.querySelector('.state-label'); + expect(onLabel?.textContent?.trim()).toBe('Enabled'); + expect(onLabel?.getAttribute('data-state')).toBe('on'); + }); + + it('hides the visible state labels from assistive technology', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + for (const label of stateLabels(element)) { + expect(label.getAttribute('aria-hidden')).toBe('true'); + } + }); + + it('describes both states in a visually hidden light DOM node', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + expect(stateDescription(element)?.textContent).toBe( + 'Check for Enabled. Uncheck for Disabled.' + ); + }); + + it('links the state description to the switch via aria-describedby', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + const description = stateDescription(element); + expect(description?.id).toBeTruthy(); + const describedBy = + switchButton(element)?.getAttribute('aria-describedby') ?? ''; + expect(describedBy.split(/\s+/)).toContain(description!.id); + }); + + it('only describes the provided state when a single label is set', async () => { + const element = await createSwitch({'on-label': 'Enabled'}); + + expect(stateDescription(element)?.textContent).toBe('Check for Enabled.'); + const labels = stateLabels(element); + expect(labels).toHaveLength(1); + expect(labels[0]?.getAttribute('data-state')).toBe('on'); + }); + + it('updates the labels and description when properties change', async () => { + const element = await createSwitch({label: 'Enable feature'}); + + element.onLabel = 'Live'; + element.offLabel = 'Offline'; + await element.updateComplete; + await element.updateComplete; + + expect(stateLabels(element)).toHaveLength(2); + expect(stateDescription(element)?.textContent).toBe( + 'Check for Live. Uncheck for Offline.' + ); + const describedBy = + switchButton(element)?.getAttribute('aria-describedby') ?? ''; + expect(describedBy.split(/\s+/)).toContain(stateDescription(element)!.id); + }); + + it('unlinks the description when the labels are removed', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + element.onLabel = ''; + element.offLabel = ''; + await element.updateComplete; + await element.updateComplete; + + expect(stateLabels(element)).toHaveLength(0); + expect(stateDescription(element)?.textContent).toBe(''); + const describedBy = + switchButton(element)?.getAttribute('aria-describedby') ?? ''; + expect(describedBy).not.toContain(stateDescription(element)!.id); + }); + + it('still toggles checked state with labels present', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + expect(element.checked).toBe(false); + switchButton(element)?.click(); + await element.updateComplete; + expect(element.checked).toBe(true); + }); + + it('sets the state when a state label is clicked', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + }); + + const onLabel = element.shadowRoot?.querySelector( + '.state-label[data-state="on"]' + ); + const offLabel = element.shadowRoot?.querySelector( + '.state-label[data-state="off"]' + ); + + onLabel?.click(); + await element.updateComplete; + expect(element.checked).toBe(true); + + // Clicking the active state again is a no-op + onLabel?.click(); + await element.updateComplete; + expect(element.checked).toBe(true); + + offLabel?.click(); + await element.updateComplete; + expect(element.checked).toBe(false); + }); + + it('ignores state label clicks when disabled', async () => { + const element = await createSwitch({ + 'on-label': 'Enabled', + 'off-label': 'Disabled', + disabled: '', + }); + + element.shadowRoot + ?.querySelector('.state-label[data-state="on"]') + ?.click(); + await element.updateComplete; + expect(element.checked).toBe(false); + }); +}); + +describe('craft-switch indeterminate state', () => { + it('exposes aria-checked="mixed" on the switch button', async () => { + const element = await createSwitch({indeterminate: ''}); + + expect(element.indeterminate).toBe(true); + expect(switchButton(element)?.getAttribute('aria-checked')).toBe('mixed'); + expect(switchButton(element)?.hasAttribute('indeterminate')).toBe(true); + }); + + it('clears the indeterminate state when turned on', async () => { + const element = await createSwitch({indeterminate: ''}); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + + expect(element.checked).toBe(true); + expect(element.indeterminate).toBe(false); + expect(switchButton(element)?.getAttribute('aria-checked')).toBe('true'); + expect(switchButton(element)?.hasAttribute('indeterminate')).toBe(false); + }); + + it('never combines indeterminate with checked', async () => { + const element = await createSwitch({checked: '', indeterminate: ''}); + expect(switchButton(element)?.getAttribute('aria-checked')).toBe('true'); + expect(element.indeterminate).toBe(false); + }); +}); + +describe('craft-switch hidden input', () => { + function hiddenInput(element: CraftSwitch): HTMLInputElement | null { + return element.querySelector('input[slot="hidden-input"]'); + } + + it('creates a hidden input when a name is set', async () => { + const element = await createSwitch({name: 'foo'}); + const input = hiddenInput(element); + expect(input).not.toBeNull(); + expect(input!.type).toBe('hidden'); + expect(input!.name).toBe('foo'); + expect(input!.value).toBe(''); + }); + + it('does not create a hidden input without a name', async () => { + const element = await createSwitch(); + expect(hiddenInput(element)).toBeNull(); + }); + + it('posts the value when on, indeterminate value when indeterminate', async () => { + const element = await createSwitch({ + name: 'foo', + value: 'yes', + 'indeterminate-value': 'maybe', + indeterminate: '', + }); + expect(hiddenInput(element)!.value).toBe('maybe'); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(hiddenInput(element)!.value).toBe('yes'); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(hiddenInput(element)!.value).toBe(''); + }); + + it('disables the hidden input when the switch is disabled', async () => { + const element = await createSwitch({name: 'foo', disabled: ''}); + expect(hiddenInput(element)!.disabled).toBe(true); + }); + + it('adopts a server-rendered hidden input instead of creating one', async () => { + const element = document.createElement('craft-switch') as CraftSwitch; + element.setAttribute('checked', ''); + element.innerHTML = + ''; + document.body.append(element); + await element.updateComplete; + await element.updateComplete; + + const inputs = element.querySelectorAll('input[slot="hidden-input"]'); + expect(inputs).toHaveLength(1); + expect((inputs[0] as HTMLInputElement).value).toBe('1'); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect((inputs[0] as HTMLInputElement).value).toBe(''); + }); +}); + +describe('craft-switch legacy JS interop', () => { + it('dispatches a bubbling native change event from the switch button', async () => { + const element = await createSwitch(); + let changeTarget: EventTarget | null = null; + document.body.addEventListener( + 'change', + (event) => { + changeTarget = event.target; + }, + {once: true} + ); + + switchButton(element)?.click(); + await element.updateComplete; + + expect(changeTarget).toBe(switchButton(element)); + }); + + it('preserves fieldtoggle hooks on a server-rendered switch button', async () => { + const element = document.createElement('craft-switch') as CraftSwitch; + element.innerHTML = + '' + + ''; + document.body.append(element); + await element.updateComplete; + await element.updateComplete; + + const btn = switchButton(element)!; + expect(btn.id).toBe('my-switch'); + expect(btn.classList.contains('fieldtoggle')).toBe(true); + expect(btn.getAttribute('data-target')).toBe('my-target'); + expect(btn.getAttribute('role')).toBe('switch'); + + // The adopted button still drives the switch + btn.click(); + await element.updateComplete; + expect(element.checked).toBe(true); + }); + + it('reveals data-target and hides data-reverse-target containers', async () => { + const target = document.createElement('div'); + target.id = 'reveal-target'; + target.className = 'hidden'; + const reverseTarget = document.createElement('div'); + reverseTarget.id = 'reverse-target'; + document.body.append(target, reverseTarget); + + const element = document.createElement('craft-switch') as CraftSwitch; + element.innerHTML = + '' + + ''; + document.body.append(element); + await element.updateComplete; + + // Initial state applied (off): target hidden, reverse target shown + expect(target.classList.contains('hidden')).toBe(true); + expect(reverseTarget.classList.contains('hidden')).toBe(false); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + + expect(target.classList.contains('hidden')).toBe(false); + expect(reverseTarget.classList.contains('hidden')).toBe(true); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + + expect(target.classList.contains('hidden')).toBe(true); + expect(reverseTarget.classList.contains('hidden')).toBe(false); + }); +}); + +describe('craft-switch external aria and labels', () => { + it('keeps server-rendered aria-labelledby and aria-describedby references', async () => { + const label = document.createElement('span'); + label.id = 'ext-label'; + label.textContent = 'External label'; + const instructions = document.createElement('div'); + instructions.id = 'ext-instructions'; + document.body.append(label, instructions); + + const element = document.createElement('craft-switch') as CraftSwitch; + element.innerHTML = + ''; + document.body.append(element); + await element.updateComplete; + await element.updateComplete; + + const btn = switchButton(element)!; + expect(btn.getAttribute('aria-labelledby') ?? '').toContain('ext-label'); + expect(btn.getAttribute('aria-describedby') ?? '').toContain( + 'ext-instructions' + ); + }); + + it('toggles when an external label element is clicked', async () => { + const label = document.createElement('label'); + label.id = 'ext-label'; + label.htmlFor = 'my-switch'; + label.textContent = 'External label'; + document.body.append(label); + + const element = document.createElement('craft-switch') as CraftSwitch; + element.innerHTML = + ''; + document.body.append(element); + await element.updateComplete; + + label.click(); + await element.updateComplete; + expect(element.checked).toBe(true); + + label.click(); + await element.updateComplete; + expect(element.checked).toBe(false); + }); +}); + +describe('craft-switch keyboard support', () => { + function pressKey(target: HTMLElement, key: string): void { + target.dispatchEvent( + new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true}) + ); + } + + it('turns on with ArrowRight and off with ArrowLeft', async () => { + const element = await createSwitch(); + const btn = switchButton(element)!; + + pressKey(btn, 'ArrowRight'); + await element.updateComplete; + expect(element.checked).toBe(true); + + pressKey(btn, 'ArrowRight'); + await element.updateComplete; + expect(element.checked).toBe(true); + + pressKey(btn, 'ArrowLeft'); + await element.updateComplete; + expect(element.checked).toBe(false); + }); + + it('clears the indeterminate state with ArrowLeft without toggling', async () => { + const element = await createSwitch({indeterminate: ''}); + const btn = switchButton(element)!; + + pressKey(btn, 'ArrowLeft'); + await element.updateComplete; + expect(element.checked).toBe(false); + expect(element.indeterminate).toBe(false); + }); +}); + +describe('craft-switch toggle reveal', () => { + async function createToggleSwitch( + buttonAttrs: Record, + {checked = false} = {} + ): Promise { + const element = document.createElement('craft-switch') as CraftSwitch; + if (checked) { + element.setAttribute('checked', ''); + } + const button = document.createElement('craft-switch-button'); + button.setAttribute('slot', 'input'); + button.setAttribute('role', 'switch'); + for (const [name, value] of Object.entries(buttonAttrs)) { + button.setAttribute(name, value); + } + element.append(button); + document.body.append(element); + await element.updateComplete; + await element.updateComplete; + return element; + } + + function target(id: string, hidden: boolean): HTMLElement { + const el = document.createElement('div'); + el.id = id; + if (hidden) { + el.classList.add('hidden'); + } + document.body.append(el); + return el; + } + + it('reveals the data-target container when turned on and hides it when turned off', async () => { + const container = target('reveal-toggle', true); + const element = await createToggleSwitch({'data-target': '#reveal-toggle'}); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(container.classList.contains('hidden')).toBe(false); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(container.classList.contains('hidden')).toBe(true); + }); + + it('treats bare selectors as element ids like Craft.FieldToggle', async () => { + const container = target('bare-toggle', true); + const element = await createToggleSwitch({'data-target': 'bare-toggle'}); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(container.classList.contains('hidden')).toBe(false); + }); + + it('toggles reverse targets in the opposite direction', async () => { + const container = target('reverse-toggle', false); + const element = await createToggleSwitch({ + 'data-reverse-target': '#reverse-toggle', + }); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(container.classList.contains('hidden')).toBe(true); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(container.classList.contains('hidden')).toBe(false); + }); + + it('syncs targets on first render for a server-rendered checked switch', async () => { + const container = target('initial-toggle', true); + await createToggleSwitch( + {'data-target': '#initial-toggle'}, + {checked: true} + ); + + expect(container.classList.contains('hidden')).toBe(false); + }); + + it('dispatches a window resize when revealing, like Craft.FieldToggle', async () => { + target('resize-toggle', true); + const element = await createToggleSwitch({'data-target': '#resize-toggle'}); + + let resized = false; + const onResize = () => { + resized = true; + }; + window.addEventListener('resize', onResize); + + try { + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(resized).toBe(true); + } finally { + window.removeEventListener('resize', onResize); + } + }); + + it('defers to a bound Craft.FieldToggle instance', async () => { + const container = target('legacy-toggle', true); + const element = await createToggleSwitch({'data-target': '#legacy-toggle'}); + + const globalWindow = window as unknown as {jQuery?: unknown}; + + try { + globalWindow.jQuery = () => ({ + data: (key: string) => (key === 'fieldtoggle' ? {} : undefined), + }); + + switchButton(element)?.click(); + await element.updateComplete; + await element.updateComplete; + expect(container.classList.contains('hidden')).toBe(true); + } finally { + delete globalWindow.jQuery; + } + }); +}); diff --git a/packages/craftcms-cp/src/components/switch/switch.ts b/packages/craftcms-cp/src/components/switch/switch.ts index 1db52dabe48..143b70b8d34 100644 --- a/packages/craftcms-cp/src/components/switch/switch.ts +++ b/packages/craftcms-cp/src/components/switch/switch.ts @@ -2,7 +2,23 @@ import {LionSwitch} from '@lion/ui/switch.js'; import CraftSwitchButton from '../switch-button/switch-button.js'; import styles from './switch.styles.js'; import {baseFieldStyles} from '@src/styles/form.styles'; +import {html, nothing, type PropertyValues} from 'lit'; import {property} from 'lit/decorators.js'; +import {t} from '@src/utilities/translate'; + +function splitIds(value: string | null): string[] { + return (value ?? '').split(/\s+/).filter(Boolean); +} + +function escapeCssId(id: string): string { + return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(id) + : id; +} + +const SYNTHETIC_CHANGE = Symbol.for('craft-switch-synthetic-change'); + +type SyntheticChangeEvent = Event & {[SYNTHETIC_CHANGE]?: boolean}; export default class CraftSwitch extends LionSwitch { static override get styles() { @@ -11,6 +27,45 @@ export default class CraftSwitch extends LionSwitch { @property({type: String, reflect: true}) size: 'small' | 'medium' = 'medium'; + /** + * Optional text rendered after the control that describes the "on" state, + * matching the legacy lightswitch's `onLabel`. When an on/off label is + * present, a visually hidden description ("Check for …. Uncheck for ….") + * is linked to the switch via `aria-describedby`, and the visible labels + * are hidden from assistive technology. Clicking a state label sets the + * corresponding state, like the legacy lightswitch. + */ + @property({type: String, attribute: 'on-label'}) onLabel = ''; + + /** + * Optional text rendered before the control that describes the "off" + * state, matching the legacy lightswitch's `offLabel`. + */ + @property({type: String, attribute: 'off-label'}) offLabel = ''; + + /** + * The value posted by the hidden input when the switch is on. Mirrors the + * legacy lightswitch's `value` param. (Named `checkedValue` because Lion + * already defines a `value` accessor that proxies the input node.) + */ + @property({attribute: 'value'}) checkedValue = '1'; + + /** The value posted by the hidden input when the switch is indeterminate. */ + @property({attribute: 'indeterminate-value'}) indeterminateValue = '-'; + + /** + * Mixed ("indeterminate") state, matching the legacy lightswitch. Only + * meaningful while the switch is off; it is cleared as soon as the switch + * is turned on. + */ + @property({type: Boolean, reflect: true}) indeterminate = false; + + private __externalLabelledByNodes: HTMLElement[] = []; + + private __externalDescribedByNodes: HTMLElement[] = []; + + private __clickableExternalLabels = new Set(); + override get slots() { return { ...super.slots, @@ -20,6 +75,27 @@ export default class CraftSwitch extends LionSwitch { btnEl.setAttribute('data-tag-name', 'craft-switch-button'); return btnEl; }, + 'state-description': () => { + const el = document.createElement('div'); + el.textContent = this._stateDescriptionText; + return el; + }, + // Posts the switch state like the legacy lightswitch's hidden input. + // Server-rendered markup (lightswitch.twig) provides its own slotted + // hidden input, in which case this one is skipped. + 'hidden-input': () => { + if (!this.name) { + // SlotMixin factories accept `undefined` for "no slot content" — + // lit's `nothing` is a Symbol and breaks SlotMixin's slot init. + return undefined; + } + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = this.name; + input.value = this._postedValue; + input.disabled = this.disabled; + return input; + }, }; } @@ -29,6 +105,381 @@ export default class CraftSwitch extends LionSwitch { 'craft-switch-button': CraftSwitchButton, }; } + + override connectedCallback(): void { + super.connectedCallback(); + this.__adoptExternalAria(); + // LionSwitch's own `checked-changed` listener (added in + // super.connectedCallback) stops propagation, but listeners on the same + // node still run. + this.addEventListener('checked-changed', this.__forwardNativeChange); + this.addEventListener('keydown', this.__onKeydown); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('checked-changed', this.__forwardNativeChange); + this.removeEventListener('keydown', this.__onKeydown); + this.__teardownExternalLabelClicks(); + } + + /** + * The hidden input that posts the switch state, either server-rendered by + * lightswitch.twig or created via the `hidden-input` slot. + */ + protected get _hiddenInputNode(): HTMLInputElement | undefined { + return (Array.from(this.children) as HTMLElement[]).find( + (el): el is HTMLInputElement => + el instanceof HTMLInputElement && el.slot === 'hidden-input' + ); + } + + /** The value the hidden input should post for the current state. */ + protected get _postedValue(): string { + if (this.checked) { + return this.checkedValue; + } + if (this.indeterminate) { + return this.indeterminateValue; + } + return ''; + } + + /** + * The visually hidden light DOM node holding the on/off state description. + */ + protected get _stateDescriptionNode(): HTMLElement | undefined { + return (Array.from(this.children) as HTMLElement[]).find( + (el) => el.slot === 'state-description' + ); + } + + protected get _stateDescriptionText(): string { + return [ + this.onLabel ? t('Check for {onLabel}.', {onLabel: this.onLabel}) : '', + this.offLabel + ? t('Uncheck for {offLabel}.', {offLabel: this.offLabel}) + : '', + ] + .filter(Boolean) + .join(' '); + } + + override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if ( + changedProperties.has('checked') && + this.checked && + this.indeterminate + ) { + this.indeterminate = false; + } + if (changedProperties.has('onLabel') || changedProperties.has('offLabel')) { + this.__syncStateDescription(); + } + if ( + ( + [ + 'checked', + 'indeterminate', + 'checkedValue', + 'indeterminateValue', + 'disabled', + 'size', + ] as const + ).some((prop) => changedProperties.has(prop)) + ) { + this.__syncSwitchButton(); + this.__syncHiddenInput(); + } + if ( + changedProperties.has('checked') || + changedProperties.has('indeterminate') + ) { + this.__syncToggleTargets(); + } + } + + /** + * Shows/hides the `data-target`/`data-reverse-target` containers, like the + * legacy lightswitch's Craft.FieldToggle behavior. On legacy Twig screens + * Craft.initUiElements binds Craft.FieldToggle to the switch button (which + * reacts to our native change events), so this only kicks in when no + * FieldToggle instance is present (e.g. Inertia screens that inject + * server-rendered settings HTML without initUiElements). + */ + private __syncToggleTargets(): void { + const btn = this._inputNode; + if (!btn) { + return; + } + const targetSelector = btn.getAttribute('data-target'); + const reverseTargetSelector = btn.getAttribute('data-reverse-target'); + if (!targetSelector && !reverseTargetSelector) { + return; + } + const jq = ( + window as unknown as { + jQuery?: (el: Element) => {data?: (key: string) => unknown}; + } + ).jQuery; + if (jq && jq(btn).data?.('fieldtoggle')) { + // Legacy Craft.FieldToggle owns the reveal. + return; + } + this.__toggleTargets(targetSelector, this.checked); + this.__toggleTargets(reverseTargetSelector, !this.checked); + } + + private __toggleTargets(selector: string | null, show: boolean): void { + if (!selector) { + return; + } + // Craft.FieldToggle treats selectors without a leading #/. as ids. + const normalized = /^[#.]/.test(selector) ? selector : `#${selector}`; + const rootNode = this.getRootNode() as Document | ShadowRoot; + let changed = false; + for (const target of rootNode.querySelectorAll(normalized)) { + if (target.classList.contains('hidden') === show) { + target.classList.toggle('hidden', !show); + changed = true; + } + } + if (changed && show) { + // Give grids etc. inside the revealed container a chance to lay out, + // like Craft.FieldToggle's resize trigger. + window.dispatchEvent(new Event('resize')); + } + } + + /** + * Sets the switch to an explicit state, clearing the indeterminate state. + * Used by the clickable on/off state labels and the arrow keys, mirroring + * the legacy lightswitch's `turnOn()`/`turnOff()`. + */ + protected _setCheckedState(checked: boolean): void { + if (this.disabled) { + return; + } + const wasIndeterminate = this.indeterminate; + if (this.checked === checked && !wasIndeterminate) { + return; + } + this.indeterminate = false; + if (this.checked !== checked) { + this.checked = checked; + // `checked-changed` from the switch button re-dispatches `change`. + } else if (wasIndeterminate) { + // Leaving the indeterminate state counts as a change, like the legacy + // lightswitch's turnOff(). + this.__dispatchNativeChange(); + } + } + + private __onKeydown = (event: KeyboardEvent): void => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + if (this.disabled || event.target !== this._inputNode) { + return; + } + event.preventDefault(); + const rtl = getComputedStyle(this).direction === 'rtl'; + this._setCheckedState(event.key === (rtl ? 'ArrowLeft' : 'ArrowRight')); + }; + + private __forwardNativeChange = (event: Event): void => { + if (event.target === this._inputNode) { + this.__dispatchNativeChange(); + } + }; + + private __dispatchNativeChange(): void { + // Legacy CP code (e.g. Craft.FieldToggle, which powers toggle/ + // reverseToggle reveals) listens for native change events on the switch + // button. + const event = new Event('change', {bubbles: true}); + (event as SyntheticChangeEvent)[SYNTHETIC_CHANGE] = true; + this._inputNode?.dispatchEvent(event); + } + + /** + * LionField converts native change events on the input node into + * `user-input-changed` events, which ChoiceInputMixin treats as a toggle. + * Our own synthetic change events (dispatched for legacy JS interop) must + * not feed back into that path, or the switch would toggle forever. + */ + protected override _onChange(ev?: Event): void { + if (ev && SYNTHETIC_CHANGE in ev) { + return; + } + super._onChange(ev); + } + + /** + * Registers server-rendered (already namespaced) aria-labelledby / + * aria-describedby references with Lion, which otherwise rebuilds those + * attributes from its own tracked nodes and would drop them. Also makes + * external `