/** @jsxImportSource preact */
import {Widget} from '@deck.gl/core';
import {render} from 'preact';
import {asPanelContainer, WidgetContainerRenderer} from './widget-containers';
import type {WidgetContainer, WidgetPanel} from './widget-containers';
import type {WidgetPlacement, WidgetProps} from '@deck.gl/core';
import type {JSX} from 'preact';
/** Static card widget properties. */
export type BoxWidgetProps = WidgetProps & {
/** The content container to show inside the box. */
container?: WidgetContainer;
/** Optional shorthand panel. When supplied, shown directly inside the box. */
panel?: WidgetPanel;
/** Placement anchor for the box. */
placement?: WidgetPlacement;
/** Optional box header title. */
title?: string;
/** Box width in pixels. */
widthPx?: number;
/** Whether the header toggles the box body open and closed. */
collapsible?: boolean;
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Controlled open state for the box body. */
open?: boolean;
/** Called when user intent changes open/closed state. */
onOpenChange?: (open: boolean) => void;
};
const BOX_WIDGET_CLASS = 'deck-widget-box';
/**
* Normalizes box width configuration into a practical render value.
*/
function normalizeBoxWidthPx(widthPx: number): number {
const clamped = Math.max(220, Math.floor(widthPx));
return Number.isFinite(clamped) ? clamped : 360;
}
/**
* Normalizes widget-container/panel inputs into a concrete widget container.
*/
function asContainer(container?: WidgetContainer, panel?: WidgetPanel): WidgetContainer {
if (container !== undefined) {
return container;
}
if (panel !== undefined) {
return asPanelContainer(panel);
}
return {
kind: 'panel',
props: {
panel: {
id: 'empty-box-panel',
title: '',
content:
}
}
};
}
function BoxWidgetView({
container,
title,
widthPx,
open,
collapsible,
onOpenChange
}: {
container: WidgetContainer;
title?: string;
widthPx: number;
open: boolean;
collapsible: boolean;
onOpenChange: (next: boolean) => void;
}) {
return (
{title ? (
{collapsible ? (
) : (
{title}
)}
) : null}
);
}
/**
* A reusable deck widget that renders a static themed card assembled from widget containers.
*/
export class BoxWidget extends Widget {
static defaultProps: Required = {
...Widget.defaultProps,
id: 'box-widget',
placement: 'bottom-left',
title: undefined!,
widthPx: 360,
collapsible: true,
defaultOpen: true,
open: undefined!,
onOpenChange: undefined!,
panel: undefined!,
container: {
kind: 'panel',
props: {
panel: {
id: 'empty-box-panel',
title: '',
content:
}
}
}
};
className = BOX_WIDGET_CLASS;
placement: WidgetPlacement = BoxWidget.defaultProps.placement;
title: string | undefined = BoxWidget.defaultProps.title;
widthPx = BoxWidget.defaultProps.widthPx;
collapsible = BoxWidget.defaultProps.collapsible;
isOpen = BoxWidget.defaultProps.defaultOpen;
#container: WidgetContainer = BoxWidget.defaultProps.container;
#rootElement: HTMLElement | null = null;
#hasOpenStateInitialized = false;
#isControlled = false;
#openChange: ((open: boolean) => void) | undefined = undefined;
constructor(props: Partial = {}) {
super({
...BoxWidget.defaultProps,
...props,
container: asContainer(props.container, props.panel)
} as BoxWidgetProps);
this.setProps(this.props);
}
setProps(props: Partial): void {
if (props.placement !== undefined) {
this.placement = props.placement;
}
if ('title' in props) {
this.title = props.title;
}
if (props.widthPx !== undefined) {
this.widthPx = normalizeBoxWidthPx(props.widthPx);
}
if (props.collapsible !== undefined) {
this.collapsible = props.collapsible;
}
this.#setOpenProps(props);
if (props.container !== undefined) {
this.#container = props.container;
} else if (props.panel !== undefined) {
this.#container = asContainer(undefined, props.panel);
}
this.#render();
super.setProps(props);
}
onRemove(): void {
if (this.#rootElement) {
render(null, this.#rootElement);
}
}
onRenderHTML(rootElement: HTMLElement): void {
this.#rootElement = rootElement;
rootElement.style.margin = '0';
this.#render();
}
#handleOpenChange = (nextOpen: boolean) => {
if (!this.#isControlled) {
this.isOpen = nextOpen;
}
this.#openChange?.(nextOpen);
this.#render();
};
#render = () => {
if (!this.#rootElement) {
return;
}
render(
,
this.#rootElement
);
};
#setOpenProps(props: Partial): void {
this.#isControlled = props.open !== undefined;
if (props.onOpenChange !== undefined) {
this.#openChange = props.onOpenChange;
}
if (props.open !== undefined) {
this.isOpen = props.open;
this.#hasOpenStateInitialized = true;
return;
}
if (!this.#hasOpenStateInitialized && props.defaultOpen !== undefined) {
this.isOpen = props.defaultOpen;
this.#hasOpenStateInitialized = true;
}
}
}
const BOX_WIDGET_STYLE = (widthPx: number): JSX.CSSProperties => ({
margin: 'var(--widget-margin, 12px)',
width: `${widthPx}px`,
maxWidth: `min(84vw, ${widthPx}px)`,
pointerEvents: 'auto',
border: 'var(--menu-border, 1px solid rgba(148, 163, 184, 0.35))',
borderRadius: '12px',
background: 'var(--menu-background, rgba(255, 255, 255, 0.96))',
backdropFilter: 'var(--menu-backdrop-filter, unset)',
color: 'var(--menu-text, rgb(24, 24, 26))',
boxShadow: 'var(--menu-shadow, 0 18px 40px rgba(15, 23, 42, 0.16))',
overflow: 'hidden'
});
const BOX_HEADER_STYLE: JSX.CSSProperties = {
padding: '14px 16px 10px',
fontSize: '18px',
fontWeight: 700,
lineHeight: 1.2,
color: 'var(--button-text, currentColor)'
};
const BOX_HEADER_BUTTON_STYLE: JSX.CSSProperties = {
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
padding: '0',
border: '0',
background: 'transparent',
color: 'inherit',
font: 'inherit',
textAlign: 'left',
cursor: 'pointer'
};
const BOX_HEADER_CHEVRON_STYLE = (open: boolean): JSX.CSSProperties => ({
display: 'block',
fontSize: '16px',
lineHeight: 1,
transform: open ? 'translateY(1px)' : 'translateY(0)'
});
const BOX_CONTENT_STYLE = (open: boolean): JSX.CSSProperties => ({
padding: '0 16px 14px',
display: open ? 'block' : 'none'
});