Improve grid size editor (#22697)

* Use table instead of grid

* Add animation

* Change size

* Simplify precideMode logic

* Add tooltip and improve slider style

* Improve size

* Back to default instead of min

* Limit number of cells for the grid when more than 24 cells
This commit is contained in:
Paul Bottein 2024-11-18 09:43:52 +01:00 committed by GitHub
parent 1990b8fa84
commit 23b55484c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 273 additions and 116 deletions

View File

@ -2,9 +2,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import "./ha-icon-button";
import { mdiRestore } from "@mdi/js";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
@ -20,7 +18,7 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public rows = 8;
@property({ attribute: false }) public columns = 4;
@property({ attribute: false }) public columns = 12;
@property({ attribute: false }) public rowMin?: number;
@ -32,6 +30,8 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public isDefault?: boolean;
@property({ attribute: false }) public step: number = 1;
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) {
@ -51,8 +51,9 @@ export class HaGridSizeEditor extends LitElement {
const rowMin = this.rowMin ?? 1;
const rowMax = this.rowMax ?? this.rows;
const columnMin = this.columnMin ?? 1;
const columnMax = this.columnMax ?? this.columns;
const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;
const columnMax =
Math.ceil((this.columnMax ?? this.columns) / this.step) * this.step;
const rowValue = autoHeight ? rowMin : this._localValue?.rows;
const columnValue = this._localValue?.columns;
@ -67,9 +68,11 @@ export class HaGridSizeEditor extends LitElement {
.max=${columnMax}
.range=${this.columns}
.value=${fullWidth ? this.columns : this.value?.columns}
.step=${this.step}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledColumns}
tooltip-mode="always"
></ha-grid-layout-slider>
<ha-grid-layout-slider
@ -85,6 +88,7 @@ export class HaGridSizeEditor extends LitElement {
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledRows}
tooltip-mode="always"
></ha-grid-layout-slider>
${!this.isDefault
? html`
@ -102,34 +106,44 @@ export class HaGridSizeEditor extends LitElement {
</ha-icon-button>
`
: nothing}
<div
class="preview ${classMap({ "full-width": fullWidth })}"
style=${styleMap({
"--total-rows": this.rows,
"--total-columns": this.columns,
"--rows": rowValue,
"--columns": fullWidth ? this.columns : columnValue,
})}
>
<div>
${Array(this.rows * this.columns)
<div class="preview">
<table>
${Array(this.rows)
.fill(0)
.map((_, index) => {
const row = Math.floor(index / this.columns) + 1;
const column = (index % this.columns) + 1;
const row = index + 1;
return html`
<div
class="cell"
data-row=${row}
data-column=${column}
@click=${this._cellClick}
></div>
<tr>
${Array(this.columns)
.fill(0)
.map((__, columnIndex) => {
const column = columnIndex + 1;
if (
column % this.step !== 0 ||
(this.columns > 24 && column % 3 !== 0)
) {
return nothing;
}
return html`
<td
data-row=${row}
data-column=${column}
@click=${this._cellClick}
></td>
`;
})}
</tr>
`;
})}
</div>
<div class="selected">
<div class="cell"></div>
</div>
</table>
<div
class="preview-card"
style=${styleMap({
"--rows": rowValue,
"--columns": fullWidth ? this.columns : columnValue,
"--total-columns": this.columns,
})}
></div>
</div>
</div>
`;
@ -223,42 +237,40 @@ export class HaGridSizeEditor extends LitElement {
}
.reset {
grid-area: reset;
--mdc-icon-button-size: 36px;
}
.preview {
position: relative;
grid-area: preview;
}
.preview > div {
position: relative;
display: grid;
grid-template-columns: repeat(var(--total-columns), 1fr);
grid-template-rows: repeat(var(--total-rows), 25px);
gap: 4px;
.preview table,
.preview tr,
.preview td {
border: 2px dotted var(--divider-color);
border-collapse: collapse;
}
.preview .cell {
background-color: var(--disabled-color);
grid-column: span 1;
grid-row: span 1;
border-radius: 4px;
opacity: 0.2;
cursor: pointer;
}
.preview .selected {
position: absolute;
pointer-events: none;
top: 0;
left: 0;
height: 100%;
.preview table {
width: 100%;
}
.selected .cell {
background-color: var(--primary-color);
grid-column: 1 / span min(var(--columns, 0), var(--total-columns));
grid-row: 1 / span min(var(--rows, 0), var(--total-rows));
opacity: 0.5;
.preview tr {
height: 30px;
}
.preview.full-width .selected .cell {
grid-column: 1 / -1;
.preview td {
cursor: pointer;
}
.preview-card {
position: absolute;
top: 0;
left: 0;
background-color: var(--primary-color);
opacity: 0.3;
border-radius: 8px;
height: calc(var(--rows, 1) * 30px);
width: calc(var(--columns, 1) * 100% / var(--total-columns, 12));
pointer-events: none;
transition:
width ease-in-out 180ms,
height ease-in-out 180ms;
}
`,
];

View File

@ -3,16 +3,11 @@ import type { LovelaceGridOptions, LovelaceLayoutOptions } from "../types";
export const GRID_COLUMN_MULTIPLIER = 3;
export const multiplyBy = <T extends number | string | undefined>(
const multiplyBy = <T extends number | string | undefined>(
value: T,
multiplier: number
): T => (typeof value === "number" ? ((value * multiplier) as T) : value);
export const divideBy = <T extends number | string | undefined>(
value: T,
divider: number
): T => (typeof value === "number" ? (Math.ceil(value / divider) as T) : value);
export const migrateLayoutToGridOptions = (
options: LovelaceLayoutOptions
): LovelaceGridOptions => {
@ -42,6 +37,9 @@ export type CardGridSize = {
columns: number | "full";
};
export const isPreciseMode = (options: LovelaceGridOptions) =>
typeof options.columns === "number" && options.columns % 3 !== 0;
export const computeCardGridSize = (
options: LovelaceGridOptions
): CardGridSize => {

View File

@ -23,6 +23,8 @@ const A11Y_KEY_CODES = new Set([
"End",
]);
type TooltipMode = "never" | "always" | "interaction";
@customElement("ha-grid-layout-slider")
export class HaGridLayoutSlider extends LitElement {
@property({ type: Boolean, reflect: true })
@ -34,6 +36,9 @@ export class HaGridLayoutSlider extends LitElement {
@property({ attribute: "touch-action" })
public touchAction?: string;
@property({ attribute: "tooltip-mode" })
public tooltipMode: TooltipMode = "interaction";
@property({ type: Number })
public value?: number;
@ -52,6 +57,9 @@ export class HaGridLayoutSlider extends LitElement {
@state()
public pressed = false;
@state()
public tooltipVisible = false;
private _mc?: HammerManager;
private get _range() {
@ -135,11 +143,13 @@ export class HaGridLayoutSlider extends LitElement {
this._mc.on("panstart", () => {
if (this.disabled) return;
this.pressed = true;
this._showTooltip();
savedValue = this.value;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this.pressed = false;
this._hideTooltip();
this.value = savedValue;
});
this._mc.on("panmove", (e) => {
@ -152,6 +162,7 @@ export class HaGridLayoutSlider extends LitElement {
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
this._hideTooltip();
const percentage = this._getPercentageFromEvent(e);
const value = this._percentageToValue(percentage);
this.value = this._steppedValue(this._boundedValue(value));
@ -223,6 +234,23 @@ export class HaGridLayoutSlider extends LitElement {
fireEvent(this, "value-changed", { value: this.value });
}
private _tooltipTimeout?: number;
_showTooltip() {
if (this._tooltipTimeout != null) window.clearTimeout(this._tooltipTimeout);
this.tooltipVisible = true;
}
_hideTooltip(delay?: number) {
if (!delay) {
this.tooltipVisible = false;
return;
}
this._tooltipTimeout = window.setTimeout(() => {
this.tooltipVisible = false;
}, delay);
}
private _getPercentageFromEvent = (e: HammerInput) => {
if (this.vertical) {
const y = e.center.y;
@ -236,6 +264,30 @@ export class HaGridLayoutSlider extends LitElement {
return Math.max(Math.min(1, (x - offset) / total), 0);
};
private _renderTooltip() {
if (this.tooltipMode === "never") return nothing;
const position = this.vertical ? "left" : "top";
const visible =
this.tooltipMode === "always" ||
(this.tooltipVisible && this.tooltipMode === "interaction");
const value = this._boundedValue(this._steppedValue(this.value ?? 0));
return html`
<span
aria-hidden="true"
class="tooltip ${classMap({
visible,
[position]: true,
})}"
>
${value}
</span>
`;
}
protected render(): TemplateResult {
return html`
<div
@ -257,9 +309,29 @@ export class HaGridLayoutSlider extends LitElement {
})}
></div>
</div>
${Array(this._range / this.step)
.fill(0)
.map((_, i) => {
const percentage = i / (this._range / this.step);
const disabled =
this.min >= i * this.step || i * this.step > this.max;
if (disabled) {
return nothing;
}
return html`
<div
class="dot"
style=${styleMap({
"--value": `${percentage}`,
})}
></div>
`;
})}
${this.value !== undefined
? html`<div class="handle"></div>`
: nothing}
${this._renderTooltip()}
</div>
</div>
`;
@ -269,7 +341,7 @@ export class HaGridLayoutSlider extends LitElement {
return css`
:host {
display: block;
--grid-layout-slider: 48px;
--grid-layout-slider: 36px;
height: var(--grid-layout-slider);
width: 100%;
outline: none;
@ -297,6 +369,7 @@ export class HaGridLayoutSlider extends LitElement {
}
.slider * {
pointer-events: none;
user-select: none;
}
.track {
position: absolute;
@ -315,12 +388,11 @@ export class HaGridLayoutSlider extends LitElement {
position: absolute;
inset: 0;
background: var(--disabled-color);
opacity: 0.2;
opacity: 0.4;
}
.active {
position: absolute;
background: grey;
opacity: 0.7;
background: var(--primary-color);
top: 0;
right: calc(var(--max) * 100%);
bottom: 0;
@ -351,6 +423,27 @@ export class HaGridLayoutSlider extends LitElement {
height: 16px;
width: 100%;
}
.dot {
position: absolute;
top: 0;
bottom: 0;
opacity: 0.6;
margin: auto;
width: 4px;
height: 4px;
flex-shrink: 0;
transform: translate(-50%, 0);
background: var(--card-background-color);
left: calc(var(--value, 0%) * 100%);
border-radius: 2px;
}
:host([vertical]) .dot {
transform: translate(0, -50%);
left: 0;
right: 0;
bottom: inherit;
top: calc(var(--value, 0%) * 100%);
}
.handle::after {
position: absolute;
inset: 0;
@ -358,7 +451,7 @@ export class HaGridLayoutSlider extends LitElement {
border-radius: 2px;
height: 100%;
margin: auto;
background: grey;
background: var(--primary-color);
content: "";
}
:host([vertical]) .handle::after {
@ -374,9 +467,88 @@ export class HaGridLayoutSlider extends LitElement {
:host(:disabled) .active {
background: var(--disabled-color);
}
.tooltip {
position: absolute;
background-color: var(--clear-background-color);
color: var(--primary-text-color);
font-size: var(--control-slider-tooltip-font-size);
border-radius: 0.8em;
padding: 0.2em 0.4em;
opacity: 0;
white-space: nowrap;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
transition:
opacity 180ms ease-in-out,
left 180ms ease-in-out,
bottom 180ms ease-in-out;
--handle-spacing: calc(2 * var(--handle-margin) + var(--handle-size));
--slider-tooltip-margin: 0px;
--slider-tooltip-range: 100%;
--slider-tooltip-offset: 0px;
--slider-tooltip-position: calc(
min(
max(
var(--value) * var(--slider-tooltip-range) +
var(--slider-tooltip-offset),
0%
),
100%
)
);
}
.tooltip.start {
--slider-tooltip-offset: calc(-0.5 * (var(--handle-spacing)));
}
.tooltip.end {
--slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
}
.tooltip.cursor {
--slider-tooltip-range: calc(100% - var(--handle-spacing));
--slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
}
.tooltip.show-handle {
--slider-tooltip-range: calc(100% - var(--handle-spacing));
--slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
}
.tooltip.visible {
opacity: 1;
}
.tooltip.top {
transform: translate3d(-50%, -100%, 0);
top: var(--slider-tooltip-margin);
left: 50%;
}
.tooltip.bottom {
transform: translate3d(-50%, 100%, 0);
bottom: var(--slider-tooltip-margin);
left: 50%;
}
.tooltip.left {
transform: translate3d(-100%, -50%, 0);
top: 50%;
left: var(--slider-tooltip-margin);
}
.tooltip.right {
transform: translate3d(100%, -50%, 0);
top: 50%;
right: var(--slider-tooltip-margin);
}
:host(:not([vertical])) .tooltip.top,
:host(:not([vertical])) .tooltip.bottom {
left: var(--slider-tooltip-position);
}
:host([vertical]) .tooltip.right,
:host([vertical]) .tooltip.left {
top: var(--slider-tooltip-position);
}
.pressed .handle {
transition: none;
}
.pressed .tooltip {
transition: opacity 180ms ease-in-out;
}
`;
}
}

View File

@ -26,16 +26,12 @@ import type { HuiCard } from "../../cards/hui-card";
import type { CardGridSize } from "../../common/compute-card-grid-size";
import {
computeCardGridSize,
divideBy,
GRID_COLUMN_MULTIPLIER,
isPreciseMode,
migrateLayoutToGridOptions,
multiplyBy,
} from "../../common/compute-card-grid-size";
import type { LovelaceGridOptions } from "../../types";
const computePreciseMode = (columns?: number | string) =>
typeof columns === "number" && columns % 3 !== 0;
@customElement("hui-card-layout-editor")
export class HuiCardLayoutEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -63,22 +59,6 @@ export class HuiCardLayoutEditor extends LitElement {
private _computeCardGridSize = memoizeOne(computeCardGridSize);
private _simplifyOptions = (
options: LovelaceGridOptions
): LovelaceGridOptions => ({
...options,
columns: divideBy(options.columns, GRID_COLUMN_MULTIPLIER),
max_columns: divideBy(options.max_columns, GRID_COLUMN_MULTIPLIER),
min_columns: divideBy(options.min_columns, GRID_COLUMN_MULTIPLIER),
});
private _standardizeOptions = (options: LovelaceGridOptions) => ({
...options,
columns: multiplyBy(options.columns, GRID_COLUMN_MULTIPLIER),
max_columns: multiplyBy(options.max_columns, GRID_COLUMN_MULTIPLIER),
min_columns: multiplyBy(options.min_columns, GRID_COLUMN_MULTIPLIER),
});
private _isDefault = memoizeOne(
(options?: LovelaceGridOptions) =>
options?.columns === undefined && options?.rows === undefined
@ -101,14 +81,11 @@ export class HuiCardLayoutEditor extends LitElement {
this._defaultGridOptions
);
const gridOptions = this._preciseMode
? options
: this._simplifyOptions(options);
const gridOptions = options;
const gridValue = this._computeCardGridSize(gridOptions);
const columnSpan = this.sectionConfig.column_span ?? 1;
const gridTotalColumns =
(12 * columnSpan) / (this._preciseMode ? 1 : GRID_COLUMN_MULTIPLIER);
const gridTotalColumns = 12 * columnSpan;
return html`
<div class="header">
@ -173,7 +150,7 @@ export class HuiCardLayoutEditor extends LitElement {
: html`
<ha-grid-size-picker
style=${styleMap({
"max-width": `${(this.sectionConfig.column_span ?? 1) * 200 + 50}px`,
"max-width": `${(this.sectionConfig.column_span ?? 1) * 250 + 40}px`,
})}
.columns=${gridTotalColumns}
.hass=${this.hass}
@ -184,6 +161,7 @@ export class HuiCardLayoutEditor extends LitElement {
.rowMax=${gridOptions.max_rows}
.columnMin=${gridOptions.min_columns}
.columnMax=${gridOptions.max_columns}
.step=${this._preciseMode ? 1 : GRID_COLUMN_MULTIPLIER}
></ha-grid-size-picker>
<ha-settings-row>
<span slot="heading" data-for="full-width">
@ -267,17 +245,21 @@ export class HuiCardLayoutEditor extends LitElement {
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("config")) {
const columns = this.config.grid_options?.columns;
const preciseMode = computePreciseMode(columns);
const options = this.config.grid_options;
// Reset precise mode when grid options config is reset
if (!options) {
this._preciseMode = this._defaultGridOptions
? isPreciseMode(this._defaultGridOptions)
: false;
return;
}
// Force precise mode if columns count is not a multiple of 3
const preciseMode = isPreciseMode(options);
if (!this._preciseMode && preciseMode) {
this._preciseMode = preciseMode;
}
// Reset precise mode when grid options config is reset
if (columns === undefined) {
const defaultColumns = this._defaultGridOptions?.columns;
this._preciseMode = computePreciseMode(defaultColumns);
}
}
}
@ -296,18 +278,10 @@ export class HuiCardLayoutEditor extends LitElement {
ev.stopPropagation();
const value = ev.detail.value as CardGridSize;
const gridOptions = {
columns: value.columns,
rows: value.rows,
};
const newOptions = this._preciseMode
? gridOptions
: this._standardizeOptions(gridOptions);
this._updateGridOptions({
...this.config.grid_options,
...newOptions,
columns: value.columns,
rows: value.rows,
});
}
@ -322,7 +296,7 @@ export class HuiCardLayoutEditor extends LitElement {
const value = ev.target.checked;
this._updateGridOptions({
...this.config.grid_options,
columns: value ? "full" : (this._defaultGridOptions?.min_columns ?? 1),
columns: value ? "full" : undefined,
});
}
@ -331,13 +305,14 @@ export class HuiCardLayoutEditor extends LitElement {
this._preciseMode = ev.target.checked;
if (this._preciseMode) return;
const newOptions = this._standardizeOptions(
this._simplifyOptions(this.config.grid_options ?? {})
);
if (newOptions.columns !== this.config.grid_options?.columns) {
const columns = this.config.grid_options?.columns;
if (typeof columns === "number" && columns % GRID_COLUMN_MULTIPLIER !== 0) {
const newColumns =
Math.ceil(columns / GRID_COLUMN_MULTIPLIER) * GRID_COLUMN_MULTIPLIER;
this._updateGridOptions({
...this.config.grid_options,
columns: newOptions.columns,
columns: newColumns,
});
}
}