import { LitElement, html, css, TemplateResult } from "lit"; import { classMap } from "lit/directives/class-map.js"; import { customElement, property, state, query } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { consume } from "@lit-labs/context"; import { Address } from "./controllers/autocomplete.parser"; import { AddressContext, addressContext } from "./context/address.context"; import "./alert-icon.ts"; import "./building-icon.ts"; import "./right-arrow-icon.ts"; import { constructUSPSAddress } from "./utils/address"; import { ifDefined } from "lit/directives/if-defined.js"; import AutocompleteController from "./controllers/autocomplete-controller"; @customElement("hvs-dropdown-menu") export default class HvsDropdownMenu extends LitElement { @property({ attribute: "addresses", type: Array }) addresses: Address[] = []; @property({ attribute: "is-loading", type: Boolean }) isLoading = false; @property({ attribute: "is-open", type: Boolean }) isOpen = false; @property({ attribute: false, type: String }) noResultsMessage?: string = "No results found"; @property({ attribute: false }) source?: string; @property({ attribute: false, type: String }) userName?: string; @property({ attribute: "contact-id", type: String }) contactID?: string; @property({ attribute: "contact-first-name", type: String }) contactFirstName?: string; @property({ attribute: "contact-last-name", type: String }) contactLastName?: string; @property({ attribute: "contact-email", type: String }) contactEmail?: string; @property({ attribute: false, type: String }) reportURL?: string; @property({ attribute: "new-window", type: Boolean }) newWindow?: boolean; @property({ type: String, attribute: false }) address = ""; @property({ attribute: false }) selectedAddress?: Address; @property({ attribute: false, type: Number }) isMobile: number = 0; @property({ attribute: false, type: Number }) hasAgentDetails: number = 0; @property({ attribute: false, type: String }) protocol: string = ""; @property({ attribute: false, type: String }) redirectUser: string = ""; @property({ attribute: "should-store-address", type: Boolean }) shouldStoreAddress: boolean = false; @state() selectedClusterHash?: string; @state() highlightedIndex = 0; @consume({ context: addressContext, subscribe: true }) @property({ attribute: false }) public adressProvider?: AddressContext; @property({ attribute: "value", type: String }) value?: string = ""; @property({ attribute: false, type: Function }) onChange: (value: string) => void = () => {}; @property({ attribute: false, type: String }) searchIcon?: string = ""; @property({ attribute: false, type: String }) placeholder?: string = ""; @property({ attribute: false, type: String }) apiKey?: string = ""; private autocomplete = new AutocompleteController(this); // @ts-ignore @query("#hvs-input", true) input!: HTMLInputElement; constructor() { super(); this.addEventListener("input", () => { this.onChange(this.input.value); }); } /** * Handles input click to reopen the dropdown with previous search results * This uses the dedicated context method for this specific purpose */ private handleInputClick = () => { // If the dropdown is already open, do nothing if (this.isOpen) { return; } // Use the dedicated method to reopen with results this.adressProvider?.reopenDropdownWithResults(); }; connectedCallback() { super.connectedCallback(); // Add keyboard event listeners after the component is connected to the DOM this.updateComplete.then(() => { const inputElement = this.shadowRoot?.querySelector('#hvs-input') as HTMLInputElement; if (inputElement) { // Add keydown event listener inputElement.addEventListener('keydown', (event) => { // Handle arrow down to move focus to the dropdown list if (event.key === 'ArrowDown' && this.isOpen && this.addresses.length > 0) { event.preventDefault(); const dropdownList = this.shadowRoot?.querySelector('.dropdown-list') as HTMLElement; if (dropdownList) { dropdownList.focus(); this.highlightedIndex = 0; this.requestUpdate(); } } }); } }); } private storeAddress(key: string, value: any): void { localStorage.setItem(key, JSON.stringify(value)); } private generateSignedLink(address: Address) { const parsedAddress = constructUSPSAddress( address.display_address as string ); if (!parsedAddress) return; return this.getReportURL({ validator: address.validator, address: parsedAddress.hasCity ? parsedAddress.address : address.url_address, latlng: address.latlng, lat: address.lat, lng: address.lng, signedAddress: address.signedAddress, }); } /** * Decrypts a signed address and stores it in localStorage * @param address The address to store * @returns A promise that resolves when the address is stored */ private async setLocalStorageAddress(address: Address): Promise { // Early return if we don't have the necessary data if (!this.apiKey || !address.signedAddress) { return; } // Decrypt the signed address const signedAddress = await this.autocomplete?.decryptAddress( address.signedAddress, { apiKey: this.apiKey } ); if (!signedAddress) { return; } // Create enhanced address with decrypted data const enhancedAddress = { ...address, address1: signedAddress.data.address1, address2: signedAddress.data.address2, streetAddress: signedAddress.data.streetAddress, }; // Update the address in the context this.adressProvider?.setAddress(enhancedAddress); // Only close the dropdown for regular addresses, not building clusters const isClusterRelated = address.startsub || address.hash; if (!isClusterRelated) { this.adressProvider?.setShowDropdown(false); } // Store in localStorage this.storeAddress("selectedAddress", enhancedAddress); } /** * Handles address selection, with special handling for building clusters * @param address The selected address */ private async onSelectedAddressHandler(address: Address) { // If this is a building cluster (has startsub property) if (address.startsub) { this.toggleClusterVisibility(address.startsub); // Store the address if needed if (this.shouldStoreAddress && this.selectedClusterHash === address.startsub) { await this.setLocalStorageAddress(address); } return; } // Clear any selected cluster this.selectedClusterHash = undefined; // Handle individual address selection if (this.shouldStoreAddress) { await this.setLocalStorageAddress(address); } else { // Fallback to original behavior if we can't decrypt the address this.adressProvider?.setAddress(address); } } private getReportURL({ address, latlng, signedAddress, }: { validator: "attom" | "melissa"; address?: string; latlng?: string; lat?: string; lng?: string; signedAddress?: string; }) { // Construct contact string const contactParts = [ this.contactID, this.contactFirstName, this.contactLastName, this.contactEmail, ]; const contact = contactParts.filter((part) => part).join("|"); // Extracted method to construct URL const constructURL = () => { let path = `/address/${address ?.trim() .replace(/\-\d\d\d\d$/, "")}${this.constructUsername()}`; if (this.isMobile) path = `/sa/${path}`; // Build a safe base for URL construction. If `reportURL` is missing or invalid, // fall back to the current origin to avoid `new URL()` throwing. let base = window.location.origin; if (this.reportURL) { try { // If reportURL is absolute and valid, use it as the base base = new URL(this.reportURL).toString(); } catch { // Keep default base on invalid reportURL } } // Use URL with base + relative path to ensure correctness let url = new URL(path, base); url.searchParams.set("address_token", signedAddress ?? ""); // Append query parameters directly for non-parts cases url.search += this.constructQueryParams(contact, latlng); url.searchParams.set("source", this.source!); // Append vaemail if present const vaemailValue = new URL(window.location.href).searchParams.get("vaemail") ?? ""; url.search += (url.search ? "&" : "?") + "vaemail=" + vaemailValue; return url.toString(); }; const href = constructURL(); return { href, target: this.newWindow ? "_blank" : "_self", }; } /** * Changes the currently highlighted item in the dropdown * @param direction The direction to move (1 for down, -1 for up) * @param config Optional configuration for skipping items */ changeHighlight( direction: number, config?: { decrement?: number; increment?: number } ): void { const { increment, decrement } = config || {}; // Calculate the new highlighted index if (decrement) { this.highlightedIndex -= decrement + 1; } else if (increment) { this.highlightedIndex += increment + 1; } else { this.highlightedIndex += direction; } // Ensure the index stays within bounds this.highlightedIndex = Math.max(0, Math.min( this.highlightedIndex, this.addresses.length - 1 )); // Update the UI and scroll to the highlighted item this.requestUpdate(); this.scrollHighlightedItemIntoView(); } // Helper method to scroll the highlighted item into view private scrollHighlightedItemIntoView() { setTimeout(() => { const element = this.shadowRoot?.querySelector( `.dropdown-item:nth-child(${this.highlightedIndex + 1})` ) as HTMLElement; if (element) { element.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, 0); } /** * Finds the end index of a cluster in the address list * @param startIndex The index to start looking from * @param startsub The cluster hash to match * @returns The index of the last item in the cluster */ private findLastClusterItemIndex(startIndex: number, startsub: string): number { let lastIndex = startIndex; while ( lastIndex + 1 < this.addresses.length && this.addresses[lastIndex + 1].startsub === startsub ) { lastIndex++; } return lastIndex; } /** * Finds the parent cluster item for a subunit * @param hash The hash of the subunit's parent cluster * @returns The index of the parent cluster item, or -1 if not found */ private findParentClusterIndex(hash: string): number { for (let i = 0; i < this.addresses.length; i++) { const address = this.addresses[i]; if (address.startsub === hash && !address.hash) { return i; } } return -1; } /** * Handles arrow down keyboard navigation with special handling for building clusters */ handleArrowDown(): void { if (!this.addresses.length) return; const nextIndex = this.highlightedIndex + 1; // Early return if we're at the end of the list if (nextIndex >= this.addresses.length) { return; } const nextAddress = this.addresses[nextIndex]; // Handle navigation around hidden clusters const isHiddenSubunit = nextAddress.hash && nextAddress.startsub && nextAddress.sub !== 0 && this.selectedClusterHash !== nextAddress.startsub; if (isHiddenSubunit) { // Skip past the entire hidden cluster const lastClusterIndex = this.findLastClusterItemIndex(nextIndex, nextAddress.startsub!); this.highlightedIndex = Math.min(lastClusterIndex + 1, this.addresses.length - 1); this.requestUpdate(); this.scrollHighlightedItemIntoView(); return; } // Regular arrow down behavior const canSkip = this.canSkipIndex(this.highlightedIndex); this.changeHighlight(1, { increment: canSkip ? this.addresses[this.highlightedIndex].nsubs : undefined, }); } /** * Handles arrow up keyboard navigation with special handling for building clusters */ handleArrowUp(): void { if (!this.addresses.length) return; const prevIndex = this.highlightedIndex - 1; // Early return if we're at the beginning of the list if (prevIndex < 0) { return; } const prevAddress = this.addresses[prevIndex]; // Handle navigation around hidden clusters const isHiddenSubunit = prevAddress.hash && this.selectedClusterHash !== prevAddress.hash; if (isHiddenSubunit) { // Jump to the parent cluster instead of entering the subunits const parentIndex = this.findParentClusterIndex(prevAddress.hash!); if (parentIndex >= 0) { this.highlightedIndex = parentIndex; this.requestUpdate(); this.scrollHighlightedItemIntoView(); return; } } // Regular arrow up behavior const canSkip = this.canSkipIndex(prevIndex); this.changeHighlight(-1, { decrement: canSkip ? this.addresses[prevIndex].nsubs : undefined, }); } /** * Determines if an address item can be skipped during keyboard navigation * @param index The index of the item to check * @returns True if the item can be skipped */ canSkipIndex(index: number): boolean { // Validate the index if (index < 0 || index >= this.addresses.length) { return false; } const address = this.addresses[index]; const sub = address?.sub ?? 0; const nsubs = address?.nsubs ?? 0; // Can skip if it's a cluster with multiple subunits and no cluster is currently selected return nsubs > 1 && sub >= 0 && this.selectedClusterHash === undefined; } /** * Handles keyboard events for dropdown navigation * @param event The keyboard event */ handleKeydown(event: KeyboardEvent): void { // Early return if dropdown is not active if (!this.isOpen || !this.addresses.length) { return; } switch (event.key) { case "ArrowDown": this.handleArrowDown(); event.preventDefault(); break; case "ArrowUp": this.handleArrowUp(); event.preventDefault(); break; case "Enter": event.preventDefault(); this.handleEnterKey(); break; case "Escape": event.preventDefault(); this.adressProvider?.setShowDropdown(false); break; } } /** * Handles Enter key press on the dropdown */ private handleEnterKey(): void { const isValidIndex = this.highlightedIndex >= 0 && this.highlightedIndex < this.addresses.length; if (isValidIndex) { const selectedAddress = this.addresses[this.highlightedIndex]; // If this is a building item, focus on it after selection if (this.isBuildingCluster(selectedAddress)) { this.selectItem(selectedAddress); // Focus on the building item after a short delay setTimeout(() => { const buildingItem = this.shadowRoot?.querySelector( `li[data-cluster-hash="${selectedAddress.startsub}"]` ) as HTMLElement; if (buildingItem) { buildingItem.focus(); } }, 50); } else { this.selectItem(selectedAddress); } } } /** * Determines if an address is a building cluster with multiple units * @param address The address to check * @returns True if the address is a building cluster with multiple units */ private isBuildingCluster(address: Address): boolean { return !!( address.nsubs && address.nsubs > 1 && address.startsub && address.startsub.length >= 1 ); } /** * Toggles the visibility of units within a building cluster * @param clusterHash The hash of the cluster to toggle */ private toggleClusterVisibility(clusterHash: string): void { this.handlingClusterClick = true; if (this.selectedClusterHash === clusterHash) { this.selectedClusterHash = undefined; } else { this.selectedClusterHash = clusterHash; } // Find the index of the clicked cluster item const clusterIndex = this.addresses.findIndex( address => address.startsub === clusterHash && !address.hash ); // Update the highlighted index if found if (clusterIndex >= 0) { this.highlightedIndex = clusterIndex; } // Keep the dropdown open this.adressProvider?.setShowDropdown(true); // Set focus on the building item setTimeout(() => { const buildingItem = this.shadowRoot?.querySelector(`li[data-cluster-hash="${clusterHash}"]`) as HTMLElement; if (buildingItem) { buildingItem.focus(); } }, 50); } /** * Handles item selection from the dropdown * @param item The selected address item */ selectItem(item: Address): void { if (this.isBuildingCluster(item)) { this.toggleClusterVisibility(item.startsub!); return; } this.onSelectedAddressHandler(item); const link = this.generateSignedLink(item); if (link && !this.shouldStoreAddress) { window.open(link.href, link.target); } } private constructUsername() { return this.redirectUser ? `/${this.redirectUser}` : this.userName ? `/${this.userName}` : ""; } private constructQueryParams(contact: string, latlng?: string) { const contactParam = contact ? `&c=${contact}` : ""; const latlngParam = latlng ? `&latlng=${latlng}` : ""; return `${contactParam}${latlngParam}`; } /** * Renders animated skeleton loaders while data is being fetched * @returns Template with animated skeleton loaders */ renderSkeleton() { return html`
  • `; } /** * Creates a highlighted display of an address with query matching * @param displayAddress The full address to display * @returns HTML template with highlighting */ private createHighlightedAddress(displayAddress?: string): TemplateResult { const matchLength = this.address?.length || 0; const boldPart = displayAddress?.slice(0, matchLength); const regularPart = displayAddress?.slice(matchLength); return html`${boldPart}${regularPart}`; } /** * Renders address content based on whether it should be a link or plain text * @param address The address to render * @param link Optional link data for the address * @returns HTML template for the address content */ private renderAddressContent(address: Address, link?: ReturnType): TemplateResult { if (this.shouldStoreAddress) { return html`${this.createHighlightedAddress(address.display_address)}`; } else { return html` ${this.createHighlightedAddress(address.display_address)} `; } } /** * Renders the content of an address item in the dropdown * @param address The address to render * @returns HTML template for the address item content */ renderAddressItemContent(address: Address): TemplateResult { const link = this.generateSignedLink(address); // Building cluster (parent) if (address.startsub && parseInt(address.startsub) >= 1) { return html`
    ${address.nsubs}
    ${this.createHighlightedAddress(address.display_address)}
    `; } // Subunit within a building if (address.hash) { return html`
    ${this.renderAddressContent(address, link)}
    `; } // Regular address return html` ${this.renderAddressContent(address, link)} `; } renderAddresses() { if (this.addresses.length >= 1) { return html`${repeat( this.addresses, (address) => address.hash, (addressTemplate, index) => { // Determine visible classes for the item, enhanced for animations const isSubBuildingItem = Boolean(addressTemplate.hash?.length); const isDisplayed = addressTemplate?.hash === this.selectedClusterHash; // Create animation delay for staggered appearance of subitems const animationStyle = isSubBuildingItem && isDisplayed ? `animation-delay: ${(addressTemplate.sub || 0) * 0.05}s` : ''; const classes = { "dropdown-item": true, highlighted: index === this.highlightedIndex, "address-item": true, selected: this.selectedAddress?.display_address === addressTemplate.display_address, "sub-building-item": isSubBuildingItem, "display-sub-building": isDisplayed, }; /** * Handle click on a dropdown item * @param item The address item being clicked */ const handleItemClick = (item: Address, currentIndex: number) => { // Update the highlighted index to match the clicked item this.highlightedIndex = currentIndex; // Set the flag if this is a building cluster item if (item.startsub) { this.handlingClusterClick = true; } this.onSelectedAddressHandler(item); }; // Add data-cluster-hash attribute if it's a building cluster const clusterHash = addressTemplate.startsub ?? ''; return html`
  • handleItemClick(addressTemplate, index)} class="${classMap(classes)}" style="${animationStyle}" tabindex="-1" data-cluster-hash="${clusterHash}" > ${this.renderAddressItemContent(addressTemplate)}
  • `; } )}`; } if (this.address.length && this.addresses.length <= 0) { return html`
  • ${this.noResultsMessage}
  • `; } return null; } // Flag to track if we're handling a cluster click private handlingClusterClick = false; /** * Handles document clicks to determine if the dropdown should be closed * @param event The click event */ private handleDocumentClick = (event: MouseEvent) => { // If we're handling a cluster click, don't close the dropdown if (this.handlingClusterClick) { this.handlingClusterClick = false; // Reset the flag return; } const target = event.target as HTMLElement; // Skip if the target element is the input itself if (target.id === 'hvs-input') { return; } // Check if the click is inside this component const clickedInside = this.contains(target); // If clicked outside and the dropdown is open, close it if (!clickedInside && this.isOpen) { this.adressProvider?.setShowDropdown(false); } }; protected firstUpdated() { // Add event listener to handle clicks outside the dropdown document.addEventListener('click', this.handleDocumentClick); } disconnectedCallback() { super.disconnectedCallback(); // Clean up event listeners when the component is disconnected document.removeEventListener('click', this.handleDocumentClick); } protected updated(changedProperties: Map) { // Focus the dropdown list when it opens if (changedProperties.has('isOpen') && this.isOpen) { setTimeout(() => { const dropdownList = this.shadowRoot?.querySelector('.dropdown-list') as HTMLElement; if (dropdownList) { dropdownList.focus(); } }, 0); } } protected render() { if (this.isLoading) { this.highlightedIndex = 0; } return html`
      ${this.isLoading ? this.renderSkeleton() : this.renderAddresses()}
    `; } static styles = css` /* Base dropdown styling */ .dropdown { position: relative; transition: all 0.2s ease; } /* Enhanced dropdown list with smoother animations */ .dropdown-list { position: absolute; width: 100%; overflow-y: auto; z-index: 1000; outline: none; max-height: 360px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 4px rgba(0, 0, 0, 0.05); border-radius: 8px; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .dropdown-list:focus { outline: var(--hvs-dropdown-focus-outline, 2px solid #3b82f6); outline-offset: 2px; } /* Item styling with hover animations */ .dropdown-item { padding: 10px 12px; cursor: pointer; outline: none; transition: all 0.2s ease; border-left: 3px solid transparent; } .dropdown-item:hover { transform: translateX(2px); } .highlighted { background-color: var( --hvs-dropdown-address-selected-bg-color, #3b82f6 ) !important; color: var(--hvs-dropdown-address-selected-text-color, white) !important; border-left-color: var(--hvs-dropdown-address-selected-bg-color, #3b82f6) !important; } .highlighted a { color: var(--hvs-dropdown-address-selected-text-color, white) !important; } /* Input styling */ .input div { position: relative; display: flex; flex-direction: row; align-items: center; width: 100%; } .input input { font-family: var(--hvs-font-family, inherit); box-sizing: border-box; padding-top: var(--hvs-input-padding-top, 8px); padding-bottom: var(--hvs-input-padding-bottom, 8px); padding-left: var(--hvs-input-padding-left, 12px); padding-right: var(--hvs-input-padding-right, 32px); border: none; border-top-left-radius: var(--hvs-input-border-top-left-radius, 8px); border-top-right-radius: var(--hvs-input-border-top-right-radius, 8px); border-bottom-left-radius: var( --hvs-input-border-bottom-left-radius, 8px ); border-bottom-right-radius: var( --hvs-input-border-bottom-right-radius, 8px ); border-top: var(--hvs-input-border-top); border-right: var(--hvs-input-border-right); border-bottom: var(--hvs-input-border-bottom); border-left: var(--hvs-input-border-left); border-color: var(--hvs-input-border-color, #d0d5dd); border-width: var(--hvs-input-border-width, 1px); border-style: var(--hvs-input-border-style, solid); width: 100%; min-width: 100%; height: var(--hvs-input-height, 40px); font-style: normal; font-weight: 400; font-size: var(--hvs-input-font-size, 16px); line-height: var(--hvs-input-line-height, 24px); background: var(--hvs-input-background, transparent); color: var(--hvs-input-text-color, #667085); transition: border-color 0.2s ease, box-shadow 0.2s ease; } input:focus-visible { outline: var(--hvs-input-ring-color, #98a2b3 auto 1px); } /* Dropdown list container */ ul { position: absolute; display: flex; z-index: 9; flex-direction: column; box-sizing: border-box; align-items: flex-start; max-height: 360px; overflow-y: auto; opacity: 0; padding: 8px 0; left: 0; border-radius: 8px; width: 100%; background: var(--hvs-dropdown-menu-color, white); margin-top: 4px; filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1)) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.06)); will-change: transform, opacity; } ul:has(li):has(.skeleton) { padding: 1rem; gap: 8px; } ul:has(li):has(.no-results) { padding: 0; } /* List item styling */ li { list-style: none; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; box-sizing: border-box; text-align: left; cursor: default; text-decoration: none; transition: transform 0.2s ease; } /* Address item styling */ .address-item { width: 100%; padding: 10px 16px; border-left: 3px solid transparent; transition: border-left-color 0.2s ease, background-color 0.2s ease; } .address-item a { color: var(--hvs-dropdown-address-text-color, #374151); text-decoration: none; width: 100%; transition: color 0.2s ease; } .address-item span.building-cluster { width: 100%; display: flex; flex-direction: row; gap: 8px; align-items: center; } .address-item span.sub-building { width: 100%; display: flex; align-items: center; gap: 8px; padding-left: 8px; transition: padding-left 0.2s ease; } .address-item span.sub-building:hover { padding-left: 12px; } .address-item:nth-child(even) { background-color: var(--hvs-dropdown-address-bg-color, rgba(243, 244, 246, 0.6)); } .address-item:hover { background-color: var(--hvs-dropdown-address-hover-bg-color, #bfdbfe); border-left-color: var(--hvs-dropdown-address-hover-bg-color, #bfdbfe); } .address-item.selected { background-color: var(--hvs-dropdown-address-selected-bg-color, #3b82f6); color: var(--hvs-dropdown-address-selected-text-color, white); border-left-color: var(--hvs-dropdown-address-selected-bg-color, #3b82f6); } .address-item.selected a { color: var(--hvs-dropdown-address-selected-text-color, white); } .address-item.selected > div.building-units { background-color: rgba(255, 255, 255, 0.9); transform: scale(1.05); } /* Special styling for building units */ .sub-building-item { display: none; opacity: 0; transform: translateY(-10px); height: 0; } .sub-building-item.display-sub-building { display: block; animation: fade-slide-down 0.3s forwards; height: auto; opacity: 1; transform: translateY(0); } .building-units { border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: small; padding: 2px 6px; gap: 4px; background-color: #e4e6e9; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); transition: all 0.2s ease; } .building-units:hover { transform: scale(1.05); } .highlight-address { display: inline-table; transition: all 0.2s ease; } .highlight-address strong { font-weight: 600; } /* No results styling */ .no-results { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; padding: 16px 0; color: var(--hvs-dropdown-no-results-text-color, #1f2937); } .no-results-container { display: flex; flex-direction: row; align-items: center; justify-content: center; width: 100%; gap: 8px; padding: 12px 16px; background-color: var(--hvs-dropdown-no-results-bg-color, #f3f4f6); border-radius: 6px; box-sizing: border-box; animation: fade-in 0.3s ease; } /* Button styling */ .get-report-button { font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; padding: 0.5rem 0.75rem; border-radius: 0.5rem; display: inline-flex; justify-content: center; text-decoration: none; color: var(--hvs-get-report-button-text-color, white); background: var(--hvs-get-report-button-bg-color, #0f172aab); transition: background-color 0.2s ease, transform 0.2s ease; } .get-report-button:hover { background-color: var(--hvs-get-report-button-bg-color, #0f172aab); opacity: 0.9; transform: translateY(-1px); } /* Loading skeleton animation */ .skeleton { animation: skeleton-pulse 1.5s ease-in-out infinite; background: linear-gradient( 90deg, rgba(229, 231, 235, 0.5) 25%, rgba(209, 213, 219, 0.5) 50%, rgba(229, 231, 235, 0.5) 75% ); background-size: 200% 100%; border-radius: 0.25rem; width: 100%; height: 24px; margin-bottom: 8px; } /* Animation classes */ .animate-close { animation: slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards; transform-origin: top center; } .animate-open { animation: slide-down 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; transform-origin: top center; } .fade-in { animation: fade-in 0.3s ease forwards; } .fade-out { animation: fade-out 0.25s ease forwards; } /* Animation keyframes */ @keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @keyframes fade-in { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes fade-out { 0% { opacity: 1; } 100% { opacity: 0; } } @keyframes slide-down { 0% { opacity: 0; transform: translateY(-8px) scale(0.98); } 100% { opacity: 1; transform: translateY(0) scale(1); } } @keyframes slide-up { 0% { opacity: 1; transform: translateY(0) scale(1); } 100% { opacity: 0; transform: translateY(-8px) scale(0.98); height: 0px; } } @keyframes fade-slide-down { 0% { opacity: 0; transform: translateY(-8px); } 100% { opacity: 1; transform: translateY(0); } } `; } declare global { interface HTMLElementTagNameMap { "hvs-dropdown-menu": HvsDropdownMenu; } }