mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-21 08:16:36 +00:00
Resize card editor (#21115)
This commit is contained in:
parent
6a3041988a
commit
321a085c0e
1
src/common/dom/prevent_default.ts
Normal file
1
src/common/dom/prevent_default.ts
Normal file
@ -0,0 +1 @@
|
||||
export const preventDefault = (ev) => ev.preventDefault();
|
233
src/components/ha-grid-size-picker.ts
Normal file
233
src/components/ha-grid-size-picker.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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[];
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
389
src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
Normal file
389
src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
`
|
||||
|
266
src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
Normal file
266
src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user