Compare commits

...

7 Commits

Author SHA1 Message Date
Aidan Timson
95997e3152 Icons 2026-04-07 14:33:49 +01:00
Aidan Timson
664584c4db Cleanup 2026-04-07 14:21:42 +01:00
Aidan Timson
924ba9ac68 Fix types 2026-04-07 14:08:49 +01:00
Aidan Timson
c32f309366 Use nothing 2026-04-07 14:00:30 +01:00
Aidan Timson
4a169a4634 Remove comment 2026-04-07 13:56:45 +01:00
Aidan Timson
af3d56601b Fill tabs by default 2026-04-07 13:49:47 +01:00
Aidan Timson
49b4744920 Add support for tabs to forms 2026-04-07 13:39:21 +01:00
12 changed files with 411 additions and 7 deletions

View File

@@ -488,6 +488,79 @@ const SCHEMAS: {
},
],
},
{
title: "Tabs",
translations: {
settings: "Settings",
tab_general: "General",
tab_appearance: "Appearance",
name: "Name",
entity: "Entity",
theme: "Theme",
state_color: "Color on state",
},
schema: [
{
type: "tabs",
name: "settings",
tabs: [
{
name: "general",
icon: "mdi:cog",
schema: [
{ name: "name", selector: { text: {} } },
{ name: "entity", required: true, selector: { entity: {} } },
],
},
{
name: "appearance",
icon: "mdi:palette",
schema: [
{ name: "theme", selector: { theme: {} } },
{ name: "state_color", selector: { boolean: {} } },
],
},
],
},
],
},
{
title: "Tabs (compact)",
translations: {
settings: "Settings",
tab_general: "General",
tab_appearance: "Appearance",
name: "Name",
entity: "Entity",
theme: "Theme",
state_color: "Color on state",
},
schema: [
{
type: "tabs",
name: "settings",
fill_tabs: false,
tabs: [
{
name: "general",
icon: "mdi:cog",
schema: [
{ name: "name", selector: { text: {} } },
{ name: "entity", required: true, selector: { entity: {} } },
],
},
{
name: "appearance",
icon: "mdi:palette",
schema: [
{ name: "theme", selector: { theme: {} } },
{ name: "state_color", selector: { boolean: {} } },
],
},
],
},
],
},
];
@customElement("demo-components-ha-form")
@@ -535,8 +608,12 @@ class DemoHaForm extends LitElement {
.error=${info.error}
.disabled=${this.disabled[idx]}
.computeError=${(error) => translations[error] || error}
.computeLabel=${(schema) =>
translations[schema.name] || schema.name}
.computeLabel=${(schema, _data, options) => {
if (options?.tab) {
return translations[`tab_${options.tab}`] || options.tab;
}
return translations[schema.name] || schema.name;
}}
.computeHelper=${() => "Helper text"}
@value-changed=${this._handleValueChanged}
.sampleIdx=${idx}

View File

@@ -38,6 +38,17 @@ export const computeInitialHaFormData = (
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
}
} else if (field.type === "tabs") {
const tabsData: Record<string, unknown> = {};
for (const tab of field.tabs) {
Object.assign(tabsData, computeInitialHaFormData(tab.schema));
}
const flattenTabs = field.flatten ?? !field.name;
if (flattenTabs) {
Object.assign(data, tabsData);
} else if (field.required || Object.keys(tabsData).length) {
data[field.name] = tabsData;
}
} else if (!field.required) {
// Do nothing.
} else if (field.type === "boolean") {

View File

@@ -0,0 +1,193 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import "../ha-icon";
import "../ha-svg-icon";
import "../ha-tab-group";
import "../ha-tab-group-tab";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
HaFormDataContainer,
HaFormElement,
HaFormSchema,
HaFormTabsSchema,
} from "./types";
@customElement("ha-form-tabs")
export class HaFormTabs extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data!: HaFormDataContainer;
@property({ attribute: false }) public schema!: HaFormTabsSchema;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public computeLabel?: (
schema: HaFormSchema,
data?: HaFormDataContainer,
options?: { path?: string[]; tab?: string }
) => string;
@property({ attribute: false }) public computeHelper?: (
schema: HaFormSchema,
options?: { path?: string[] }
) => string;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
@state() private _activeTab?: string;
private _handleTabShow = (ev: CustomEvent<{ name: string }>) => {
const name = ev.detail?.name;
if (name !== undefined) {
this._activeTab = name;
}
};
protected willUpdate(changedProps: Map<PropertyKey, unknown>): void {
super.willUpdate(changedProps);
if (changedProps.has("schema") && this.schema.tabs.length) {
const first = this.schema.tabs[0]!.name;
if (
this._activeTab === undefined ||
!this.schema.tabs.some((t) => t.name === this._activeTab)
) {
this._activeTab = first;
}
}
}
public reportValidity(): boolean {
const forms = this.renderRoot.querySelectorAll<HaForm>("ha-form");
let valid = true;
forms.forEach((form) => {
if (!form.reportValidity()) {
valid = false;
}
});
return valid;
}
private _computeLabel = (
schema: HaFormSchema,
data?: HaFormDataContainer,
options?: { path?: string[] }
) => {
if (!this.computeLabel) {
return undefined;
}
return this.computeLabel(schema, data, {
...options,
path: [...(options?.path || []), this.schema.name],
});
};
private _computeHelper = (
schema: HaFormSchema,
options?: { path?: string[] }
) => {
if (!this.computeHelper) {
return undefined;
}
return this.computeHelper(schema, {
...options,
path: [...(options?.path || []), this.schema.name],
});
};
private _tabTitle(tabName: string): string {
if (!this.computeLabel) {
return tabName;
}
return (
this.computeLabel(this.schema, this.data, {
path: [...(this.schema.name ? [this.schema.name] : [])],
tab: tabName,
}) ?? tabName
);
}
protected render() {
const tabs = this.schema.tabs;
if (!tabs.length) {
return nothing;
}
const active = this._activeTab ?? tabs[0]!.name;
const fillTabs = this.schema.fill_tabs !== false;
return html`
<ha-tab-group ?fill-tabs=${fillTabs} @wa-tab-show=${this._handleTabShow}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab.name}
.active=${active === tab.name}
>
${tab.icon
? html`<ha-icon .icon=${tab.icon}></ha-icon>`
: tab.iconPath
? html`<ha-svg-icon .path=${tab.iconPath}></ha-svg-icon>`
: nothing}
${this._tabTitle(tab.name)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
<div class="panels">
${tabs.map((tab) => {
const hidden = active !== tab.name;
return html`
<div class="panel" ?hidden=${hidden}>
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${tab.schema}
.disabled=${this.disabled}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
</div>
`;
})}
</div>
`;
}
static styles = css`
:host {
display: flex !important;
flex-direction: column;
}
.panels {
padding-top: var(--ha-space-4);
}
.panel[hidden] {
display: none !important;
}
:host ha-form {
display: block;
}
ha-tab-group {
display: block;
}
ha-tab-group-tab ha-icon,
ha-tab-group-tab ha-svg-icon {
flex-shrink: 0;
margin-inline-end: var(--ha-space-2);
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-tabs": HaFormTabs;
}
}

View File

@@ -14,6 +14,7 @@ const LOAD_ELEMENTS = {
float: () => import("./ha-form-float"),
grid: () => import("./ha-form-grid"),
expandable: () => import("./ha-form-expandable"),
tabs: () => import("./ha-form-tabs"),
integer: () => import("./ha-form-integer"),
multi_select: () => import("./ha-form-multi_select"),
positive_time_period_dict: () =>

View File

@@ -14,7 +14,8 @@ export type HaFormSchema =
| HaFormSelector
| HaFormGridSchema
| HaFormExpandableSchema
| HaFormOptionalActionsSchema;
| HaFormOptionalActionsSchema
| HaFormTabsSchema;
export interface HaFormBaseSchema {
name: string;
@@ -54,6 +55,26 @@ export interface HaFormOptionalActionsSchema extends HaFormBaseSchema {
schema: readonly HaFormSchema[];
}
/** One tab pane inside a {@link HaFormTabsSchema} (not a standalone form field). */
export interface HaFormTabDefinition {
name: string;
icon?: string;
iconPath?: string;
schema: readonly HaFormSchema[];
}
export interface HaFormTabsSchema extends HaFormBaseSchema {
type: "tabs";
/** When true (default), tab field values merge into the parent data object. */
flatten?: boolean;
/**
* When true (default), tab labels share width equally across the tab bar.
* Set to false for compact tabs that only use their natural width.
*/
fill_tabs?: boolean;
tabs: readonly HaFormTabDefinition[];
}
export interface HaFormSelector extends HaFormBaseSchema {
type?: never;
selector: Selector;
@@ -104,6 +125,13 @@ export interface HaFormTimeSchema extends HaFormBaseSchema {
}
// Type utility to unionize a schema array by flattening any grid schemas
type SchemaUnionTabs<T extends readonly HaFormTabDefinition[]> =
T[number] extends infer Tab
? Tab extends HaFormTabDefinition
? SchemaUnion<Tab["schema"]>
: never
: never;
export type SchemaUnion<
SchemaArray extends readonly HaFormSchema[],
Schema = SchemaArray[number],
@@ -112,7 +140,9 @@ export type SchemaUnion<
| HaFormExpandableSchema
| HaFormOptionalActionsSchema
? SchemaUnion<Schema["schema"]> | Schema
: Schema;
: Schema extends HaFormTabsSchema
? SchemaUnionTabs<Schema["tabs"]> | Schema
: Schema;
export type HaFormDataContainer = Record<string, HaFormData>;

View File

@@ -26,6 +26,11 @@ export class HaTabGroupTab extends Tab {
opacity: 1;
}
.tab {
width: var(--ha-tab-base-width, auto);
justify-content: var(--ha-tab-base-justify-content, flex-start);
}
@media (hover: hover) {
:host(:hover:not([disabled]):not([active])) .tab {
color: var(--wa-color-brand-on-quiet);

View File

@@ -13,6 +13,10 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
/** When true (default), each tab trigger grows to fill the tab row evenly. */
@property({ type: Boolean, reflect: true, attribute: "fill-tabs" })
fillTabs = true;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
@@ -70,6 +74,13 @@ export class HaTabGroup extends TabGroup {
.scroll-button::part(base):hover {
background-color: transparent;
}
:host([fill-tabs]) .tab-group-top .tabs ::slotted(ha-tab-group-tab),
:host([fill-tabs]) .tab-group-bottom .tabs ::slotted(ha-tab-group-tab) {
flex: 1;
--ha-tab-base-width: 100%;
--ha-tab-base-justify-content: center;
}
`,
];
}

