diff --git a/src/components/ha-grid-size-picker.ts b/src/components/ha-grid-size-picker.ts index f3b5607c21..49a293e33d 100644 --- a/src/components/ha-grid-size-picker.ts +++ b/src/components/ha-grid-size-picker.ts @@ -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" > ${!this.isDefault ? html` @@ -102,34 +106,44 @@ export class HaGridSizeEditor extends LitElement { ` : nothing} -
-
- ${Array(this.rows * this.columns) +
+ + ${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` -
+ + ${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` + + `; + })} + `; })} - -
-
-
+
+
`; @@ -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; } `, ]; diff --git a/src/panels/lovelace/common/compute-card-grid-size.ts b/src/panels/lovelace/common/compute-card-grid-size.ts index cc0be5ac76..49bc4946ca 100644 --- a/src/panels/lovelace/common/compute-card-grid-size.ts +++ b/src/panels/lovelace/common/compute-card-grid-size.ts @@ -3,16 +3,11 @@ import type { LovelaceGridOptions, LovelaceLayoutOptions } from "../types"; export const GRID_COLUMN_MULTIPLIER = 3; -export const multiplyBy = ( +const multiplyBy = ( value: T, multiplier: number ): T => (typeof value === "number" ? ((value * multiplier) as T) : value); -export const divideBy = ( - 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 => { diff --git a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts index 206436ce95..e133be40c1 100644 --- a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts +++ b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts @@ -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` + + `; + } + protected render(): TemplateResult { return html`
+ + ${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` +
+ `; + })} ${this.value !== undefined ? html`
` : nothing} + ${this._renderTooltip()} `; @@ -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; + } `; } } diff --git a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts index 494dcd9edb..a79e6eeccd 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts @@ -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`
@@ -173,7 +150,7 @@ export class HuiCardLayoutEditor extends LitElement { : html` @@ -267,17 +245,21 @@ export class HuiCardLayoutEditor extends LitElement { protected willUpdate(changedProps: PropertyValues): 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, }); } }