Files
frontend/src/components/ha-generic-picker.ts
2025-10-24 14:45:37 +02:00

359 lines
10 KiB
TypeScript

import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
HaPickerComboBox,
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-generic-picker")
export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@property({ attribute: "popover-placement" })
public popoverPlacement:
| "bottom"
| "top"
| "left"
| "right"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@state() private _popoverWidth = 0;
@state() private _openedNarrow = false;
private _narrow = false;
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
private _unsubscribeTinyKeys?: () => void;
protected render() {
return html`
${this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing}
<div class="container">
<div id="picker">
<slot name="field">
${this.addButtonLabel && !this.value
? html`<ha-button
size="small"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
</ha-picker-field>`}
</slot>
</div>
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: this._pickerWrapperOpen || this._opened
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>`
: nothing}
</div>
${this._renderHelper()}
`;
}
private _renderComboBox(dialogMode = false) {
if (!this._opened) {
return nothing;
}
return html`
<ha-picker-combo-box
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
(this.hass?.localize("ui.common.search") || "Search")}
.value=${this.value}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"}
></ha-picker-combo-box>
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
}
private _dialogOpened = () => {
this._opened = true;
requestAnimationFrame(() => {
this._comboBox?.focus();
});
};
private _hidePicker(ev) {
ev.stopPropagation();
if (this._newValue) {
fireEvent(this, "value-changed", { value: this._newValue });
this._newValue = undefined;
}
this._opened = false;
this._pickerWrapperOpen = false;
this._unsubscribeTinyKeys?.();
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
return;
}
this._pickerWrapperOpen = false;
this._newValue = value;
}
private _clear(e) {
e.stopPropagation();
this._setValue(undefined);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open(ev?: Event) {
ev?.stopPropagation();
if (this.disabled) {
return;
}
this._openedNarrow = this._narrow;
this._popoverWidth = this._containerElement?.offsetWidth || 250;
this._pickerWrapperOpen = true;
this._unsubscribeTinyKeys = tinykeys(this, {
Escape: this._handleEscClose,
});
}
connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._unsubscribeTinyKeys?.();
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
if (!this._openedNarrow && this._pickerWrapperOpen) {
this._popoverWidth = this._containerElement?.offsetWidth || 250;
}
};
private _handleEscClose = (ev: KeyboardEvent) => {
ev.stopPropagation();
};
static get styles(): CSSResultGroup {
return [
css`
.container {
position: relative;
display: block;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
label {
display: block;
margin: 0 0 8px;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
wa-popover {
--wa-space-l: var(--ha-space-0);
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
max-width: max(var(--body-width), 250px);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color);
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
}
ha-picker-field.opened {
--mdc-text-field-idle-line-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-generic-picker": HaGenericPicker;
}
}