mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-15 15:14:35 +00:00
Compare commits
7 Commits
fix-select
...
ha-form-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95997e3152 | ||
|
|
664584c4db | ||
|
|
924ba9ac68 | ||
|
|
c32f309366 | ||
|
|
4a169a4634 | ||
|
|
af3d56601b | ||
|
|
49b4744920 |
@@ -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}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
193
src/components/ha-form/ha-form-tabs.ts
Normal file
193
src/components/ha-form/ha-form-tabs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user