mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-27 09:26:28 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93511350a1 | ||
|
|
010711c358 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user