/** @jsxImportSource preact */
import {Widget} from '@deck.gl/core';
import {render} from 'preact';
import {asPanelContainer, WidgetContainerRenderer} from './widget-containers';
import {IconButton, makeTextIcon} from '../widget-components/icon-button';
import type {WidgetContainer, WidgetPanel} from './widget-containers';
import type {WidgetPlacement, WidgetProps} from '@deck.gl/core';
import type {JSX} from 'preact';
/** Trigger and panel configuration for a modal-style widget. */
export type ModalWidgetProps = WidgetProps & {
/** Trigger icon alias for legacy compatibility. */
icon?: string;
/** The content container to show when the modal is open. */
container?: WidgetContainer;
/** Optional shorthand panel. When supplied, shown directly in the modal body. */
panel?: WidgetPanel;
/** Button and panel placement anchor. */
placement?: WidgetPlacement;
/** Optional modal title shown in the header. */
title?: string;
/** Optional trigger button label visible in the UI. */
triggerLabel?: string;
/** Optional trigger icon. Defaults to a menu-like glyph. */
triggerIcon?: string;
/**
* Hides the trigger. Useful when trigger is implemented externally.
*/
hideTrigger?: boolean;
/**
* Whether to render the built-in trigger button.
* If false, no built-in trigger is rendered.
*/
button?: boolean;
/**
* Uncontrolled default open state.
*/
defaultOpen?: boolean;
/**
* Controlled open state. If supplied, callers own open/closed state.
*/
open?: boolean;
/**
* Called when user intent changes open/closed state.
*/
onOpenChange?: (open: boolean) => void;
};
const MODAL_WIDGET_CLASS = 'deck-widget-modal';
const MODAL_TRIGGER_ICON = makeTextIcon('▦', 18, 24);
const DIALOG_MAX_WIDTH = 'min(88vw, 620px)';
const MODAL_OPEN_CONTAINER_Z_INDEX = '40';
/**
* 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: 'accordeon',
props: {
panels: []
}
};
}
/**
* Resolves the trigger icon from legacy and new prop names.
*/
function resolveTriggerIcon({icon, triggerIcon}: {icon?: string; triggerIcon?: string}): string {
if (icon !== undefined) {
return icon;
}
if (triggerIcon !== undefined) {
return triggerIcon;
}
return MODAL_TRIGGER_ICON;
}
/**
* Resolves final trigger visibility from explicit and legacy hide props.
*/
function resolveHideTrigger({
hideTrigger,
button
}: {
hideTrigger?: boolean;
button?: boolean;
}): boolean {
if (button !== undefined) {
return !button;
}
return hideTrigger ?? false;
}
/**
* Stops bubbling from inner modal controls so parent listeners do not receive the interaction.
*/
function stopPropagation(event: Event): void {
event.stopPropagation();
}
function ModalWidgetView({
container,
title,
hideTrigger,
triggerLabel,
triggerIcon,
open,
onOpenChange
}: {
container: WidgetContainer;
title: string;
hideTrigger: boolean;
triggerIcon: string;
triggerLabel: string;
open: boolean;
onOpenChange: (next: boolean) => void;
}) {
return (
{!hideTrigger && (
onOpenChange(!open)}
/>
)}
{!open && }
{open && (
<>
);
}
/**
* A reusable deck widget that renders a trigger + modal panel assembled from widget containers.
*/
export class ModalWidget extends Widget {
static defaultProps: Required = {
...Widget.defaultProps,
id: 'modal-widget',
placement: 'top-right',
title: 'Panel',
triggerLabel: 'Open panel',
triggerIcon: MODAL_TRIGGER_ICON,
icon: undefined!,
panel: undefined!,
hideTrigger: false,
button: undefined!,
defaultOpen: false,
onOpenChange: undefined!,
open: undefined!,
container: {
kind: 'panel',
props: {
panel: {
id: 'empty-modal-panel',
title: 'Empty',
content:
}
}
}
};
className = MODAL_WIDGET_CLASS;
placement: WidgetPlacement = ModalWidget.defaultProps.placement;
triggerLabel: string = ModalWidget.defaultProps.triggerLabel;
triggerIcon: string = ModalWidget.defaultProps.triggerIcon;
hideTrigger = ModalWidget.defaultProps.hideTrigger;
title = ModalWidget.defaultProps.title;
isOpen = false;
#hasOpenStateInitialized = false;
#container: WidgetContainer = ModalWidget.defaultProps.container;
#isControlled = false;
#openChange: ((open: boolean) => void) | undefined = undefined;
#rootElement: HTMLElement | null = null;
#placementContainer: HTMLElement | null = null;
#placementContainerZIndex = '';
#isDocumentKeyListenerAttached = false;
constructor(props: Partial = {}) {
super({
...ModalWidget.defaultProps,
...props,
container: asContainer(props.container, props.panel),
triggerIcon: resolveTriggerIcon(props)
} as ModalWidgetProps);
this.setProps(this.props);
}
setProps(props: Partial): void {
this.#setDisplayProps(props);
this.#setContainerProps(props);
this.#setOpenProps(props);
this.#render();
super.setProps(props);
}
onAdd(): void {
this.#render();
}
onRemove(): void {
this.#detachDocumentKeyListener();
this.#syncPlacementContainerZIndex();
if (this.#rootElement) {
render(null, this.#rootElement);
}
}
onRenderHTML(rootElement: HTMLElement): void {
this.#rootElement = rootElement;
this.#placementContainer ??= rootElement.parentElement;
if (this.#placementContainer && !this.#placementContainerZIndex) {
this.#placementContainerZIndex = this.#placementContainer.style.zIndex;
}
const className = ['deck-widget', this.className, this.props.className]
.filter(Boolean)
.join(' ');
rootElement.className = className;
this.#render();
}
#handleOpenChange = (nextOpen: boolean) => {
if (!this.#isControlled) {
this.isOpen = nextOpen;
}
this.#openChange?.(nextOpen);
this.#render();
};
#setDisplayProps(props: Partial): void {
if (props.icon !== undefined) {
this.triggerIcon = props.icon;
}
if (props.placement !== undefined) {
this.placement = props.placement;
}
if (props.triggerLabel !== undefined) {
this.triggerLabel = props.triggerLabel;
}
if (props.triggerIcon !== undefined) {
this.triggerIcon = props.triggerIcon;
}
if (props.hideTrigger !== undefined || props.button !== undefined) {
this.hideTrigger = resolveHideTrigger({
hideTrigger: props.hideTrigger,
button: props.button
});
}
if (props.title !== undefined) {
this.title = props.title;
}
}
#setContainerProps(props: Partial): void {
if (props.container !== undefined) {
this.#container = props.container;
} else if (props.panel !== undefined) {
this.#container = asContainer(undefined, props.panel);
}
if (props.onOpenChange !== undefined) {
this.#openChange = props.onOpenChange;
}
}
#setOpenProps(props: Partial): void {
this.#isControlled = props.open !== undefined;
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;
}
}
/**
* Closes the modal when the user presses Escape while it is open.
*/
#handleDocumentKeyDown = (event: KeyboardEvent) => {
if (!this.isOpen || event.key !== 'Escape') {
return;
}
event.preventDefault();
this.#handleOpenChange(false);
};
/**
* Keeps the document Escape handler attached only while the modal is open.
*/
#syncDocumentKeyListener(): void {
if (this.isOpen) {
if (!this.#isDocumentKeyListenerAttached) {
document.addEventListener('keydown', this.#handleDocumentKeyDown);
this.#isDocumentKeyListenerAttached = true;
}
return;
}
this.#detachDocumentKeyListener();
}
/**
* Removes the document Escape handler when the modal is closed or unmounted.
*/
#detachDocumentKeyListener(): void {
if (!this.#isDocumentKeyListenerAttached) {
return;
}
document.removeEventListener('keydown', this.#handleDocumentKeyDown);
this.#isDocumentKeyListenerAttached = false;
}
/**
* Keeps the modal's placement container above sibling widget containers while the modal is open.
*/
#syncPlacementContainerZIndex(): void {
if (!this.#placementContainer) {
return;
}
this.#placementContainer.style.zIndex = this.isOpen
? MODAL_OPEN_CONTAINER_Z_INDEX
: this.#placementContainerZIndex;
}
#render = () => {
if (!this.#rootElement) {
return;
}
this.#syncDocumentKeyListener();
this.#syncPlacementContainerZIndex();
render(
,
this.#rootElement
);
};
}
const OVERLAY_BACKDROP_STYLE: JSX.CSSProperties = {
position: 'fixed',
inset: '0',
backgroundColor: 'rgba(17, 24, 39, 0.28)',
border: 'none',
padding: '0',
margin: '0',
zIndex: 30
};
const MODAL_DIALOG_WRAPPER_STYLE: JSX.CSSProperties = {
position: 'fixed',
inset: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 31
};
const MODAL_DIALOG_PANEL_STYLE: JSX.CSSProperties = {
pointerEvents: 'auto',
width: 'min(90vw, 760px)',
maxWidth: DIALOG_MAX_WIDTH,
maxHeight: 'min(84vh, 84dvh)',
borderRadius: 'var(--menu-corner-radius, 10px)',
border: 'var(--menu-border, 1px solid rgba(148, 163, 184, 0.35))',
background: 'var(--menu-background, #fff)',
color: 'var(--menu-text, rgb(24, 24, 26))',
boxShadow: 'var(--menu-shadow, 0px 12px 30px rgba(0,0,0,0.25))',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
};
const MODAL_HEADER_STYLE: JSX.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
padding: '10px 12px',
borderBottom: 'var(--menu-divider, var(--menu-border, 1px solid rgba(148, 163, 184, 0.25)))',
backgroundColor:
'var(--menu-weak-background, var(--button-background, var(--menu-background, #fff)))',
color: 'var(--menu-text, rgb(24, 24, 26))'
};
const MODAL_HEADER_TITLE_STYLE: JSX.CSSProperties = {
margin: 0,
fontSize: '13px',
fontWeight: 700
};
const MODAL_CLOSE_BUTTON_STYLE: JSX.CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '999px',
border: 'var(--menu-inner-border, 1px solid rgba(148, 163, 184, 0.35))',
backgroundColor: 'transparent',
color: 'var(--button-text, rgb(24, 24, 26))',
cursor: 'pointer'
};
const MODAL_CONTENT_STYLE: JSX.CSSProperties = {
flex: 1,
overflow: 'auto',
padding: '10px'
};