diff --git a/gallery/src/pages/components/ha-control-select.markdown b/gallery/src/pages/components/ha-control-select.markdown
new file mode 100644
index 0000000000..92def91fbe
--- /dev/null
+++ b/gallery/src/pages/components/ha-control-select.markdown
@@ -0,0 +1,3 @@
+---
+title: Control Select
+---
diff --git a/gallery/src/pages/components/ha-control-select.ts b/gallery/src/pages/components/ha-control-select.ts
new file mode 100644
index 0000000000..2f9eda7564
--- /dev/null
+++ b/gallery/src/pages/components/ha-control-select.ts
@@ -0,0 +1,212 @@
+import { mdiFanOff, mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3 } from "@mdi/js";
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, state } from "lit/decorators";
+import { ifDefined } from "lit/directives/if-defined";
+import { repeat } from "lit/directives/repeat";
+import "../../../../src/components/ha-card";
+import "../../../../src/components/ha-control-select";
+import type { ControlSelectOption } from "../../../../src/components/ha-control-select";
+
+const fullOptions: ControlSelectOption[] = [
+ {
+ value: "off",
+ label: "Off",
+ path: mdiFanOff,
+ },
+ {
+ value: "low",
+ label: "Low",
+ path: mdiFanSpeed1,
+ },
+ {
+ value: "medium",
+ label: "Medium",
+ path: mdiFanSpeed2,
+ },
+ {
+ value: "high",
+ label: "High",
+ path: mdiFanSpeed3,
+ },
+];
+
+const iconOptions: ControlSelectOption[] = [
+ {
+ value: "off",
+ path: mdiFanOff,
+ },
+ {
+ value: "low",
+ path: mdiFanSpeed1,
+ },
+ {
+ value: "medium",
+ path: mdiFanSpeed2,
+ },
+ {
+ value: "high",
+ path: mdiFanSpeed3,
+ },
+];
+
+const labelOptions: ControlSelectOption[] = [
+ {
+ value: "off",
+ label: "Off",
+ },
+ {
+ value: "low",
+ label: "Low",
+ },
+ {
+ value: "medium",
+ label: "Medium",
+ },
+ {
+ value: "high",
+ label: "High",
+ },
+];
+
+const selects: {
+ id: string;
+ label: string;
+ class?: string;
+ options: ControlSelectOption[];
+ disabled?: boolean;
+}[] = [
+ {
+ id: "label",
+ label: "Select with labels",
+ options: labelOptions,
+ },
+ {
+ id: "icon",
+ label: "Select with icons",
+ options: iconOptions,
+ },
+ {
+ id: "icon",
+ label: "Disabled select",
+ options: iconOptions,
+ disabled: true,
+ },
+ {
+ id: "custom",
+ label: "Select and custom style",
+ class: "custom",
+ options: fullOptions,
+ },
+];
+
+@customElement("demo-components-ha-control-select")
+export class DemoHaControlSelect extends LitElement {
+ @state() private value?: string = "off";
+
+ handleValueChanged(e: CustomEvent) {
+ this.value = e.detail.value as string;
+ }
+
+ protected render(): TemplateResult {
+ return html`
+
+
+
Slider values
+
+
+
+ value |
+ ${this.value ?? "-"} |
+
+
+
+
+
+ ${repeat(selects, (select) => {
+ const { id, label, options, ...config } = select;
+ return html`
+
+
+
+
Config: ${JSON.stringify(config)}
+
+
+
+
+ `;
+ })}
+
+
+
Vertical
+
+ ${repeat(selects, (select) => {
+ const { id, label, options, ...config } = select;
+ return html`
+
+
+ `;
+ })}
+
+
+
+ `;
+ }
+
+ static get styles() {
+ return css`
+ ha-card {
+ max-width: 600px;
+ margin: 24px auto;
+ }
+ pre {
+ margin-top: 0;
+ margin-bottom: 8px;
+ }
+ p {
+ margin: 0;
+ }
+ label {
+ font-weight: 600;
+ }
+ .custom {
+ --mdc-icon-size: 24px;
+ --control-select-color: var(--state-fan-active-color);
+ --control-select-thickness: 100px;
+ --control-select-border-radius: 24px;
+ }
+ .vertical-selects {
+ height: 300px;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+ p.title {
+ margin-bottom: 12px;
+ }
+ .vertical-selects > *:not(:last-child) {
+ margin-right: 4px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "demo-components-ha-control-select": DemoHaControlSelect;
+ }
+}
diff --git a/src/components/ha-control-button.ts b/src/components/ha-control-button.ts
index d169fb6d08..8b4437b743 100644
--- a/src/components/ha-control-button.ts
+++ b/src/components/ha-control-button.ts
@@ -107,8 +107,9 @@ export class HaControlButton extends LitElement {
outline: none;
overflow: hidden;
background: none;
- z-index: 1;
--mdc-ripple-color: var(--control-button-background-color);
+ /* For safari border-radius overflow */
+ z-index: 0;
}
.button::before {
content: "";
diff --git a/src/components/ha-control-select.ts b/src/components/ha-control-select.ts
new file mode 100644
index 0000000000..7689c148d1
--- /dev/null
+++ b/src/components/ha-control-select.ts
@@ -0,0 +1,333 @@
+import {
+ css,
+ CSSResultGroup,
+ html,
+ LitElement,
+ nothing,
+ PropertyValues,
+} from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { ifDefined } from "lit/directives/if-defined";
+import { repeat } from "lit/directives/repeat";
+import { fireEvent } from "../common/dom/fire_event";
+import "./ha-icon";
+import "./ha-svg-icon";
+
+export type ControlSelectOption = {
+ value: string;
+ label?: string;
+ icon?: string;
+ path?: string;
+};
+
+@customElement("ha-control-select")
+export class HaControlSelect extends LitElement {
+ @property({ type: Boolean, reflect: true }) disabled = false;
+
+ @property() public label?: string;
+
+ @property() public options?: ControlSelectOption[];
+
+ @property() public value?: string;
+
+ @property({ type: Boolean, reflect: true })
+ public vertical = false;
+
+ @property({ type: Boolean, attribute: "hide-label" })
+ public hideLabel = false;
+
+ @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 =
+ (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;
+ switch (ev.key) {
+ case " ":
+ this.value = this.options[this._activeIndex].value;
+ fireEvent(this, "value-changed", { value: this.value });
+ break;
+ case "ArrowUp":
+ case "ArrowLeft":
+ this._activeIndex =
+ this._activeIndex <= 0
+ ? this.options.length - 1
+ : this._activeIndex - 1;
+ 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;
+ break;
+ default:
+ return;
+ }
+ ev.preventDefault();
+ }
+
+ private _handleOptionClick(ev: MouseEvent) {
+ if (this.disabled) return;
+ const value = (ev.target as any).value;
+ this.value = value;
+ fireEvent(this, "value-changed", { value: this.value });
+ }
+
+ private _handleOptionMouseDown(ev: MouseEvent) {
+ if (this.disabled) return;
+ ev.preventDefault();
+ const value = (ev.target as any).value;
+ this._activeIndex = this.options?.findIndex(
+ (option) => option.value === value
+ );
+ }
+
+ private _handleOptionMouseUp(ev: MouseEvent) {
+ ev.preventDefault();
+ this._activeIndex = undefined;
+ }
+
+ protected render() {
+ return html`
+
+ ${this.options
+ ? repeat(
+ this.options,
+ (option) => option.value,
+ (option, idx) => this._renderOption(option, idx)
+ )
+ : nothing}
+
+ `;
+ }
+
+ private _renderOption(option: ControlSelectOption, index: number) {
+ return html`
+
+
+ ${option.path
+ ? html``
+ : option.icon
+ ? html` `
+ : nothing}
+ ${option.label && !this.hideLabel
+ ? html`${option.label}`
+ : nothing}
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ display: block;
+ --control-select-color: var(--primary-color);
+ --control-select-focused-opacity: 0.2;
+ --control-select-selected-opacity: 1;
+ --control-select-background: var(--disabled-color);
+ --control-select-background-opacity: 0.2;
+ --control-select-thickness: 40px;
+ --control-select-border-radius: 12px;
+ --control-select-padding: 4px;
+ --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: 500;
+ 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%;
+ }
+ .container {
+ position: relative;
+ height: 100%;
+ 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;
+ }
+ .container::before {
+ position: absolute;
+ content: "";
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background: var(--control-select-background);
+ opacity: var(--control-select-background-opacity);
+ }
+
+ .container > *:not(:last-child) {
+ margin-right: var(--control-select-padding);
+ margin-inline-end: var(--control-select-padding);
+ margin-inline-start: initial;
+ direction: var(--direction);
+ }
+ .option {
+ cursor: pointer;
+ position: relative;
+ flex: 1;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: calc(
+ var(--control-select-border-radius) - var(--control-select-padding)
+ );
+ overflow: hidden;
+ color: var(--primary-text-color);
+ /* For safari border-radius overflow */
+ z-index: 0;
+ }
+ .content > *:not(:last-child) {
+ margin-bottom: 4px;
+ }
+ .option::before {
+ position: absolute;
+ content: "";
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background-color: var(--control-select-color);
+ opacity: 0;
+ transition: background-color ease-in-out 180ms, opacity ease-in-out 80ms;
+ }
+ .option.focused::before,
+ .option:hover::before {
+ opacity: var(--control-select-focused-opacity);
+ }
+ .option.selected {
+ color: white;
+ }
+ .option.selected::before {
+ opacity: var(--control-select-selected-opacity);
+ }
+ .option .content {
+ position: relative;
+ pointer-events: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
+ }
+ :host([vertical]) {
+ width: var(--control-select-thickness);
+ height: auto;
+ }
+ :host([vertical]) .container {
+ flex-direction: column;
+ }
+ :host([vertical]) .container > *:not(:last-child) {
+ margin-right: initial;
+ margin-inline-end: initial;
+ margin-bottom: var(--control-select-padding);
+ }
+ :host([disabled]) {
+ --control-select-color: var(--disabled-color);
+ --control-select-focused-opacity: 0;
+ }
+ :host([disabled]) .option {
+ cursor: not-allowed;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-control-select": HaControlSelect;
+ }
+}
diff --git a/src/components/ha-control-slider.ts b/src/components/ha-control-slider.ts
index 2b761d4c30..6b31e6d2e6 100644
--- a/src/components/ha-control-slider.ts
+++ b/src/components/ha-control-slider.ts
@@ -29,19 +29,6 @@ const A11Y_KEY_CODES = new Set([
"End",
]);
-const getPercentageFromEvent = (e: HammerInput, vertical: boolean) => {
- if (vertical) {
- const y = e.center.y;
- const offset = e.target.getBoundingClientRect().top;
- const total = e.target.clientHeight;
- return Math.max(Math.min(1, 1 - (y - offset) / total), 0);
- }
- const x = e.center.x;
- const offset = e.target.getBoundingClientRect().left;
- const total = e.target.clientWidth;
- return Math.max(Math.min(1, (x - offset) / total), 0);
-};
-
@customElement("ha-control-slider")
export class HaControlSlider extends LitElement {
@property({ type: Boolean, reflect: true })
@@ -157,7 +144,7 @@ export class HaControlSlider extends LitElement {
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
- const percentage = getPercentageFromEvent(e, this.vertical);
+ const percentage = this._getPercentageFromEvent(e);
this.value = this.percentageToValue(percentage);
const value = this.steppedValue(this.value);
fireEvent(this, "slider-moved", { value });
@@ -165,7 +152,7 @@ export class HaControlSlider extends LitElement {
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
- const percentage = getPercentageFromEvent(e, this.vertical);
+ const percentage = this._getPercentageFromEvent(e);
this.value = this.steppedValue(this.percentageToValue(percentage));
fireEvent(this, "slider-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this.value });
@@ -173,7 +160,7 @@ export class HaControlSlider extends LitElement {
this._mc.on("singletap", (e) => {
if (this.disabled) return;
- const percentage = getPercentageFromEvent(e, this.vertical);
+ const percentage = this._getPercentageFromEvent(e);
this.value = this.steppedValue(this.percentageToValue(percentage));
fireEvent(this, "value-changed", { value: this.value });
});
@@ -234,6 +221,19 @@ export class HaControlSlider extends LitElement {
fireEvent(this, "value-changed", { value: this.value });
}
+ private _getPercentageFromEvent = (e: HammerInput) => {
+ if (this.vertical) {
+ const y = e.center.y;
+ const offset = e.target.getBoundingClientRect().top;
+ const total = e.target.clientHeight;
+ return Math.max(Math.min(1, 1 - (y - offset) / total), 0);
+ }
+ const x = e.center.x;
+ const offset = e.target.getBoundingClientRect().left;
+ const total = e.target.clientWidth;
+ return Math.max(Math.min(1, (x - offset) / total), 0);
+ };
+
protected render(): TemplateResult {
return html`
`
@@ -259,7 +258,6 @@ export class HaControlSlider extends LitElement {