View File

@@ -99,6 +99,16 @@ export const showConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
return (
hass.localize(
`component.${step.handler}.config.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.name`,
step.description_placeholders
) || options.tab
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
@@ -117,6 +127,17 @@ export const showConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
const tabDescription = hass.localize(
`component.${step.translation_domain || step.handler}.config.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.description`,
step.description_placeholders
);
return tabDescription
? html`<ha-markdown breaks .content=${tabDescription}></ha-markdown>`
: "";
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(

View File

@@ -62,14 +62,14 @@ export interface FlowConfig {
hass: HomeAssistant,
step: DataEntryFlowStepForm,
field: HaFormSchema,
options: { path?: string[]; [key: string]: any }
options: { path?: string[]; tab?: string; [key: string]: any }
): string;
renderShowFormStepFieldHelper(
hass: HomeAssistant,
step: DataEntryFlowStepForm,
field: HaFormSchema,
options: { path?: string[]; [key: string]: any }
options: { path?: string[]; tab?: string; [key: string]: any }
): TemplateResult | string;
renderShowFormStepFieldError(

View File

@@ -103,6 +103,16 @@ export const showOptionsFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.options.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.name`,
step.description_placeholders
) || options.tab
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
@@ -121,6 +131,20 @@ export const showOptionsFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
const tabDescription = hass.localize(
`component.${step.translation_domain || configEntry.domain}.options.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.description`,
step.description_placeholders
);
return tabDescription
? html`<ha-markdown
breaks
.content=${tabDescription}
></ha-markdown>`
: "";
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(

View File

@@ -95,6 +95,16 @@ export const showSubConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.name`,
step.description_placeholders
) || options.tab
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
@@ -113,6 +123,17 @@ export const showSubConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
const tabDescription = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.description`,
step.description_placeholders
);
return tabDescription
? html`<ha-markdown breaks .content=${tabDescription}></ha-markdown>`
: "";
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(

View File

@@ -75,7 +75,17 @@ class StepFlowForm extends LitElement {
handleReadOnlyField(sectionField)
),
}
: handleReadOnlyField(field)
: field.type === "tabs" && field.tabs
? {
...field,
tabs: field.tabs.map((tab) => ({
...tab,
schema: tab.schema.map((tabField) =>
handleReadOnlyField(tabField)
),
})),
}
: handleReadOnlyField(field)
);
});