Compare commits

...
2 changed files with 172 additions and 74 deletions
+26 -6
View File
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
@@ -12,6 +13,7 @@ import {
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
@@ -115,6 +117,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@query("wa-popover") private _popover?: WaPopover;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@@ -125,6 +129,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _popoverWidth = 0;
@state() private _popoverMinHeight = 0;
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
@@ -232,9 +238,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
style=${styleMap({
"--body-width": `${this._popoverWidth}px`,
"--body-min-height": `${this._popoverMinHeight}px`,
})}
without-arrow
distance="-4"
distance="0"
.placement=${this.popoverPlacement}
.for=${this.popoverAnchor ? null : "picker"}
.anchor=${this.popoverAnchor ?? null}
@@ -257,12 +266,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
private _renderComboBox(dialogMode = false) {
if (!this._opened) {
// The list sizes the popover, so it is rendered as it opens, not once shown.
if (!this._pickerWrapperOpen && !this._opened) {
return nothing;
}
return html`
<ha-picker-combo-box
id="combo-box"
.shown=${this._opened}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel}
.value=${this._selectedValue ?? this.value}
@@ -330,6 +341,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _dialogOpened = () => {
this._opened = true;
// Filtering must not shrink the popover under the size it opened with.
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
fireEvent(this, "picker-opened");
requestAnimationFrame(() => {
// Set initial field value if needed
@@ -363,6 +376,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._opened = false;
this._pickerWrapperOpen = false;
this._popoverMinHeight = 0;
this._selectedValue = undefined;
this._unsubscribeTinyKeys?.();
fireEvent(this, "picker-closed");
@@ -465,6 +479,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
wa-popover {
--wa-space-l: 0;
/* The surface of a dropdown menu, not of a dialog. */
--wa-panel-border-radius: var(--ha-border-radius-md);
}
wa-popover::part(dialog)::backdrop {
@@ -477,14 +493,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--ha-generic-picker-max-width,
var(--ha-generic-picker-width, max(var(--body-width), 250px))
);
max-height: 500px;
height: 70vh;
display: flex;
flex-direction: column;
box-shadow: var(--ha-box-shadow-m);
height: fit-content;
min-height: var(--body-min-height, 0);
max-height: min(70vh, 500px);
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
max-height: min(70vh, 400px);
}
}
+146 -68
View File
@@ -13,6 +13,7 @@ import {
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { repeat } from "lit/directives/repeat";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
@@ -74,6 +75,10 @@ type PickerComboBoxRowElement = HTMLDivElement & {
value: string;
};
// Under this count the list is rendered without the virtualizer, so it can size the
// popover to its content instead of filling a fixed height.
const MAX_PLAIN_LIST_ITEMS = 12;
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
@@ -153,6 +158,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
/** Set once the surface holding the list is done animating in. */
@property({ type: Boolean }) public shown = false;
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
@@ -178,6 +186,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query(".plain-list") private _plainListElement?: HTMLElement;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@@ -186,6 +196,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
@state() private _plainList = false;
@state() private _selectedSection?: string;
public setFieldValue(value: string) {
@@ -195,7 +207,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
protected get scrollableElement(): HTMLElement | null {
return this.virtualizerElement as HTMLElement | null;
return this._listElement ?? null;
}
private get _listElement(): HTMLElement | undefined {
return this._plainList ? this._plainListElement : this.virtualizerElement;
}
@state() private _sectionTitle?: string;
@@ -221,10 +237,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._selectedSection = this.selectedSection;
this._allItems = this._getItems();
this._items = this._allItems;
this._updateListMode();
}
}
@@ -238,6 +254,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (!this._search || this.sections?.length) {
this._items = this._allItems;
}
this._updateListMode();
}
// Filtering keeps the mode it opened with, only the full list decides it.
private _updateListMode() {
this._plainList =
!this.sections?.length && this._allItems.length <= MAX_PLAIN_LIST_ITEMS;
if (!this._plainList) {
loadVirtualizer();
}
}
protected render() {
@@ -271,36 +297,61 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
`
: nothing
}
<div class="virtualizer-wrapper">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${
this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined
}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
<div class="list-wrapper ${this._plainList ? "" : "virtualized"}">
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
${this.renderScrollableFades()}
</div>`;
}
private _renderPlainList() {
return html`
<div
class="plain-list ${this._listScrolled ? "scrolled" : ""}"
tabindex="0"
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
>
${repeat(this._items, this._keyFunction, this._renderItem)}
</div>
`;
}
private _renderVirtualList() {
// The virtualizer measures its rows, so it must not do it through the scale
// the surface animates in with.
if (!this.shown) {
return nothing;
}
return html`
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${
this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined
}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
`;
}
private _renderSectionButtons() {
if (!this.sections || this.sections.length === 0) {
return nothing;
@@ -564,7 +615,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._items = this._getItems();
// Reset scroll position when filter changes
this.virtualizerElement?.element(0)?.scrollIntoView();
this._resetListScroll();
}
private _registerKeyboardShortcuts() {
@@ -578,6 +629,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
private _resetListScroll() {
if (this._plainList) {
this._listElement?.scrollTo({ top: 0 });
return;
}
this.virtualizerElement?.element(0)?.scrollIntoView();
}
private _scrollRowIntoView(index: number) {
if (this._plainList) {
this._listElement
?.querySelector(`#list-item-${index}`)
?.scrollIntoView({ block: "nearest" });
return;
}
this.virtualizerElement?.element(index)?.scrollIntoView({
block: "nearest",
});
}
private _focusList() {
if (this._selectedItemIndex === -1) {
this._initializeSelectedIndex();
@@ -589,7 +660,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
* or fall back to the first item when searching (skipping section titles).
*/
private _initializeSelectedIndex(): void {
if (!this.virtualizerElement?.items?.length) {
if (!this._items.length) {
return;
}
const initialIndex = this._getInitialSelectedIndex();
@@ -599,11 +670,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
let index = initialIndex;
// Skip section titles (strings)
if (typeof this.virtualizerElement.items[index] === "string") {
if (typeof this._items[index] === "string") {
index += 1;
}
// Bounds check: ensure index is valid after skipping section title
if (index >= this.virtualizerElement.items.length) {
if (index >= this._items.length) {
return;
}
this._selectedItemIndex = index;
@@ -613,13 +684,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this.virtualizerElement) {
if (!this._listElement) {
return;
}
this._searchFieldElement?.focus();
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const items = this._items;
const maxItems = items.length - 1;
@@ -661,14 +732,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this.virtualizerElement) {
if (!this._listElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const items = this._items;
if (!items[nextIndex]) {
return;
@@ -690,13 +761,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
if (!this._listElement || !this._items.length) {
return;
}
const nextIndex = 0;
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
if (typeof this._items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -707,13 +778,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
if (!this._listElement || !this._items.length) {
return;
}
const nextIndex = this.virtualizerElement.items.length - 1;
const nextIndex = this._items.length - 1;
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
if (typeof this._items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -723,16 +794,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _scrollToSelectedItem = () => {
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this.virtualizerElement
?.element(this._selectedItemIndex)
?.scrollIntoView({ block: "nearest" });
this._scrollRowIntoView(this._selectedItemIndex);
requestAnimationFrame(() => {
this.virtualizerElement
this._listElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
@@ -749,14 +816,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
ev.stopPropagation();
if (
this.virtualizerElement?.items?.length !== undefined &&
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
this.virtualizerElement.items.filter((item) => typeof item !== "string")
.length === 1
this._items.length < 4 && // it still can have a section title and a padding item
this._items.filter((item) => typeof item !== "string").length === 1
) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
this._items.forEach((item, index) => {
if (typeof item !== "string" && !item.disabled) {
this._fireSelectedEvents(item.id, index, newTab);
}
@@ -774,18 +837,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
// if filter button is focused
ev.preventDefault();
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
const item = this._items[this._selectedItemIndex];
if (item && !item.disabled) {
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};
private _resetSelectedItem() {
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this._selectedItemIndex = -1;
}
@@ -793,11 +852,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (!this.virtualizerElement || this._search || !this.value) {
if (this._search || !this.value) {
return 0;
}
const index = this.virtualizerElement.items.findIndex(
const index = this._items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
@@ -820,6 +879,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
flex-direction: column;
padding-top: var(--ha-space-4);
flex: 1;
min-height: 0;
}
:host([clearable]) {
@@ -851,23 +911,41 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
.virtualizer-wrapper {
.list-wrapper {
position: relative;
flex: 1;
flex: 0 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
lit-virtualizer {
/* The virtualizer is size contained, so it fills a height rather than
providing one. Asking for the whole viewport leaves the popover to cap it. */
.list-wrapper.virtualized {
flex: 1 1 100vh;
}
/* A sheet has its own height, so the list fills it instead of sizing it. */
:host([mode="dialog"]) .list-wrapper {
flex: 1;
}
lit-virtualizer:focus-visible {
lit-virtualizer,
.plain-list {
flex: 1;
min-height: 0;
}
.plain-list {
overflow: auto;
}
lit-virtualizer:focus-visible,
.plain-list:focus-visible {
outline: none;
}
lit-virtualizer.scrolled {
.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}