Compare commits

...
Author SHA1 Message Date
Aidan Timson e1851be1ba Limit form popovers to quick edits in the notes 2026-10-23 13:14:50 +01:00
Aidan Timson 6a28ea1ad0 Soften the design system note on the popover look 2026-10-23 13:13:40 +01:00
Aidan Timson 8b810ec443 Defer the popover look to the new design system in the notes 2026-10-23 13:12:30 +01:00
Aidan Timson 75d89621c6 Drop the Windows reference from the notes 2026-10-23 13:10:50 +01:00
Aidan Timson d1ad7bba51 Link earlier popover work and radius references in the notes 2026-10-23 13:08:02 +01:00
Aidan Timson 4891090b28 Drop the stale energy capture note 2026-10-23 13:03:23 +01:00
Aidan Timson 0a1cf4d700 Note open questions on popovers in dialogs and forms 2026-10-23 13:02:04 +01:00
Aidan Timson f39fd0ee78 Add notes on the anchored popover concept 2026-10-23 12:58:07 +01:00
Aidan Timson 0dd969c9e5 Anchor automation trigger and action renames 2026-10-23 12:57:59 +01:00
Aidan Timson 4ff6cb41b0 Keep form popovers at dialog width, let messages grow 2026-10-23 12:54:55 +01:00
Aidan Timson c0f4f34083 Show title-only confirmations as plain text in popovers 2026-10-23 12:51:24 +01:00
Aidan Timson f6ad7095e7 Show simple forms as a popover next to their anchor 2026-10-23 12:49:19 +01:00
Aidan Timson b53c2aae7e Let compact popovers grow to fit their text 2026-10-23 12:47:44 +01:00
Aidan Timson c92271dc6b Anchor confirmations to the buttons that open them 2026-10-23 12:47:35 +01:00
Aidan Timson ae0c05b6f5 Anchor the Marketplace group dismiss confirmation 2026-10-23 12:40:48 +01:00
Aidan Timson 63a762ee49 Show confirmations as a popover next to their anchor 2026-10-23 12:40:40 +01:00
Aidan Timson 9287002e45 Size group header actions like other header icon buttons 2026-10-23 12:27:07 +01:00
Aidan Timson dcf64f43bd Let pages change the colour of group header actions 2026-10-23 12:27:07 +01:00
Aidan Timson f934a72f41 Keep the error alert open when dismissing new repositories fails 2026-10-23 12:27:07 +01:00
Aidan Timson 71acef882c Keep group header actions in view and forward ungrouped slots 2026-10-23 12:27:07 +01:00
Aidan Timson cef7461935 Style group header actions like the rows' icon buttons 2026-10-23 12:27:07 +01:00
Aidan Timson 25231b3353 Dismiss new repositories from the Marketplace group header 2026-10-23 12:27:07 +01:00
Aidan Timson 09ea28a8b0 Add an actions slot to data table group headers 2026-10-23 12:27:07 +01:00
26 changed files with 617 additions and 187 deletions

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
# 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.
+113 -38
View File
@@ -124,7 +124,25 @@ 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.
@@ -209,6 +227,9 @@ 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;
@@ -720,25 +741,14 @@ export class HaDataTable extends LitElement {
</div>
`
: html`
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
)}"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
@visibilityChanged=${this._handleVisibilityChanged}
.items=${items}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
${
// 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"
aria-hidden="true"
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
@@ -751,6 +761,18 @@ export class HaDataTable extends LitElement {
</div>`
: nothing
}
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
)}"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
@visibilityChanged=${this._handleVisibilityChanged}
.items=${items}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
`
}
</div>
@@ -1025,12 +1047,10 @@ export class HaDataTable extends LitElement {
if (groupColumn) {
const isGroupSortColumn = sortColumn === 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 grouped = groupBy(items, (item) =>
dataTableGroupKey(item[groupColumn])
);
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
grouped
)
@@ -1107,12 +1127,14 @@ export class HaDataTable extends LitElement {
localize: LocalizeFunc | undefined,
sticky = false
) {
// 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.
// 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.
return html`<div
class="mdc-data-table__cell group-header"
role="cell"
role=${ifDefined(sticky ? undefined : "cell")}
.group=${groupName}
@click=${sticky ? nothing : this._collapseGroup}
>
@@ -1127,14 +1149,27 @@ export class HaDataTable extends LitElement {
?inert=${sticky}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
<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>
</div>`;
}
// Actions don't toggle the group their header belongs to
private _stopGroupToggle(ev: Event) {
ev.stopPropagation();
}
private _memFilterData = memoizeOne(
(
data: DataTableRowData[],
@@ -1436,6 +1471,11 @@ 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 })
@@ -1503,18 +1543,15 @@ export class HaDataTable extends LitElement {
}
public collapseAllGroups() {
if (
!this.groupColumn ||
!this.data.some((item) => item[this.groupColumn!])
) {
const groupColumn = this.groupColumn;
if (!groupColumn || !this.data.some((item) => item[groupColumn])) {
return;
}
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;
}
const grouped = groupBy(this.data, (item) =>
dataTableGroupKey(item[groupColumn])
);
this._collapsedGroups = Object.keys(grouped);
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
@@ -1873,6 +1910,16 @@ 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 {
@@ -1883,6 +1930,34 @@ 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;
+48 -1
View File
@@ -11,12 +11,24 @@ 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;
@@ -81,6 +93,7 @@ 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}
@@ -285,7 +298,11 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
padding: 0;
border-color: transparent;
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
min-width: var(--width, var(--full-width));
/* 0 fits the popover to its content, up to its width */
min-width: var(
--ha-adaptive-popover-min-width,
var(--width, var(--full-width))
);
max-width: var(--width);
max-height: var(
--ha-dialog-max-height,
@@ -334,6 +351,36 @@ 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)
+7 -2
View File
@@ -16,6 +16,8 @@ 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.
*
@@ -80,11 +82,14 @@ export class HaDialogHeader extends LitElement {
}
.header-content {
flex: 1;
padding: 10px var(--ha-space-1);
padding: var(
--ha-dialog-header-content-padding,
10px var(--ha-space-1)
);
display: flex;
flex-direction: column;
justify-content: center;
min-height: var(--ha-space-12);
min-height: var(--ha-dialog-header-min-height, var(--ha-space-12));
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -262,6 +262,7 @@ export class HaObjectSelector extends LitElement {
};
const newItem = await showFormDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.common.add"),
schema,
data,
@@ -290,6 +291,7 @@ 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,
+72 -38
View File
@@ -11,6 +11,7 @@ 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";
@@ -39,8 +40,13 @@ 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 = {};
@@ -61,6 +67,7 @@ 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;
@@ -250,12 +257,7 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
@@ -265,7 +267,10 @@ export class DialogForm
this._focusFirstControl();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
// Only the dialog keeps its own scroll position, a popover fits its form
if (this._dialog) {
this._dialog.bodyContainer.scrollTop = scrollTop;
}
}
private _dialogClosed(): void {
@@ -284,6 +289,7 @@ export class DialogForm
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._anchor = undefined;
this._initialData = {};
this._data = {};
this._open = false;
@@ -387,6 +393,23 @@ 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}
@@ -394,41 +417,52 @@ export class DialogForm
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${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>
${this._renderForms(levels)} ${this._renderFooter(params)}
</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,6 +5,12 @@ 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
@@ -30,6 +36,7 @@ export const showFormDialog = (
fireEvent(element, "show-dialog", {
dialogTag: "dialog-form",
dialogImport: () => import("./dialog-form"),
dialogAnchor: dialogParams.anchor,
dialogParams: {
...dialogParams,
cancel: () => {
+138 -82
View File
@@ -4,6 +4,7 @@ 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";
@@ -27,8 +28,13 @@ 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";
@@ -48,6 +54,7 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
await this._closePromise;
}
this._params = params;
this._anchor = this.dialogAnchor;
this._validInput = true;
this._open = true;
this._initDirtyTracking(
@@ -84,6 +91,42 @@ 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}
@@ -126,95 +169,97 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
: nothing
}
</ha-dialog-header>
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
${
this._params.prompt && !this._params.multiline
${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
? html`
<ha-input
<ha-textarea
resize="auto"
autofocus
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}
.value=${params.defaultValue}
.placeholder=${params.placeholder}
.label=${params.inputLabel}
.disabled=${this._loading}
@input=${this._validateInput}
>
${
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>
></ha-textarea>
`
: 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
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>
</ha-button>
</ha-dialog-footer>
`;
}
@@ -287,6 +332,7 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
}
this._closeState = undefined;
this._params = undefined;
this._anchor = undefined;
this._open = false;
this._loading = false;
this._closeResolve?.();
@@ -294,6 +340,16 @@ 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,6 +4,12 @@ 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;
@@ -66,6 +72,7 @@ const showDialogHelper = (
fireEvent(element, "show-dialog", {
dialogTag: "dialog-box",
dialogImport: loadGenericDialog,
dialogAnchor: dialogParams.anchor,
addHistory: dialogParams.addHistory,
dialogParams: {
...dialogParams,
+29 -1
View File
@@ -17,6 +17,7 @@ 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";
@@ -27,7 +28,10 @@ import {
dataTableQueryStateFromUrl,
} from "../common/url/data-table-query-params";
import "../components/chips/ha-assist-chip";
import "../components/data-table/ha-data-table";
import {
dataTableGroupKey,
groupActionsSlot,
} from "../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
@@ -631,6 +635,13 @@ 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`
@@ -846,6 +857,23 @@ 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,6 +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 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";
@@ -130,10 +131,13 @@ export class HaConfigAppsRegistries extends LitElement {
});
}
private _removeRegistry = async (ev: Event) => {
const registry = (ev.currentTarget as any).registry;
private _removeRegistry = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { registry: string }>
) => {
const registry = ev.currentTarget.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,7 +3,10 @@ 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 } from "../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} 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";
@@ -218,11 +221,14 @@ export class HaConfigAppsRepositories extends LitElement {
});
}
private _removeRepository = async (ev: Event) => {
const slug = (ev.currentTarget as any).slug;
private _removeRepository = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { slug: string }>
) => {
const slug = ev.currentTarget.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,6 +225,8 @@ 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;
@@ -485,6 +487,7 @@ 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>
@@ -1013,8 +1016,9 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlMode = true;
}
private _renameAction = async (): Promise<void> => {
private _renameAction = async (anchor?: Element): Promise<void> => {
const alias = await showPromptDialog(this, {
anchor,
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.change_alias"
),
@@ -1303,7 +1307,8 @@ export default class HaAutomationActionRow extends LitElement {
this._runAction();
break;
case "rename":
this._renameAction();
// The menu has closed, so open next to its button
this._renameAction(this._menuTrigger);
break;
case "edit_note":
this._editNoteAction();
@@ -181,6 +181,8 @@ 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;
@@ -391,6 +393,7 @@ 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>
@@ -924,9 +927,10 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _renameTrigger = async (): Promise<void> => {
private _renameTrigger = async (anchor?: Element): 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"
),
@@ -1174,7 +1178,8 @@ export default class HaAutomationTriggerRow extends LitElement {
switch (action) {
case "rename":
this._renameTrigger();
// The menu has closed, so open next to its button
this._renameTrigger(this._menuTrigger);
break;
case "edit_note":
this._editNoteTrigger();
@@ -205,6 +205,7 @@ export class EnergyBatterySettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -269,6 +269,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -266,6 +266,7 @@ export class EnergyDeviceSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -197,6 +197,7 @@ export class EnergyGasSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -261,6 +261,7 @@ 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"),
}))
) {
@@ -323,6 +324,7 @@ export class EnergyGridSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -207,6 +207,7 @@ export class EnergySolarSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -186,6 +186,7 @@ export class EnergyWaterSettings extends LitElement {
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize("ui.panel.config.energy.delete_source"),
}))
) {
@@ -3,7 +3,10 @@ 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 } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-picker";
@@ -185,10 +188,13 @@ class HaInputSelectForm extends LitElement {
input.value = "";
}
private async _removeOption(ev: Event) {
const index = (ev.target as any).index;
private async _removeOption(
ev: HASSDomCurrentTargetEvent<HTMLElement & { index: number }>
) {
const index = ev.currentTarget.index;
if (
!(await showConfirmationDialog(this, {
anchor: ev.currentTarget,
title: this.hass.localize(
"ui.dialogs.helper_settings.input_select.confirm_delete.delete"
),
@@ -284,6 +284,7 @@ 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, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dropdown";
@@ -50,6 +50,8 @@ 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()
@@ -95,6 +97,7 @@ 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>
@@ -292,6 +295,8 @@ 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"
),
@@ -2,6 +2,7 @@ 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,
@@ -34,6 +35,7 @@ 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,
@@ -405,6 +407,14 @@ 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
@@ -942,18 +952,48 @@ export class HaMarketplaceDashboard extends LitElement {
showMarketplaceAddFromLink(this, this._i18n.localize, this.marketplace);
}
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 _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 _statusStates = memoize(
+5
View File
@@ -12950,6 +12950,11 @@
"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?"