Compare commits

...
4 changed files with 225 additions and 50 deletions

No files matched your search

+129 -37
View File
@@ -124,7 +124,28 @@ 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 GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
export const groupActionsSlot = (group: string) =>
`${GROUP_ACTIONS_SLOT_PREFIX}${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.
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 +230,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;
@@ -480,6 +504,14 @@ export class HaDataTable extends LitElement {
)
: [];
// The pinned group header is the same row as its group's header
const stickyGroupIndex =
this._stickyGroup === undefined
? -1
: items.findIndex(
(item) => item.append && item.groupHeader === this._stickyGroup
);
return html`
<div class="mdc-data-table">
<slot name="header" @slotchange=${this._calcTableHeight}>
@@ -720,25 +752,20 @@ 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"
role="row"
aria-rowindex=${ifDefined(
stickyGroupIndex === -1
? undefined
: stickyGroupIndex + (this.loadError ? 3 : 2)
)}
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
@@ -751,6 +778,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 +1064,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,9 +1144,11 @@ 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"
@@ -1127,14 +1166,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 +1488,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 +1560,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 +1927,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 +1947,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;
+44 -1
View File
@@ -27,7 +27,7 @@ import {
dataTableQueryStateFromUrl,
} from "../common/url/data-table-query-params";
import "../components/chips/ha-assist-chip";
import "../components/data-table/ha-data-table";
import { GROUP_ACTIONS_SLOT_PREFIX } from "../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
@@ -631,6 +631,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.columnOrder=${this.columnOrder}
.hiddenColumns=${this.hiddenColumns}
>
${this._groupActionsSlots.map(
(slot) => html`<slot name=${slot} slot=${slot}></slot>`
)}
${
!this.narrow
? html`
@@ -846,6 +849,46 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
this._setGroupColumn(group);
}
// Slots of the group actions the page supplies, passed through to the table
@state() private _groupActionsSlots: string[] = [];
private _groupActionsObserver = new MutationObserver(() =>
this._updateGroupActionsSlots()
);
public connectedCallback(): void {
super.connectedCallback();
this._updateGroupActionsSlots();
// Subtree, so slot changes on the page's children are seen too
this._groupActionsObserver.observe(this, {
childList: true,
subtree: true,
attributeFilter: ["slot"],
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._groupActionsObserver.disconnect();
}
private _updateGroupActionsSlots() {
const slots = [
...new Set(
Array.from(this.children, (child) => child.slot).filter((slot) =>
slot.startsWith(GROUP_ACTIONS_SLOT_PREFIX)
)
),
];
if (
slots.length !== this._groupActionsSlots.length ||
slots.some((slot, index) => slot !== this._groupActionsSlots[index])
) {
this._groupActionsSlots = slots;
}
}
private _setGroupColumn(columnId: string, link = false) {
this._groupColumn = columnId;
fireEvent(this, "grouping-changed", { value: columnId, link });
@@ -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?"