Skip to content
Merged
126 changes: 126 additions & 0 deletions src/__testing__/BottomSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import BottomSheet from '../custom/BottomSheet/BottomSheet';
import {
createCustomTheme,
readableTextColor,
SistentThemeProvider,
ThemeProvider
} from '../theme';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const noop = () => {};

const rgb = (hex: string | undefined) => {
let h = String(hex).replace('#', '');
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('');
const int = parseInt(h, 16);
return `rgb(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255})`;
};

/** The flex container that carries the resolved header `background`/`color`. */
const getHeader = () => screen.getByText('Test Title').parentElement as HTMLElement;

function renderSheet(
props: Partial<React.ComponentProps<typeof BottomSheet>> = {},
{ mode = 'light' }: { mode?: 'light' | 'dark' } = {}
) {
return render(
<SistentThemeProvider initialMode={mode}>
<BottomSheet open title="Test Title" onClose={noop} {...props}>
<p>content</p>
</BottomSheet>
</SistentThemeProvider>
);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('BottomSheet header color resolution', () => {
it('falls back to surface.tint for the background and light ink for the text', () => {
const theme = createCustomTheme('light');
renderSheet();

const header = getHeader();
expect(getComputedStyle(header).background).toContain('gradient');
expect(getComputedStyle(header).color).toBe(rgb(theme.palette.common.white));
});

it('keeps the surface.tint header readable in dark mode (light ink, not text.inverse)', () => {
const theme = createCustomTheme('dark');
// In the dark palette text.inverse is near-black, which would be invisible
// on the dark surface.tint gradient — the tint header must stay light.
expect(rgb(theme.palette.text.inverse)).not.toBe(rgb(theme.palette.common.white));

renderSheet({}, { mode: 'dark' });

const header = getHeader();
expect(getComputedStyle(header).background).toContain('gradient');
expect(getComputedStyle(header).color).toBe(rgb(theme.palette.common.white));
});

it('falls back to background.default when the palette has no surface.tint', () => {
const theme = createCustomTheme('light');
delete (theme.palette.surface as { tint?: string }).tint;

render(
<ThemeProvider theme={theme}>
<BottomSheet open title="Test Title" onClose={noop}>
<p>content</p>
</BottomSheet>
</ThemeProvider>
);

const header = getHeader();
expect(getComputedStyle(header).background).toBe(rgb(theme.palette.background.default));
expect(getComputedStyle(header).color).toBe(rgb(theme.palette.text.default));
});

it('picks the light (inverse) ink for a dark custom background', () => {
const theme = createCustomTheme('light');
renderSheet({ headerBackgroundColor: '#121212' });

const expected = readableTextColor(
'#121212',
theme.palette.text.inverse,
theme.palette.text.default
);
expect(expected).toBe(theme.palette.text.inverse);
expect(getComputedStyle(getHeader()).color).toBe(rgb(expected));
});

it('picks the dark (default) ink for a light custom background', () => {
const theme = createCustomTheme('light');
renderSheet({ headerBackgroundColor: '#f5f5f5' });

const expected = readableTextColor(
'#f5f5f5',
theme.palette.text.inverse,
theme.palette.text.default
);
expect(expected).toBe(theme.palette.text.default);
expect(getComputedStyle(getHeader()).color).toBe(rgb(expected));
});

it('lets an explicit headerTextColor win over the computed ink', () => {
renderSheet({ headerBackgroundColor: '#121212', headerTextColor: '#ff0000' });

expect(getComputedStyle(getHeader()).color).toBe('rgb(255, 0, 0)');
});

it('applies the same resolved ink to the close-button icon', () => {
renderSheet({ headerBackgroundColor: '#121212', headerTextColor: '#ff0000' });

const icon = screen.getByLabelText('Close').querySelector('svg') as SVGElement;
expect(getComputedStyle(icon).fill).toBe('#ff0000');
});
});
34 changes: 27 additions & 7 deletions src/custom/BottomSheet/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Slide, { SlideProps } from '@mui/material/Slide';
import { readableTextColor, useTheme } from '../../theme';
import React, { useId } from 'react';
import { Box } from '../../base/Box';
import { Dialog } from '../../base/Dialog';
Expand Down Expand Up @@ -38,6 +39,25 @@ const BottomSheet = ({
headerTextColor
}: BottomSheetProps) => {
const titleId = useId();
const theme = useTheme();

const tint = theme.palette.surface?.tint;
const finalHeaderBackgroundColor =
headerBackgroundColor || tint || theme.palette.background.default;

const defaultForeground = headerBackgroundColor
? readableTextColor(
headerBackgroundColor,
theme.palette.text.inverse,
theme.palette.text.default
)
: tint
? // surface.tint is a dark gradient in both palettes, so always light ink
// (matches Modal / UniversalFilter tinted headers).
theme.palette.common.white
: theme.palette.text.default;

const finalHeaderTextColor = headerTextColor ?? defaultForeground;

return (
<Dialog
Expand All @@ -61,15 +81,15 @@ const BottomSheet = ({
{title && (
<>
<Box
sx={(theme) => ({
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1rem',
textAlign: 'center',
background: headerBackgroundColor || theme.palette.surface.tint,
color: headerTextColor || theme.palette.text.primary
})}
background: finalHeaderBackgroundColor,
color: finalHeaderTextColor
}}
>
<Typography
id={titleId}
Expand All @@ -91,17 +111,17 @@ const BottomSheet = ({
onClick={onClose}
size="small"
edge="end"
sx={(theme) => ({
sx={{
'& svg': {
fill: headerTextColor || theme.palette.text.primary
fill: finalHeaderTextColor
},
transform: 'rotate(-90deg)',
'&:hover': {
transform: 'rotate(90deg)',
transition: 'all 0.3s ease-in',
cursor: 'pointer'
}
})}
}}
>
<CloseIcon />
</IconButton>
Expand Down
14 changes: 12 additions & 2 deletions src/custom/DashboardLayout/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ 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 100vh */
/** Optional fixed height for the sticky sidebar. Defaults to 100dvh */
sidebarHeight?: string | number;

/** Background color for the mobile bottom sheet header */
headerBackgroundColor?: string;

/** Text color for the mobile bottom sheet header */
headerTextColor?: string;
}

export const DashboardLayout: React.FC<DashboardLayoutProps> = ({
Expand All @@ -36,7 +42,9 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({
sidebarTitle = 'Widget Picker',
sidebarWidth = { xs: '100%', md: '350px' },
sidebarTopOffset = '0',
sidebarHeight = '100vh'
sidebarHeight = '100dvh',
headerBackgroundColor,
headerTextColor
}) => {
const theme = useTheme();
// We use the 'md' breakpoint (900px default) to switch between mobile and desktop layout
Expand Down Expand Up @@ -76,6 +84,8 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({
onClose={() => setIsSheetVisible(false)}
title={sidebarTitle}
maxHeight="50vh"
headerBackgroundColor={headerBackgroundColor}
headerTextColor={headerTextColor}
>
{sidebarContent}
</BottomSheet>
Expand Down
Loading