diff --git a/demo/src/stubs/todo.ts b/demo/src/stubs/todo.ts index 2d30294c18..b1a9755521 100644 --- a/demo/src/stubs/todo.ts +++ b/demo/src/stubs/todo.ts @@ -2,26 +2,36 @@ import type { TodoItem } from "../../../src/data/todo"; import { TodoItemStatus } from "../../../src/data/todo"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; -export const mockTodo = (hass: MockHomeAssistant) => { - hass.mockWS("todo/item/list", () => ({ - items: [ - { - uid: "12", - summary: "Milk", - status: TodoItemStatus.NeedsAction, - }, - { - uid: "13", - summary: "Eggs", - status: TodoItemStatus.NeedsAction, - }, - { - uid: "14", - summary: "Oranges", - status: TodoItemStatus.Completed, - }, - ] as TodoItem[], - })); - // eslint-disable-next-line @typescript-eslint/no-empty-function - hass.mockWS("todo/item/subscribe", (_msg, _hass) => () => {}); +const items = { + items: [ + { + uid: "12", + summary: "Milk", + status: TodoItemStatus.NeedsAction, + }, + { + uid: "13", + summary: "Eggs", + status: TodoItemStatus.NeedsAction, + }, + { + uid: "14", + summary: "Oranges", + status: TodoItemStatus.Completed, + }, + { + uid: "15", + summary: "Beer", + }, + ] as TodoItem[], +}; + +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 () => {}; + }); }; diff --git a/gallery/src/pages/lovelace/todo-list-card.ts b/gallery/src/pages/lovelace/todo-list-card.ts index 2ed2d68ddd..ebababd4f5 100644 --- a/gallery/src/pages/lovelace/todo-list-card.ts +++ b/gallery/src/pages/lovelace/todo-list-card.ts @@ -1,11 +1,11 @@ import type { PropertyValues, TemplateResult } from "lit"; import { html, LitElement } from "lit"; 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 "../../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 = [ getEntity("todo", "shopping_list", "2", { diff --git a/src/components/ha-check-list-item.ts b/src/components/ha-check-list-item.ts index 0152f19fcc..289f43481d 100644 --- a/src/components/ha-check-list-item.ts +++ b/src/components/ha-check-list-item.ts @@ -1,17 +1,54 @@ -import { css } from "lit"; 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 } 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 "./ha-checkbox"; @customElement("ha-check-list-item") export class HaCheckListItem extends CheckListItemBase { + @property({ type: Boolean, attribute: "checkbox-disabled" }) + checkboxDisabled = false; + + @property({ type: Boolean }) + indeterminate = false; + async onChange(event) { super.onChange(event); 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} + + + + + ${this.left ? text : ""} ${meta}`; + } + static override styles = [ styles, controlStyles, diff --git a/src/data/todo.ts b/src/data/todo.ts index 0c02d76c1a..7f72f74506 100644 --- a/src/data/todo.ts +++ b/src/data/todo.ts @@ -25,7 +25,7 @@ export enum TodoSortMode { export interface TodoItem { uid: string; summary: string; - status: TodoItemStatus; + status: TodoItemStatus | null; description?: string | null; due?: string | null; } @@ -72,7 +72,7 @@ export const fetchItems = async ( export const subscribeItems = ( hass: HomeAssistant, entity_id: string, - callback: (item) => void + callback: (update: TodoItems) => void ) => hass.connection.subscribeMessage(callback, { type: "todo/item/subscribe", diff --git a/src/panels/lovelace/cards/hui-todo-list-card.ts b/src/panels/lovelace/cards/hui-todo-list-card.ts index 8c37699dd3..f385243343 100644 --- a/src/panels/lovelace/cards/hui-todo-list-card.ts +++ b/src/panels/lovelace/cards/hui-todo-list-card.ts @@ -156,6 +156,19 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { 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( (items?: TodoItem[], sort?: string | undefined): TodoItem[] => 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( changedProperties: PropertyValueMap | Map ): void { @@ -235,6 +258,18 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { 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` - ${uncheckedItems.length + ${!uncheckedItems.length && !itemsWithoutStatus.length + ? html`

+ ${this.hass.localize( + "ui.panel.lovelace.cards.todo-list.no_unchecked_items" + )} +

` + : this._reordering + ? html`
+

+ ${this.hass!.localize( + "ui.panel.lovelace.cards.todo-list.reorder_items" + )} +

+ ${this._renderMenu(this._config, unavailable)} +
+ ${this._renderItems(reorderableItems ?? [], unavailable)}` + : nothing} + ${!this._reordering && uncheckedItems.length ? html`

@@ -282,47 +334,35 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { "ui.panel.lovelace.cards.todo-list.unchecked_items" )}

