Render todo items with no state, change look of read only items (#24529)

* Render todo items with no state, change look of read only items

Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
This commit is contained in:
Bram Kragten 2025-04-28 16:06:02 +02:00 committed by GitHub
parent 7c46d2d2f4
commit 66dbafb5f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 229 additions and 90 deletions

View File

@ -2,8 +2,7 @@ import type { TodoItem } from "../../../src/data/todo";
import { TodoItemStatus } from "../../../src/data/todo"; import { TodoItemStatus } from "../../../src/data/todo";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTodo = (hass: MockHomeAssistant) => { const items = {
hass.mockWS("todo/item/list", () => ({
items: [ items: [
{ {
uid: "12", uid: "12",
@ -20,8 +19,19 @@ export const mockTodo = (hass: MockHomeAssistant) => {
summary: "Oranges", summary: "Oranges",
status: TodoItemStatus.Completed, status: TodoItemStatus.Completed,
}, },
{
uid: "15",
summary: "Beer",
},
] as TodoItem[], ] as TodoItem[],
})); };
// eslint-disable-next-line @typescript-eslint/no-empty-function
hass.mockWS("todo/item/subscribe", (_msg, _hass) => () => {}); export const mockTodo = (hass: MockHomeAssistant) => {
hass.mockWS("todo/item/list", () => items);
hass.mockWS("todo/item/move", () => undefined);
hass.mockWS("todo/item/subscribe", (_msg, _hass, onChange) => {
onChange!(items);
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
}; };

View File

@ -1,11 +1,11 @@
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators"; import { customElement, query } from "lit/decorators";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards"; import "../../components/demo-cards";
import { getEntity } from "../../../../src/fake_data/entity";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockIcons } from "../../../../demo/src/stubs/icons";
const ENTITIES = [ const ENTITIES = [
getEntity("todo", "shopping_list", "2", { getEntity("todo", "shopping_list", "2", {

View File

@ -1,17 +1,54 @@
import { css } from "lit";
import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base"; import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css"; import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css"; import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators"; import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import "./ha-checkbox";
@customElement("ha-check-list-item") @customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase { export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
async onChange(event) { async onChange(event) {
super.onChange(event); super.onChange(event);
fireEvent(this, event.type); fireEvent(this, event.type);
} }
override render() {
const checkboxClasses = {
"mdc-deprecated-list-item__graphic": this.left,
"mdc-deprecated-list-item__meta": !this.left,
};
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<span class=${classMap(checkboxClasses)}>
<ha-checkbox
reducedTouchTarget
tabindex=${this.tabindex}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
>
</ha-checkbox>
</span>
${this.left ? text : ""} ${meta}`;
}
static override styles = [ static override styles = [
styles, styles,
controlStyles, controlStyles,

View File

@ -25,7 +25,7 @@ export enum TodoSortMode {
export interface TodoItem { export interface TodoItem {
uid: string; uid: string;
summary: string; summary: string;
status: TodoItemStatus; status: TodoItemStatus | null;
description?: string | null; description?: string | null;
due?: string | null; due?: string | null;
} }
@ -72,7 +72,7 @@ export const fetchItems = async (
export const subscribeItems = ( export const subscribeItems = (
hass: HomeAssistant, hass: HomeAssistant,
entity_id: string, entity_id: string,
callback: (item) => void callback: (update: TodoItems) => void
) => ) =>
hass.connection.subscribeMessage<any>(callback, { hass.connection.subscribeMessage<any>(callback, {
type: "todo/item/subscribe", type: "todo/item/subscribe",

View File

@ -156,6 +156,19 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
return items; return items;
} }
private _getUncheckedAndItemsWithoutStatus = memoizeOne(
(items?: TodoItem[], sort?: string | undefined): TodoItem[] =>
items
? this._sortItems(
items.filter(
(item) =>
item.status === TodoItemStatus.NeedsAction || !item.status
),
sort
)
: []
);
private _getCheckedItems = memoizeOne( private _getCheckedItems = memoizeOne(
(items?: TodoItem[], sort?: string | undefined): TodoItem[] => (items?: TodoItem[], sort?: string | undefined): TodoItem[] =>
items items
@ -176,6 +189,16 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
: [] : []
); );
private _getItemsWithoutStatus = memoizeOne(
(items?: TodoItem[], sort?: string | undefined): TodoItem[] =>
items
? this._sortItems(
items.filter((item) => !item.status),
sort
)
: []
);
public willUpdate( public willUpdate(
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown> changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void { ): void {
@ -235,6 +258,18 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
this._config.display_order this._config.display_order
); );
const itemsWithoutStatus = this._getItemsWithoutStatus(
this._items,
this._config.display_order
);
const reorderableItems = this._reordering
? this._getUncheckedAndItemsWithoutStatus(
this._items,
this._config.display_order
)
: undefined;
return html` return html`
<ha-card <ha-card
.header=${this._config.title} .header=${this._config.title}
@ -274,7 +309,24 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
@item-moved=${this._itemMoved} @item-moved=${this._itemMoved}
> >
<ha-list wrapFocus multi> <ha-list wrapFocus multi>
${uncheckedItems.length ${!uncheckedItems.length && !itemsWithoutStatus.length
? html`<p class="empty">
${this.hass.localize(
"ui.panel.lovelace.cards.todo-list.no_unchecked_items"
)}
</p>`
: this._reordering
? html`<div class="header" role="seperator">
<h2>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.reorder_items"
)}
</h2>
${this._renderMenu(this._config, unavailable)}
</div>
${this._renderItems(reorderableItems ?? [], unavailable)}`
: nothing}
${!this._reordering && uncheckedItems.length
? html` ? html`
<div class="header" role="seperator"> <div class="header" role="seperator">
<h2> <h2>
@ -282,47 +334,35 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
"ui.panel.lovelace.cards.todo-list.unchecked_items" "ui.panel.lovelace.cards.todo-list.unchecked_items"
)} )}
</h2> </h2>
${(!this._config.display_order || ${this._renderMenu(this._config, unavailable)}
this._config.display_order === TodoSortMode.NONE) &&
this._todoListSupportsFeature(
TodoListEntityFeature.MOVE_TODO_ITEM
)
? html`<ha-button-menu
@closed=${stopPropagation}
fixed
@action=${this._handlePrimaryMenuAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
${this.hass!.localize(
this._reordering
? "ui.panel.lovelace.cards.todo-list.exit_reorder_items"
: "ui.panel.lovelace.cards.todo-list.reorder_items"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiSort}
.disabled=${unavailable}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>`
: nothing}
</div> </div>
${this._renderItems(uncheckedItems, unavailable)} ${this._renderItems(uncheckedItems, unavailable)}
` `
: html`<p class="empty"> : nothing}
${this.hass.localize( ${!this._reordering && itemsWithoutStatus.length
"ui.panel.lovelace.cards.todo-list.no_unchecked_items" ? html`
<div>
${uncheckedItems.length
? html`<div class="divider" role="seperator"></div>`
: nothing}
<div class="header" role="seperator">
<h2>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.no_status_items"
)} )}
</p>`} </h2>
${!uncheckedItems.length
? this._renderMenu(this._config, unavailable)
: nothing}
</div>
</div>
${this._renderItems(itemsWithoutStatus, unavailable)}
`
: nothing}
${!this._config.hide_completed && checkedItems.length ${!this._config.hide_completed && checkedItems.length
? html` ? html`
<div role="separator"> <div>
<div class="divider"></div> <div class="divider" role="separator"></div>
<div class="header"> <div class="header">
<h2> <h2>
${this.hass!.localize( ${this.hass!.localize(
@ -359,13 +399,43 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
</div> </div>
${this._renderItems(checkedItems, unavailable)} ${this._renderItems(checkedItems, unavailable)}
` `
: ""} : nothing}
</ha-list> </ha-list>
</ha-sortable> </ha-sortable>
</ha-card> </ha-card>
`; `;
} }
private _renderMenu(config: TodoListCardConfig, unavailable: boolean) {
return (!config.display_order ||
config.display_order === TodoSortMode.NONE) &&
this._todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM)
? html`<ha-button-menu
@closed=${stopPropagation}
fixed
@action=${this._handlePrimaryMenuAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
${this.hass!.localize(
this._reordering
? "ui.panel.lovelace.cards.todo-list.exit_reorder_items"
: "ui.panel.lovelace.cards.todo-list.reorder_items"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiSort}
.disabled=${unavailable}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>`
: nothing;
}
private _getDueDate(item: TodoItem): Date | undefined { private _getDueDate(item: TodoItem): Date | undefined {
return item.due return item.due
? item.due.includes("T") ? item.due.includes("T")
@ -397,16 +467,19 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
left left
.hasMeta=${showReorder || showDelete} .hasMeta=${showReorder || showDelete}
class="editRow ${classMap({ class="editRow ${classMap({
draggable: item.status === TodoItemStatus.NeedsAction, draggable: item.status !== TodoItemStatus.Completed,
completed: item.status === TodoItemStatus.Completed, completed: item.status === TodoItemStatus.Completed,
multiline: Boolean(item.description || item.due), multiline: Boolean(item.description || item.due),
})}" })}"
.selected=${item.status === TodoItemStatus.Completed} .selected=${item.status === TodoItemStatus.Completed}
.disabled=${unavailable || .disabled=${unavailable}
!this._todoListSupportsFeature( .checkboxDisabled=${!this._todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM
)}
.indeterminate=${!item.status}
.noninteractive=${!this._todoListSupportsFeature(
TodoListEntityFeature.UPDATE_TODO_ITEM TodoListEntityFeature.UPDATE_TODO_ITEM
)} )}
item-id=${item.uid}
.itemId=${item.uid} .itemId=${item.uid}
@change=${this._completeItem} @change=${this._completeItem}
@click=${this._openItem} @click=${this._openItem}
@ -631,35 +704,53 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
this._moveItem(oldIndex, newIndex); this._moveItem(oldIndex, newIndex);
} }
private async _moveItem(oldIndex: number, newIndex: number) { private _findFirstItem(
// correct index for header items: HTMLCollection,
oldIndex -= 1; start: number,
newIndex -= 1; direction: "up" | "down"
const uncheckedItems = this._getUncheckedItems(this._items); ) {
const item = uncheckedItems[oldIndex]; let item: Element | undefined;
let prevItem: TodoItem | undefined; let index = direction === "up" ? start - 1 : start;
if (newIndex > 0) { while (item?.localName !== "ha-check-list-item") {
if (newIndex < oldIndex) { item = items[index];
prevItem = uncheckedItems[newIndex - 1]; index = direction === "up" ? index - 1 : index + 1;
} else { if (!item) {
prevItem = uncheckedItems[newIndex]; break;
} }
} }
return item;
}
private async _moveItem(oldIndex: number, newIndex: number) {
await this.updateComplete;
const list = this.renderRoot.querySelector("ha-list")!;
const items = list.children;
const itemId = (items[oldIndex] as any).itemId as string;
const prevItemId = (
this._findFirstItem(
items,
newIndex,
newIndex < oldIndex ? "up" : "down"
) as any
)?.itemId;
// Optimistic change // Optimistic change
const itemIndex = this._items!.findIndex((itm) => itm.uid === item.uid); const itemIndex = this._items!.findIndex((itm) => itm.uid === itemId);
this._items!.splice(itemIndex, 1); const item = this._items!.splice(itemIndex, 1)[0];
if (newIndex === 0) {
if (!prevItemId) {
this._items!.unshift(item); this._items!.unshift(item);
} else { } else {
const prevIndex = this._items!.findIndex( const prevIndex = this._items!.findIndex((itm) => itm.uid === prevItemId);
(itm) => itm.uid === prevItem!.uid
);
this._items!.splice(prevIndex + 1, 0, item); this._items!.splice(prevIndex + 1, 0, item);
} }
this._items = [...this._items!]; this._items = [...this._items!];
await moveItem(this.hass!, this._entityId!, item.uid, prevItem?.uid); await moveItem(this.hass!, this._entityId!, itemId, prevItemId);
} }
static styles = css` static styles = css`

View File

@ -6529,6 +6529,7 @@
"unchecked_items": "Active", "unchecked_items": "Active",
"no_unchecked_items": "You have no to-do items!", "no_unchecked_items": "You have no to-do items!",
"checked_items": "Completed", "checked_items": "Completed",
"no_status_items": "No status",
"clear_items": "Remove completed items", "clear_items": "Remove completed items",
"add_item": "Add item", "add_item": "Add item",
"today": "Today", "today": "Today",