/** @jsxImportSource preact */
import {Widget} from '@deck.gl/core';
import {render} from 'preact';
import {asPanelContainer, WidgetContainerRenderer} from './widget-containers';
import {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';
/** Sidebar widget properties. */
export type SidebarWidgetProps = WidgetProps & {
/** Trigger icon alias for legacy compatibility. */
icon?: string;
/** The content container to show in the sidebar. */
container?: WidgetContainer;
/** Optional shorthand panel. When supplied, shown directly inside the sidebar. */
panel?: WidgetPanel;
/** Preferred sidebar edge. */
side?: 'left' | 'right';
/** Sidebar width in pixels. */
widthPx?: number;
/** Container placement inside the selected widget container. */
placement?: WidgetPlacement;
/** Sidebar header title. */
title?: string;
/** 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;
/** Optional trigger label. */
triggerLabel?: string;
/** Optional trigger icon. Defaults to a panel-like glyph. */
triggerIcon?: string;
/**
* Hides the trigger. Useful when trigger is implemented externally.
*/
hideTrigger?: boolean;
/**
* Whether to render the built-in icon trigger button.
* If false, a text trigger button is used unless hidden.
*/
button?: boolean;
};
const SIDEBAR_WIDGET_CLASS = 'deck-widget-sidebar';
const SIDEBAR_TRIGGER_ICON = makeTextIcon('☰', 16, 24);
const SIDEBAR_HANDLE_WIDTH_PX = 36;
const SIDEBAR_HANDLE_GAP_PX = 8;
const SIDEBAR_TRANSITION_MS = 320;
const SIDEBAR_OVERLAY_Z_INDEX = '35';
/**
* 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 SIDEBAR_TRIGGER_ICON;
}
/**
* Renders a sidebar container with an edge-mounted trigger and animated slide-over panel.
*/
function SidebarWidgetView({
container,
side,
title,
triggerLabel,
open,
button,
hideTrigger,
panelWidthPx,
onOpenChange
}: {
container: WidgetContainer;
side: 'left' | 'right';
title?: string;
triggerLabel: string;
open: boolean;
button: boolean;
hideTrigger: boolean;
panelWidthPx: number;
onOpenChange: (next: boolean) => void;
}) {
const shouldRenderShell = open || !hideTrigger;
const panelWidthWithHandlePx = hideTrigger
? panelWidthPx
: panelWidthPx + SIDEBAR_HANDLE_WIDTH_PX + SIDEBAR_HANDLE_GAP_PX;
const handleChevron = getSidebarHandleChevron(side, open);
const handleLabel = open ? `Close ${triggerLabel}` : triggerLabel;
return (
{!shouldRenderShell ? null : (
)}
);
}
/**
* Prevents pointer and wheel events from reaching the underlying deck canvas.
*/
function stopSidebarEventPropagation(event: Event): void {
event.stopPropagation();
}
/**
* Normalizes sidebar width configuration into a practical render value.
*/
function normalizeSidebarWidthPx(widthPx: number): number {
const clamped = Math.max(220, Math.floor(widthPx));
return Number.isFinite(clamped) ? clamped : 360;
}
/**
* A reusable deck widget that renders a side-anchored panel with configurable container content.
*/
export class SidebarWidget extends Widget {
static defaultProps: Required = {
...Widget.defaultProps,
id: 'sidebar-widget',
panel: undefined!,
container: {
kind: 'accordeon',
props: {
panels: []
}
},
side: 'right',
widthPx: 360,
placement: 'top-right',
title: undefined!,
defaultOpen: true,
open: undefined!,
onOpenChange: undefined!,
icon: undefined!,
hideTrigger: false,
triggerLabel: 'Open sidebar',
triggerIcon: SIDEBAR_TRIGGER_ICON,
button: false
};
className = SIDEBAR_WIDGET_CLASS;
placement: WidgetPlacement = SidebarWidget.defaultProps.placement;
side: 'left' | 'right' = SidebarWidget.defaultProps.side;
widthPx = SidebarWidget.defaultProps.widthPx;
title: string | undefined = SidebarWidget.defaultProps.title;
triggerLabel = SidebarWidget.defaultProps.triggerLabel;
hideTrigger = SidebarWidget.defaultProps.hideTrigger;
triggerIcon = SidebarWidget.defaultProps.triggerIcon;
button = SidebarWidget.defaultProps.button;
isOpen = false;
#hasOpenStateInitialized = false;
#container: WidgetContainer = SidebarWidget.defaultProps.container;
#isControlled = false;
#openChange: ((open: boolean) => void) | undefined = undefined;
#rootElement: HTMLElement | null = null;
#overlayParent: HTMLElement | null = null;
constructor(props: Partial = {}) {
super({
...SidebarWidget.defaultProps,
...props,
container: props.container ?? asContainer(props.panel),
triggerIcon: resolveTriggerIcon(props)
} as SidebarWidgetProps);
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 {
if (this.#rootElement) {
render(null, this.#rootElement);
}
}
onRenderHTML(rootElement: HTMLElement): void {
this.#rootElement = rootElement;
this.#overlayParent ??= this.#resolveOverlayParent(rootElement);
if (this.#overlayParent && rootElement.parentElement !== this.#overlayParent) {
this.#overlayParent.append(rootElement);
}
const className = ['deck-widget', this.className, this.props.className]
.filter(Boolean)
.join(' ');
rootElement.className = className;
rootElement.style.position = 'absolute';
rootElement.style.top = 'var(--widget-margin, 12px)';
rootElement.style.bottom = 'var(--widget-margin, 12px)';
rootElement.style.left = this.side === 'left' ? '-1px' : 'var(--widget-margin, 12px)';
rootElement.style.right = this.side === 'right' ? '-1px' : 'var(--widget-margin, 12px)';
rootElement.style.width = 'auto';
rootElement.style.height = 'auto';
rootElement.style.margin = '0';
rootElement.style.overflow = 'hidden';
rootElement.style.pointerEvents = 'none';
rootElement.style.zIndex = SIDEBAR_OVERLAY_Z_INDEX;
(this.props as {_widgetContainer?: 'overlay'})._widgetContainer = 'overlay';
this.#render();
}
#handleOpenChange = (nextOpen: boolean) => {
if (!this.#isControlled) {
this.isOpen = nextOpen;
}
this.#openChange?.(nextOpen);
this.#render();
};
#render = () => {
if (!this.#rootElement) {
return;
}
render(
,
this.#rootElement
);
};
#setDisplayProps(props: Partial): void {
if (props.icon !== undefined) {
this.triggerIcon = props.icon;
}
if (props.placement !== undefined) {
this.placement = props.placement;
}
if ('title' in props) {
this.title = props.title;
}
if (props.side !== undefined) {
this.side = props.side;
}
if (props.widthPx !== undefined) {
this.widthPx = normalizeSidebarWidthPx(props.widthPx);
}
if (props.triggerLabel !== undefined) {
this.triggerLabel = props.triggerLabel;
}
if (props.triggerIcon !== undefined) {
this.triggerIcon = props.triggerIcon;
}
if (props.hideTrigger !== undefined) {
this.hideTrigger = props.hideTrigger;
}
if (props.button !== undefined) {
this.button = props.button;
}
}
#setContainerProps(props: Partial): void {
if (props.container !== undefined) {
this.#container = props.container;
} else if (props.panel !== undefined) {
this.#container = asContainer(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;
}
}
#resolveOverlayParent(rootElement: HTMLElement): HTMLElement | null {
const explicitContainer = this.props._container;
if (explicitContainer && typeof explicitContainer !== 'string') {
return explicitContainer;
}
return rootElement.parentElement?.parentElement;
}
}
function asContainer(panel?: WidgetPanel): WidgetContainer {
if (panel === undefined) {
return {
kind: 'accordeon',
props: {
panels: []
}
};
}
return asPanelContainer(panel);
}
/**
* Returns the directional chevron shown in the built-in sidebar handle.
*/
function getSidebarHandleChevron(side: 'left' | 'right', open: boolean): string {
if (side === 'left') {
return open ? '‹' : '›';
}
return open ? '›' : '‹';
}
const SIDEBAR_TRIGGER_STYLE: JSX.CSSProperties = {
border: '1px solid var(--menu-border, rgba(148, 163, 184, 0.35))',
borderRadius: '6px',
background: 'var(--menu-background, #fff)',
color: 'var(--button-text, rgb(24, 24, 26))',
fontSize: '12px',
lineHeight: '1.1',
padding: '8px 10px',
cursor: 'pointer'
};
const SIDEBAR_PANEL_WRAPPER_STYLE: JSX.CSSProperties = {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: 31
};
const SIDEBAR_SHELL_STYLE = (
side: 'left' | 'right',
panelWidthPx: number,
panelWidthWithHandlePx: number,
open: boolean
): JSX.CSSProperties => ({
position: 'absolute',
top: '0',
bottom: '0',
[side]: '0',
width: `${panelWidthWithHandlePx}px`,
display: 'flex',
flexDirection: side === 'left' ? 'row-reverse' : 'row',
alignItems: 'flex-start',
pointerEvents: 'auto',
transform: open
? 'translateX(0px)'
: `translateX(${side === 'left' ? -panelWidthPx : panelWidthPx}px)`,
transition: `transform ${SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`,
willChange: 'transform',
gap: `${SIDEBAR_HANDLE_GAP_PX}px`
});
const SIDEBAR_HANDLE_WRAPPER_STYLE: JSX.CSSProperties = {
width: `${SIDEBAR_HANDLE_WIDTH_PX}px`,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
pointerEvents: 'auto'
};
const SIDEBAR_HANDLE_BUTTON_WRAPPER_STYLE: JSX.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
};
const SIDEBAR_HANDLE_BUTTON_STYLE: JSX.CSSProperties = {
width: `${SIDEBAR_HANDLE_WIDTH_PX}px`,
minWidth: `${SIDEBAR_HANDLE_WIDTH_PX}px`,
height: '40px',
border: 'var(--button-inner-stroke, 1px solid rgba(148, 163, 184, 0.35))',
borderRadius: '2px',
background: 'var(--button-background, #fff)',
backdropFilter: 'var(--button-backdrop-filter, unset)',
color: 'var(--button-text, rgb(24, 24, 26))',
boxShadow: 'var(--button-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
pointerEvents: 'auto',
padding: '0'
};
const SIDEBAR_HANDLE_CHEVRON_STYLE: JSX.CSSProperties = {
display: 'block',
fontSize: '22px',
fontWeight: 700,
lineHeight: '1',
transform: 'translateY(-1px)',
color: 'var(--button-icon-idle, #616166)',
transition: `transform ${SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
};
const SIDEBAR_PANEL_STYLE = (
side: 'left' | 'right',
panelWidthPx: number,
open: boolean
): JSX.CSSProperties => ({
pointerEvents: 'auto',
width: `${panelWidthPx}px`,
maxWidth: `min(84vw, ${panelWidthPx}px)`,
minWidth: `${Math.min(panelWidthPx, 260)}px`,
height: '100%',
borderLeft:
side === 'right' ? '1px solid var(--menu-border, rgba(148, 163, 184, 0.35))' : undefined,
borderRight:
side === 'left' ? '1px solid var(--menu-border, rgba(148, 163, 184, 0.35))' : undefined,
background: 'var(--menu-background, #fff)',
color: 'var(--menu-text, rgb(24, 24, 26))',
boxShadow: 'var(--menu-shadow, -8px 0 25px rgba(0, 0, 0, 0.22))',
display: 'flex',
flexDirection: 'column',
opacity: open ? 1 : 0.98
});
const SIDEBAR_HEADER_STYLE: JSX.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '10px',
padding: '10px 12px',
borderBottom: 'var(--menu-divider, var(--menu-border, 1px solid rgba(148, 163, 184, 0.25)))',
background: 'var(--menu-weak-background, var(--button-background, var(--menu-background, #fff)))',
color: 'var(--menu-text, rgb(24, 24, 26))',
fontSize: '13px',
fontWeight: 700
};
const SIDEBAR_CONTENT_STYLE: JSX.CSSProperties = {
flex: 1,
overflow: 'auto',
padding: '10px'
};