From 0b45924d10948a69acc77dab443554300be7e62a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 7 Jul 2025 19:32:03 +0200 Subject: [PATCH] Improve aria support in control elements --- .../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 | 113 +++++++----------- src/components/ha-control-slider.ts | 46 ++++--- src/components/ha-control-switch.ts | 62 +++++----- .../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, 148 insertions(+), 166 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..5f706e2d12 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,58 +26,14 @@ 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"); - } - } - - 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 ?? ""); - } - 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 = @@ -95,6 +51,7 @@ export class HaControlSelect extends LitElement { const value = this.options[this._activeIndex].value; switch (ev.key) { case " ": + case "Enter": this.value = value; fireEvent(this, "value-changed", { value }); break; @@ -143,8 +100,26 @@ export class HaControlSelect extends LitElement { } protected render() { + const activeValue = + this._activeIndex != null + ? this.options?.[this._activeIndex]?.value + : undefined; + const activedescendant = + activeValue != null ? `option-${activeValue}` : undefined; + return html` -
+
${this.options ? repeat( this.options, @@ -167,7 +142,7 @@ export class HaControlSelect extends LitElement { })} role="option" .value=${option.value} - aria-selected=${this.value === option.value} + aria-selected=${this.value === option.value ? "true" : "false"} aria-label=${ifDefined(option.label)} title=${ifDefined(option.label)} @click=${this._handleOptionClick} @@ -178,7 +153,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 +178,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%; @@ -230,6 +199,8 @@ export class HaControlSelect extends LitElement { 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; @@ -248,6 +219,20 @@ 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; + } + + .container:focus-visible { + box-shadow: 0 0 0 2px var(--control-select-color); + } + .option { cursor: pointer; position: relative; @@ -319,14 +304,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..8d82e0627b 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,18 @@ export class HaControlSwitch extends LitElement { protected render(): TemplateResult { return html` -
+