import { css, html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import type { Cell } from "@commontools/runner"; import { isCell } from "@commontools/runner"; import { fabAnimations } from "./styles.ts"; /** * A morphing floating action button that expands into a panel. * * @element ct-fab * * @attr {boolean} expanded - Whether the FAB is expanded (controlled state) * @attr {string} variant - Visual variant: "default" | "primary" * @attr {string} position - Screen position: "bottom-right" | "bottom-left" | "top-right" | "top-left" * * @fires ct-fab-backdrop-click - Fired when user clicks backdrop * @fires ct-fab-escape - Fired when user presses Escape * * @slot icon - Content for the FAB icon (collapsed state) * @slot - Content for the expanded panel * * @csspart fab - The morphing container element * @csspart backdrop - The backdrop overlay * @csspart icon - The icon container * @csspart panel - The panel container */ export class CTFab extends BaseElement { static override styles = [ BaseElement.baseStyles, fabAnimations, css` :host { display: block; box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } /* Backdrop overlay */ .backdrop { position: fixed; inset: 0; backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px); pointer-events: none; transition: background var(--ct-theme-animation-duration, 300ms) ease, backdrop-filter var(--ct-theme-animation-duration, 300ms) ease, -webkit-backdrop-filter var(--ct-theme-animation-duration, 300ms) ease; z-index: 998; } .backdrop.active { backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); pointer-events: auto; } /* Position-specific backdrop masks */ :host([position="bottom-right"]) .backdrop { mask-image: radial-gradient( circle at bottom right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); -webkit-mask-image: radial-gradient( circle at bottom right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); } :host([position="bottom-left"]) .backdrop { mask-image: radial-gradient( circle at bottom left, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); -webkit-mask-image: radial-gradient( circle at bottom left, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); } :host([position="top-right"]) .backdrop { mask-image: radial-gradient( circle at top right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); -webkit-mask-image: radial-gradient( circle at top right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); } :host([position="top-left"]) .backdrop { mask-image: radial-gradient( circle at top left, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); -webkit-mask-image: radial-gradient( circle at top left, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.5) 40%, rgba(0, 0, 0, 0) 70% ); } /* FAB container - positioned by host */ .fab-container { position: fixed; z-index: 999; } /* Position variants */ :host([position="bottom-right"]) .fab-container { bottom: 24px; right: 24px; } :host([position="bottom-left"]) .fab-container { bottom: 24px; left: 24px; } :host([position="top-right"]) .fab-container { top: 24px; right: 24px; } :host([position="top-left"]) .fab-container { top: 24px; left: 24px; } /* Main morphing element */ .fab { position: relative; width: 56px; height: 56px; background: var(--ct-theme-color-surface, #000); border-radius: 16%; /*box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1), 0 4px 16px rgba(0, 0, 0, 0.08);*/ cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; transition: width var(--ct-theme-animation-duration, 400ms) cubic-bezier(0.34, 1.56, 0.64, 1), height var(--ct-theme-animation-duration, 400ms) cubic-bezier(0.34, 1.56, 0.64, 1), border-radius var(--ct-theme-animation-duration, 400ms) cubic-bezier(0.34, 1.56, 0.64, 1), background var(--ct-theme-animation-duration, 300ms) ease; } /* Variant: primary */ :host([variant="primary"]) .fab { background: var(--ct-theme-color-primary, #3b82f6); } /* Expanded state */ :host([expanded]) .fab { width: 400px; min-height: 128px; max-height: 90vh; height: auto; border-radius: 6px; cursor: default; background: var(--ct-theme-color-background, #fafafa); overflow: visible; border: 1px solid var(--ct-theme-color-border, #ccc); } /* Mobile responsive - don't exceed viewport */ @media (max-width: 768px) { :host([expanded]) .fab { width: calc(100vw - 48px); max-width: 400px; max-height: calc(100vh - 48px); } :host([position="bottom-right"]) .fab-container, :host([position="bottom-left"]) .fab-container { bottom: 16px; } :host([position="bottom-right"]) .fab-container { right: 16px; } :host([position="bottom-left"]) .fab-container { left: 16px; } } /* Extra small screens - nearly full screen when expanded */ @media (max-width: 480px) { :host([expanded]) .fab { width: calc(100vw - 32px); max-height: calc(100vh - 32px); } :host([position="bottom-right"]) .fab-container, :host([position="bottom-left"]) .fab-container, :host([position="top-right"]) .fab-container, :host([position="top-left"]) .fab-container { bottom: 12px; right: 12px; top: auto; left: auto; } } /* Collapsing state - triggers content fade-out */ :host([collapsing]) .fab { cursor: default; } /* FAB icon */ .fab-icon { position: absolute; width: 24px; height: 24px; color: white; display: flex; align-items: center; justify-content: center; pointer-events: none; opacity: 1; transform: scale(1) rotate(0deg); transition: opacity calc(var(--ct-theme-animation-duration, 300ms) * 0.5) ease, transform var(--ct-theme-animation-duration, 300ms) cubic-bezier(0.34, 1.56, 0.64, 1); } :host([expanded]) .fab-icon, :host([collapsing]) .fab-icon { opacity: 0; transform: scale(0.5) rotate(90deg); } /* Preview notification */ .preview-notification { position: fixed; bottom: 88px; right: 24px; max-width: 360px; background: none; padding: 0; z-index: 998; animation: slideIn 300ms ease; } @keyframes slideIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } /* Panel content */ .fab-panel { width: 100%; height: 100%; opacity: 0; transform: scale(0.95); pointer-events: none; transition: opacity calc(var(--ct-theme-animation-duration, 300ms) * 0.5) ease, transform calc(var(--ct-theme-animation-duration, 300ms) * 0.5) cubic-bezier(0.34, 1.56, 0.64, 1); } :host([expanded]) .fab-panel { opacity: 1; transform: scale(1); pointer-events: auto; transition-delay: calc(var(--ct-theme-animation-duration, 300ms) * 0.3); } :host([collapsing]) .fab-panel { opacity: 0; transform: scale(0.95); pointer-events: none; transition-delay: 0s; } /* ARIA */ .fab[aria-expanded="false"] { cursor: pointer; } `, ]; static override properties = { expanded: { type: Boolean, reflect: true }, variant: { type: String, reflect: true }, position: { type: String, reflect: true }, previewMessage: { type: Object, attribute: false }, pending: { type: Boolean, reflect: true }, }; /** * Whether the FAB is expanded (controlled by parent) */ @property({ type: Boolean, reflect: true }) declare expanded: boolean; /** * Visual variant */ @property({ type: String, reflect: true }) declare variant: "default" | "primary"; /** * Screen position */ @property({ type: String, reflect: true }) declare position: | "bottom-right" | "bottom-left" | "top-right" | "top-left"; /** * Latest message to show as preview notification */ @property({ type: Object, attribute: false }) declare previewMessage: Cell | string | undefined; /** * Whether the FAB is in pending/loading state */ @property({ type: Boolean, reflect: true }) declare pending: boolean; /** * Internal collapsing state for animation timing */ @state() private collapsing = false; @state() private showPreview = false; private collapseTimeout: number | null = null; private _previewUnsubscribe: (() => void) | null = null; private _previewTimeout: number | null = null; constructor() { super(); this.expanded = false; this.variant = "default"; this.position = "bottom-right"; this.pending = false; } override connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this._handleKeydown); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener("keydown", this._handleKeydown); if (this.collapseTimeout !== null) { clearTimeout(this.collapseTimeout); } if (this._previewUnsubscribe) { this._previewUnsubscribe(); this._previewUnsubscribe = null; } if (this._previewTimeout !== null) { clearTimeout(this._previewTimeout); } } override updated(changedProperties: Map) { super.updated(changedProperties); // Handle preview message Cell subscription if (changedProperties.has("previewMessage")) { if (this._previewUnsubscribe) { this._previewUnsubscribe(); this._previewUnsubscribe = null; } if (this.previewMessage && isCell(this.previewMessage)) { this._previewUnsubscribe = this.previewMessage.sink(() => { const msg = (this.previewMessage as Cell).get(); if (msg && !this.expanded) { this._showPreviewNotification(); } }); } else if ( this.previewMessage && typeof this.previewMessage === "string" ) { // Handle plain string case if (this.previewMessage && !this.expanded) { this._showPreviewNotification(); } } } if (changedProperties.has("expanded")) { if ( !this.expanded && changedProperties.get("expanded") === true ) { // Started collapsing this.collapsing = true; this.toggleAttribute("collapsing", true); // Clear any existing timeout if (this.collapseTimeout !== null) { clearTimeout(this.collapseTimeout); } // Reset collapsing state after animation completes this.collapseTimeout = setTimeout(() => { this.collapsing = false; this.toggleAttribute("collapsing", false); this.collapseTimeout = null; }, 400) as unknown as number; } else if (this.expanded) { // Expanding - clear collapsing state immediately this.collapsing = false; this.toggleAttribute("collapsing", false); if (this.collapseTimeout !== null) { clearTimeout(this.collapseTimeout); this.collapseTimeout = null; } } } } private _handleFabClick = (e: MouseEvent) => { // When collapsed, let the click bubble up to parent's onClick handler // When expanded, ignore clicks on the FAB content area if (this.expanded) { // Don't let clicks on expanded content close the FAB e.stopPropagation(); } }; private _handleBackdropClick = (e: MouseEvent) => { if (this.expanded) { e.stopPropagation(); // Prevent event from bubbling to host element's onClick this.emit("ct-fab-backdrop-click"); } }; private _handleKeydown = (e: KeyboardEvent) => { if (e.key === "Escape" && this.expanded) { this.emit("ct-fab-escape"); } }; private _showPreviewNotification() { this.showPreview = true; // Clear any existing timeout if (this._previewTimeout !== null) { clearTimeout(this._previewTimeout); } // Hide after 5 seconds this._previewTimeout = setTimeout(() => { this.showPreview = false; this._previewTimeout = null; }, 5000) as unknown as number; } override render() { const previewMsg = this.previewMessage && isCell(this.previewMessage) ? this.previewMessage.get() : this.previewMessage; return html`
${this.showPreview && !this.expanded && previewMsg ? html`
` : nothing} `; } } if (!globalThis.customElements.get("ct-fab")) { globalThis.customElements.define("ct-fab", CTFab); }