Resize card editor (#21115)

This commit is contained in:
Paul Bottein 2024-06-24 22:10:31 +02:00 committed by GitHub
parent 6a3041988a
commit 321a085c0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 971 additions and 19 deletions

View File

@ -0,0 +1 @@
export const preventDefault = (ev) => ev.preventDefault();

View File

@ -0,0 +1,233 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "./ha-icon-button";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import { mdiRestore } from "@mdi/js";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { HomeAssistant } from "../types";
type GridSizeValue = {
rows?: number;
columns?: number;
};
@customElement("ha-grid-size-picker")
export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: GridSizeValue;
@property({ attribute: false }) public rows = 6;
@property({ attribute: false }) public columns = 4;
@property({ attribute: false }) public rowMin?: number;
@property({ attribute: false }) public rowMax?: number;
@property({ attribute: false }) public columnMin?: number;
@property({ attribute: false }) public columnMax?: number;
@property({ attribute: false }) public isDefault?: boolean;
@state() public _localValue?: GridSizeValue = undefined;
protected willUpdate(changedProperties) {
if (changedProperties.has("value")) {
this._localValue = this.value;
}
}
protected render() {
return html`
<div class="grid">
<ha-grid-layout-slider
aria-label=${this.hass.localize(
"ui.components.grid-size-picker.columns"
)}
id="columns"
.min=${this.columnMin ?? 1}
.max=${this.columnMax ?? this.columns}
.range=${this.columns}
.value=${this.value?.columns}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
></ha-grid-layout-slider>
<ha-grid-layout-slider
aria-label=${this.hass.localize(
"ui.components.grid-size-picker.rows"
)}
id="rows"
.min=${this.rowMin ?? 1}
.max=${this.rowMax ?? this.rows}
.range=${this.rows}
vertical
.value=${this.value?.rows}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
></ha-grid-layout-slider>
${!this.isDefault
? html`
<ha-icon-button
@click=${this._reset}
class="reset"
.path=${mdiRestore}
label=${this.hass.localize(
"ui.components.grid-size-picker.reset_default"
)}
title=${this.hass.localize(
"ui.components.grid-size-picker.reset_default"
)}
>
</ha-icon-button>
`
: nothing}
<div
class="preview"
style=${styleMap({
"--total-rows": this.rows,
"--total-columns": this.columns,
"--rows": this._localValue?.rows,
"--columns": this._localValue?.columns,
})}
>
<div>
${Array(this.rows * this.columns)
.fill(0)
.map((_, index) => {
const row = Math.floor(index / this.columns) + 1;
const column = (index % this.columns) + 1;
const disabled =
(this.rowMin !== undefined && row < this.rowMin) ||
(this.rowMax !== undefined && row > this.rowMax) ||
(this.columnMin !== undefined && column < this.columnMin) ||
(this.columnMax !== undefined && column > this.columnMax);
return html`
<div
class="cell"
data-row=${row}
data-column=${column}
?disabled=${disabled}
@click=${this._cellClick}
></div>
`;
})}
</div>
<div class="selected">
<div class="cell"></div>
</div>
</div>
</div>
`;
}
_cellClick(ev) {
const cell = ev.currentTarget as HTMLElement;
if (cell.getAttribute("disabled") !== null) return;
const rows = Number(cell.getAttribute("data-row"));
const columns = Number(cell.getAttribute("data-column"));
fireEvent(this, "value-changed", {
value: { rows, columns },
});
}
private _valueChanged(ev) {
ev.stopPropagation();
const key = ev.currentTarget.id;
const newValue = {
...this.value,
[key]: ev.detail.value,
};
fireEvent(this, "value-changed", { value: newValue });
}
private _reset(ev) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
rows: undefined,
columns: undefined,
},
});
}
private _sliderMoved(ev) {
ev.stopPropagation();
const key = ev.currentTarget.id;
const value = ev.detail.value;
if (value === undefined) return;
this._localValue = {
...this.value,
[key]: ev.detail.value,
};
}
static styles = [
css`
.grid {
display: grid;
grid-template-areas:
"reset column-slider"
"row-slider preview";
grid-template-rows: auto 1fr;
grid-template-columns: auto 1fr;
gap: 8px;
}
#columns {
grid-area: column-slider;
}
#rows {
grid-area: row-slider;
}
.reset {
grid-area: reset;
}
.preview {
position: relative;
grid-area: preview;
aspect-ratio: 1 / 1;
}
.preview > div {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: grid;
grid-template-columns: repeat(var(--total-columns), 1fr);
grid-template-rows: repeat(var(--total-rows), 1fr);
gap: 4px;
}
.preview .cell {
background-color: var(--disabled-color);
grid-column: span 1;
grid-row: span 1;
border-radius: 4px;
opacity: 0.2;
cursor: pointer;
}
.preview .cell[disabled] {
opacity: 0.05;
cursor: initial;
}
.selected {
pointer-events: none;
}
.selected .cell {
background-color: var(--primary-color);
grid-column: 1 / span var(--columns, 0);
grid-row: 1 / span var(--rows, 0);
opacity: 0.5;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-grid-size-picker": HaGridSizeEditor;
}
}

View File

@ -30,6 +30,7 @@ export interface LovelaceViewElement extends HTMLElement {
export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
preview?: boolean;
viewIndex?: number;
index?: number;
cards?: HuiCard[];

View File

@ -86,6 +86,10 @@ export class HuiCard extends ReactiveElement {
return configOptions;
}
public getElementLayoutOptions(): LovelaceLayoutOptions {
return this._element?.getLayoutOptions?.() ?? {};
}
private _createElement(config: LovelaceCardConfig) {
const element = createCardElement(config);
element.hass = this.hass;
@ -155,7 +159,7 @@ export class HuiCard extends ReactiveElement {
protected willUpdate(
changedProps: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
if (changedProps.has("hass") || changedProps.has("lovelace")) {
if (changedProps.has("hass") || changedProps.has("preview")) {
this._updateVisibility();
}
}

View File

@ -0,0 +1,389 @@
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../../../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"slider-moved": { value?: number };
}
}
const A11Y_KEY_CODES = new Set([
"ArrowRight",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
]);
@customElement("ha-grid-layout-slider")
export class HaGridLayoutSlider extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ type: Boolean, reflect: true })
public vertical = false;
@property({ attribute: "touch-action" })
public touchAction?: string;
@property({ type: Number })
public value?: number;
@property({ type: Number })
public step = 1;
@property({ type: Number })
public min = 1;
@property({ type: Number })
public max = 4;
@property({ type: Number })
public range?: number;
@state()
public pressed = false;
private _mc?: HammerManager;
private get _range() {
return this.range ?? this.max;
}
private _valueToPercentage(value: number) {
const percentage = this._boundedValue(value) / this._range;
return percentage;
}
private _percentageToValue(percentage: number) {
return this._range * percentage;
}
private _steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
}
private _boundedValue(value: number) {
return Math.min(Math.max(value, this.min), this.max);
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.setupListeners();
this.setAttribute("role", "slider");
if (!this.hasAttribute("tabindex")) {
this.setAttribute("tabindex", "0");
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("value")) {
const valuenow = this._steppedValue(this.value ?? 0);
this.setAttribute("aria-valuenow", valuenow.toString());
this.setAttribute("aria-valuetext", valuenow.toString());
}
if (changedProps.has("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
}
if (changedProps.has("max")) {
this.setAttribute("aria-valuemax", this.max.toString());
}
if (changedProps.has("vertical")) {
const orientation = this.vertical ? "vertical" : "horizontal";
this.setAttribute("aria-orientation", orientation);
}
}
connectedCallback(): void {
super.connectedCallback();
this.setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.destroyListeners();
}
@query("#slider")
private slider;
setupListeners() {
if (this.slider && !this._mc) {
this._mc = new Manager(this.slider, {
touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
});
this._mc.add(
new Pan({
threshold: 10,
direction: DIRECTION_ALL,
enable: true,
})
);
this._mc.add(new Tap({ event: "singletap" }));
let savedValue;
this._mc.on("panstart", () => {
if (this.disabled) return;
this.pressed = true;
savedValue = this.value;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this.pressed = false;
this.value = savedValue;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
const percentage = this._getPercentageFromEvent(e);
this.value = this._percentageToValue(percentage);
const value = this._steppedValue(this._boundedValue(this.value));
fireEvent(this, "slider-moved", { value });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
const percentage = this._getPercentageFromEvent(e);
const value = this._percentageToValue(percentage);
this.value = this._steppedValue(this._boundedValue(value));
fireEvent(this, "slider-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this.value });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
const percentage = this._getPercentageFromEvent(e);
const value = this._percentageToValue(percentage);
this.value = this._steppedValue(this._boundedValue(value));
fireEvent(this, "value-changed", { value: this.value });
});
this.addEventListener("keydown", this._handleKeyDown);
this.addEventListener("keyup", this._handleKeyUp);
}
}
destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyUp);
}
private get _tenPercentStep() {
return Math.max(this.step, (this.max - this.min) / 10);
}
_handleKeyDown(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
switch (e.code) {
case "ArrowRight":
case "ArrowUp":
this.value = this._boundedValue((this.value ?? 0) + this.step);
break;
case "ArrowLeft":
case "ArrowDown":
this.value = this._boundedValue((this.value ?? 0) - this.step);
break;
case "PageUp":
this.value = this._steppedValue(
this._boundedValue((this.value ?? 0) + this._tenPercentStep)
);
break;
case "PageDown":
this.value = this._steppedValue(
this._boundedValue((this.value ?? 0) - this._tenPercentStep)
);
break;
case "Home":
this.value = this.min;
break;
case "End":
this.value = this.max;
break;
}
fireEvent(this, "slider-moved", { value: this.value });
}
_handleKeyUp(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
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, (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`
<div
class="container${classMap({
pressed: this.pressed,
})}"
style=${styleMap({
"--value": `${this._valueToPercentage(this.value ?? 0)}`,
})}
>
<div id="slider" class="slider">
<div class="track">
<div class="background">
<div
class="active"
style=${styleMap({
"--min": `${this.min / this._range}`,
"--max": `${1 - this.max / this._range}`,
})}
></div>
</div>
</div>
${this.value !== undefined
? html`<div class="handle"></div>`
: nothing}
</div>
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
--grid-layout-slider: 48px;
height: var(--grid-layout-slider);
width: 100%;
outline: none;
transition: box-shadow 180ms ease-in-out;
}
:host(:focus-visible) {
box-shadow: 0 0 0 2px var(--primary-color);
}
:host([vertical]) {
width: var(--grid-layout-slider);
height: 100%;
}
.container {
position: relative;
height: 100%;
width: 100%;
}
.slider {
position: relative;
height: 100%;
width: 100%;
transform: translateZ(0);
overflow: visible;
cursor: pointer;
}
.slider * {
pointer-events: none;
}
.track {
position: absolute;
inset: 0;
margin: auto;
height: 16px;
width: 100%;
border-radius: 8px;
overflow: hidden;
}
:host([vertical]) .track {
width: 16px;
height: 100%;
}
.background {
position: absolute;
inset: 0;
background: var(--disabled-color);
opacity: 0.5;
}
.active {
position: absolute;
background: grey;
top: 0;
right: calc(var(--max) * 100%);
bottom: 0;
left: calc(var(--min) * 100%);
}
:host([vertical]) .active {
top: calc(var(--min) * 100%);
right: 0;
bottom: calc(var(--max) * 100%);
left: 0;
}
.handle {
position: absolute;
top: 0;
height: 100%;
width: 16px;
transform: translate(-50%, 0);
background: var(--card-background-color);
left: calc(var(--value, 0%) * 100%);
transition:
left 180ms ease-in-out,
top 180ms ease-in-out;
}
:host([vertical]) .handle {
transform: translate(0, -50%);
left: 0;
top: calc(var(--value, 0%) * 100%);
height: 16px;
width: 100%;
}
.handle::after {
position: absolute;
inset: 0;
width: 4px;
border-radius: 2px;
height: 100%;
margin: auto;
background: grey;
content: "";
}
:host([vertical]) .handle::after {
height: 4px;
width: 100%;
}
:host(:disabled) .slider {
cursor: not-allowed;
}
.pressed .handle {
transition: none;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-grid-layout-slider": HaGridLayoutSlider;
}
}

