diff --git a/src/custom/DashboardLayout/DashboardLayout.tsx b/src/custom/DashboardLayout/DashboardLayout.tsx new file mode 100644 index 000000000..acf80415f --- /dev/null +++ b/src/custom/DashboardLayout/DashboardLayout.tsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Fab } from '../../base'; +import { AddIcon } from '../../icons/Add'; +import { useTheme, useMediaQuery } from '../../theme'; +import { BottomSheet } from '../BottomSheet'; + +export interface DashboardLayoutProps { + /** The main dashboard content (typically the React-Grid-Layout) */ + children: React.ReactNode; + + /** Whether Edit Mode is active (controls sidebar visibility). When this + * transitions from false → true the mobile sheet auto-opens. */ + isSidebarOpen: boolean; + + /** The content to render inside the sidebar (e.g., Widget Gallery) */ + sidebarContent: React.ReactNode; + + /** Accessible title for the mobile bottom sheet (used as aria-labelledby on the Dialog). + * Defaults to 'Widget Picker'. */ + sidebarTitle?: string; + + /** Optional custom width for the sidebar. Defaults to responsive width. */ + sidebarWidth?: string | number | Partial>; + + /** 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 100vh */ + sidebarHeight?: string | number; +} + +export const DashboardLayout: React.FC = ({ + children, + isSidebarOpen, + sidebarContent, + sidebarTitle = 'Widget Picker', + sidebarWidth = { xs: '100%', md: '350px' }, + sidebarTopOffset = '0', + sidebarHeight = '100vh' +}) => { + 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); + const prevIsSidebarOpen = useRef(isSidebarOpen); + + useEffect(() => { + if (isSidebarOpen && !prevIsSidebarOpen.current) { + // Edit Mode just turned ON → pop the sheet open + setIsSheetVisible(true); + } + if (!isSidebarOpen) { + // Edit Mode turned OFF → close the sheet and hide the FAB + setIsSheetVisible(false); + } + prevIsSidebarOpen.current = isSidebarOpen; + }, [isSidebarOpen]); + + return ( + + + {children} + + + {isSidebarOpen && isMobile && ( + <> + setIsSheetVisible(false)} + title={sidebarTitle} + maxHeight="50vh" + > + {sidebarContent} + + + {/* FAB appears when Edit Mode is active but the sheet has been minimized, + letting users rearrange the dashboard and pull the picker back up. */} + {!isSheetVisible && ( + setIsSheetVisible(true)} + sx={(fabTheme) => ({ + position: 'fixed', + bottom: 24, + right: 24, + zIndex: fabTheme.zIndex.drawer, + })} + > + + + )} + + )} + + {isSidebarOpen && !isMobile && ( + + {sidebarContent} + + )} + + ); +}; diff --git a/src/custom/DashboardLayout/index.tsx b/src/custom/DashboardLayout/index.tsx new file mode 100644 index 000000000..552a08c05 --- /dev/null +++ b/src/custom/DashboardLayout/index.tsx @@ -0,0 +1,2 @@ +export { DashboardLayout } from './DashboardLayout'; +export type { DashboardLayoutProps } from './DashboardLayout'; diff --git a/src/custom/DashboardWidgets/PlainCard.tsx b/src/custom/DashboardWidgets/PlainCard.tsx index 9eeddd8c1..8eb2b38a4 100644 --- a/src/custom/DashboardWidgets/PlainCard.tsx +++ b/src/custom/DashboardWidgets/PlainCard.tsx @@ -1,6 +1,7 @@ import { Box, Card, CardContent, Link, Typography } from '../../base'; import { OpenInNewIcon } from '../../icons'; import { styled } from '../../theme'; +import { WidgetEmptyState } from '../WidgetEmptyState'; const StyledCard = styled(Card)(({ theme }) => ({ height: '100%', @@ -72,27 +73,31 @@ export const PlainCard = ({ title, icon, resources }: PlainCardProps): JSX.Eleme - - {resources.map((item) => ( - - {item.icon} + {resources.length === 0 ? ( + + ) : ( + + {resources.map((item) => ( + + {item.icon} - - {item.name} - + + {item.name} + - {item.external && ( - - - - )} - - ))} - + {item.external && ( + + + + )} + + ))} + + )} diff --git a/src/custom/ResponsiveDataTable.tsx b/src/custom/ResponsiveDataTable.tsx index 9678cf7d4..54eb93311 100644 --- a/src/custom/ResponsiveDataTable.tsx +++ b/src/custom/ResponsiveDataTable.tsx @@ -1,4 +1,4 @@ -import MUIDataTable, { MUIDataTableColumn } from '@sistent/mui-datatables'; +import MUIDataTable, { MUIDataTableColumn, MUIDataTableOptions } from '@sistent/mui-datatables'; import React, { useCallback } from 'react'; import { Checkbox, Collapse, ListItemIcon, ListItemText, Menu, MenuItem } from '../base'; import { ShareIcon } from '../icons'; @@ -8,6 +8,7 @@ import { styled, useTheme } from './../theme'; import { ColView } from './Helpers/ResponsiveColumns/responsive-coulmns.tsx'; import { TableAction } from './TableActions'; import { TooltipIcon } from './TooltipIconButton'; +import { WidgetEmptyState } from './WidgetEmptyState'; export const IconWrapper = styled('div', { shouldForwardProp: (prop) => prop !== 'disabled' @@ -141,7 +142,7 @@ export interface Column { export interface ResponsiveDataTableProps { data: string[][]; columns: MUIDataTableColumn[]; - options?: object; + options?: MUIDataTableOptions; tableCols?: MUIDataTableColumn[]; updateCols?: ((columns: MUIDataTableColumn[]) => void) | undefined; columnVisibility: Record | undefined; @@ -158,8 +159,23 @@ const ResponsiveDataTable = ({ rowsPerPageOptions = [10, 25, 50, 100], ...props }: ResponsiveDataTableProps): JSX.Element => { + const textLabels = options?.textLabels || {}; + const bodyTextLabels = textLabels.body || {}; + + const noMatchMessage = + typeof bodyTextLabels.noMatch === 'string' + ? bodyTextLabels.noMatch + : 'No data available'; + const updatedOptions = { ...options, + textLabels: { + ...textLabels, + body: { + ...bodyTextLabels, + noMatch: + } + }, print: false, download: false, search: false, diff --git a/src/custom/WidgetEmptyState/WidgetEmptyState.tsx b/src/custom/WidgetEmptyState/WidgetEmptyState.tsx new file mode 100644 index 000000000..a2098ce61 --- /dev/null +++ b/src/custom/WidgetEmptyState/WidgetEmptyState.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Box, Typography, Button, Stack } from '../../base'; +import { useTheme } from '../../theme'; + +export interface WidgetEmptyStateProps { + /** The message to display when no data is available */ + message?: string; + + /** Optional icon to display above the message */ + icon?: React.ReactNode; + + /** Optional action button configuration */ + action?: { + label: string; + onClick: () => void; + }; +} + +export const WidgetEmptyState: React.FC = ({ + message = 'No data available', + icon, + action, +}) => { + const theme = useTheme(); + + return ( + // Outer container is a plain presentational box — role="status" is scoped + // only to the message Typography below so interactive descendants (icon, Button) + // are not degraded by the live-region semantics. + + + {icon && ( + + {icon} + + )} + {/* role="status" + aria-live scoped only to the message text, not the + interactive siblings — per ARIA spec, live regions must not contain + interactive elements or AT may hide/degrade their semantics. */} + + {message} + + {action && ( + + )} + + + ); +}; diff --git a/src/custom/WidgetEmptyState/index.tsx b/src/custom/WidgetEmptyState/index.tsx new file mode 100644 index 000000000..334bd8b95 --- /dev/null +++ b/src/custom/WidgetEmptyState/index.tsx @@ -0,0 +1,2 @@ +export { WidgetEmptyState } from './WidgetEmptyState'; +export type { WidgetEmptyStateProps } from './WidgetEmptyState'; diff --git a/src/custom/WidgetPicker/WidgetPicker.tsx b/src/custom/WidgetPicker/WidgetPicker.tsx new file mode 100644 index 000000000..15aca9415 --- /dev/null +++ b/src/custom/WidgetPicker/WidgetPicker.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { Box, IconButton, Stack, Typography } from '../../base'; +import { AddIcon, CloseIcon } from '../../icons'; +import { useTheme } from '../../theme'; +import type { SxProps, Theme } from '@mui/material'; + +export interface WidgetItem { + key: string; + title: string; + thumbnail?: string; + [key: string]: unknown; // Allow passing extra widget properties +} + +export interface WidgetPickerProps { + /** The list of widgets available to add */ + widgetsToAdd: WidgetItem[]; + + /** 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) */ + onClose?: () => void; + + /** Custom background color for the header. Defaults to theme.palette.background.default */ + headerBackgroundColor?: string; + + /** Custom text color for the header. Defaults to theme.palette.text.primary */ + headerTextColor?: string; + + /** Custom styles for the outer container (e.g. for custom box shadows or borders) */ + containerSx?: SxProps; +} + +export const WidgetPicker: React.FC = ({ + widgetsToAdd, + onAddWidget, + onClose, + headerBackgroundColor, + headerTextColor, + containerSx = {}, +}) => { + const theme = useTheme(); + + return ( + + + + Widgets + + {onClose && ( + + + + )} + + + + {widgetsToAdd.length === 0 && ( + + All widgets added to the layout. + + )} + + {widgetsToAdd.map(({ key, ...widget }) => ( + + + {widget.title} + onAddWidget(widget, key)} + > + + + + {widget.thumbnail && ( + {widget.title} + )} + + ))} + + + ); +}; diff --git a/src/custom/WidgetPicker/index.tsx b/src/custom/WidgetPicker/index.tsx new file mode 100644 index 000000000..c33ea9de1 --- /dev/null +++ b/src/custom/WidgetPicker/index.tsx @@ -0,0 +1 @@ +export { WidgetPicker, type WidgetPickerProps, type WidgetItem } from './WidgetPicker'; diff --git a/src/custom/index.ts b/src/custom/index.ts index 3f35c351e..bfc9cd185 100644 --- a/src/custom/index.ts +++ b/src/custom/index.ts @@ -1,7 +1,10 @@ // Export all custom components export * from './CustomTooltip'; +export * from './DashboardLayout'; export * from './HelperTextPopover'; export * from './Markdown'; export * from './Modal'; export * from './RJSFFormWrapper'; export * from './StyledAccordion'; +export * from './WidgetPicker'; +export * from './WidgetEmptyState'; diff --git a/src/index.tsx b/src/index.tsx index fce5d8899..a639a673b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,11 @@ export { type DangerConfirmationCheckbox, type DangerConfirmationModalProps } from './custom/DangerConfirmationModal'; + +export { + DashboardLayout, + type DashboardLayoutProps +} from './custom/DashboardLayout'; // Same nested-barrel dts-drop quirk as FeedbackButton above: UniversalFilter // (and its FilterColumn / UniversalFilterProps types) reaches the entry only // through `export * from './custom'`, so rollup-plugin-dts drops it from the @@ -89,6 +94,17 @@ export { type PermissionUserContext } from './custom/permissions'; +export { + WidgetPicker, + type WidgetPickerProps, + type WidgetItem +} from './custom/WidgetPicker'; + +export { + WidgetEmptyState, + type WidgetEmptyStateProps +} from './custom/WidgetEmptyState'; + export { BottomSheet, type BottomSheetProps } from './custom/BottomSheet'; export { ActionButton, type ActionButtonProps, type Option } from './custom/ActionButton';