Compare commits

..
Author SHA1 Message Date
Paul Bottein ef626c08c3 Add the device section to the section picker 2026-10-23 16:23:21 +02:00
Paul Bottein 2d3d59b47f Clear the section editor changes on close and fit strategy tiles on small screens 2026-10-23 13:49:19 +02:00
Paul Bottein 58fc2b41c9 Add a built-in sections heading when custom section strategies are listed 2026-10-23 13:49:19 +02:00
Paul Bottein 6857dc1aae Add a dialog to choose the type of section to create 2026-10-23 13:49:19 +02:00
Paul Bottein 734bc63100 Add a preview and a clearer width setting to the section editor (#54961)
* Add a preview and a clearer width setting to the section editor

* Use early returns in the section editor

* Limit the section preview to the view width and reset a cleared width
2026-10-23 13:49:14 +02:00
54 changed files with 1313 additions and 731 deletions

No files matched your search

-84
View File
@@ -1,84 +0,0 @@
# Anchored popovers for confirmations and simple forms
Concept branch: `anchored-confirmations`. Not for merging as it is. It exists to try the idea in real screens and to drive a UX design discussion.
## The idea
When a confirmation or a small form opens from a specific button, show it as a popover next to that button on larger screens, instead of a centred dialog. On small screens it becomes a bottom sheet. Confirmations opened without a button, such as after a save, keep today's dialog.
## What is in the branch
The branch sits on top of the data table group header actions (#55235), which added the first example.
### Shared pieces
- `showConfirmationDialog`, `showAlertDialog` and `showPromptDialog` take an optional `anchor`. `dialog-box` renders `ha-adaptive-popover` when it has one, and `ha-dialog` otherwise.
- `showFormDialog` takes the same `anchor`, and `dialog-form` follows the same rule.
- `ha-adaptive-popover` already switched to a bottom sheet below 870px wide or 500px tall. It now has:
- `compact`, with tighter spacing for short content.
- `accessibleLabel`, for when the title isn't the popover's own header.
- `--ha-adaptive-popover-min-width` and `--ha-adaptive-popover-width`.
- `ha-dialog-header` takes `--ha-dialog-header-content-padding` and `--ha-dialog-header-min-height`. Both default to today's values.
- Confirmations with only a title show it as the message, without a header, so they don't read as a heading with nothing under it.
### Where it is used
| Where | Opened from | Type |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------- |
| Marketplace | New group header action | Confirmation |
| Dashboard edit mode | Section menu, Delete | Confirmation, destructive |
| Energy settings | Delete on grid, solar, battery, gas and water sources, individual devices, and the CO2 integration | Confirmation |
| Dashboard resources | Delete | Confirmation, destructive |
| Apps | Remove repository, remove registry | Confirmation, destructive |
| Dropdown helper settings | Remove option | Confirmation over a dialog |
| Automation editor | Trigger and action menus, Rename | Prompt |
| Object selector (for example, card editor buttons) | Add, Edit | Form |
Menu items anchor to the menu's button, since the menu has closed by the time the popover opens.
## Needs deciding
1. **Whether to do this at all**, and for which kinds: confirmations, prompts (rename), small forms, or all three.
2. **Look.** The current popover uses the dialog's surface: large radius, shadow and filled buttons. It reads as Material. A flatter, less rounded popover would suit a small inline control better.
3. **Width.**
- Messages are sized to their content, up to the screen width, so text only wraps when it has to.
- Prompts have a minimum of 400px.
- Forms use the 580px dialog width.
- Are those the right three rules?
4. **Title-only confirmations.** Show the title as plain text (current), or keep a heading?
5. **Placement.** On some screens the popover covers the button that opened it (energy devices, dashboard sections). Should it always open beside or below the button?
6. **Mobile.** Bottom sheet, the same as adaptive dialogs, or a popover there too?
7. **Destructive confirmations.** A popover is lighter than a dialog. Is that too easy to confirm for deletes?
8. **Rollout.** There are about 170 confirmation calls. Opt in per call site (current), or anchor automatically when the click event is available?
9. **Popovers inside dialogs.** For example, removing a dropdown option from the helper settings dialog. Should popovers open there at all, and if they do, should they be kept inside the dialog's bounds?
10. **Forms.** A popover suits a quick edit with one or two fields. The object selector's edit form has more than that and doesn't work well as a popover stacked over the card editor dialog. A longer form should probably be a view of its own instead of a stacked popover or dialog.
## Known gaps
- Confirmations block dismissing by clicking outside or pressing Escape, the same as the dialog today. A popover may be expected to dismiss more easily.
- Focus lands on the popover rather than the default button.
- Renames started from the automation sidebar have no anchor, so they stay dialogs.
- Timer presets and other forms opened from custom events can't be anchored yet, because the event doesn't carry the clicked element.
## Values to turn into variables or tokens
These were tuned by eye in the concept and are hard-coded:
| Where | Value | Why |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `ha-adaptive-popover`, compact footer | `18px` right and bottom padding | Looked even with the 20px around the text |
| `ha-adaptive-popover`, compact | `var(--ha-space-5)` (20px) edges, `--ha-space-3` (12px) under the title, `--ha-space-4` (16px) above the footer | Should be `--ha-popover-padding` and gap tokens |
| `dialog-box`, prompt popover | `min(400px, 95vw)` minimum width | A text field has no width of its own |
| `dialog-box`, message popover | `95vw` maximum width | Lets messages grow before wrapping |
| `ha-data-table`, group actions | `margin-top: -12px` | Undoes the group header's `padding-top: 12px` |
| `ha-data-table`, group actions | `48px` in the end padding calculation | The rows' icon button size, to line up with overflow menus |
## Related earlier work
- [#51641](https://github.com/home-assistant/frontend/issues/51641): card features with actions showing a popover instead of a full dialog. The original idea was a popover mode for more info, similar to the Apple Home controls, which needed a new "mini more info" and more work.
- [#51650](https://github.com/home-assistant/frontend/pull/51650): added `ha-adaptive-popover`, used by the set date card feature. This branch builds on it.
- [OpenHomeFoundation/ux-design#43](https://github.com/OpenHomeFoundation/ux-design/issues/43): showing contextual actions in popover dialogs.
## The look
The popover reuses the dialog's 24px radius (`--ha-border-radius-3xl`), shadow and filled buttons. The look from the new design system in the works would probably suit this idea better.
@@ -0,0 +1,4 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,4 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,5 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,5 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,6 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,6 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,3 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,4 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,4 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,5 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,5 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,3 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,4 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,4 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,3 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

+44 -119
View File
@@ -124,25 +124,7 @@ export interface DataTableRowData {
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
// Slot for the actions at the end of a group's header, they don't toggle the
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
// colour and a smaller size by default, which `--data-table-group-actions-color`,
// `--data-table-group-actions-button-size` and
// `--data-table-group-actions-icon-size` change for all of them; style a
// button itself for its own colour or size.
export const groupActionsSlot = (group: string) => `group-actions-${group}`;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
// The group a row's value of the group column puts it in. Rows without a value
// share a reserved group, sorted to the bottom.
export const dataTableGroupKey = (
value: string | number | boolean | null | undefined
) => {
const key = String(value);
return key === "undefined" ? UNDEFINED_GROUP_KEY : key;
};
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
// Default row height, used to fill the viewport with skeleton rows.
@@ -227,9 +209,6 @@ export class HaDataTable extends LitElement {
@query(".sticky-group-header .group-header")
private _stickyGroupHeaderCell?: HTMLElement;
@query(".sticky-group-header .group-actions")
private _stickyGroupActions?: HTMLElement;
// Group pinned to the top of the scroller while its own header is scrolled out of view
@state() private _stickyGroup?: string;
@@ -741,26 +720,6 @@ export class HaDataTable extends LitElement {
</div>
`
: html`
${
// Rendered before the rows, so the group's actions slot
// shows here and not in its scrolled out header row
this.groupColumn &&
!this.autoHeight &&
this._stickyGroup !== undefined
? html`<div
class="mdc-data-table__row sticky-group-header"
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
${this._renderGroupHeader(
this._stickyGroup,
this._collapsedGroups.includes(this._stickyGroup),
this._i18n?.localize,
true
)}
</div>`
: nothing
}
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
@@ -773,6 +732,25 @@ export class HaDataTable extends LitElement {
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
${
this.groupColumn &&
!this.autoHeight &&
this._stickyGroup !== undefined
? html`<div
class="mdc-data-table__row sticky-group-header"
aria-hidden="true"
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
${this._renderGroupHeader(
this._stickyGroup,
this._collapsedGroups.includes(this._stickyGroup),
this._i18n?.localize,
true
)}
</div>`
: nothing
}
`
}
</div>
@@ -1047,10 +1025,12 @@ export class HaDataTable extends LitElement {
if (groupColumn) {
const isGroupSortColumn = sortColumn === groupColumn;
const grouped = groupBy(items, (item) =>
dataTableGroupKey(item[groupColumn])
);
const grouped = groupBy(items, (item) => item[groupColumn]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
grouped
)
@@ -1127,14 +1107,12 @@ export class HaDataTable extends LitElement {
localize: LocalizeFunc | undefined,
sticky = false
) {
// The sticky copy's button and name are hidden from assistive technology,
// and its button is made inert. It handles clicks on its whole row
// instead, as its cell shifts with horizontal scrolling. The group's
// actions show in the sticky copy while it is pinned, so they stay
// available there.
// The sticky copy is hidden from assistive technology, so its button is
// made inert. It handles clicks on its whole row instead, as its cell
// shifts with horizontal scrolling.
return html`<div
class="mdc-data-table__cell group-header"
role=${ifDefined(sticky ? undefined : "cell")}
role="cell"
.group=${groupName}
@click=${sticky ? nothing : this._collapseGroup}
>
@@ -1149,27 +1127,14 @@ export class HaDataTable extends LitElement {
?inert=${sticky}
>
</ha-icon-button>
<span
class="group-name"
aria-hidden=${ifDefined(sticky ? "true" : undefined)}
>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
</span>
<div class="group-actions" @click=${this._stopGroupToggle}>
<slot name=${groupActionsSlot(groupName)}></slot>
</div>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
</div>`;
}
// Actions don't toggle the group their header belongs to
private _stopGroupToggle(ev: Event) {
ev.stopPropagation();
}
private _memFilterData = memoizeOne(
(
data: DataTableRowData[],
@@ -1471,11 +1436,6 @@ export class HaDataTable extends LitElement {
if (this._stickyGroupHeaderCell) {
this._stickyGroupHeaderCell.style.transform = `translateX(${-scrollLeft}px)`;
}
// Its actions stay at the end of the visible row, like the rows' own
if (this._stickyGroupActions) {
this._stickyGroupActions.style.transform = `translateX(${scrollLeft}px)`;
}
}
@eventOptions({ passive: true })
@@ -1543,15 +1503,18 @@ export class HaDataTable extends LitElement {
}
public collapseAllGroups() {
const groupColumn = this.groupColumn;
if (!groupColumn || !this.data.some((item) => item[groupColumn])) {
if (
!this.groupColumn ||
!this.data.some((item) => item[this.groupColumn!])
) {
return;
}
const grouped = groupBy(this.data, (item) =>
dataTableGroupKey(item[groupColumn])
);
const grouped = groupBy(this.data, (item) => item[this.groupColumn!]);
if (grouped.undefined) {
// undefined is a reserved group name
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
this._collapsedGroups = Object.keys(grouped);
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
@@ -1910,16 +1873,6 @@ export class HaDataTable extends LitElement {
display: flex;
align-items: center;
cursor: pointer;
/* Clipping would make the cell the actions' sticky scroll container,
and cut off the pinned header's actions */
overflow: visible;
}
.group-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-header ha-icon-button {
@@ -1930,34 +1883,6 @@ export class HaDataTable extends LitElement {
transform: rotate(180deg);
}
.group-actions {
display: flex;
align-items: center;
gap: var(--ha-space-2);
/* Centre in the row, not below the header's top padding */
align-self: stretch;
margin-top: -12px;
margin-inline-start: auto;
/* Smaller than the rows' icon buttons, inherited by the slotted ones */
--_button-size: var(--data-table-group-actions-button-size, 36px);
--ha-icon-button-size: var(--_button-size);
--mdc-icon-size: var(--data-table-group-actions-icon-size, 20px);
/* Centre the last action over the row's last cell, such as an
overflow menu, which is a 48px icon button */
padding-inline-end: calc(
var(--ha-space-4) + (48px - var(--_button-size)) / 2
);
/* Stay in view when the rows scroll horizontally */
position: sticky;
inset-inline-end: 0;
cursor: default;
/* Inherited by the slotted icon buttons, like the rows' icon cells */
color: var(
--data-table-group-actions-color,
var(--secondary-text-color)
);
}
.sticky-group-header {
position: absolute;
top: 0;
+1 -48
View File
@@ -11,24 +11,12 @@ import { HaAdaptiveDialog } from "./ha-adaptive-dialog";
*
* Uses an anchored desktop popover when an anchor is available and falls back
* to `ha-adaptive-dialog` otherwise.
*
* @cssprop --ha-adaptive-popover-min-width - Minimum popover width. Defaults to its width preset; 0 fits it to its content, up to that width.
* @cssprop --ha-adaptive-popover-width - Width of a compact popover. Defaults to the medium dialog width.
*/
@customElement("ha-adaptive-popover")
export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
@property({ attribute: false })
public dialogAnchor?: Element;
/** Tighter spacing in the popover, for short content such as confirmations */
@property({ type: Boolean, reflect: true }) public compact = false;
/**
* Names the popover when it has no header title to label it, such as when
* its title lives in another shadow root
*/
@property({ attribute: false }) public accessibleLabel?: string;
private _allowPopoverHide = false;
private _openPopoverAnimationFrame?: number;
@@ -93,7 +81,6 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
role="dialog"
aria-modal="true"
aria-labelledby=${ifDefined(this._defaultAriaLabelledBy)}
aria-label=${ifDefined(this.accessibleLabel)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@pointerdown=${this._handlePopoverPointerDown}
@wa-show=${this._handlePopoverShow}
@@ -298,11 +285,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
padding: 0;
border-color: transparent;
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
/* 0 fits the popover to its content, up to its width */
min-width: var(
--ha-adaptive-popover-min-width,
var(--width, var(--full-width))
);
min-width: var(--width, var(--full-width));
max-width: var(--width);
max-height: var(
--ha-dialog-max-height,
@@ -351,36 +334,6 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
padding: 0;
}
/* 20px around the edges, 12px below the title and 16px above the
footer. The header bar and its empty navigation icon slot already
give the title 16px on the side. */
:host([compact]) wa-popover {
--ha-dialog-header-content-padding: var(--ha-space-5)
var(--ha-space-1) var(--ha-space-3);
--ha-dialog-header-min-height: 0;
/* Content that sizes itself, such as a short message, can let the
popover grow wider so text only wraps when there is no room */
--width: var(
--ha-adaptive-popover-width,
min(var(--ha-dialog-width-md, 580px), var(--full-width))
);
--ha-dialog-header-white-space: normal;
--ha-dialog-header-title-height: auto;
--dialog-content-padding: 0 var(--ha-space-5);
}
/* 18px beside and below the buttons looks even with the 20px around
the text */
/* Without a header the content starts the popover, so it takes the
header's place at the top */
:host([compact][without-header]) wa-popover {
--dialog-content-padding: var(--ha-space-5) var(--ha-space-5) 0;
}
:host([compact]) ::slotted([slot="footer"]) {
padding: var(--ha-space-4) 18px 18px var(--ha-space-5);
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
+2 -7
View File
@@ -16,8 +16,6 @@ import { customElement, property } from "lit/decorators";
*
* @cssprop --ha-dialog-header-white-space - White space of the title and subtitle, set to `normal` to let them wrap. Defaults to `nowrap`.
* @cssprop --ha-dialog-header-title-height - Height of the title, set to `auto` when it wraps.
* @cssprop --ha-dialog-header-content-padding - Padding around the title and subtitle.
* @cssprop --ha-dialog-header-min-height - Minimum height of the title and subtitle area.
* @cssprop --ha-dialog-header-title-color - Color of the title.
* @cssprop --ha-dialog-header-subtitle-color - Color of the subtitle.
*
@@ -82,14 +80,11 @@ export class HaDialogHeader extends LitElement {
}
.header-content {
flex: 1;
padding: var(
--ha-dialog-header-content-padding,
10px var(--ha-space-1)
);
padding: 10px var(--ha-space-1);
display: flex;
flex-direction: column;
justify-content: center;
min-height: var(--ha-dialog-header-min-height, var(--ha-space-12));
min-height: var(--ha-space-12);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -262,7 +262,6 @@ export class HaObjectSelector extends LitElement {
};
const newItem = await showFormDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.common.add"),
schema,
data,
@@ -291,7 +290,6 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
const updatedItem = await showFormDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.common.edit"),
schema: this._schema(this.selector),
data: item,
+38 -72
View File
@@ -11,7 +11,6 @@ import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-adaptive-popover";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -40,13 +39,8 @@ export class DialogForm
{
@property({ attribute: false }) public hass?: HomeAssistant;
// Set by the dialog manager before each showDialog
@property({ attribute: false }) public dialogAnchor?: Element;
@state() private _params?: FormDialogParams;
@state() private _anchor?: Element;
@state() private _data: FormDialogData = {};
private _initialData: FormDialogData = {};
@@ -67,7 +61,6 @@ export class DialogForm
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._anchor = this.dialogAnchor;
this._data = params.data || {};
this._initialData = deepClone(this._data);
this._open = true;
@@ -257,7 +250,12 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this.isConnected || !this._open || this._params !== expectedParams) {
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
return;
}
@@ -267,10 +265,7 @@ export class DialogForm
this._focusFirstControl();
}
// Only the dialog keeps its own scroll position, a popover fits its form
if (this._dialog) {
this._dialog.bodyContainer.scrollTop = scrollTop;
}
this._dialog.bodyContainer.scrollTop = scrollTop;
}
private _dialogClosed(): void {
@@ -289,7 +284,6 @@ export class DialogForm
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._anchor = undefined;
this._initialData = {};
this._data = {};
this._open = false;
@@ -393,23 +387,6 @@ export class DialogForm
},
];
if (this._anchor) {
// A popover next to what opened it, or a bottom sheet on small screens
return html`
<ha-adaptive-popover
.dialogAnchor=${this._anchor}
compact
.open=${this._open}
.headerTitle=${params.title}
.hideCloseButton=${true}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._renderForms(levels)} ${this._renderFooter(params)}
</ha-adaptive-popover>
`;
}
return html`
<ha-dialog
.open=${this._open}
@@ -417,52 +394,41 @@ export class DialogForm
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._renderForms(levels)} ${this._renderFooter(params)}
${levels.map((level, index) => {
const isActive = index === levels.length - 1;
return html`
<ha-form
?hidden=${!isActive}
?autofocus=${isActive}
.hass=${this.hass}
.computeLabel=${level.params.computeLabel}
.computeHelper=${level.params.computeHelper}
.data=${level.data}
.schema=${level.params.schema}
.error=${level.error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
`;
})}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderForms(
levels: Pick<StackEntry, "params" | "data" | "error">[]
) {
return levels.map((level, index) => {
const isActive = index === levels.length - 1;
return html`
<ha-form
?hidden=${!isActive}
?autofocus=${isActive}
.hass=${this.hass}
.computeLabel=${level.params.computeLabel}
.computeHelper=${level.params.computeHelper}
.data=${level.data}
.schema=${level.params.schema}
.error=${level.error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
`;
});
}
private _renderFooter(params: FormDialogParams) {
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${params.cancelText || this.hass?.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${params.submitText || this.hass?.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
`;
}
static styles = [haStyleDialog, css``];
}
-7
View File
@@ -5,12 +5,6 @@ export type FormDialogData = Record<string, any>;
export interface FormDialogParams {
title: string;
/**
* The element that opened the form, such as the button that was clicked.
* The form then opens as a popover next to it on larger screens, and as a
* bottom sheet on smaller ones.
*/
anchor?: Element;
schema: HaFormSchema[];
data?: FormDialogData;
// Extra submit-time validation beyond the schema's own checks. Returned
@@ -36,7 +30,6 @@ export const showFormDialog = (
fireEvent(element, "show-dialog", {
dialogTag: "dialog-form",
dialogImport: () => import("./dialog-form"),
dialogAnchor: dialogParams.anchor,
dialogParams: {
...dialogParams,
cancel: () => {
+82 -138
View File
@@ -4,7 +4,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-adaptive-popover";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -28,13 +27,8 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
) {
@property({ attribute: false }) public hass!: HomeAssistant;
// Set by the dialog manager before each showDialog
@property({ attribute: false }) public dialogAnchor?: Element;
@state() private _params?: DialogBoxParams;
@state() private _anchor?: Element;
@state() private _open = false;
@state() private _closeState?: "canceled" | "confirmed";
@@ -54,7 +48,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
await this._closePromise;
}
this._params = params;
this._anchor = this.dialogAnchor;
this._validInput = true;
this._open = true;
this._initDirtyTracking(
@@ -91,42 +84,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
(this._params.confirmation &&
this.hass.localize("ui.dialogs.generic.default_confirmation_title"));
if (this._anchor) {
// With only a title, it reads as the message rather than a heading
const titleOnly =
!!dialogTitle &&
!this._params.text &&
!this._params.subtitle &&
!this._params.prompt;
// A popover next to what opened it, or a bottom sheet on small screens.
// The title is the popover's own, so it can name the dialog.
return html`
<ha-adaptive-popover
.dialogAnchor=${this._anchor}
compact
class=${this._params.prompt ? "prompt" : ""}
?without-header=${titleOnly}
.open=${this._open}
.preventScrimClose=${!!this._params.confirmation || this.isDirtyState}
.headerTitle=${titleOnly ? undefined : dialogTitle || undefined}
.headerSubtitle=${this._params.subtitle}
.accessibleLabel=${titleOnly ? dialogTitle : undefined}
.hideCloseButton=${confirmPrompt && !this._params.showCloseButton}
@closed=${this._dialogClosed}
>
${
titleOnly
? html`<div id="dialog-box-description">
<p>${dialogTitle}</p>
</div>`
: this._renderDescription(this._params)
}
${this._renderFooter(this._params, confirmPrompt)}
</ha-adaptive-popover>
`;
}
return html`
<ha-dialog
.open=${this._open}
@@ -169,97 +126,95 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
: nothing
}
</ha-dialog-header>
${this._renderDescription(this._params)}
${this._renderFooter(this._params, confirmPrompt)}
</ha-dialog>
`;
}
private _renderDescription(params: DialogBoxParams) {
return html`
<div id="dialog-box-description">
${params.text ? html` <p>${params.text}</p> ` : ""}
${
params.prompt && !params.multiline
? html`
<ha-input
autofocus
value=${ifDefined(params.defaultValue)}
.placeholder=${params.placeholder}
.label=${params.inputLabel ? params.inputLabel : ""}
.type=${params.inputType ? params.inputType : "text"}
.min=${params.inputMin}
.max=${params.inputMax}
.disabled=${this._loading}
@input=${this._validateInput}
>
${
params.inputSuffix
? html`<span slot="end">${params.inputSuffix}</span>`
: nothing
}
</ha-input>
`
: params.prompt && params.multiline
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
${
this._params.prompt && !this._params.multiline
? html`
<ha-textarea
resize="auto"
<ha-input
autofocus
.value=${params.defaultValue}
.placeholder=${params.placeholder}
.label=${params.inputLabel}
value=${ifDefined(this._params.defaultValue)}
.placeholder=${this._params.placeholder}
.label=${
this._params.inputLabel ? this._params.inputLabel : ""
}
.type=${
this._params.inputType ? this._params.inputType : "text"
}
.min=${this._params.inputMin}
.max=${this._params.inputMax}
.disabled=${this._loading}
@input=${this._validateInput}
></ha-textarea>
>
${
this._params.inputSuffix
? html`<span slot="end"
>${this._params.inputSuffix}</span
>`
: nothing
}
</ha-input>
`
: this._params.prompt && this._params.multiline
? html`
<ha-textarea
resize="auto"
autofocus
.value=${this._params.defaultValue}
.placeholder=${this._params.placeholder}
.label=${this._params.inputLabel}
.disabled=${this._loading}
@input=${this._validateInput}
></ha-textarea>
`
: nothing
}
${
this._params.prompt && this._params.textBelowInput
? html`<div class="after-input">
${this._params.textBelowInput}
</div>`
: nothing
}
</div>
<ha-dialog-footer slot="footer">
${
confirmPrompt
? html`
<ha-button
slot="secondaryAction"
@click=${this._dismiss}
?autofocus=${!this._params.prompt && this._params.destructive}
?disabled=${this._loading}
appearance="plain"
>
${
this._params.dismissText
? this._params.dismissText
: this.hass.localize("ui.common.cancel")
}
</ha-button>
`
: nothing
}
${
params.prompt && params.textBelowInput
? html`<div class="after-input">${params.textBelowInput}</div>`
: nothing
}
</div>
`;
}
private _renderFooter(params: DialogBoxParams, confirmPrompt: boolean) {
return html`
<ha-dialog-footer slot="footer">
${
confirmPrompt
? html`
<ha-button
slot="secondaryAction"
@click=${this._dismiss}
?autofocus=${!params.prompt && params.destructive}
?disabled=${this._loading}
appearance="plain"
>
${
params.dismissText
? params.dismissText
: this.hass.localize("ui.common.cancel")
}
</ha-button>
`
: nothing
}
<ha-button
slot="primaryAction"
@click=${this._confirm}
?autofocus=${!params.prompt && !params.destructive}
?disabled=${this._loading || (!!params.prompt && !this._validInput)}
.loading=${this._loading}
variant=${params.destructive ? "danger" : "brand"}
>
${
params.confirmText
? params.confirmText
: this.hass.localize("ui.common.ok")
}
</ha-button>
</ha-dialog-footer>
<ha-button
slot="primaryAction"
@click=${this._confirm}
?autofocus=${!this._params.prompt && !this._params.destructive}
?disabled=${
this._loading || (!!this._params.prompt && !this._validInput)
}
.loading=${this._loading}
variant=${this._params.destructive ? "danger" : "brand"}
>
${
this._params.confirmText
? this._params.confirmText
: this.hass.localize("ui.common.ok")
}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
@@ -332,7 +287,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
}
this._closeState = undefined;
this._params = undefined;
this._anchor = undefined;
this._open = false;
this._loading = false;
this._closeResolve?.();
@@ -340,16 +294,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
}
static styles = css`
ha-adaptive-popover {
/* As wide as the message and buttons need, up to the screen width, so
text only wraps when there is no room left */
--ha-adaptive-popover-min-width: 0;
--ha-adaptive-popover-width: 95vw;
}
ha-adaptive-popover.prompt {
/* A text field has no width of its own to fit to */
--ha-adaptive-popover-min-width: min(400px, 95vw);
}
:host([inert]) {
pointer-events: initial !important;
cursor: initial !important;
-7
View File
@@ -4,12 +4,6 @@ import { fireEvent } from "../../common/dom/fire_event";
interface BaseDialogBoxParams {
/** Whether back closes the dialog. Off while a navigation is in flight. */
addHistory?: boolean;
/**
* The element that opened the dialog, such as the button that was clicked.
* The dialog then opens as a popover next to it on larger screens, and as a
* bottom sheet on smaller ones.
*/
anchor?: Element;
confirmText?: string;
/** Show the close button for confirmations and prompts, which hide it by default. */
showCloseButton?: boolean;
@@ -72,7 +66,6 @@ const showDialogHelper = (
fireEvent(element, "show-dialog", {
dialogTag: "dialog-box",
dialogImport: loadGenericDialog,
dialogAnchor: dialogParams.anchor,
addHistory: dialogParams.addHistory,
dialogParams: {
...dialogParams,
+1 -29
View File
@@ -17,7 +17,6 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
import { QueryParamsController } from "../common/controllers/query-params-controller";
import { consume } from "../common/decorators/consume";
@@ -28,10 +27,7 @@ import {
dataTableQueryStateFromUrl,
} from "../common/url/data-table-query-params";
import "../components/chips/ha-assist-chip";
import {
dataTableGroupKey,
groupActionsSlot,
} from "../components/data-table/ha-data-table";
import "../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
@@ -635,13 +631,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.columnOrder=${this.columnOrder}
.hiddenColumns=${this.hiddenColumns}
>
${this._groups(this.data, this._groupColumn).map(
(group) =>
html`<slot
name=${groupActionsSlot(group)}
slot=${groupActionsSlot(group)}
></slot>`
)}
${
!this.narrow
? html`
@@ -857,23 +846,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
this._setGroupColumn(group);
}
// Groups to pass the actions slots of through to the table
private _groups = memoizeOne(
(data: DataTableRowData[], groupColumn: string | undefined) => {
if (!groupColumn) {
return [];
}
const groups = new Set<string>();
for (const row of data) {
groups.add(dataTableGroupKey(row[groupColumn]));
}
return [...groups];
}
);
private _setGroupColumn(columnId: string, link = false) {
this._groupColumn = columnId;
fireEvent(this, "grouping-changed", { value: columnId, link });
@@ -3,7 +3,6 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import "../../../components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
@@ -131,13 +130,10 @@ export class HaConfigAppsRegistries extends LitElement {
});
}
private _removeRegistry = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { registry: string }>
) => {
const registry = ev.currentTarget.registry;
private _removeRegistry = async (ev: Event) => {
const registry = (ev.currentTarget as any).registry;
showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.apps.registries.remove"),
text: this.hass.localize(
"ui.panel.config.apps.registries.confirm_remove",
@@ -3,10 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
@@ -221,14 +218,11 @@ export class HaConfigAppsRepositories extends LitElement {
});
}
private _removeRepository = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { slug: string }>
) => {
const slug = ev.currentTarget.slug;
private _removeRepository = async (ev: Event) => {
const slug = (ev.currentTarget as any).slug;
const repo = this._repositories?.find((r) => r.slug === slug);
showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.apps.repositories.remove"),
text: this.hass.localize(
"ui.panel.config.apps.repositories.confirm_remove",
@@ -225,8 +225,6 @@ export default class HaAutomationActionRow extends LitElement {
@query("ha-automation-row")
private _automationRowElement?: HaAutomationRow;
@query(".menu-trigger") private _menuTrigger?: HTMLElement;
private _runResultTimeout?: number;
private _sidebarAction?: Action;
@@ -487,7 +485,6 @@ export default class HaAutomationActionRow extends LitElement {
>
<ha-icon-button
slot="trigger"
class="menu-trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
@@ -1016,9 +1013,8 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlMode = true;
}
private _renameAction = async (anchor?: Element): Promise<void> => {
private _renameAction = async (): Promise<void> => {
const alias = await showPromptDialog(this, {
anchor,
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.change_alias"
),
@@ -1307,8 +1303,7 @@ export default class HaAutomationActionRow extends LitElement {
this._runAction();
break;
case "rename":
// The menu has closed, so open next to its button
this._renameAction(this._menuTrigger);
this._renameAction();
break;
case "edit_note":
this._editNoteAction();
@@ -181,8 +181,6 @@ export default class HaAutomationTriggerRow extends LitElement {
@query("ha-automation-row")
private _automationRowElement?: HaAutomationRow;
@query(".menu-trigger") private _menuTrigger?: HTMLElement;
@query(".triggered-chip")
private _eventChipElement?: HaAutomationRowEventChip;
@@ -393,7 +391,6 @@ export default class HaAutomationTriggerRow extends LitElement {
>
<ha-icon-button
slot="trigger"
class="menu-trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
@@ -927,10 +924,9 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _renameTrigger = async (anchor?: Element): Promise<void> => {
private _renameTrigger = async (): Promise<void> => {
if (isTriggerList(this.trigger)) return;
const alias = await showPromptDialog(this, {
anchor,
title: this.hass.localize(
"ui.panel.config.automation.editor.triggers.change_alias"
),
@@ -1178,8 +1174,7 @@ export default class HaAutomationTriggerRow extends LitElement {
switch (action) {
case "rename":
// The menu has closed, so open next to its button
this._renameTrigger(this._menuTrigger);
this._renameTrigger();
break;
case "edit_note":
this._editNoteTrigger();
@@ -205,7 +205,6 @@ export class EnergyBatterySettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -269,7 +269,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -266,7 +266,6 @@ export class EnergyDeviceSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -197,7 +197,6 @@ export class EnergyGasSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -261,7 +261,6 @@ export class EnergyGridSettings extends LitElement {
const entryId = ev.currentTarget.closest(".row").entry.entry_id;
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_integration"),
}))
) {
@@ -324,7 +323,6 @@ export class EnergyGridSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -207,7 +207,6 @@ export class EnergySolarSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -186,7 +186,6 @@ export class EnergyWaterSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -3,10 +3,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-picker";
@@ -188,13 +185,10 @@ class HaInputSelectForm extends LitElement {
input.value = "";
}
private async _removeOption(
ev: HASSDomCurrentTargetEvent<HTMLElement & { index: number }>
) {
const index = ev.currentTarget.index;
private async _removeOption(ev: Event) {
const index = (ev.target as any).index;
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize(
"ui.dialogs.helper_settings.input_select.confirm_delete.delete"
),
@@ -284,7 +284,6 @@ export class HaConfigLovelaceResources extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: event.currentTarget,
title: this.hass!.localize(
"ui.panel.config.lovelace.resources.confirm_delete_title"
),
@@ -13,7 +13,7 @@ import {
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dropdown";
@@ -50,8 +50,6 @@ export class HuiSectionEditMode extends LitElement {
@property({ type: Boolean, attribute: "is-strategy", reflect: true })
public isStrategy = false;
@query(".menu-trigger") private _menuTrigger?: HTMLElement;
// Session storage keeps a copied or cut section while switching views and
// dashboards.
@state()
@@ -97,7 +95,6 @@ export class HuiSectionEditMode extends LitElement {
>
<ha-icon-button
slot="trigger"
class="menu-trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
@@ -295,8 +292,6 @@ export class HuiSectionEditMode extends LitElement {
if (hasContent) {
const confirm = await showConfirmationDialog(this, {
// The menu has closed, so open next to its button
anchor: this._menuTrigger,
title: this.hass.localize(
"ui.panel.lovelace.editor.delete_section.title"
),
@@ -0,0 +1,153 @@
import type { ContextType } from "@lit/context";
import { mdiAutoFix, mdiContentPaste, mdiViewGridPlusOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import { consume } from "../../../../common/decorators/consume";
import { storage } from "../../../../common/decorators/storage";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import { internationalizationContext } from "../../../../data/context";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { generateDefaultSection } from "../../views/default-section";
import { addSection } from "../config-util";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showSelectSectionStrategyDialog } from "./show-create-section-dialog";
@customElement("hui-dialog-create-section")
export class HuiDialogCreateSection extends DialogMixin<CreateSectionDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@storage({
key: "dashboardSectionClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceSectionRawConfig;
protected render() {
if (!this.params) {
return nothing;
}
const localize = this._i18n.localize;
return html`
<ha-adaptive-dialog
open
header-title=${localize("ui.panel.lovelace.editor.create_section.header")}
>
<ha-list-base>
<ha-list-item-button @click=${this._createManual}>
<ha-svg-icon
slot="start"
.path=${mdiViewGridPlusOutline}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.manual")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.manual_description"
)}
</span>
</ha-list-item-button>
<ha-list-item-button @click=${this._pickStrategy}>
<ha-svg-icon slot="start" .path=${mdiAutoFix}></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.automatic")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.automatic_description"
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
${
this._clipboard
? html`
<ha-list-item-button @click=${this._paste}>
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.paste")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.paste_description"
)}
</span>
</ha-list-item-button>
`
: nothing
}
</ha-list-base>
</ha-adaptive-dialog>
`;
}
private _createManual() {
const { lovelace, containerPath } = this.params!;
this.closeDialog();
lovelace.saveConfig(
addSection(
lovelace.config,
containerPath,
generateDefaultSection(this._i18n.localize, true)
)
);
}
private _pickStrategy() {
showSelectSectionStrategyDialog(this, this.params!);
this.closeDialog();
}
private async _paste() {
if (!this._clipboard) {
return;
}
const { lovelace, containerPath } = this.params!;
const section = deepClone(this._clipboard);
this.closeDialog();
try {
await lovelace.saveConfig(
addSection(lovelace.config, containerPath, section)
);
} catch (_err: unknown) {
lovelace.showToast({
message: this._i18n.localize(
"ui.panel.lovelace.editor.section.paste_error"
),
});
}
}
static styles = css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
ha-list-base {
--ha-list-padding: 0 0 var(--ha-space-3);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-create-section": HuiDialogCreateSection;
}
}
@@ -1,26 +1,38 @@
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
import {
mdiCellphone,
mdiClose,
mdiDotsVertical,
mdiFileMoveOutline,
mdiMonitor,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, 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 { cache } from "lit/directives/cache";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { isStrategySection } from "../../../../data/lovelace/config/section";
import {
DEFAULT_SECTION_COLUMN_SPAN,
isStrategySection,
} from "../../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
@@ -32,6 +44,7 @@ import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider
import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
@@ -41,10 +54,13 @@ import {
deleteAtPath,
getAtPath,
getViewPath,
insertAtPath,
moveAtPath,
setAtPath,
} from "../lovelace-path";
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "../../sections/hui-section";
import "../../sections/hui-section-background";
import "./hui-section-settings-editor";
import "./hui-section-visibility-editor";
import "./hui-section-strategy-element-editor";
@@ -53,11 +69,20 @@ import type { ConfigChangedEvent } from "../hui-element-editor";
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
import { DEFAULT_MAX_COLUMNS, SECTIONS_VIEW_LAYOUT } from "../../views/const";
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
type PreviewMode = "desktop" | "mobile";
const PREVIEW_MODES: { mode: PreviewMode; iconPath: string }[] = [
{ mode: "mobile", iconPath: mdiCellphone },
{ mode: "desktop", iconPath: mdiMonitor },
];
const TWO_COLUMNS_MIN_WIDTH = 2 * 320 + 3 * 32;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
@@ -67,6 +92,8 @@ export class HuiDialogEditSection
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _params?: EditSectionDialogParams;
@state() private _config?: LovelaceSectionRawConfig;
@@ -83,6 +110,15 @@ export class HuiDialogEditSection
@state() private _yamlError = false;
@storage({
key: "dashboardSectionPreviewMode",
state: true,
subscribe: false,
})
private _storedPreviewMode?: PreviewMode;
@state() private _defaultPreviewMode: PreviewMode = "mobile";
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
@query("hui-section-strategy-element-editor")
@@ -104,10 +140,9 @@ export class HuiDialogEditSection
this.lovelace = params.lovelace;
this._config = getAtPath<LovelaceSectionRawConfig>(
params.lovelaceConfig,
params.path
);
this._config =
params.newSection ??
getAtPath<LovelaceSectionRawConfig>(params.lovelaceConfig, params.path);
this._currTab =
this._config && isStrategySection(this._config)
? "tab-strategy"
@@ -117,7 +152,18 @@ export class HuiDialogEditSection
params.lovelaceConfig,
viewPath
);
this._initDirtyTracking({ type: "deep" }, this._config);
if (params.newSection) {
this._initDirtyTracking({ type: "deep" }, {});
this._updateDirtyState(params.newSection);
} else {
this._initDirtyTracking({ type: "deep" }, this._config);
}
this._defaultPreviewMode =
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
}
private get _previewMode(): PreviewMode {
return this._storedPreviewMode ?? this._defaultPreviewMode;
}
public closeDialog() {
@@ -127,11 +173,13 @@ export class HuiDialogEditSection
private _dialogClosed(): void {
this._params = undefined;
this.large = false;
this._yamlMode = false;
this._config = undefined;
this._currTab = TABS[0];
this._strategyError = false;
this._yamlError = false;
this._discardDirtyStateChanges();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -140,19 +188,23 @@ export class HuiDialogEditSection
return nothing;
}
const heading = this.hass!.localize(
"ui.panel.lovelace.editor.edit_section.header"
);
const strategy = isStrategySection(this._config)
? cleanLegacyStrategyConfig(this._config.strategy)
: undefined;
const heading = this._computeHeading(strategy);
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
const currentTab =
!strategy && this._currTab === "tab-strategy"
? "tab-appearance"
: this._currTab;
const columnSpan = Math.min(
this._config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
this._viewConfig?.max_columns || DEFAULT_MAX_COLUMNS
);
const singleColumn = columnSpan === 1;
const previewColumnSpan = this._previewMode === "desktop" ? columnSpan : 1;
let content: TemplateResult<1> | typeof nothing = nothing;
if (this._yamlMode) {
@@ -202,65 +254,160 @@ export class HuiDialogEditSection
return html`
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({
"yaml-mode": this._yamlMode,
})}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this._cancel}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${heading}</span>
<ha-dropdown
slot="actionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
${
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
<ha-icon-button
slot="headerNavigationIcon"
@click=${this._cancel}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span
slot="headerTitle"
class="title-enlargeable"
@click=${this._enlarge}
>${heading}</span
>
${
this._params.newSection
? nothing
: html`
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-tab-group>
`
: nothing
}
</ha-dialog-header>
${this._yamlMode ? content : cache(content)}
</ha-dropdown-item>
</ha-dropdown>
`
}
<div class="content">
<div class="element-editor ha-scrollbar">
${
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
`
: nothing
}
${this._yamlMode ? content : cache(content)}
</div>
<div class="element-preview ha-scrollbar">
<div class="preview-header">
<span id="preview-label">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_section.preview"
)}
</span>
${
singleColumn
? nothing
: html`
<div
class="preview-modes"
role="group"
aria-labelledby="preview-label"
>
${PREVIEW_MODES.map(
({ mode, iconPath }) => html`
<button
id="preview-mode-${mode}"
class=${classMap({
"preview-mode": true,
active: this._previewMode === mode,
})}
aria-pressed=${this._previewMode === mode}
aria-label=${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
)}
.mode=${mode}
@click=${this._previewModeClicked}
>
<ha-svg-icon .path=${iconPath}></ha-svg-icon>
</button>
<ha-tooltip
for="preview-mode-${mode}"
placement="bottom"
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
)}
</ha-tooltip>
`
)}
</div>
`
}
</div>
<div class="preview-frame">
<div
class=${classMap({
"section-preview": true,
wide: previewColumnSpan > 1,
})}
style=${styleMap({
"--column-span": previewColumnSpan,
})}
>
<div
class=${classMap({
"section-container": true,
"has-background": this._config.background !== undefined,
blur: this._yamlError || this._strategyError,
})}
>
${
this._config.background !== undefined
? html`
<hui-section-background
.hass=${this.hass}
.background=${this._config.background}
.theme=${this._config.theme}
></hui-section-background>
`
: nothing
}
<hui-section
.hass=${this.hass}
.config=${this._config}
preview
></hui-section>
</div>
</div>
</div>
</div>
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -319,6 +466,37 @@ export class HuiDialogEditSection
this._updateDirtyState(this._config);
}
private _computeHeading(strategy?: LovelaceStrategyConfig) {
const header = this.hass.localize(
"ui.panel.lovelace.editor.edit_section.header"
);
if (!strategy) {
return header;
}
const strategyName = this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${strategy.type}.name`
);
if (!strategyName) {
return header;
}
return this.hass.localize(
"ui.panel.lovelace.editor.edit_section.typed_header",
{ type: strategyName }
);
}
private _previewModeClicked(ev: Event) {
this._storedPreviewMode = (
ev.currentTarget as HTMLElement & { mode: PreviewMode }
).mode;
}
private _enlarge() {
withViewTransition(() => {
this.large = !this.large;
});
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
if (newTab === this._currTab) {
@@ -516,11 +694,13 @@ export class HuiDialogEditSection
) {
return;
}
const newConfig = setAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
);
const newConfig = this._params.newSection
? insertAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
)
: setAtPath(this._params.lovelaceConfig, this._params.path, this._config);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
@@ -531,15 +711,153 @@ export class HuiDialogEditSection
return [
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
css`
:host {
--code-mirror-max-height: calc(100vh - 209px);
}
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
--dialog-content-padding: var(--ha-space-2);
}
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
.content {
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
}
.gui-mode-button {
margin-inline-end: auto;
@media all and (max-width: 450px), all and (max-height: 500px) {
.content {
gap: var(--ha-space-3);
}
}
@media all and (min-width: 451px) and (min-height: 501px) {
:host([large]) .content {
max-width: none;
}
}
.content .preview-frame {
padding: 4px;
overflow-x: auto;
}
.content .section-preview {
--preview-column-width: 390px;
display: block;
margin: 0 auto;
max-width: var(--preview-column-width);
}
.content .section-preview.wide {
max-width: none;
width: calc(
var(--column-span) * var(--preview-column-width) +
(var(--column-span) - 1) *
var(--ha-view-sections-column-gap, 32px)
);
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 32px;
padding: var(--ha-space-1) var(--ha-space-2);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
.preview-modes {
display: flex;
gap: 2px;
padding: 2px;
border-radius: var(--ha-border-radius-pill);
background: var(--ha-color-fill-neutral-normal-resting);
}
.preview-mode {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--ha-border-radius-pill);
background: none;
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 18px;
}
.preview-mode:hover {
color: var(--primary-text-color);
}
.preview-mode.active {
color: var(--primary-text-color);
background: var(--card-background-color);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
}
.preview-mode:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.content .element-editor {
padding-inline-start: var(--ha-space-1);
padding-inline-end: var(--ha-space-2);
margin-bottom: 0;
}
@media (min-width: 1000px) {
.content {
flex-direction: row;
max-height: var(--code-mirror-max-height);
}
.content > .element-editor {
padding-inline-end: var(--ha-space-4);
max-width: 500px;
}
.content > .element-editor,
.content > .element-preview {
flex-basis: 0;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
height: auto;
}
.content .preview-frame {
padding: 8px 10px;
}
}
.element-editor {
margin-bottom: 8px;
}
.blur {
filter: blur(2px) grayscale(100%);
}
.element-preview {
position: relative;
height: max-content;
background: var(--primary-background-color);
padding: 4px;
border-radius: var(--ha-border-radius-sm);
}
.section-container {
position: relative;
}
.section-container.has-background {
padding: var(--ha-space-2);
border-radius: var(
--ha-section-border-radius,
var(--ha-border-radius-xl)
);
}
.section-container hui-section {
display: block;
}
ha-tab-group {
margin-bottom: 16px;
}
ha-tab-group-tab {
flex: 1;
@@ -548,6 +866,14 @@ export class HuiDialogEditSection
width: 100%;
justify-content: center;
}
.gui-mode-button {
margin-right: auto;
margin-inline-end: auto;
margin-inline-start: initial;
}
.title-enlargeable {
display: block;
}
`,
];
}
@@ -0,0 +1,307 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-button-arrow-prev";
import "../../../../components/ha-ripple";
import type { LovelaceStrategySectionConfig } from "../../../../data/lovelace/config/section";
import { CUSTOM_TYPE_PREFIX } from "../../../../data/lovelace_custom_cards";
import { getCustomStrategiesForType } from "../../../../data/lovelace_custom_strategies";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../../types";
import "../../sections/hui-section";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import type { LovelaceSectionStrategy } from "../../strategies/types";
import { getAtPath } from "../lovelace-path";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showCreateSectionDialog } from "./show-create-section-dialog";
import { showEditSectionDialog } from "./show-edit-section-dialog";
interface BuiltInSectionStrategy {
type: "common-controls" | "device";
isAvailable?: (hass: HomeAssistant) => boolean;
}
const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
{
type: "common-controls",
isAvailable: (hass) => isComponentLoaded(hass.config, "usage_prediction"),
},
{
type: "device",
},
];
interface StrategyOption {
type: string;
name: string;
description: string;
}
@customElement("hui-dialog-select-section-strategy")
export class HuiDialogSelectSectionStrategy
extends LitElement
implements HassDialog<CreateSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: CreateSectionDialogParams;
@state() private _open = false;
@state() private _previews: Record<string, LovelaceStrategySectionConfig> =
{};
public showDialog(params: CreateSectionDialogParams): void {
this._params = params;
this._open = true;
this._previews = {};
this._loadPreviews(params);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._previews = {};
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _availableBuiltInTypes() {
return BUILT_IN_SECTION_STRATEGIES.filter(
(strategy) => strategy.isAvailable?.(this.hass) ?? true
).map((strategy) => strategy.type);
}
private _builtInOptions(): StrategyOption[] {
return this._availableBuiltInTypes().map((type) => ({
type,
name: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.name`
),
description: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.description`
),
}));
}
private _customOptions(): StrategyOption[] {
return getCustomStrategiesForType("section").map((strategy) => ({
type: CUSTOM_TYPE_PREFIX + strategy.type,
name: strategy.name || strategy.type,
description: strategy.description || "",
}));
}
private async _loadPreviews(params: CreateSectionDialogParams) {
const types = [
...this._availableBuiltInTypes(),
...getCustomStrategiesForType("section").map(
(strategy) => CUSTOM_TYPE_PREFIX + strategy.type
),
];
await Promise.all(
types.map(async (type) => {
try {
const strategy = (await getLovelaceStrategy(
"section",
type
)) as LovelaceSectionStrategy;
const stubConfig = await strategy.getStubConfig?.(this.hass);
if (!stubConfig || this._params !== params) {
return;
}
this._previews = {
...this._previews,
[type]: { strategy: stubConfig },
};
} catch (_err: unknown) {
// The tile is shown without a preview
}
})
);
}
protected render() {
if (!this._params) {
return nothing;
}
const customOptions = this._customOptions();
return html`
<ha-adaptive-dialog
.open=${this._open}
width="large"
@closed=${this._dialogClosed}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.header"
)}
>
<ha-icon-button-arrow-prev
slot="headerNavigationIcon"
@click=${this._back}
></ha-icon-button-arrow-prev>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.built_in"
)}
</h3>
`
: nothing
}
<div class="options">
${this._builtInOptions().map((option) => this._renderOption(option))}
</div>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.custom"
)}
</h3>
<div class="options">
${customOptions.map((option) => this._renderOption(option))}
</div>
`
: nothing
}
</ha-adaptive-dialog>
`;
}
private _renderOption(option: StrategyOption) {
const preview = this._previews[option.type];
return html`
<div
class="option"
role="button"
tabindex="0"
aria-label=${option.name}
data-type=${option.type}
@click=${this._strategyPicked}
@keydown=${this._handleKeyDown}
>
<div class="header">
<h4>${option.name}</h4>
<p>${option.description}</p>
</div>
${
preview
? html`
<div class="preview" inert>
<hui-section
.hass=${this.hass}
.config=${preview}
preview
></hui-section>
</div>
`
: nothing
}
<ha-ripple></ha-ripple>
</div>
`;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
(ev.currentTarget as HTMLElement).click();
}
}
private _back() {
showCreateSectionDialog(this, this._params!);
this.closeDialog();
}
private _strategyPicked(ev: Event) {
const type = (ev.currentTarget as HTMLElement).dataset.type!;
const { lovelace, containerPath } = this._params!;
const sectionsPath = [...containerPath, "sections"];
const sectionCount =
getAtPath<unknown[]>(lovelace.config, sectionsPath)?.length ?? 0;
showEditSectionDialog(this, {
lovelace,
lovelaceConfig: lovelace.config,
saveConfig: (newConfig) => {
lovelace.saveConfig(newConfig);
},
path: [...sectionsPath, sectionCount],
newSection: this._previews[type] ?? { strategy: { type } },
});
this.closeDialog();
}
static styles = css`
h3 {
margin: 0 0 var(--ha-space-3) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
.options + h3 {
margin-top: var(--ha-space-6);
}
.options {
display: grid;
gap: var(--ha-space-4);
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
}
.option {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
cursor: pointer;
outline: none;
}
.option:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.header {
flex: 1;
padding: var(--ha-space-3) var(--ha-space-4);
}
h4 {
margin: 0 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
p {
margin: 0;
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.preview {
height: 200px;
overflow: hidden;
padding: var(--ha-space-3);
background: var(--primary-background-color);
pointer-events: none;
mask-image: linear-gradient(to bottom, black 75%, transparent);
}
hui-section {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-select-section-strategy": HuiDialogSelectSectionStrategy;
}
}
@@ -19,8 +19,71 @@ import {
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import { DEFAULT_MAX_COLUMNS } from "../../views/const";
const COLUMN_SPAN_NAMES = ["single", "double", "triple"] as const;
const MAX_NAMED_COLUMN_SPAN = COLUMN_SPAN_NAMES.length + 1;
const computeColumnSpanLabel = (
span: number,
maxColumns: number,
localize: LocalizeFunc
) => {
if (span >= maxColumns) {
return localize(
"ui.panel.lovelace.editor.edit_section.settings.column_span_options.full"
);
}
return localize(
`ui.panel.lovelace.editor.edit_section.settings.column_span_options.${COLUMN_SPAN_NAMES[span - 1]}`
);
};
const computeColumnSpanSchemas = (
maxColumns: number,
localize: LocalizeFunc
) => {
if (maxColumns <= 1) {
return [];
}
if (maxColumns > MAX_NAMED_COLUMN_SPAN) {
return [
{
name: "column_span",
selector: {
number: { min: 1, max: maxColumns, slider_ticks: true },
},
},
] as const;
}
const options = Array.from({ length: maxColumns }, (_, i) => {
const span = i + 1;
return {
value: String(span),
label: computeColumnSpanLabel(span, maxColumns, localize),
image: {
src: `/static/images/form/section_column_span_${span}_of_${maxColumns}.svg`,
src_dark: `/static/images/form/section_column_span_${span}_of_${maxColumns}_dark.svg`,
flip_rtl: true,
},
};
});
return [
{
name: "column_span",
selector: {
select: {
mode: "box",
box_max_columns:
maxColumns === MAX_NAMED_COLUMN_SPAN ? 2 : maxColumns,
options,
},
},
},
] as const;
};
interface SettingsData {
column_span?: number;
column_span?: string | number;
background_enabled?: boolean;
background_color?: string;
background_opacity?: number;
@@ -36,20 +99,30 @@ export class HuiDialogEditSection extends LitElement {
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
private get _maxColumns() {
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
}
private get _displayedColumnSpan() {
return String(
Math.min(
this.config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
this._maxColumns
)
);
}
private get _columnSpanFormValue() {
if (this._maxColumns > MAX_NAMED_COLUMN_SPAN) {
return Number(this._displayedColumnSpan);
}
return this._displayedColumnSpan;
}
private _schema = memoizeOne(
(maxColumns: number, localize: LocalizeFunc) =>
(localize: LocalizeFunc, maxColumns: number) =>
[
{
name: "column_span",
default: DEFAULT_SECTION_COLUMN_SPAN,
selector: {
number: {
min: 1,
max: maxColumns,
slider_ticks: true,
},
},
},
...computeColumnSpanSchemas(maxColumns, localize),
{
name: "background_enabled",
selector: { boolean: {} },
@@ -109,17 +182,14 @@ export class HuiDialogEditSection extends LitElement {
const backgroundEnabled = background !== undefined;
const data: SettingsData = {
column_span: this.config.column_span,
column_span: this._columnSpanFormValue,
background_enabled: backgroundEnabled,
background_color: background?.color,
background_opacity: background?.opacity,
theme: this.config.theme,
};
const schema = this._schema(
this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
this._localize
);
const schema = this._schema(this._localize, this._maxColumns);
return html`
<ha-form
@@ -154,10 +224,15 @@ export class HuiDialogEditSection extends LitElement {
...this.config,
};
if (newData.column_span) {
newConfig.column_span = newData.column_span;
} else {
if (newData.column_span === undefined) {
delete newConfig.column_span;
} else if (String(newData.column_span) !== this._displayedColumnSpan) {
const columnSpan = Number(newData.column_span);
if (columnSpan === DEFAULT_SECTION_COLUMN_SPAN) {
delete newConfig.column_span;
} else {
newConfig.column_span = columnSpan;
}
}
if (newData.background_enabled) {
@@ -0,0 +1,30 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface CreateSectionDialogParams {
lovelace: Lovelace;
containerPath: LovelacePath;
}
export const showCreateSectionDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-create-section",
dialogImport: () => import("./hui-dialog-create-section"),
dialogParams: params,
});
};
export const showSelectSectionStrategyDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-select-section-strategy",
dialogImport: () => import("./hui-dialog-select-section-strategy"),
dialogParams: params,
});
};
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
@@ -8,6 +9,7 @@ export interface EditSectionDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
newSection?: LovelaceSectionRawConfig;
}
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
+5 -1
View File
@@ -279,7 +279,8 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
}
private async _initializeConfig() {
let sectionConfig = { ...this.config };
const config = this.config;
let sectionConfig = { ...config };
let isStrategy = false;
if (isStrategySection(sectionConfig)) {
@@ -288,6 +289,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
sectionConfig,
this.hass!
);
if (config !== this.config) {
return;
}
}
sectionConfig = {
+5 -1
View File
@@ -35,7 +35,11 @@ export type LovelaceDashboardStrategyGetCreateSuggestions = (
export interface LovelaceViewStrategy extends LovelaceStrategy<LovelaceViewConfig> {}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {
getStubConfig?(
hass: HomeAssistant
): LovelaceStrategyConfig | Promise<LovelaceStrategyConfig>;
}
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
setConfig(config: LovelaceStrategyConfig): void;
@@ -40,6 +40,10 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
);
}
public static getStubConfig(): CommonControlsSectionStrategyConfig {
return { type: "common-controls" };
}
static async generate(
config: CommonControlsSectionStrategyConfig,
hass: HomeAssistant
@@ -30,6 +30,7 @@ import "../components/hui-section-edit-mode";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
import type { HuiSection } from "../sections/hui-section";
import {
renderSectionContainer,
@@ -462,12 +463,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _createSection(): void {
const newConfig = addSection(
this.lovelace!.config,
this.path!,
generateDefaultSection(this.hass.localize, true)
);
this.lovelace!.saveConfig(newConfig);
showCreateSectionDialog(this, {
lovelace: this.lovelace!,
containerPath: this.path!,
});
}
private _sectionMoved(ev: CustomEvent) {
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import type { ContextType } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import {
mdiCheckAll,
mdiCheckCircleOutline,
mdiCompassOutline,
mdiDotsVertical,
@@ -35,7 +34,6 @@ import {
sortedMarketplaceFilterQueryString,
} from "../../../common/url/marketplace-query-params";
import { shallowEqual } from "../../../common/util/shallow-equal";
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
SelectionChangedEvent,
@@ -407,14 +405,6 @@ export class HaMarketplaceDashboard extends LitElement {
@sorting-changed=${this._tableSettings.sortingChanged}
>
${this._renderToolbar()}
<ha-icon-button
slot=${groupActionsSlot(
this._i18n.localize("ui.panel.marketplace.repository_status.new")
)}
.label=${this._i18n.localize("ui.panel.marketplace.menu.dismiss")}
.path=${mdiCheckAll}
@click=${this._dismissNewFromGroup}
></ha-icon-button>
${
this.tab === "installed"
? html`<ha-assist-chip
@@ -952,48 +942,18 @@ export class HaMarketplaceDashboard extends LitElement {
showMarketplaceAddFromLink(this, this._i18n.localize, this.marketplace);
}
private _dismissNewFromGroup(ev: HASSDomCurrentTargetEvent<HTMLElement>) {
this._dismissNew(ev.currentTarget);
}
private _dismissNew(anchor?: Element) {
showConfirmationDialog(this, {
anchor,
title: this._i18n.localize(
"ui.panel.marketplace.dialog.dismiss_new.title"
),
// Dismissing clears all of them, not only the ones the filters show
text: this._i18n.localize(
"ui.panel.marketplace.dialog.dismiss_new.text",
{
count: this.marketplace.repositories.filter(
(repository) => repository.new
).length,
}
),
confirmText: this._i18n.localize(
"ui.panel.marketplace.dialog.dismiss_new.confirm"
),
dismissText: this._i18n.localize("ui.common.cancel"),
action: async () => {
try {
await dismissNewMarketplaceRepositories(
this._api,
this.marketplace.info.categories
);
} catch (err: unknown) {
showAlertDialog(this, {
title: this._i18n.localize(
"ui.panel.marketplace.dialog.error.title"
),
text: marketplaceErrorMessage(err, this._i18n.localize),
});
// The alert reuses the confirmation's dialog, which only stays open
// when its action fails
throw err;
}
},
});
private async _dismissNew() {
try {
await dismissNewMarketplaceRepositories(
this._api,
this.marketplace.info.categories
);
} catch (err: unknown) {
showAlertDialog(this, {
title: this._i18n.localize("ui.panel.marketplace.dialog.error.title"),
text: marketplaceErrorMessage(err, this._i18n.localize),
});
}
}
private _statusStates = memoize(
+36 -7
View File
@@ -10429,8 +10429,36 @@
"error_section": {
"title": "Error"
},
"section_strategy": {
"common-controls": {
"name": "Common controls",
"description": "Shows the entities you use most, based on your activity."
},
"device": {
"name": "Device",
"description": "Shows the controls and sensors of a device."
}
},
"create_section": {
"header": "Create section",
"manual": "Manual section",
"manual_description": "Start empty and add your own cards.",
"automatic": "[%key:ui::panel::lovelace::editor::section::automatic%]",
"automatic_description": "Fills itself with cards and stays up to date.",
"paste": "Paste section",
"paste_description": "Add the section you copied."
},
"select_section_strategy": {
"header": "Choose an automatic section",
"built_in": "Built-in sections",
"custom": "Community sections"
},
"edit_section": {
"header": "Edit section",
"header": "Section configuration",
"typed_header": "{type} section configuration",
"preview": "Preview",
"preview_desktop": "Desktop preview",
"preview_mobile": "Mobile preview",
"tab_strategy": "Configuration",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
"tab_appearance": "Appearance",
@@ -10438,7 +10466,13 @@
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
"settings": {
"column_span": "Width",
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
"column_span_helper": "Smaller screens may show it narrower, and phones stack sections one under the other.",
"column_span_options": {
"single": "Single",
"double": "Double",
"triple": "Triple",
"full": "Full width"
},
"background": "Background options",
"background_enabled": "Background",
"background_enabled_helper": "Display a colored background behind the section",
@@ -12950,11 +12984,6 @@
"error": {
"title": "Error"
},
"dismiss_new": {
"title": "Dismiss new repositories?",
"text": "{count, plural,\n one {The new repository will no longer be marked as new.}\n other {The {count} new repositories will no longer be marked as new.}\n}",
"confirm": "Dismiss"
},
"reload": {
"description": "You need to reload your browser for the updated resources to be used.",
"confirm": "Do you want to do that now?"