import { css, html } from "lit";
import { BaseElement } from "../../core/base-element.ts";
import { placeCard } from "./placement.ts";
import { HoverPresence } from "./presence.ts";
// How far the card sits from its anchor, and from the edge of the window.
const CARD_GAP_PX = 6;
/**
* CFHoverCard - A small card that appears beside its content while the pointer
* rests on the content or focus is inside it, as a reaction count shows who
* reacted.
*
* The card is a popover in the browser's top layer, so no ancestor that clips
* its overflow can cut it off. It sits above the content, or below when there
* is no room above, and moves inward from the window's edges. It is placed
* again whenever its size changes, since what it shows can arrive after it
* opens, and whenever anything scrolls or the window resizes, so that it
* follows its content. It hides when the pointer and focus have both left.
*
* The pointer cannot reach the card, so what it shows is for reading, not for
* clicking. The card's content is the caller's, as is the content it belongs
* to, so a caller that wants the card read as a description of that content
* points `aria-describedby` from one to the other.
*
* @element cf-hover-card
*
* @slot - The content the card belongs to
* @slot card - What the card shows
*
* @csspart card - The card
*
* @example
*
* 😺 2
* Alice, Bob
*
*/
export class CFHoverCard extends BaseElement {
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: inline-block;
}
.card {
position: fixed;
inset: auto;
margin: 0;
padding: var(--cf-spacing-2, 0.5rem);
border: 1px solid var(--cf-theme-color-border, #e5e7eb);
border-radius: var(--cf-border-radius-md, 0.5rem);
background: var(--cf-theme-color-surface, #ffffff);
color: inherit;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
pointer-events: none;
}
`,
];
#presence = new HoverPresence();
#resizeObserver: ResizeObserver | undefined;
#onPointerEnter = () => {
this.#presence.enter("pointer");
this.#show();
};
#onPointerLeave = () => {
this.#presence.leave("pointer");
this.#hideUnlessInside();
};
#onFocusIn = () => {
this.#presence.enter("focus");
this.#show();
};
#onFocusOut = (event: FocusEvent) => {
const next = event.relatedTarget;
if (next instanceof Node && this.contains(next)) return;
this.#presence.leave("focus");
this.#hideUnlessInside();
};
#onLayoutChange = () => {
const card = this.#card;
if (card && this.open) this.#place(card);
};
/** Whether the card is showing. */
get open(): boolean {
return this.#card?.matches(":popover-open") ?? false;
}
override connectedCallback() {
super.connectedCallback();
this.addEventListener("pointerenter", this.#onPointerEnter);
this.addEventListener("pointerleave", this.#onPointerLeave);
this.addEventListener("focusin", this.#onFocusIn);
this.addEventListener("focusout", this.#onFocusOut);
}
override disconnectedCallback() {
this.removeEventListener("pointerenter", this.#onPointerEnter);
this.removeEventListener("pointerleave", this.#onPointerLeave);
this.removeEventListener("focusin", this.#onFocusIn);
this.removeEventListener("focusout", this.#onFocusOut);
// A focused element that leaves the document gets no `focusout`, so what
// was inside is forgotten here rather than carried into a reconnection.
this.#presence.reset();
this.#hide();
super.disconnectedCallback();
}
override render() {
return html`
`;
}
get #card(): HTMLElement | null | undefined {
return this.shadowRoot?.querySelector(".card");
}
#hideUnlessInside() {
if (!this.#presence.inside) this.#hide();
}
#show() {
const card = this.#card;
if (!card) {
// Not rendered yet: show once it is, if what asked is still inside.
void this.updateComplete.then(() => {
if (this.isConnected && this.#presence.inside) {
this.#show();
}
});
return;
}
if (this.open) return;
card.showPopover();
this.#place(card);
this.#resizeObserver = new ResizeObserver(() => this.#place(card));
this.#resizeObserver.observe(card);
globalThis.addEventListener("scroll", this.#onLayoutChange, {
capture: true,
passive: true,
});
globalThis.addEventListener("resize", this.#onLayoutChange);
}
#hide() {
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
globalThis.removeEventListener("scroll", this.#onLayoutChange, {
capture: true,
});
globalThis.removeEventListener("resize", this.#onLayoutChange);
const card = this.#card;
if (card && this.open) card.hidePopover();
}
#place(card: HTMLElement) {
const { top, left } = placeCard(
this.getBoundingClientRect(),
{ width: card.offsetWidth, height: card.offsetHeight },
{ width: globalThis.innerWidth, height: globalThis.innerHeight },
CARD_GAP_PX,
);
card.style.top = `${top}px`;
card.style.left = `${left}px`;
}
}