diff --git a/src/__testing__/DashboardLayout.test.tsx b/src/__testing__/DashboardLayout.test.tsx
new file mode 100644
index 000000000..b14090074
--- /dev/null
+++ b/src/__testing__/DashboardLayout.test.tsx
@@ -0,0 +1,389 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+import { DashboardLayout } from '../custom/DashboardLayout/DashboardLayout';
+import { DashboardLayoutContext, useDashboardLayoutContext } from '../custom/DashboardLayout/DashboardLayoutContext';
+import { SistentThemeProvider } from '../theme';
+
+// ---------------------------------------------------------------------------
+// Mock breakpoint / media query
+// ---------------------------------------------------------------------------
+
+let mockIsMobile = false;
+
+jest.mock('@mui/material', () => ({
+ ...jest.requireActual('@mui/material'),
+ useMediaQuery: () => mockIsMobile
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const WIDGETS =
Widget Gallery
;
+
+function renderLayout(
+ props: Partial> & {
+ isSidebarOpen: boolean;
+ }
+) {
+ return render(
+
+
+ Dashboard Content
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Mobile tests
+// ---------------------------------------------------------------------------
+
+describe('DashboardLayout – mobile', () => {
+ beforeEach(() => {
+ mockIsMobile = true;
+ });
+ afterAll(() => {
+ mockIsMobile = false;
+ });
+
+ it('renders BottomSheet title and no reopen FAB when isSidebarOpen=true', () => {
+ renderLayout({ isSidebarOpen: true });
+ // BottomSheet renders its title text
+ expect(screen.queryByText('Widget Picker')).not.toBeNull();
+ // No reopen FAB while sheet is open
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('does not render BottomSheet or FAB when isSidebarOpen=false', () => {
+ renderLayout({ isSidebarOpen: false });
+ // BottomSheet title is not rendered
+ expect(screen.queryByText('Widget Picker')).toBeNull();
+ // No reopen FAB either
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('shows FAB when BottomSheet is closed and keeps edit mode active', () => {
+ renderLayout({ isSidebarOpen: true });
+ // BottomSheet has its own close button labelled 'Close'
+ const closeBtn = screen.getByLabelText('Close');
+ fireEvent.click(closeBtn);
+ // FAB appears immediately so the user can reopen, while the sheet transitions closed
+ expect(screen.queryByLabelText('Open Widget Picker')).not.toBeNull();
+ });
+
+ it('reopens BottomSheet when FAB is clicked', () => {
+ renderLayout({ isSidebarOpen: true });
+ // Close the sheet first
+ fireEvent.click(screen.getByLabelText('Close'));
+ // FAB is now visible
+ expect(screen.queryByLabelText('Open Widget Picker')).not.toBeNull();
+ // Click the reopen FAB
+ fireEvent.click(screen.getByLabelText('Open Widget Picker'));
+ // FAB disappears because sheet is open again
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('hides FAB when showReopenFab=false even when sheet is minimized', () => {
+ renderLayout({ isSidebarOpen: true, showReopenFab: false });
+ fireEvent.click(screen.getByLabelText('Close'));
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('provides isSheet=true to sidebarContent via context', () => {
+ let capturedContext: ReturnType = null;
+
+ function ContextCapture() {
+ capturedContext = useDashboardLayoutContext();
+ return null;
+ }
+
+ renderLayout({ isSidebarOpen: true, sidebarContent: });
+
+ expect(capturedContext).not.toBeNull();
+ expect(capturedContext?.isSheet).toBe(true);
+ expect(capturedContext?.isMobile).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Desktop tests
+// ---------------------------------------------------------------------------
+
+describe('DashboardLayout – desktop', () => {
+ beforeEach(() => {
+ mockIsMobile = false;
+ });
+
+ it('renders sidebar content and no FAB when isSidebarOpen=true', () => {
+ renderLayout({ isSidebarOpen: true });
+ expect(screen.queryByText('Widget Gallery')).not.toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('does not render sidebar or FAB when isSidebarOpen=false', () => {
+ renderLayout({ isSidebarOpen: false });
+ expect(screen.queryByText('Widget Gallery')).toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('provides isSheet=false to sidebarContent via context', () => {
+ let capturedContext: ReturnType = null;
+
+ function ContextCapture() {
+ capturedContext = useDashboardLayoutContext();
+ return null;
+ }
+
+ // Must use WIDGETS to have sidebarContent that renders AND the context capture
+ renderLayout({
+ isSidebarOpen: true,
+ sidebarContent: (
+ <>
+
+ {WIDGETS}
+ >
+ )
+ });
+
+ expect(capturedContext).not.toBeNull();
+ expect(capturedContext?.isSheet).toBe(false);
+ expect(capturedContext?.isMobile).toBe(false);
+ });
+
+ it('shows FAB and hides sidebar when closeSidebar is called from context', () => {
+ let capturedContext: ReturnType = null;
+
+ function MinimizeButton() {
+ capturedContext = useDashboardLayoutContext();
+ return (
+
+ );
+ }
+
+ renderLayout({ isSidebarOpen: true, sidebarContent: });
+
+ // sidebar is open: close button visible, no FAB
+ expect(screen.queryByLabelText('minimize')).not.toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+
+ // Minimize the sidebar via context
+ fireEvent.click(screen.getByLabelText('minimize'));
+
+ // sidebar is hidden: minimize button gone, reopen FAB visible
+ expect(screen.queryByLabelText('minimize')).toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).not.toBeNull();
+ });
+
+ it('reopens desktop sidebar when FAB is clicked after minimizing', () => {
+ let capturedContext: ReturnType = null;
+
+ function MinimizeButton() {
+ capturedContext = useDashboardLayoutContext();
+ return (
+
+ );
+ }
+
+ renderLayout({ isSidebarOpen: true, sidebarContent: });
+
+ // Minimize sidebar
+ fireEvent.click(screen.getByLabelText('minimize'));
+ expect(screen.queryByLabelText('Open Widget Picker')).not.toBeNull();
+
+ // Click reopen FAB
+ fireEvent.click(screen.getByLabelText('Open Widget Picker'));
+ // Sidebar is back: minimize button visible, no FAB
+ expect(screen.queryByLabelText('minimize')).not.toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('respects controlled sidebarVisible=true prop', () => {
+ const onSidebarVisibilityChange = jest.fn();
+ render(
+
+
+ Dashboard
+
+
+ );
+
+ expect(screen.queryByText('Widget Gallery')).not.toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).toBeNull();
+ });
+
+ it('respects controlled sidebarVisible=false prop (shows FAB)', () => {
+ const onSidebarVisibilityChange = jest.fn();
+ render(
+
+
+ Dashboard
+
+
+ );
+
+ expect(screen.queryByText('Widget Gallery')).toBeNull();
+ expect(screen.queryByLabelText('Open Widget Picker')).not.toBeNull();
+ });
+
+ it('calls onSidebarVisibilityChange when FAB is clicked in controlled mode', () => {
+ const onSidebarVisibilityChange = jest.fn();
+ render(
+
+
+ Dashboard
+
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Open Widget Picker'));
+ expect(onSidebarVisibilityChange).toHaveBeenCalledWith(true);
+ });
+
+ it('does not emit onSidebarVisibilityChange on initial mount when isSidebarOpen is false', () => {
+ const onSidebarVisibilityChange = jest.fn();
+ render(
+
+
+ Dashboard
+
+
+ );
+
+ expect(onSidebarVisibilityChange).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Context value tests
+// ---------------------------------------------------------------------------
+
+describe('DashboardLayoutContext – standalone usage', () => {
+ it('useDashboardLayoutContext returns null when used outside DashboardLayout', () => {
+ let ctxValue: ReturnType = undefined as unknown as null;
+
+ function Probe() {
+ ctxValue = useDashboardLayoutContext();
+ return null;
+ }
+
+ render();
+ expect(ctxValue).toBeNull();
+ });
+
+ it('DashboardLayoutContext.Provider propagates value correctly', () => {
+ let ctxValue: ReturnType = null;
+ const mockClose = jest.fn();
+ const mockOpen = jest.fn();
+
+ function Probe() {
+ ctxValue = useDashboardLayoutContext();
+ return null;
+ }
+
+ render(
+
+
+
+ );
+
+ expect(ctxValue?.isMobile).toBe(false);
+ expect(ctxValue?.isSheet).toBe(false);
+ expect(ctxValue?.isSidebarVisible).toBe(true);
+
+ ctxValue?.closeSidebar();
+ expect(mockClose).toHaveBeenCalledTimes(1);
+
+ ctxValue?.openSidebar();
+ expect(mockOpen).toHaveBeenCalledTimes(1);
+ });
+
+ describe('sidebar height derivation from sidebarTopOffset (#1843)', () => {
+ it('derives height as calc(100dvh - offset) when sidebarTopOffset is provided without sidebarHeight', () => {
+ renderLayout({
+ isSidebarOpen: true,
+ sidebarTopOffset: '64px',
+ sidebarContent: content
+ });
+
+ const container = screen.getByTestId('sidebar-child').parentElement;
+ const styles = window.getComputedStyle(container as Element);
+ expect(styles.height).toBe('calc(100dvh - 64px)');
+ expect(styles.maxHeight).toBe('calc(100dvh - 64px)');
+ expect(styles.top).toBe('64px');
+ });
+
+ it('formats numeric sidebarTopOffset in pixels for height calc', () => {
+ renderLayout({
+ isSidebarOpen: true,
+ sidebarTopOffset: 80,
+ sidebarContent: content
+ });
+
+ const container = screen.getByTestId('sidebar-child').parentElement;
+ const styles = window.getComputedStyle(container as Element);
+ expect(styles.height).toBe('calc(100dvh - 80px)');
+ expect(styles.maxHeight).toBe('calc(100dvh - 80px)');
+ expect(styles.top).toBe('80px');
+ });
+
+ it('respects explicit sidebarHeight when both are provided', () => {
+ renderLayout({
+ isSidebarOpen: true,
+ sidebarTopOffset: '64px',
+ sidebarHeight: '500px',
+ sidebarContent: content
+ });
+
+ const container = screen.getByTestId('sidebar-child').parentElement;
+ const styles = window.getComputedStyle(container as Element);
+ expect(styles.height).toBe('500px');
+ expect(styles.maxHeight).toBe('500px');
+ expect(styles.top).toBe('64px');
+ });
+
+ it('defaults to 100dvh when sidebarTopOffset is 0 or not provided', () => {
+ renderLayout({
+ isSidebarOpen: true,
+ sidebarTopOffset: '0',
+ sidebarContent: content
+ });
+
+ const container = screen.getByTestId('sidebar-child').parentElement;
+ const styles = window.getComputedStyle(container as Element);
+ expect(styles.height).toBe('100dvh');
+ expect(styles.maxHeight).toBe('100dvh');
+ });
+ });
+});
diff --git a/src/__testing__/WidgetPicker.test.tsx b/src/__testing__/WidgetPicker.test.tsx
new file mode 100644
index 000000000..22bbaba19
--- /dev/null
+++ b/src/__testing__/WidgetPicker.test.tsx
@@ -0,0 +1,180 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { WidgetPicker, type WidgetPickerProps } from '../custom/WidgetPicker/WidgetPicker';
+import { DashboardLayoutContext, type DashboardLayoutContextValue } from '../custom/DashboardLayout/DashboardLayoutContext';
+import { SistentThemeProvider } from '../theme';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const noop = () => {};
+
+const WIDGETS: WidgetPickerProps['widgetsToAdd'] = [
+ { key: 'chart', title: 'Chart Widget' },
+ { key: 'table', title: 'Table Widget' }
+];
+
+function makeContext(
+ overrides: Partial = {}
+): DashboardLayoutContextValue {
+ return {
+ isMobile: false,
+ isSheet: false,
+ isSidebarVisible: true,
+ closeSidebar: noop,
+ openSidebar: noop,
+ ...overrides
+ };
+}
+
+function renderPicker(
+ props: Partial = {},
+ context?: DashboardLayoutContextValue | null
+) {
+ const element = (
+
+
+
+ );
+
+ if (context === undefined) {
+ // No context wrapper — standalone usage
+ return render(element);
+ }
+
+ return render(
+
+ {element}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Close button auto-detection
+// ---------------------------------------------------------------------------
+
+describe('WidgetPicker – close button auto-detection', () => {
+ it('renders close button when standalone with onClose provided', () => {
+ const handleClose = jest.fn();
+ renderPicker({ onClose: handleClose });
+ expect(screen.queryByLabelText('Close widget picker')).not.toBeNull();
+ });
+
+ it('does not render close button when standalone with no onClose and no context', () => {
+ renderPicker();
+ expect(screen.queryByLabelText('Close widget picker')).toBeNull();
+ });
+
+ it('suppresses close button when embedded in BottomSheet (isSheet=true)', () => {
+ renderPicker(
+ { onClose: noop },
+ makeContext({ isSheet: true })
+ );
+ expect(screen.queryByLabelText('Close widget picker')).toBeNull();
+ });
+
+ it('renders close button when embedded in desktop sidebar (isSheet=false) with onClose', () => {
+ renderPicker(
+ { onClose: noop },
+ makeContext({ isSheet: false })
+ );
+ expect(screen.queryByLabelText('Close widget picker')).not.toBeNull();
+ });
+
+ it('renders close button when embedded in desktop sidebar with only closeSidebar in context', () => {
+ const closeSidebar = jest.fn();
+ renderPicker(
+ {},
+ makeContext({ isSheet: false, closeSidebar })
+ );
+ expect(screen.queryByLabelText('Close widget picker')).not.toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// showCloseButton explicit override
+// ---------------------------------------------------------------------------
+
+describe('WidgetPicker – showCloseButton explicit override', () => {
+ it('showCloseButton=true forces close button even inside BottomSheet', () => {
+ renderPicker(
+ { showCloseButton: true },
+ makeContext({ isSheet: true })
+ );
+ expect(screen.queryByLabelText('Close widget picker')).not.toBeNull();
+ });
+
+ it('showCloseButton=false hides close button even when onClose is provided', () => {
+ renderPicker({ onClose: noop, showCloseButton: false });
+ expect(screen.queryByLabelText('Close widget picker')).toBeNull();
+ });
+
+ it('showCloseButton=false hides close button even in desktop context with closeSidebar', () => {
+ const closeSidebar = jest.fn();
+ renderPicker(
+ { showCloseButton: false },
+ makeContext({ isSheet: false, closeSidebar })
+ );
+ expect(screen.queryByLabelText('Close widget picker')).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Close handler wiring
+// ---------------------------------------------------------------------------
+
+describe('WidgetPicker – close handler wiring', () => {
+ it('calls onClose when close button is clicked (standalone)', () => {
+ const handleClose = jest.fn();
+ renderPicker({ onClose: handleClose });
+ fireEvent.click(screen.getByLabelText('Close widget picker'));
+ expect(handleClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose (not closeSidebar) when both are available', () => {
+ const handleClose = jest.fn();
+ const closeSidebar = jest.fn();
+ renderPicker(
+ { onClose: handleClose },
+ makeContext({ isSheet: false, closeSidebar })
+ );
+ fireEvent.click(screen.getByLabelText('Close widget picker'));
+ expect(handleClose).toHaveBeenCalledTimes(1);
+ expect(closeSidebar).not.toHaveBeenCalled();
+ });
+
+ it('calls closeSidebar when onClose is not provided but context has closeSidebar', () => {
+ const closeSidebar = jest.fn();
+ renderPicker(
+ {},
+ makeContext({ isSheet: false, closeSidebar })
+ );
+ fireEvent.click(screen.getByLabelText('Close widget picker'));
+ expect(closeSidebar).toHaveBeenCalledTimes(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Widget list rendering
+// ---------------------------------------------------------------------------
+
+describe('WidgetPicker – widget list', () => {
+ it('renders all widget titles', () => {
+ renderPicker();
+ expect(screen.queryByText('Chart Widget')).not.toBeNull();
+ expect(screen.queryByText('Table Widget')).not.toBeNull();
+ });
+
+ it('shows empty-state message when widgetsToAdd is empty', () => {
+ renderPicker({ widgetsToAdd: [] });
+ expect(screen.queryByText('All widgets added to the layout.')).not.toBeNull();
+ });
+
+ it('calls onAddWidget with correct args when add button is clicked', () => {
+ const handleAddWidget = jest.fn();
+ renderPicker({ onAddWidget: handleAddWidget });
+ fireEvent.click(screen.getByLabelText('Add Chart Widget widget'));
+ expect(handleAddWidget).toHaveBeenCalledWith({ title: 'Chart Widget' }, 'chart');
+ });
+});
diff --git a/src/custom/DashboardLayout/DashboardLayout.tsx b/src/custom/DashboardLayout/DashboardLayout.tsx
index ba19afb68..c14c55e22 100644
--- a/src/custom/DashboardLayout/DashboardLayout.tsx
+++ b/src/custom/DashboardLayout/DashboardLayout.tsx
@@ -1,8 +1,9 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Box, Fab } from '../../base';
import { AddIcon } from '../../icons/Add';
import { useTheme, useMediaQuery } from '../../theme';
import { BottomSheet } from '../BottomSheet';
+import { DashboardLayoutContext } from './DashboardLayoutContext';
export interface DashboardLayoutProps {
/** The main dashboard content (typically the React-Grid-Layout) */
@@ -25,7 +26,7 @@ export interface DashboardLayoutProps {
/** Optional sticky top offset for the sidebar (useful if page has a top navbar) */
sidebarTopOffset?: string | number;
- /** Optional fixed height for the sticky sidebar. Defaults to 100dvh */
+ /** Optional fixed height for the sticky sidebar. Defaults to `calc(100dvh - )`. */
sidebarHeight?: string | number;
/** Background color for the mobile bottom sheet header */
@@ -33,6 +34,36 @@ export interface DashboardLayoutProps {
/** Text color for the mobile bottom sheet header */
headerTextColor?: string;
+
+ /**
+ * Controlled sidebar-panel visibility.
+ * When provided, DashboardLayout becomes a controlled component for the
+ * panel's open/minimized state and will not manage `sidebarVisible`
+ * internally. Pair with `onSidebarVisibilityChange`.
+ */
+ sidebarVisible?: boolean;
+
+ /**
+ * Initial panel visibility when running uncontrolled.
+ * Ignored when `sidebarVisible` is provided.
+ * Defaults to `true` (panel starts open).
+ */
+ defaultSidebarVisible?: boolean;
+
+ /**
+ * Called when the panel's open/minimized state changes.
+ * Receives the next value (`true` = expanded, `false` = minimized).
+ */
+ onSidebarVisibilityChange?: (visible: boolean) => void;
+
+ /**
+ * Whether to render the floating reopen FAB when the sidebar panel is
+ * minimized while edit mode is still active.
+ * Defaults to `true`.
+ * Set to `false` when the host application provides its own toolbar button
+ * that can reopen the panel.
+ */
+ showReopenFab?: boolean;
}
export const DashboardLayout: React.FC = ({
@@ -42,34 +73,62 @@ export const DashboardLayout: React.FC = ({
sidebarTitle = 'Widget Picker',
sidebarWidth = { xs: '100%', md: '350px' },
sidebarTopOffset = '0',
- sidebarHeight = '100dvh',
+ sidebarHeight,
headerBackgroundColor,
- headerTextColor
+ headerTextColor,
+ sidebarVisible: controlledVisible,
+ defaultSidebarVisible = true,
+ onSidebarVisibilityChange,
+ showReopenFab = true,
}) => {
const theme = useTheme();
// We use the 'md' breakpoint (900px default) to switch between mobile and desktop layout
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
- // isSheetVisible is independently owned by DashboardLayout:
- // - resets to true whenever Edit Mode (isSidebarOpen) transitions OFF → ON
- // - can be set to false by the user dismissing the sheet (FAB appears instead)
- // - set to false when Edit Mode turns OFF
- // This two-dimension model prevents the sheet from re-opening on every
- // isSidebarOpen change after the user has intentionally minimized it.
- const [isSheetVisible, setIsSheetVisible] = useState(isSidebarOpen);
+ // isSidebarVisible represents "panel expanded" (true) vs "panel minimized" (false).
+ // This is independent of isSidebarOpen (the edit-session flag).
+ // In uncontrolled mode, we manage visibility internally.
+ // In controlled mode (sidebarVisible prop provided), the caller drives it.
+ const isControlled = controlledVisible !== undefined;
+ const [internalVisible, setInternalVisible] = useState(defaultSidebarVisible);
+
+ const isSidebarVisible = isControlled ? (controlledVisible as boolean) : internalVisible;
+
+ const setSidebarVisible = useCallback(
+ (next: boolean) => {
+ if (!isControlled) {
+ setInternalVisible(next);
+ }
+ onSidebarVisibilityChange?.(next);
+ },
+ [isControlled, onSidebarVisibilityChange]
+ );
+
const prevIsSidebarOpen = useRef(isSidebarOpen);
useEffect(() => {
if (isSidebarOpen && !prevIsSidebarOpen.current) {
- // Edit Mode just turned ON → pop the sheet open
- setIsSheetVisible(true);
+ // Edit Mode just turned ON → expand the panel
+ setSidebarVisible(true);
}
- if (!isSidebarOpen) {
- // Edit Mode turned OFF → close the sheet and hide the FAB
- setIsSheetVisible(false);
+ if (!isSidebarOpen && prevIsSidebarOpen.current) {
+ // Edit Mode turned OFF → collapse the panel
+ setSidebarVisible(false);
}
prevIsSidebarOpen.current = isSidebarOpen;
- }, [isSidebarOpen]);
+ }, [isSidebarOpen, setSidebarVisible]);
+
+ const closeSidebar = useCallback(() => setSidebarVisible(false), [setSidebarVisible]);
+ const openSidebar = useCallback(() => setSidebarVisible(true), [setSidebarVisible]);
+
+ // Derive sidebar height: if sidebarTopOffset is a non-zero string or number,
+ // use calc(100dvh - ) so the sidebar never pushes content off viewport.
+ const resolvedSidebarHeight =
+ sidebarHeight !== undefined
+ ? sidebarHeight
+ : sidebarTopOffset && sidebarTopOffset !== '0' && sidebarTopOffset !== 0
+ ? `calc(100dvh - ${typeof sidebarTopOffset === 'number' ? `${sidebarTopOffset}px` : sidebarTopOffset})`
+ : '100dvh';
return (
@@ -79,24 +138,28 @@ export const DashboardLayout: React.FC = ({
{isSidebarOpen && isMobile && (
<>
- setIsSheetVisible(false)}
- title={sidebarTitle}
- maxHeight="50vh"
- headerBackgroundColor={headerBackgroundColor}
- headerTextColor={headerTextColor}
+
- {sidebarContent}
-
+
+ {sidebarContent}
+
+
- {/* FAB appears when Edit Mode is active but the sheet has been minimized,
+ {/* FAB appears when Edit Mode is active but the panel has been minimized,
letting users rearrange the dashboard and pull the picker back up. */}
- {!isSheetVisible && (
+ {!isSidebarVisible && showReopenFab && (
setIsSheetVisible(true)}
+ onClick={openSidebar}
sx={(fabTheme) => ({
position: 'fixed',
bottom: 24,
@@ -111,19 +174,43 @@ export const DashboardLayout: React.FC = ({
)}
{isSidebarOpen && !isMobile && (
-
- {sidebarContent}
-
+ <>
+ {isSidebarVisible ? (
+
+
+ {sidebarContent}
+
+
+ ) : (
+ showReopenFab && (
+ ({
+ position: 'fixed',
+ bottom: 24,
+ right: 24,
+ zIndex: fabTheme.zIndex.drawer,
+ })}
+ >
+
+
+ )
+ )}
+ >
)}
);
diff --git a/src/custom/DashboardLayout/DashboardLayoutContext.tsx b/src/custom/DashboardLayout/DashboardLayoutContext.tsx
new file mode 100644
index 000000000..e32cf589b
--- /dev/null
+++ b/src/custom/DashboardLayout/DashboardLayoutContext.tsx
@@ -0,0 +1,27 @@
+import { createContext, useContext } from 'react';
+
+export interface DashboardLayoutContextValue {
+ /** Whether the dashboard is currently at the mobile breakpoint (below 'md'). */
+ isMobile: boolean;
+ /** Whether the sidebarContent is currently rendered inside the mobile BottomSheet. */
+ isSheet: boolean;
+ /** Whether the sidebar panel is currently visible/expanded (not minimized). */
+ isSidebarVisible: boolean;
+ /** Minimize the sidebar panel (shows the reopen FAB without ending edit mode). */
+ closeSidebar: () => void;
+ /** Expand the sidebar panel. */
+ openSidebar: () => void;
+}
+
+export const DashboardLayoutContext = createContext(null);
+
+/**
+ * Consume the DashboardLayoutContext value.
+ *
+ * Returns `null` when called outside a DashboardLayout — consumers (e.g.
+ * WidgetPicker) should treat `null` as "no layout context available" and fall
+ * back to their standalone behaviour.
+ */
+export function useDashboardLayoutContext(): DashboardLayoutContextValue | null {
+ return useContext(DashboardLayoutContext);
+}
diff --git a/src/custom/DashboardLayout/index.tsx b/src/custom/DashboardLayout/index.tsx
index 552a08c05..9ca808709 100644
--- a/src/custom/DashboardLayout/index.tsx
+++ b/src/custom/DashboardLayout/index.tsx
@@ -1,2 +1,7 @@
export { DashboardLayout } from './DashboardLayout';
export type { DashboardLayoutProps } from './DashboardLayout';
+export {
+ DashboardLayoutContext,
+ useDashboardLayoutContext,
+ type DashboardLayoutContextValue
+} from './DashboardLayoutContext';
diff --git a/src/custom/WidgetPicker/WidgetPicker.tsx b/src/custom/WidgetPicker/WidgetPicker.tsx
index 15aca9415..cf07466a0 100644
--- a/src/custom/WidgetPicker/WidgetPicker.tsx
+++ b/src/custom/WidgetPicker/WidgetPicker.tsx
@@ -3,6 +3,7 @@ import { Box, IconButton, Stack, Typography } from '../../base';
import { AddIcon, CloseIcon } from '../../icons';
import { useTheme } from '../../theme';
import type { SxProps, Theme } from '@mui/material';
+import { useDashboardLayoutContext } from '../DashboardLayout/DashboardLayoutContext';
export interface WidgetItem {
key: string;
@@ -18,9 +19,22 @@ export interface WidgetPickerProps {
/** Callback when a widget is clicked to be added */
onAddWidget: (widget: Omit, key: string) => void;
- /** Optional callback to close the picker (renders a Close icon if provided) */
+ /**
+ * Optional callback to close the picker. When provided, takes precedence
+ * over the layout context's closeSidebar handler.
+ */
onClose?: () => void;
+ /**
+ * Explicit override for close-button visibility.
+ * - `true` → always render the close button.
+ * - `false` → never render it.
+ * - `undefined` (default) → auto-detect: hidden when embedded in a
+ * DashboardLayout BottomSheet (to avoid a duplicate 'X'), shown when
+ * embedded in the desktop sticky sidebar or used standalone.
+ */
+ showCloseButton?: boolean;
+
/** Custom background color for the header. Defaults to theme.palette.background.default */
headerBackgroundColor?: string;
@@ -35,11 +49,30 @@ export const WidgetPicker: React.FC = ({
widgetsToAdd,
onAddWidget,
onClose,
+ showCloseButton,
headerBackgroundColor,
headerTextColor,
containerSx = {},
}) => {
const theme = useTheme();
+ const layoutContext = useDashboardLayoutContext();
+
+ // Resolve whether to show the close button.
+ // Explicit `showCloseButton` prop always wins.
+ // Otherwise: hide when embedded in the mobile BottomSheet (the sheet already
+ // has its own close/drag affordance), show when on desktop sidebar or standalone.
+ const shouldShowClose =
+ showCloseButton !== undefined
+ ? showCloseButton
+ : !layoutContext?.isSheet && Boolean(onClose ?? layoutContext?.closeSidebar);
+
+ const handleClose = () => {
+ if (onClose) {
+ onClose();
+ } else {
+ layoutContext?.closeSidebar?.();
+ }
+ };
return (
= ({
Widgets
- {onClose && (
-
+ {shouldShowClose && (
+
)}
diff --git a/src/index.tsx b/src/index.tsx
index 24a87e1a1..500c9791e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -35,7 +35,10 @@ export {
export {
DashboardLayout,
- type DashboardLayoutProps
+ type DashboardLayoutProps,
+ DashboardLayoutContext,
+ useDashboardLayoutContext,
+ type DashboardLayoutContextValue
} from './custom/DashboardLayout';
// Same nested-barrel dts-drop quirk as FeedbackButton above: UniversalFilter
// (and its FilterColumn / UniversalFilterProps types) reaches the entry only