From 27098c5f3faa40ec72622a28b762165d57d70309 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 17 Mar 2023 16:16:51 +0100 Subject: [PATCH] Create control select component (#15819) --- .../components/ha-control-select.markdown | 3 + .../src/pages/components/ha-control-select.ts | 212 +++++++++++ src/components/ha-control-button.ts | 3 +- src/components/ha-control-select.ts | 333 ++++++++++++++++++ src/components/ha-control-slider.ts | 46 ++- 5 files changed, 572 insertions(+), 25 deletions(-) create mode 100644 gallery/src/pages/components/ha-control-select.markdown create mode 100644 gallery/src/pages/components/ha-control-select.ts create mode 100644 src/components/ha-control-select.ts 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 {