UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

193 lines 8.71 kB
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