- ${(!this._config.display_order || - this._config.display_order === TodoSortMode.NONE) && - this._todoListSupportsFeature( - TodoListEntityFeature.MOVE_TODO_ITEM - ) - ? html` - - - ${this.hass!.localize( - this._reordering - ? "ui.panel.lovelace.cards.todo-list.exit_reorder_items" - : "ui.panel.lovelace.cards.todo-list.reorder_items" - )} - - - - ` - : nothing} + ${this._renderMenu(this._config, unavailable)}
${this._renderItems(uncheckedItems, unavailable)} ` - : html`

- ${this.hass.localize( - "ui.panel.lovelace.cards.todo-list.no_unchecked_items" - )} -

`} + : nothing} + ${!this._reordering && itemsWithoutStatus.length + ? html` +
+ ${uncheckedItems.length + ? html`
` + : nothing} +
+

+ ${this.hass!.localize( + "ui.panel.lovelace.cards.todo-list.no_status_items" + )} +

+ ${!uncheckedItems.length + ? this._renderMenu(this._config, unavailable) + : nothing} +
+
+ ${this._renderItems(itemsWithoutStatus, unavailable)} + ` + : nothing} ${!this._config.hide_completed && checkedItems.length ? html` -
-
+
+

${this.hass!.localize( @@ -359,13 +399,43 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {

${this._renderItems(checkedItems, unavailable)} ` - : ""} + : nothing} `; } + private _renderMenu(config: TodoListCardConfig, unavailable: boolean) { + return (!config.display_order || + config.display_order === TodoSortMode.NONE) && + this._todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM) + ? html` + + + ${this.hass!.localize( + this._reordering + ? "ui.panel.lovelace.cards.todo-list.exit_reorder_items" + : "ui.panel.lovelace.cards.todo-list.reorder_items" + )} + + + + ` + : nothing; + } + private _getDueDate(item: TodoItem): Date | undefined { return item.due ? item.due.includes("T") @@ -397,16 +467,19 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { left .hasMeta=${showReorder || showDelete} class="editRow ${classMap({ - draggable: item.status === TodoItemStatus.NeedsAction, + draggable: item.status !== TodoItemStatus.Completed, completed: item.status === TodoItemStatus.Completed, multiline: Boolean(item.description || item.due), })}" .selected=${item.status === TodoItemStatus.Completed} - .disabled=${unavailable || - !this._todoListSupportsFeature( + .disabled=${unavailable} + .checkboxDisabled=${!this._todoListSupportsFeature( + TodoListEntityFeature.UPDATE_TODO_ITEM + )} + .indeterminate=${!item.status} + .noninteractive=${!this._todoListSupportsFeature( TodoListEntityFeature.UPDATE_TODO_ITEM )} - item-id=${item.uid} .itemId=${item.uid} @change=${this._completeItem} @click=${this._openItem} @@ -631,35 +704,53 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { this._moveItem(oldIndex, newIndex); } - private async _moveItem(oldIndex: number, newIndex: number) { - // correct index for header - oldIndex -= 1; - newIndex -= 1; - const uncheckedItems = this._getUncheckedItems(this._items); - const item = uncheckedItems[oldIndex]; - let prevItem: TodoItem | undefined; - if (newIndex > 0) { - if (newIndex < oldIndex) { - prevItem = uncheckedItems[newIndex - 1]; - } else { - prevItem = uncheckedItems[newIndex]; + private _findFirstItem( + items: HTMLCollection, + start: number, + direction: "up" | "down" + ) { + let item: Element | undefined; + let index = direction === "up" ? start - 1 : start; + while (item?.localName !== "ha-check-list-item") { + item = items[index]; + index = direction === "up" ? index - 1 : index + 1; + if (!item) { + 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 - const itemIndex = this._items!.findIndex((itm) => itm.uid === item.uid); - this._items!.splice(itemIndex, 1); - if (newIndex === 0) { + const itemIndex = this._items!.findIndex((itm) => itm.uid === itemId); + const item = this._items!.splice(itemIndex, 1)[0]; + + if (!prevItemId) { this._items!.unshift(item); } else { - const prevIndex = this._items!.findIndex( - (itm) => itm.uid === prevItem!.uid - ); + const prevIndex = this._items!.findIndex((itm) => itm.uid === prevItemId); this._items!.splice(prevIndex + 1, 0, item); } 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` diff --git a/src/translations/en.json b/src/translations/en.json index 754e42f2a4..8f950019b8 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6529,6 +6529,7 @@ "unchecked_items": "Active", "no_unchecked_items": "You have no to-do items!", "checked_items": "Completed", + "no_status_items": "No status", "clear_items": "Remove completed items", "add_item": "Add item", "today": "Today",