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