Compare commits

...
9 changed files with 247 additions and 51 deletions

No files matched your search

+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;
+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 });
@@ -122,6 +122,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import {
getAreaTableColumn,
@@ -564,6 +565,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.path=${mdiHelpCircleOutline}
@click=${this._showHelp}
></ha-icon-button>
${renderCategoryGroupActions(this.hass, "automation", this._categories)}
<ha-filter-floor-areas
.type=${"automation"}
.value=${this._filters["ha-filter-floor-areas"]?.value}
@@ -0,0 +1,45 @@
import { mdiPencil } from "@mdi/js";
import { html } from "lit";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-icon-button";
import type { CategoryRegistryEntry } from "../../../data/category_registry";
import { updateCategoryRegistryEntry } from "../../../data/category_registry";
import type { HomeAssistant } from "../../../types";
import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail";
type CategoryEditButton = HTMLElement & {
category: CategoryRegistryEntry;
scope: string;
hass: HomeAssistant;
};
const editCategory = (ev: HASSDomCurrentTargetEvent<CategoryEditButton>) => {
const { category, scope, hass } = ev.currentTarget;
showCategoryRegistryDetailDialog(ev.currentTarget, {
scope,
entry: category,
updateEntry: (updates) =>
updateCategoryRegistryEntry(hass, scope, category.category_id, updates),
});
};
// Edit buttons for the category group headers of a table grouped by category,
// which names its groups after the categories
export const renderCategoryGroupActions = (
hass: HomeAssistant,
scope: string,
categories: CategoryRegistryEntry[] | undefined
) =>
categories?.map(
(category) =>
html`<ha-icon-button
slot=${groupActionsSlot(category.name)}
.label=${hass.localize("ui.panel.config.category.editor.edit")}
.path=${mdiPencil}
.category=${category}
.scope=${scope}
.hass=${hass}
@click=${editCategory}
></ha-icon-button>`
);
@@ -121,6 +121,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import {
getAreaTableColumn,
@@ -869,6 +870,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
${renderCategoryGroupActions(this.hass, "helpers", this._categories)}
<ha-button slot="fab" size="l" @click=${this._createHelper}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.helpers.picker.create_helper")}
@@ -109,6 +109,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import {
getAreaTableColumn,
@@ -562,6 +563,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircleOutline}
></ha-icon-button>
${renderCategoryGroupActions(this.hass, "scene", this._categories)}
<ha-filter-floor-areas
.type=${"scene"}
@@ -113,6 +113,7 @@ import { showToast } from "../../../util/toast";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showNewAutomationDialog } from "../automation/show-dialog-new-automation";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import {
getAreaTableColumn,
@@ -496,6 +497,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
.path=${mdiHelpCircleOutline}
@click=${this._showHelp}
></ha-icon-button>
${renderCategoryGroupActions(this.hass, "script", this._categories)}
<ha-filter-floor-areas
.type=${"script"}
.value=${this._filters["ha-filter-floor-areas"]?.value}
@@ -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._dismissNew}
></ha-icon-button>
${
this.tab === "installed"
? html`<ha-assist-chip
@@ -942,18 +952,43 @@ 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 _dismissNew() {
showConfirmationDialog(this, {
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?"