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`