@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
193 lines • 8.71 kB
JavaScript
import { defineService } from '@furystack/inject';
import { ObservableValue } from '@furystack/utils';
/**
* CSS variable names managed by LayoutService. Exposed so consumer components
* can reference the same names in their own CSS.
*/
export const LAYOUT_CSS_VARIABLES = {
appBarHeight: '--layout-appbar-height',
topGap: '--layout-top-gap',
sideGap: '--layout-side-gap',
contentPaddingTop: '--layout-content-padding-top',
drawerLeftWidth: '--layout-drawer-left-width',
drawerRightWidth: '--layout-drawer-right-width',
drawerLeftConfiguredWidth: '--layout-drawer-left-configured-width',
drawerRightConfiguredWidth: '--layout-drawer-right-configured-width',
contentMarginTop: '--layout-content-margin-top',
contentMarginLeft: '--layout-content-margin-left',
contentMarginRight: '--layout-content-margin-right',
};
/**
* Thrown when a component tries to resolve {@link LayoutService} but no
* `<PageLayout>` ancestor has bound one on its scoped injector.
*/
export class LayoutServiceNotConfiguredError extends Error {
constructor() {
super('LayoutService is not configured on this injector scope. Render components that depend on LayoutService inside a <PageLayout>.');
this.name = 'LayoutServiceNotConfiguredError';
}
}
/**
* Creates a fresh {@link LayoutService} instance. Used by `<PageLayout>` to
* bind a per-scope service inside a child injector. Consumer code should go
* through the {@link LayoutService} token rather than calling this directly.
* @param targetElement - Optional element (or ref) that will have CSS variables
* applied as the layout state changes. When omitted, CSS variables are not
* written — the page-layout component applies them via host props instead.
*/
export const createLayoutService = (targetElement) => {
const drawerState = new ObservableValue({});
const appBarVisible = new ObservableValue(true);
const appBarVariant = new ObservableValue('permanent');
const appBarHeight = new ObservableValue('48px');
const topGap = new ObservableValue('0px');
const sideGap = new ObservableValue('0px');
const getTarget = () => {
if (!targetElement)
return undefined;
if ('current' in targetElement)
return targetElement.current ?? undefined;
return targetElement;
};
const getContentMarginForDrawer = (state) => {
if (!state)
return '0px';
switch (state.variant) {
case 'temporary':
return '0px';
case 'permanent':
return state.width;
case 'collapsible':
default:
return state.open ? state.width : '0px';
}
};
const updateCssVariables = () => {
const target = getTarget();
if (!target)
return;
const state = drawerState.getValue();
const appBarHeightValue = appBarHeight.getValue();
const appBarVariantValue = appBarVariant.getValue();
const topGapValue = topGap.getValue();
const sideGapValue = sideGap.getValue();
target.style.setProperty(LAYOUT_CSS_VARIABLES.appBarHeight, appBarHeightValue);
target.style.setProperty(LAYOUT_CSS_VARIABLES.topGap, topGapValue);
target.style.setProperty(LAYOUT_CSS_VARIABLES.sideGap, sideGapValue);
const contentPaddingTop = appBarVariantValue === 'auto-hide' ? topGapValue : `calc(${appBarHeightValue} + ${topGapValue})`;
target.style.setProperty(LAYOUT_CSS_VARIABLES.contentPaddingTop, contentPaddingTop);
target.style.setProperty(LAYOUT_CSS_VARIABLES.contentMarginTop, appBarHeightValue);
const leftConfiguredWidth = state.left?.width ?? '0px';
const leftWidth = state.left?.open ? state.left.width : '0px';
const leftContentMargin = getContentMarginForDrawer(state.left);
target.style.setProperty(LAYOUT_CSS_VARIABLES.drawerLeftConfiguredWidth, leftConfiguredWidth);
target.style.setProperty(LAYOUT_CSS_VARIABLES.drawerLeftWidth, leftWidth);
target.style.setProperty(LAYOUT_CSS_VARIABLES.contentMarginLeft, leftContentMargin);
const rightConfiguredWidth = state.right?.width ?? '0px';
const rightWidth = state.right?.open ? state.right.width : '0px';
const rightContentMargin = getContentMarginForDrawer(state.right);
target.style.setProperty(LAYOUT_CSS_VARIABLES.drawerRightConfiguredWidth, rightConfiguredWidth);
target.style.setProperty(LAYOUT_CSS_VARIABLES.drawerRightWidth, rightWidth);
target.style.setProperty(LAYOUT_CSS_VARIABLES.contentMarginRight, rightContentMargin);
};
const subscriptions = [];
const track = (observable) => {
subscriptions.push(observable.subscribe(() => updateCssVariables()));
};
track(drawerState);
track(appBarHeight);
track(appBarVariant);
track(topGap);
track(sideGap);
updateCssVariables();
const setDrawerOpen = (position, open) => {
const currentState = drawerState.getValue();
const existingConfig = currentState[position];
drawerState.setValue({
...currentState,
[position]: {
width: existingConfig?.width ?? '240px',
variant: existingConfig?.variant ?? 'collapsible',
open,
},
});
};
const setDrawerWidth = (position, width) => {
const currentState = drawerState.getValue();
const existingConfig = currentState[position];
drawerState.setValue({
...currentState,
[position]: {
open: existingConfig?.open ?? false,
variant: existingConfig?.variant ?? 'collapsible',
width,
},
});
};
const initDrawer = (position, config) => {
drawerState.setValue({ ...drawerState.getValue(), [position]: config });
};
const removeDrawer = (position) => {
if (drawerState.isDisposed)
return;
const currentState = drawerState.getValue();
if (currentState[position]) {
const { [position]: _, ...rest } = currentState;
drawerState.setValue(rest);
}
};
const toggleDrawer = (position) => {
const currentState = drawerState.getValue();
const drawerConfig = currentState[position];
if (drawerConfig) {
setDrawerOpen(position, !drawerConfig.open);
}
};
return {
drawerState,
appBarVisible,
appBarVariant,
appBarHeight,
topGap,
sideGap,
toggleDrawer,
setDrawerOpen,
setDrawerWidth,
initDrawer,
removeDrawer,
setTopGap: (gap) => topGap.setValue(gap),
setSideGap: (gap) => sideGap.setValue(gap),
getContentMarginForPosition: (position) => getContentMarginForDrawer(drawerState.getValue()[position]),
[Symbol.dispose]() {
for (const subscription of subscriptions) {
subscription[Symbol.dispose]();
}
subscriptions.length = 0;
// eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <PageLayout> via useDisposable.
drawerState[Symbol.dispose]();
// eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <PageLayout> via useDisposable.
appBarVisible[Symbol.dispose]();
// eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <PageLayout> via useDisposable.
appBarVariant[Symbol.dispose]();
// eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <PageLayout> via useDisposable.
appBarHeight[Symbol.dispose]();
// eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <PageLayout> via useDisposable.
topGap[Symbol.dispose]();
// eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <PageLayout> via useDisposable.
sideGap[Symbol.dispose]();
},
};
};
/**
* Scoped {@link LayoutService} token. The default factory throws — a
* `<PageLayout>` ancestor binds a real instance on its child scope via
* {@link createLayoutService}.
*/
export const LayoutService = defineService({
name: '@furystack/shades-common-components/LayoutService',
lifetime: 'scoped',
factory: () => {
throw new LayoutServiceNotConfiguredError();
},
});
//# sourceMappingURL=layout-service.js.map