From 8d60f39cf421840b86390c443f81f2a9c5a5d505 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 14 Jul 2025 11:56:31 +0200 Subject: [PATCH] Improve aria support in control elements (#26107) * Improve aria support in control elements * Use radiogroup for control select * Fix switch --- .../src/pages/components/ha-control-select.ts | 4 +- .../src/pages/components/ha-control-slider.ts | 4 +- .../src/pages/components/ha-control-switch.ts | 4 +- src/components/ha-control-select.ts | 192 +++++++++--------- src/components/ha-control-slider.ts | 46 +++-- src/components/ha-control-switch.ts | 61 +++--- .../lights/light-color-temp-picker.ts | 2 +- .../hui-alarm-modes-card-feature.ts | 6 +- .../hui-climate-fan-modes-card-feature.ts | 7 +- .../hui-climate-hvac-modes-card-feature.ts | 4 +- .../hui-climate-preset-modes-card-feature.ts | 4 +- ...ate-swing-horizontal-modes-card-feature.ts | 4 +- .../hui-climate-swing-modes-card-feature.ts | 2 +- .../hui-cover-position-card-feature.ts | 2 +- .../hui-cover-tilt-position-card-feature.ts | 2 +- .../hui-fan-preset-modes-card-feature.ts | 4 +- .../hui-fan-speed-card-feature.ts | 6 +- .../hui-humidifier-modes-card-feature.ts | 4 +- .../hui-humidifier-toggle-card-feature.ts | 4 +- .../card-features/hui-toggle-card-feature.ts | 2 +- ...ter-heater-operation-modes-card-feature.ts | 4 +- ...state-control-alarm_control_panel-modes.ts | 4 +- .../cover/ha-state-control-cover-position.ts | 2 +- .../ha-state-control-cover-tilt-position.ts | 2 +- .../cover/ha-state-control-cover-toggle.ts | 2 +- .../fan/ha-state-control-fan-speed.ts | 4 +- src/state-control/ha-state-control-toggle.ts | 2 +- .../ha-state-control-light-brightness.ts | 2 +- .../lock/ha-state-control-lock-toggle.ts | 2 +- .../valve/ha-state-control-valve-position.ts | 2 +- .../valve/ha-state-control-valve-toggle.ts | 2 +- 31 files changed, 196 insertions(+), 196 deletions(-) diff --git a/gallery/src/pages/components/ha-control-select.ts b/gallery/src/pages/components/ha-control-select.ts index 8666f42a1f..160c8dfc41 100644 --- a/gallery/src/pages/components/ha-control-select.ts +++ b/gallery/src/pages/components/ha-control-select.ts @@ -135,7 +135,7 @@ export class DemoHaControlSelect extends LitElement { .options=${options} class=${ifDefined(config.class)} @value-changed=${this.handleValueChanged} - aria-labelledby=${id} + .label=${label} ?disabled=${config.disabled} > @@ -156,7 +156,7 @@ export class DemoHaControlSelect extends LitElement { vertical class=${ifDefined(config.class)} @value-changed=${this.handleValueChanged} - aria-labelledby=${id} + .label=${label} ?disabled=${config.disabled} > diff --git a/gallery/src/pages/components/ha-control-slider.ts b/gallery/src/pages/components/ha-control-slider.ts index 5d8fb36cb5..ed2237a561 100644 --- a/gallery/src/pages/components/ha-control-slider.ts +++ b/gallery/src/pages/components/ha-control-slider.ts @@ -97,7 +97,7 @@ export class DemoHaBarSlider extends LitElement { class=${ifDefined(config.class)} @value-changed=${this.handleValueChanged} @slider-moved=${this.handleSliderMoved} - aria-labelledby=${id} + .label=${label} .unit=${config.unit} > @@ -119,7 +119,7 @@ export class DemoHaBarSlider extends LitElement { class=${ifDefined(config.class)} @value-changed=${this.handleValueChanged} @slider-moved=${this.handleSliderMoved} - aria-label=${label} + .label=${label} .unit=${config.unit} > diff --git a/gallery/src/pages/components/ha-control-switch.ts b/gallery/src/pages/components/ha-control-switch.ts index e175390948..83252764b9 100644 --- a/gallery/src/pages/components/ha-control-switch.ts +++ b/gallery/src/pages/components/ha-control-switch.ts @@ -63,7 +63,7 @@ export class DemoHaControlSwitch extends LitElement { @change=${this.handleValueChanged} .pathOn=${mdiLightbulb} .pathOff=${mdiLightbulbOff} - aria-labelledby=${id} + .label=${label} ?disabled=${config.disabled} ?reversed=${config.reversed} > @@ -84,7 +84,7 @@ export class DemoHaControlSwitch extends LitElement { vertical class=${ifDefined(config.class)} @change=${this.handleValueChanged} - aria-label=${label} + .label=${label} .pathOn=${mdiGarageOpen} .pathOff=${mdiGarage} ?disabled=${config.disabled} diff --git a/src/components/ha-control-select.ts b/src/components/ha-control-select.ts index e2126994a8..da38ed5eb9 100644 --- a/src/components/ha-control-select.ts +++ b/src/components/ha-control-select.ts @@ -1,4 +1,4 @@ -import type { PropertyValues, TemplateResult } from "lit"; +import type { TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; @@ -17,7 +17,7 @@ export interface ControlSelectOption { @customElement("ha-control-select") export class HaControlSelect extends LitElement { - @property({ type: Boolean, reflect: true }) disabled = false; + @property({ type: Boolean }) disabled = false; @property({ attribute: false }) public options?: ControlSelectOption[]; @@ -26,94 +26,70 @@ export class HaControlSelect extends LitElement { @property({ type: Boolean, reflect: true }) public vertical = false; - @property({ type: Boolean, attribute: "hide-label" }) - public hideLabel = false; + @property({ type: Boolean, attribute: "hide-option-label" }) + public hideOptionLabel = false; + + @property({ type: String }) + public label?: string; @state() private _activeIndex?: number; - protected firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this.setAttribute("role", "listbox"); - if (!this.hasAttribute("tabindex")) { - this.setAttribute("tabindex", "0"); + private _handleFocus(ev: FocusEvent) { + if (this.disabled || !this.options) return; + + // Only handle focus if coming to the container + if (ev.target === ev.currentTarget) { + // Focus the selected radio or the first one + const selectedIndex = + this.value != null + ? this.options.findIndex((option) => option.value === this.value) + : -1; + const focusIndex = selectedIndex !== -1 ? selectedIndex : 0; + this._focusOption(focusIndex); } } - protected updated(changedProps: PropertyValues) { - super.updated(changedProps); - if (changedProps.has("_activeIndex")) { - const activeValue = - this._activeIndex != null - ? this.options?.[this._activeIndex]?.value - : undefined; - const activedescendant = - activeValue != null ? `option-${activeValue}` : undefined; - this.setAttribute("aria-activedescendant", activedescendant ?? ""); + private _focusOption(index: number) { + this._activeIndex = index; + this.requestUpdate(); + this.updateComplete.then(() => { + const option = this.shadowRoot?.querySelector( + `#option-${this.options![index].value}` + ) as HTMLElement; + option?.focus(); + }); + } + + private _handleBlur(ev: FocusEvent) { + // Only reset if focus is leaving the entire component + if (!this.contains(ev.relatedTarget as Node)) { + this._activeIndex = undefined; } - if (changedProps.has("vertical")) { - const orientation = this.vertical ? "vertical" : "horizontal"; - this.setAttribute("aria-orientation", orientation); - } - } - - public connectedCallback(): void { - super.connectedCallback(); - this._setupListeners(); - } - - public disconnectedCallback(): void { - super.disconnectedCallback(); - this._destroyListeners(); - } - - private _setupListeners() { - this.addEventListener("focus", this._handleFocus); - this.addEventListener("blur", this._handleBlur); - this.addEventListener("keydown", this._handleKeydown); - } - - private _destroyListeners() { - this.removeEventListener("focus", this._handleFocus); - this.removeEventListener("blur", this._handleBlur); - this.removeEventListener("keydown", this._handleKeydown); - } - - private _handleFocus() { - if (this.disabled) return; - this._activeIndex = - (this.value != null - ? this.options?.findIndex((option) => option.value === this.value) - : undefined) ?? 0; - } - - private _handleBlur() { - this._activeIndex = undefined; } private _handleKeydown(ev: KeyboardEvent) { - if (!this.options || this._activeIndex == null || this.disabled) return; - const value = this.options[this._activeIndex].value; + if (!this.options || this.disabled) return; + + let newIndex = this._activeIndex ?? 0; + switch (ev.key) { case " ": - this.value = value; - fireEvent(this, "value-changed", { value }); + case "Enter": + if (this._activeIndex != null) { + const value = this.options[this._activeIndex].value; + this.value = value; + fireEvent(this, "value-changed", { value }); + } break; case "ArrowUp": case "ArrowLeft": - this._activeIndex = - this._activeIndex <= 0 - ? this.options.length - 1 - : this._activeIndex - 1; + newIndex = newIndex <= 0 ? this.options.length - 1 : newIndex - 1; + this._focusOption(newIndex); break; case "ArrowDown": case "ArrowRight": - this._activeIndex = (this._activeIndex + 1) % this.options.length; - break; - case "Home": - this._activeIndex = 0; - break; - case "End": - this._activeIndex = this.options.length - 1; + newIndex = (newIndex + 1) % this.options.length; + this._focusOption(newIndex); break; default: return; @@ -139,38 +115,56 @@ export class HaControlSelect extends LitElement { private _handleOptionMouseUp(ev: MouseEvent) { ev.preventDefault(); - this._activeIndex = undefined; + } + + private _handleOptionFocus(ev: FocusEvent) { + if (this.disabled) return; + const value = (ev.target as any).value; + this._activeIndex = this.options?.findIndex( + (option) => option.value === value + ); } protected render() { return html` -
+
${this.options ? repeat( this.options, (option) => option.value, - (option, idx) => this._renderOption(option, idx) + (option) => this._renderOption(option) ) : nothing}
`; } - private _renderOption(option: ControlSelectOption, index: number) { + private _renderOption(option: ControlSelectOption) { + const isSelected = this.value === option.value; + return html`
@@ -178,7 +172,7 @@ export class HaControlSelect extends LitElement { ${option.path ? html`` : option.icon || nothing} - ${option.label && !this.hideLabel + ${option.label && !this.hideOptionLabel ? html`${option.label}` : nothing}
@@ -203,18 +197,12 @@ export class HaControlSelect extends LitElement { --mdc-icon-size: 20px; height: var(--control-select-thickness); width: 100%; - border-radius: var(--control-select-border-radius); - outline: none; - transition: box-shadow 180ms ease-in-out; font-style: normal; font-weight: var(--ha-font-weight-medium); color: var(--primary-text-color); user-select: none; -webkit-tap-highlight-color: transparent; } - :host(:focus-visible) { - box-shadow: 0 0 0 2px var(--control-select-color); - } :host([vertical]) { width: var(--control-select-thickness); height: 100%; @@ -225,11 +213,12 @@ export class HaControlSelect extends LitElement { width: 100%; border-radius: var(--control-select-border-radius); transform: translateZ(0); - overflow: hidden; display: flex; flex-direction: row; padding: var(--control-select-padding); box-sizing: border-box; + outline: none; + transition: box-shadow 180ms ease-in-out; } .container::before { position: absolute; @@ -240,6 +229,7 @@ export class HaControlSelect extends LitElement { width: 100%; background: var(--control-select-background); opacity: var(--control-select-background-opacity); + border-radius: var(--control-select-border-radius); } .container > *:not(:last-child) { @@ -248,6 +238,16 @@ export class HaControlSelect extends LitElement { margin-inline-start: initial; direction: var(--direction); } + .container[disabled] { + --control-select-color: var(--disabled-color); + --control-select-focused-opacity: 0; + color: var(--disabled-color); + } + + .container[disabled] .option { + cursor: not-allowed; + } + .option { cursor: pointer; position: relative; @@ -258,9 +258,13 @@ export class HaControlSelect extends LitElement { align-items: center; justify-content: center; border-radius: var(--control-select-button-border-radius); - overflow: hidden; /* For safari border-radius overflow */ z-index: 0; + outline: none; + transition: box-shadow 180ms ease-in-out; + } + .option:focus-visible { + box-shadow: 0 0 0 2px var(--control-select-color); } .content > *:not(:last-child) { margin-bottom: 4px; @@ -274,11 +278,11 @@ export class HaControlSelect extends LitElement { width: 100%; background-color: var(--control-select-color); opacity: 0; + border-radius: var(--control-select-button-border-radius); transition: background-color ease-in-out 180ms, opacity ease-in-out 80ms; } - .option.focused::before, .option:hover::before { opacity: var(--control-select-focused-opacity); } @@ -319,14 +323,6 @@ export class HaControlSelect extends LitElement { margin-inline-end: initial; margin-bottom: var(--control-select-padding); } - :host([disabled]) { - --control-select-color: var(--disabled-color); - --control-select-focused-opacity: 0; - color: var(--disabled-color); - } - :host([disabled]) .option { - cursor: not-allowed; - } `; } diff --git a/src/components/ha-control-slider.ts b/src/components/ha-control-slider.ts index 054eaeac9a..dc31618d96 100644 --- a/src/components/ha-control-slider.ts +++ b/src/components/ha-control-slider.ts @@ -3,11 +3,12 @@ import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { fireEvent } from "../common/dom/fire_event"; -import type { FrontendLocaleData } from "../data/translation"; import { formatNumber } from "../common/number/format_number"; import { blankBeforeUnit } from "../common/translations/blank_before_unit"; +import type { FrontendLocaleData } from "../data/translation"; declare global { interface HASSDomEvents { @@ -75,6 +76,9 @@ export class HaControlSlider extends LitElement { @property({ type: Number }) public max = 100; + @property({ type: String }) + public label?: string; + @state() public pressed = false; @@ -107,10 +111,6 @@ export class HaControlSlider extends LitElement { protected firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); this.setupListeners(); - this.setAttribute("role", "slider"); - if (!this.hasAttribute("tabindex")) { - this.setAttribute("tabindex", "0"); - } } protected updated(changedProps: PropertyValues) { @@ -197,9 +197,6 @@ export class HaControlSlider extends LitElement { this.value = this.steppedValue(this.percentageToValue(percentage)); fireEvent(this, "value-changed", { value: this.value }); }); - - this.addEventListener("keydown", this._handleKeyDown); - this.addEventListener("keyup", this._handleKeyUp); } } @@ -208,8 +205,6 @@ export class HaControlSlider extends LitElement { this._mc.destroy(); this._mc = undefined; } - this.removeEventListener("keydown", this._handleKeyDown); - this.removeEventListener("keyup", this._handleKeyUp); } private get _tenPercentStep() { @@ -323,6 +318,7 @@ export class HaControlSlider extends LitElement { } protected render(): TemplateResult { + const valuenow = this.steppedValue(this.value ?? 0); return html`
-
+
${this.mode === "cursor" @@ -371,12 +384,6 @@ export class HaControlSlider extends LitElement { --control-slider-tooltip-font-size: var(--ha-font-size-m); height: var(--control-slider-thickness); width: 100%; - border-radius: var(--control-slider-border-radius); - outline: none; - transition: box-shadow 180ms ease-in-out; - } - :host(:focus-visible) { - box-shadow: 0 0 0 2px var(--control-slider-color); } :host([vertical]) { width: var(--control-slider-thickness); @@ -471,9 +478,14 @@ export class HaControlSlider extends LitElement { width: 100%; border-radius: var(--control-slider-border-radius); transform: translateZ(0); + transition: box-shadow 180ms ease-in-out; + outline: none; overflow: hidden; cursor: pointer; } + .slider:focus-visible { + box-shadow: 0 0 0 2px var(--control-slider-color); + } .slider * { pointer-events: none; } diff --git a/src/components/ha-control-switch.ts b/src/components/ha-control-switch.ts index d9984402d1..ab4e4f572b 100644 --- a/src/components/ha-control-switch.ts +++ b/src/components/ha-control-switch.ts @@ -9,18 +9,19 @@ import { import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, query } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; import "./ha-svg-icon"; @customElement("ha-control-switch") export class HaControlSwitch extends LitElement { - @property({ type: Boolean, reflect: true }) public disabled = false; + @property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public vertical = false; @property({ type: Boolean }) public reversed = false; - @property({ type: Boolean, reflect: true }) public checked = false; + @property({ type: Boolean }) public checked = false; // SVG icon path (if you need a non SVG icon instead, use the provided on icon slot to pass an in) @property({ attribute: false, type: String }) pathOn?: string; @@ -28,6 +29,9 @@ export class HaControlSwitch extends LitElement { // SVG icon path (if you need a non SVG icon instead, use the provided off icon slot to pass an in) @property({ attribute: false, type: String }) pathOff?: string; + @property({ type: String }) + public label?: string; + @property({ attribute: "touch-action" }) public touchAction?: string; @@ -36,17 +40,6 @@ export class HaControlSwitch extends LitElement { protected firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); this.setupListeners(); - this.setAttribute("role", "switch"); - if (!this.hasAttribute("tabindex")) { - this.setAttribute("tabindex", "0"); - } - } - - protected updated(changedProps: PropertyValues) { - super.updated(changedProps); - if (changedProps.has("checked")) { - this.setAttribute("aria-checked", this.checked ? "true" : "false"); - } } private _toggle() { @@ -112,8 +105,6 @@ export class HaControlSwitch extends LitElement { if (this.disabled) return; this._toggle(); }); - - this.addEventListener("keydown", this._keydown); } } @@ -122,7 +113,6 @@ export class HaControlSwitch extends LitElement { this._mc.destroy(); this._mc = undefined; } - this.removeEventListener("keydown", this._keydown); } private _keydown(ev: any) { @@ -135,7 +125,17 @@ export class HaControlSwitch extends LitElement { protected render(): TemplateResult { return html` -
+