@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
301 lines • 15.3 kB
JavaScript
import { ScreenService, Shade, createComponent } from '@furystack/shades';
import { cssVariableTheme } from '../../services/css-variable-theme.js';
import { LAYOUT_CSS_VARIABLES, LayoutService, createLayoutService } from '../../services/layout-service.js';
const DEFAULT_APPBAR_HEIGHT = '48px';
const DEFAULT_DRAWER_WIDTH = '240px';
/**
* PageLayout component for full-viewport application layouts.
*
* Provides a structured layout with:
* - Optional AppBar (permanent or auto-hide)
* - Optional left/right drawers (permanent, collapsible, or temporary)
* - Main content area with automatic margin management
* - Configurable gaps between AppBar/drawers and content
* - Responsive drawer collapse via `collapseOnBreakpoint`
*
* The LayoutService is scoped to this component, so CSS variables are isolated
* and automatically cleaned up when navigating away.
*
* @example
* ```tsx
* <PageLayout
* appBar={{
* variant: 'permanent',
* component: <MyAppBar />,
* }}
* drawer={{
* left: {
* variant: 'collapsible',
* component: <Sidebar />,
* collapseOnBreakpoint: 'md', // Auto-collapse below 900px
* },
* }}
* topGap="16px"
* sideGap="24px"
* >
* <MainContent />
* </PageLayout>
* ```
*/
export const PageLayout = Shade({
customElementName: 'shade-page-layout',
css: {
display: 'block',
fontFamily: cssVariableTheme.typography.fontFamily,
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
overflow: 'hidden',
background: cssVariableTheme.background.default,
'& div[is="shade-paper"]': {
margin: '0',
},
// AppBar container (> * > scopes to the wrapper div to prevent bleeding into nested PageLayouts)
'& > * > .page-layout-appbar': {
position: 'fixed',
top: '0',
left: '0',
right: '0',
zIndex: cssVariableTheme.zIndex.appBar,
transition: `top ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}`,
height: `var(${LAYOUT_CSS_VARIABLES.appBarHeight})`,
},
// Auto-hide AppBar styles (controlled via host data attributes)
'&[data-appbar-auto-hide] > * > .page-layout-appbar': {
top: 'calc(-1 * var(--layout-appbar-height, 48px))',
},
'&[data-appbar-auto-hide] > * > .page-layout-appbar:hover': {
top: '0',
},
'&[data-appbar-auto-hide][data-appbar-visible] > * > .page-layout-appbar': {
top: '0',
},
// Drawer containers - use CSS transitions
'& > * > .page-layout-drawer': {
position: 'fixed',
top: 'var(--layout-appbar-height, 48px)',
bottom: '0',
zIndex: cssVariableTheme.zIndex.drawer,
overflow: 'hidden',
background: cssVariableTheme.background.paper,
backgroundImage: cssVariableTheme.background.paperImage,
transition: `transform ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}`,
},
'& > * > .page-layout-drawer-left': {
left: '0',
width: 'var(--layout-drawer-left-configured-width, 240px)',
borderRight: `1px solid ${cssVariableTheme.divider}`,
transform: 'translateX(0)',
},
'& > * > .page-layout-drawer-right': {
right: '0',
width: 'var(--layout-drawer-right-configured-width, 240px)',
borderLeft: `1px solid ${cssVariableTheme.divider}`,
transform: 'translateX(0)',
},
// Drawer closed states (controlled via host data attributes)
'&[data-drawer-left-closed] > * > .page-layout-drawer-left': {
transform: 'translateX(-100%)',
pointerEvents: 'none',
},
'&[data-drawer-right-closed] > * > .page-layout-drawer-right': {
transform: 'translateX(100%)',
pointerEvents: 'none',
},
// Temporary drawer backdrop
'& > * > .page-layout-drawer-backdrop': {
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
backgroundColor: cssVariableTheme.action.backdrop,
zIndex: cssVariableTheme.zIndex.drawer,
opacity: '0',
pointerEvents: 'none',
transition: `opacity ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}`,
},
'&[data-backdrop-visible] > * > .page-layout-drawer-backdrop': {
opacity: '1',
pointerEvents: 'auto',
},
// Contained mode - use absolute positioning instead of fixed so the layout
// fills its nearest positioned ancestor rather than the viewport
'&[data-contained]': {
position: 'absolute',
},
'&[data-contained] > * > .page-layout-appbar': {
position: 'absolute',
},
'&[data-contained] > * > .page-layout-drawer': {
position: 'absolute',
},
'&[data-contained] > * > .page-layout-drawer-backdrop': {
position: 'absolute',
},
// Content area - uses CSS variables for positioning
'& > * > .page-layout-content': {
position: 'absolute',
top: '0',
bottom: '0',
overflow: 'auto',
paddingTop: 'var(--layout-content-padding-top, 0px)',
paddingLeft: 'var(--layout-side-gap, 0px)',
paddingRight: 'var(--layout-side-gap, 0px)',
left: 'var(--layout-content-margin-left, 0px)',
right: 'var(--layout-content-margin-right, 0px)',
transition: `left ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}, right ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.easeInOut}`,
},
},
render: ({ props, children, injector, useObservable, useDisposable, useHostProps }) => {
const layoutService = useDisposable('layoutService', () => createLayoutService());
const childInjector = useDisposable('childInjector', () => {
const child = injector.createScope({ owner: 'page-layout' });
child.bind(LayoutService, () => layoutService);
return child;
});
useHostProps({ injector: childInjector });
const screenService = injector.get(ScreenService);
// Initialize AppBar
const appBarHeight = props.appBar?.height ?? DEFAULT_APPBAR_HEIGHT;
if (props.appBar) {
layoutService.appBarHeight.setValue(appBarHeight);
// Only reset appBarVisible when transitioning to auto-hide (not on every render)
const prevVariant = layoutService.appBarVariant.getValue();
layoutService.appBarVariant.setValue(props.appBar.variant);
if (props.appBar.variant === 'auto-hide' && prevVariant !== 'auto-hide') {
layoutService.appBarVisible.setValue(false);
}
}
else {
layoutService.appBarHeight.setValue('0px');
}
// Initialize gaps
layoutService.setTopGap(props.topGap ?? '0px');
layoutService.setSideGap(props.sideGap ?? '0px');
// Initialize drawers
const initializeDrawer = (position, config) => {
const width = config.width ?? DEFAULT_DRAWER_WIDTH;
// Permanent drawers are always open
// Collapsible drawers default to open unless defaultOpen is false
// Temporary drawers default to closed unless defaultOpen is true
const isOpen = config.variant === 'permanent' ||
(config.variant === 'collapsible' && (config.defaultOpen ?? true)) ||
(config.variant === 'temporary' && config.defaultOpen === true);
const currentState = layoutService.drawerState.getValue()[position];
// Only initialize if not already set (preserve user interactions)
if (!currentState) {
layoutService.initDrawer(position, { open: isOpen, width, variant: config.variant });
}
else if (currentState.width !== width || currentState.variant !== config.variant) {
// Update if width or variant changed
layoutService.initDrawer(position, { ...currentState, width, variant: config.variant });
}
};
if (props.drawer?.left) {
initializeDrawer('left', props.drawer.left);
}
if (props.drawer?.right) {
initializeDrawer('right', props.drawer.right);
}
// Set up responsive breakpoint listeners for drawers
const setupBreakpointListener = (position, config) => {
const { collapseOnBreakpoint, variant } = config;
if (!collapseOnBreakpoint || variant === 'permanent') {
return { [Symbol.dispose]: () => { } };
}
const breakpointObservable = screenService.screenSize.atLeast[collapseOnBreakpoint];
const applyBreakpoint = (isAtLeast) => {
const currentState = layoutService.drawerState.getValue()[position];
const currentlyOpen = currentState?.open ?? false;
// When screen becomes smaller than breakpoint, close the drawer
// When screen becomes larger than breakpoint, open the drawer (for collapsible)
if (variant === 'collapsible') {
// Only update if the state needs to change
if (isAtLeast !== currentlyOpen) {
layoutService.setDrawerOpen(position, isAtLeast);
}
}
else if (variant === 'temporary' && isAtLeast && currentlyOpen) {
// For temporary drawers, close when screen is large enough
layoutService.setDrawerOpen(position, false);
}
};
const subscription = breakpointObservable.subscribe(applyBreakpoint);
// Apply the current breakpoint value immediately since subscribe only fires on changes
applyBreakpoint(breakpointObservable.getValue());
return subscription;
};
// Set up breakpoint listeners for left and right drawers
useDisposable('breakpoint-listener-left', () => {
if (props.drawer?.left?.collapseOnBreakpoint) {
return setupBreakpointListener('left', props.drawer.left);
}
return { [Symbol.dispose]: () => { } };
});
useDisposable('breakpoint-listener-right', () => {
if (props.drawer?.right?.collapseOnBreakpoint) {
return setupBreakpointListener('right', props.drawer.right);
}
return { [Symbol.dispose]: () => { } };
});
// Subscribe to drawer state and appbar visibility - re-render to update host props
const [drawerState] = useObservable('drawerState', layoutService.drawerState);
const [isAppBarVisible] = useObservable('appBarVisible', layoutService.appBarVisible);
// Set host classes via useHostProps for CSS-based animations
const isLeftOpen = drawerState.left?.open ?? false;
const isRightOpen = drawerState.right?.open ?? false;
const isLeftTemporaryOpen = props.drawer?.left?.variant === 'temporary' && isLeftOpen;
const isRightTemporaryOpen = props.drawer?.right?.variant === 'temporary' && isRightOpen;
// Compute CSS variables from LayoutService state
const appBarHeightVal = layoutService.appBarHeight.getValue();
const appBarVariantVal = layoutService.appBarVariant.getValue();
const topGapVal = layoutService.topGap.getValue();
const sideGapVal = layoutService.sideGap.getValue();
const contentPaddingTop = appBarVariantVal === 'auto-hide' ? topGapVal : `calc(${appBarHeightVal} + ${topGapVal})`;
const leftWidth = drawerState.left?.open ? (drawerState.left.width ?? '0px') : '0px';
const rightWidth = drawerState.right?.open ? (drawerState.right.width ?? '0px') : '0px';
const leftContentMargin = layoutService.getContentMarginForPosition('left');
const rightContentMargin = layoutService.getContentMarginForPosition('right');
useHostProps({
...(props.contained ? { 'data-contained': '' } : {}),
...(!isLeftOpen ? { 'data-drawer-left-closed': '' } : {}),
...(!isRightOpen ? { 'data-drawer-right-closed': '' } : {}),
...(props.appBar?.variant === 'auto-hide' ? { 'data-appbar-auto-hide': '' } : {}),
...(props.appBar?.variant === 'auto-hide' && isAppBarVisible ? { 'data-appbar-visible': '' } : {}),
...(isLeftTemporaryOpen || isRightTemporaryOpen ? { 'data-backdrop-visible': '' } : {}),
style: {
'--layout-appbar-height': appBarHeightVal,
'--layout-top-gap': topGapVal,
'--layout-side-gap': sideGapVal,
'--layout-content-padding-top': contentPaddingTop,
'--layout-content-margin-top': appBarHeightVal,
'--layout-drawer-left-configured-width': drawerState.left?.width ?? '0px',
'--layout-drawer-left-width': leftWidth,
'--layout-content-margin-left': leftContentMargin,
'--layout-drawer-right-configured-width': drawerState.right?.width ?? '0px',
'--layout-drawer-right-width': rightWidth,
'--layout-content-margin-right': rightContentMargin,
},
});
// Handle temporary drawer backdrop click
const handleBackdropClick = () => {
const state = layoutService.drawerState.getValue();
if (props.drawer?.left?.variant === 'temporary' && state.left?.open) {
layoutService.setDrawerOpen('left', false);
}
if (props.drawer?.right?.variant === 'temporary' && state.right?.open) {
layoutService.setDrawerOpen('right', false);
}
};
return (createComponent("div", { style: { display: 'contents' } },
props.appBar && (createComponent("div", { className: "page-layout-appbar", "data-testid": "page-layout-appbar" }, props.appBar.component)),
createComponent("div", { className: "page-layout-drawer-backdrop", onclick: handleBackdropClick, "data-testid": "page-layout-backdrop" }),
props.drawer?.left && (createComponent("div", { className: "page-layout-drawer page-layout-drawer-left", "data-testid": "page-layout-drawer-left" }, props.drawer.left.component)),
props.drawer?.right && (createComponent("div", { className: "page-layout-drawer page-layout-drawer-right", "data-testid": "page-layout-drawer-right" }, props.drawer.right.component)),
createComponent("main", { className: "page-layout-content", "data-testid": "page-layout-content", "data-nav-section": "content" }, children)));
},
});
//# sourceMappingURL=index.js.map