diff --git a/gallery/public/images/select_box/card.svg b/gallery/public/images/select_box/card.svg new file mode 100644 index 0000000000..3ec10d8fc5 --- /dev/null +++ b/gallery/public/images/select_box/card.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/gallery/public/images/select_box/text_only.svg b/gallery/public/images/select_box/text_only.svg new file mode 100644 index 0000000000..c40999475c --- /dev/null +++ b/gallery/public/images/select_box/text_only.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gallery/src/pages/components/ha-select-box.markdown b/gallery/src/pages/components/ha-select-box.markdown new file mode 100644 index 0000000000..f5902208e7 --- /dev/null +++ b/gallery/src/pages/components/ha-select-box.markdown @@ -0,0 +1,3 @@ +--- +title: Select box +--- diff --git a/gallery/src/pages/components/ha-select-box.ts b/gallery/src/pages/components/ha-select-box.ts new file mode 100644 index 0000000000..ed2d182227 --- /dev/null +++ b/gallery/src/pages/components/ha-select-box.ts @@ -0,0 +1,152 @@ +import type { TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-select-box"; +import type { SelectBoxOption } from "../../../../src/components/ha-select-box"; + +const basicOptions: SelectBoxOption[] = [ + { + value: "text-only", + label: "Text only", + }, + { + value: "card", + label: "Card", + }, + { + value: "disabled", + label: "Disabled option", + disabled: true, + }, +]; + +const fullOptions: SelectBoxOption[] = [ + { + value: "text-only", + label: "Text only", + description: "Only text, no border and background", + image: "/images/select_box/text_only.svg", + }, + { + value: "card", + label: "Card", + description: "With border and background", + image: "/images/select_box/card.svg", + }, + { + value: "disabled", + label: "Disabled", + description: "Option that can not be selected", + disabled: true, + }, +]; + +const selects: { + id: string; + label: string; + class?: string; + options: SelectBoxOption[]; + disabled?: boolean; +}[] = [ + { + id: "basic", + label: "Basic", + options: basicOptions, + }, + { + id: "full", + label: "With description and image", + options: fullOptions, + }, +]; + +@customElement("demo-components-ha-select-box") +export class DemoHaSelectBox extends LitElement { + @state() private value?: string = "off"; + + handleValueChanged(e: CustomEvent) { + this.value = e.detail.value as string; + } + + protected render(): TemplateResult { + return html` + ${repeat(selects, (select) => { + const { id, label, options } = select; + return html` + +
+ + + +
+
+ `; + })} + +
+

Column layout

+
+ ${repeat(selects, (select) => { + const { options } = select; + return html` + + + `; + })} +
+
+
+ `; + } + + static styles = css` + ha-card { + max-width: 600px; + margin: 24px auto; + } + pre { + margin-top: 0; + margin-bottom: 8px; + } + p { + margin: 0; + } + label { + font-weight: 600; + margin-bottom: 8px; + display: block; + } + .custom { + --mdc-icon-size: 24px; + --control-select-color: var(--state-fan-active-color); + --control-select-thickness: 130px; + --control-select-border-radius: 36px; + } + + p.title { + margin-bottom: 12px; + } + + .vertical-selects ha-select-box { + display: block; + margin-bottom: 24px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-select-box": DemoHaSelectBox; + } +} diff --git a/public/static/images/form/markdown_card.svg b/public/static/images/form/markdown_card.svg new file mode 100644 index 0000000000..3ec10d8fc5 --- /dev/null +++ b/public/static/images/form/markdown_card.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/form/markdown_card_dark.svg b/public/static/images/form/markdown_card_dark.svg new file mode 100644 index 0000000000..e3046ade87 --- /dev/null +++ b/public/static/images/form/markdown_card_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/form/markdown_text_only.svg b/public/static/images/form/markdown_text_only.svg new file mode 100644 index 0000000000..c40999475c --- /dev/null +++ b/public/static/images/form/markdown_text_only.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/static/images/form/markdown_text_only_dark.svg b/public/static/images/form/markdown_text_only_dark.svg new file mode 100644 index 0000000000..ff1c96a5c6 --- /dev/null +++ b/public/static/images/form/markdown_text_only_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components/ha-select-box.ts b/src/components/ha-select-box.ts new file mode 100644 index 0000000000..fb0edbd6a3 --- /dev/null +++ b/src/components/ha-select-box.ts @@ -0,0 +1,193 @@ +import { customElement, property } from "lit/decorators"; +import { css, html, LitElement, nothing } from "lit"; +import "./ha-radio"; +import { classMap } from "lit/directives/class-map"; +import { styleMap } from "lit/directives/style-map"; +import type { HaRadio } from "./ha-radio"; +import { fireEvent } from "../common/dom/fire_event"; + +export interface SelectBoxOption { + label?: string; + description?: string; + image?: string; + value: string; + disabled?: boolean; +} + +@customElement("ha-select-box") +export class HaSelectBox extends LitElement { + @property({ attribute: false }) public options: SelectBoxOption[] = []; + + @property({ attribute: false }) public value?: string; + + @property({ type: Boolean }) public disabled?: boolean; + + @property({ type: Number, attribute: "max_columns" }) + public maxColumns?: number; + + render() { + const maxColumns = this.maxColumns ?? 3; + const columns = Math.min(maxColumns, this.options.length); + + return html` +
+ ${this.options.map((option) => this._renderOption(option))} +
+ `; + } + + private _renderOption(option: SelectBoxOption) { + const horizontal = this.maxColumns === 1; + const disabled = option.disabled || this.disabled || false; + const selected = option.value === this.value; + return html` + + `; + } + + private _labelClick(ev) { + ev.stopPropagation(); + ev.currentTarget.querySelector("ha-radio")?.click(); + } + + private _radioChanged(ev: CustomEvent) { + const radio = ev.currentTarget as HaRadio; + const value = radio.value; + if (this.disabled || value === undefined || value === (this.value ?? "")) { + return; + } + fireEvent(this, "value-changed", { + value: value, + }); + } + + static styles = css` + .list { + display: grid; + grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr)); + gap: 12px; + } + .option { + position: relative; + display: block; + border: 1px solid var(--divider-color); + border-radius: var(--ha-card-border-radius, 12px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + padding: 12px; + gap: 8px; + overflow: hidden; + cursor: pointer; + } + + .option .content { + position: relative; + display: flex; + flex-direction: row; + gap: 8px; + min-width: 0; + width: 100%; + } + .option .content ha-radio { + margin: -12px; + flex: none; + } + .option .content .text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; + } + .option .content .text .label { + color: var(--primary-text-color); + font-size: 14px; + font-weight: 400; + line-height: 20px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .option .content .text .description { + color: var(--secondary-text-color); + font-size: 13px; + font-weight: 400; + line-height: 16px; + } + img { + position: relative; + max-width: var(--ha-select-box-image-size, 96px); + max-height: var(--ha-select-box-image-size, 96px); + margin: auto; + } + + .option.horizontal { + flex-direction: row; + align-items: flex-start; + } + + .option.horizontal img { + margin: 0; + } + + .option:before { + content: ""; + display: block; + inset: 0; + position: absolute; + background-color: transparent; + pointer-events: none; + opacity: 0.2; + transition: + background-color 180ms ease-in-out, + opacity 180ms ease-in-out; + } + .option:hover:before { + background-color: var(--divider-color); + } + .option.selected:before { + background-color: var(--primary-color); + } + .option[disabled] { + cursor: not-allowed; + } + .option[disabled] .content, + .option[disabled] img { + opacity: 0.5; + } + .option[disabled]:before { + background-color: var(--disabled-color); + opacity: 0.05; + } + `; +} +declare global { + interface HTMLElementTagNameMap { + "ha-select-box": HaSelectBox; + } +} diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts index 7e0f9ed52d..850dd60aa4 100644 --- a/src/components/ha-selector/ha-selector-select.ts +++ b/src/components/ha-selector/ha-selector-select.ts @@ -19,6 +19,7 @@ import "../ha-input-helper-text"; import "../ha-radio"; import "../ha-select"; import "../ha-sortable"; +import "../ha-select-box"; @customElement("ha-selector-select") export class HaSelectSelector extends LitElement { @@ -91,6 +92,24 @@ export class HaSelectSelector extends LitElement { ); } + if ( + !this.selector.select?.multiple && + !this.selector.select?.reorder && + !this.selector.select?.custom_value && + this._mode === "box" + ) { + return html` + ${this.label ? html`${this.label}` : nothing} + + ${this._renderHelper()} + `; + } + if ( !this.selector.select?.custom_value && !this.selector.select?.reorder && @@ -269,7 +288,7 @@ export class HaSelectSelector extends LitElement { : ""; } - private get _mode(): "list" | "dropdown" { + private get _mode(): "list" | "dropdown" | "box" { return ( this.selector.select?.mode || ((this.selector.select?.options?.length || 0) < 6 ? "list" : "dropdown") @@ -411,6 +430,15 @@ export class HaSelectSelector extends LitElement { padding: 8px 0; } + .label { + display: block; + margin: 0 0 8px; + } + + ha-select-box + ha-input-helper-text { + margin-top: 4px; + } + .sortable-fallback { display: none; opacity: 0; diff --git a/src/data/selector.ts b/src/data/selector.ts index 59f14165c8..42e82f0347 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -346,6 +346,8 @@ export interface AssistPipelineSelector { export interface SelectOption { value: any; label: string; + description?: string; + image?: string; disabled?: boolean; } @@ -353,11 +355,12 @@ export interface SelectSelector { select: { multiple?: boolean; custom_value?: boolean; - mode?: "list" | "dropdown"; + mode?: "list" | "dropdown" | "box"; options: readonly string[] | readonly SelectOption[]; translation_key?: string; sort?: boolean; reorder?: boolean; + box_max_columns?: number; } | null; } diff --git a/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts index 8b96938011..a900ebd5e5 100644 --- a/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts @@ -38,18 +38,19 @@ export class HuiMarkdownCardEditor } private _schema = memoizeOne( - (localize: LocalizeFunc, text_only: boolean) => + (localize: LocalizeFunc, text_only: boolean, isDark: boolean) => [ { name: "style", required: true, selector: { select: { - mode: "dropdown", + mode: "box", options: ["card", "text-only"].map((style) => ({ label: localize( `ui.panel.lovelace.editor.card.markdown.style_options.${style}` ), + image: `/static/images/form/markdown_${style.replace("-", "_")}${isDark ? "_dark" : ""}.svg`, value: style, })), }, @@ -74,7 +75,8 @@ export class HuiMarkdownCardEditor const schema = this._schema( this.hass.localize, - this._config.text_only || false + this._config.text_only || false, + this.hass.themes.darkMode ); return html`