View File

@ -6,17 +6,21 @@ import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { getCardElementClass } from "../../create-element/create-card-element";
import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import "./hui-card-layout-editor";
import "./hui-card-visibility-editor";
const TABS = ["config", "visibility"] as const;
type Tab = "config" | "visibility" | "layout";
@customElement("hui-card-element-editor")
export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
@state() private _curTab: (typeof TABS)[number] = TABS[0];
@state() private _curTab: Tab = "config";
@property({ type: Boolean, attribute: "show-visibility-tab" })
public showVisibilityTab = false;
@property({ type: Boolean, attribute: "show-layout-tab" })
public showLayoutTab = false;
protected async getConfigElement(): Promise<LovelaceCardEditor | undefined> {
const elClass = await getCardElementClass(this.configElementType!);
@ -52,7 +56,11 @@ export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
}
protected renderConfigElement(): TemplateResult {
if (!this.showVisibilityTab) return super.renderConfigElement();
const displayedTabs: Tab[] = ["config"];
if (this.showVisibilityTab) displayedTabs.push("visibility");
if (this.showLayoutTab) displayedTabs.push("layout");
if (displayedTabs.length === 1) return super.renderConfigElement();
let content: TemplateResult<1> | typeof nothing = nothing;
@ -69,19 +77,28 @@ export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
></hui-card-visibility-editor>
`;
break;
case "layout":
content = html`
<hui-card-layout-editor
.hass=${this.hass}
.config=${this.value}
@value-changed=${this._configChanged}
>
</hui-card-layout-editor>
`;
}
return html`
<paper-tabs
scrollable
hide-scroll-buttons
.selected=${TABS.indexOf(this._curTab)}
.selected=${displayedTabs.indexOf(this._curTab)}
@selected-item-changed=${this._handleTabSelected}
>
${TABS.map(
${displayedTabs.map(
(tab, index) => html`
<paper-tab id=${tab} .dialogInitialFocus=${index === 0}>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_card.tab-${tab}`
`ui.panel.lovelace.editor.edit_card.tab_${tab}`
)}
</paper-tab>
`

View File

@ -0,0 +1,266 @@
import type { ActionDetail } from "@material/mwc-list";
import { mdiCheck, mdiDotsVertical } from "@mdi/js";
import { LitElement, PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-button";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-grid-size-picker";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-slider";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { HuiCard } from "../../cards/hui-card";
import { DEFAULT_GRID_OPTIONS } from "../../sections/hui-grid-section";
import { LovelaceLayoutOptions } from "../../types";
@customElement("hui-card-layout-editor")
export class HuiCardLayoutEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: LovelaceCardConfig;
@state() _defaultLayoutOptions?: LovelaceLayoutOptions;
@state() public _yamlMode = false;
@state() public _uiAvailable = true;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
private _cardElement?: HuiCard;
private _gridSizeValue = memoizeOne(
(
options?: LovelaceLayoutOptions,
defaultOptions?: LovelaceLayoutOptions
) => ({
rows:
options?.grid_rows ??
defaultOptions?.grid_rows ??
DEFAULT_GRID_OPTIONS.grid_rows,
columns:
options?.grid_columns ??
defaultOptions?.grid_columns ??
DEFAULT_GRID_OPTIONS.grid_columns,
})
);
private _isDefault = memoizeOne(
(options?: LovelaceLayoutOptions) =>
options?.grid_columns === undefined && options?.grid_rows === undefined
);
render() {
return html`
<div class="header">
<p class="intro">
${this.hass.localize(
`ui.panel.lovelace.editor.edit_card.layout.explanation`
)}
</p>
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@closed=${stopPropagation}
fixed
.corner=${"BOTTOM_END"}
.menuCorner=${"END"}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
>
</ha-icon-button>
<ha-list-item graphic="icon" .disabled=${!this._uiAvailable}>
${this.hass.localize("ui.panel.lovelace.editor.edit_card.edit_ui")}
${!this._yamlMode
? html`
<ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon>
`
: nothing}
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.edit_yaml"
)}
${this._yamlMode
? html`
<ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon>
`
: nothing}
</ha-list-item>
</ha-button-menu>
</div>
${this._yamlMode
? html`
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.config.layout_options}
@value-changed=${this._valueChanged}
></ha-yaml-editor>
`
: html`
<ha-grid-size-picker
.hass=${this.hass}
.value=${this._gridSizeValue(
this.config.layout_options,
this._defaultLayoutOptions
)}
.isDefault=${this._isDefault(this.config.layout_options)}
@value-changed=${this._gridSizeChanged}
></ha-grid-size-picker>
`}
`;
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
try {
this._cardElement = document.createElement("hui-card");
this._cardElement.hass = this.hass;
this._cardElement.preview = true;
this._cardElement.config = this.config;
this._cardElement.addEventListener("card-updated", (ev: Event) => {
ev.stopPropagation();
this._defaultLayoutOptions =
this._cardElement?.getElementLayoutOptions();
});
this._defaultLayoutOptions = this._cardElement.getElementLayoutOptions();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
}
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (this._cardElement) {
if (changedProps.has("hass")) {
this._cardElement.hass = this.hass;
}
if (changedProps.has("config")) {
this._cardElement.config = this.config;
}
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._yamlMode = false;
break;
case 1:
this._yamlMode = true;
break;
case 2:
this._reset();
break;
}
}
private async _reset() {
const newConfig = { ...this.config };
delete newConfig.layout_options;
this._yamlEditor?.setValue({});
fireEvent(this, "value-changed", { value: newConfig });
}
private _gridSizeChanged(ev: CustomEvent): void {
ev.stopPropagation();
const value = ev.detail.value;
const newConfig: LovelaceCardConfig = {
...this.config,
layout_options: {
...this.config.layout_options,
grid_columns: value.columns,
grid_rows: value.rows,
},
};
if (newConfig.layout_options!.grid_columns === undefined) {
delete newConfig.layout_options!.grid_columns;
}
if (newConfig.layout_options!.grid_rows === undefined) {
delete newConfig.layout_options!.grid_rows;
}
if (Object.keys(newConfig.layout_options!).length === 0) {
delete newConfig.layout_options;
}
fireEvent(this, "value-changed", { value: newConfig });
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const options = ev.detail.value as LovelaceLayoutOptions;
const newConfig: LovelaceCardConfig = {
...this.config,
layout_options: options,
};
fireEvent(this, "value-changed", { value: newConfig });
}
static styles = [
haStyle,
css`
.header {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.header .intro {
flex: 1;
margin: 0;
color: var(--secondary-text-color);
}
.header ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
margin-top: -8px;
}
.selected_menu_item {
color: var(--primary-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
ha-grid-size-editor {
display: block;
max-width: 250px;
margin: 16px auto;
}
ha-yaml-editor {
display: block;
margin: 16px 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-card-layout-editor": HuiCardLayoutEditor;
}
}

View File

@ -1,4 +1,4 @@
import { LitElement, html } from "lit";
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
@ -16,11 +16,11 @@ export class HuiCardVisibilityEditor extends LitElement {
render() {
const conditions = this.config.visibility ?? [];
return html`
<ha-alert alert-type="info">
<p class="intro">
${this.hass.localize(
`ui.panel.lovelace.editor.edit_card.visibility.explanation`
)}
</ha-alert>
</p>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${conditions}
@ -42,6 +42,14 @@ export class HuiCardVisibilityEditor extends LitElement {
}
fireEvent(this, "value-changed", { value: newConfig });
}
static styles = css`
.intro {
margin: 0;
color: var(--secondary-text-color);
margin-bottom: 8px;
}
`;
}
declare global {

View File

@ -236,6 +236,7 @@ export class HuiDialogEditCard
<div class="content">
<div class="element-editor">
<hui-card-element-editor
.showLayoutTab=${this._shouldShowLayoutTab()}
.showVisibilityTab=${this._cardConfig?.type !== "conditional"}
.hass=${this.hass}
.lovelace=${this._params.lovelaceConfig}
@ -352,6 +353,18 @@ export class HuiDialogEditCard
return this._params!.path.length === 2;
}
private _shouldShowLayoutTab(): boolean {
/**
* Only show layout tab for cards in a grid section
* In the future, every section and view should be able to bring their own editor for layout.
* For now, we limit it to grid sections as it's the only section type
* */
return (
this._isInSection &&
(!this._containerConfig.type || this._containerConfig.type === "grid")
);
}
private _cardConfigInSection = memoizeOne(
(cardConfig?: LovelaceCardConfig) => {
const { cards, title, ...containerConfig } = this

View File

@ -1,4 +1,3 @@
import { preventDefault } from "@fullcalendar/core/internal";
import { ActionDetail } from "@material/mwc-list";
import { mdiCheck, mdiDelete, mdiDotsVertical, mdiFlask } from "@mdi/js";
import { LitElement, PropertyValues, css, html, nothing } from "lit";
@ -6,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";

View File

@ -14,7 +14,7 @@ import type { HomeAssistant } from "../../../types";
import { HuiCard } from "../cards/hui-card";
import "../components/hui-card-edit-mode";
import { moveCard } from "../editor/config-util";
import type { Lovelace } from "../types";
import type { Lovelace, LovelaceLayoutOptions } from "../types";
const CARD_SORTABLE_OPTIONS: HaSortableOptions = {
delay: 100,
@ -23,6 +23,11 @@ const CARD_SORTABLE_OPTIONS: HaSortableOptions = {
invertedSwapThreshold: 0.7,
} as HaSortableOptions;
export const DEFAULT_GRID_OPTIONS: LovelaceLayoutOptions = {
grid_columns: 4,
grid_rows: 1,
};
export class GridSection extends LitElement implements LovelaceSectionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -95,11 +100,15 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
const card = this.cards![idx];
const layoutOptions = card.getLayoutOptions();
const columnSize =
layoutOptions.grid_columns ?? DEFAULT_GRID_OPTIONS.grid_columns;
const rowSize =
layoutOptions.grid_rows ?? DEFAULT_GRID_OPTIONS.grid_rows;
return html`
<div
style=${styleMap({
"--column-size": layoutOptions.grid_columns,
"--row-size": layoutOptions.grid_rows,
"--column-size": columnSize,
"--row-size": rowSize,
})}
class="card ${classMap({
"fit-rows": typeof layoutOptions?.grid_rows === "number",
@ -216,8 +225,8 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
.card {
border-radius: var(--ha-card-border-radius, 12px);
position: relative;
grid-row: span var(--row-size, 1);
grid-column: span var(--column-size, 4);
grid-row: span var(--row-size);
grid-column: span var(--column-size);
}
.card.fit-rows {

View File

@ -122,6 +122,7 @@ export class HuiSection extends ReactiveElement {
this._layoutElement.lovelace = this.lovelace;
}
if (changedProperties.has("preview")) {
this._layoutElement.preview = this.preview;
this._cards.forEach((element) => {
element.preview = this.preview;
});
@ -129,7 +130,7 @@ export class HuiSection extends ReactiveElement {
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
}
if (changedProperties.has("hass") || changedProperties.has("lovelace")) {
if (changedProperties.has("hass") || changedProperties.has("preview")) {
this._updateElement();
}
}

View File

@ -210,6 +210,7 @@ export class HUIView extends ReactiveElement {
try {
element.hass = this.hass;
element.lovelace = this.lovelace;
element.preview = this.lovelace.editMode;
} catch (e: any) {
this._rebuildSection(element, createErrorSectionConfig(e.message));
}

View File

@ -741,6 +741,11 @@
"last_year": "Last year"
}
},
"grid-size-picker": {
"reset_default": "Reset to default size",
"columns": "Number of columns",
"rows": "Number of rows"
},
"relative_time": {
"never": "Never"
},
@ -5507,10 +5512,14 @@
"increase_position": "Increase card position",
"options": "More options",
"search_cards": "Search cards",
"tab-config": "Config",
"tab-visibility": "Visibility",
"tab_config": "Config",
"tab_visibility": "Visibility",
"tab_layout": "Layout",
"visibility": {
"explanation": "The card will be shown when ALL conditions below are fulfilled. If no conditions are set, the card will always be shown."
},
"layout": {
"explanation": "Configure how the card will appear on the dashboard. This settings will override the default size and position of the card."
}
},
"move_card": {