@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
480 lines • 27 kB
JavaScript
import { using } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LAYOUT_CSS_VARIABLES, createLayoutService } from './layout-service.js';
describe('LayoutService', () => {
let mockSetProperty;
let mockElement;
beforeEach(() => {
mockSetProperty = vi.fn();
mockElement = {
style: {
setProperty: mockSetProperty,
},
};
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('Constructor', () => {
it('should initialize with default values', () => {
using(createLayoutService(mockElement), (service) => {
expect(service.drawerState.getValue()).toEqual({});
expect(service.appBarVisible.getValue()).toBe(true);
expect(service.appBarHeight.getValue()).toBe('48px');
expect(service.topGap.getValue()).toBe('0px');
expect(service.sideGap.getValue()).toBe('0px');
});
});
it('should update CSS variables on initialization when element is provided', () => {
using(createLayoutService(mockElement), () => {
expect(mockSetProperty).toHaveBeenCalledWith('--layout-appbar-height', '48px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-top-gap', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-side-gap', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-padding-top', 'calc(48px + 0px)');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-top', '48px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-width', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-right-width', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-configured-width', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-right-configured-width', '0px');
});
});
it('should not throw when element is undefined', () => {
expect(() => {
using(createLayoutService(undefined), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.setTopGap('16px');
service.setSideGap('24px');
});
}).not.toThrow();
});
});
describe('Drawer State Management', () => {
describe('toggleDrawer', () => {
it('should toggle drawer from closed to open', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: false, width: '240px', variant: 'collapsible' });
service.toggleDrawer('left');
expect(service.drawerState.getValue().left?.open).toBe(true);
});
});
it('should toggle drawer from open to closed', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.toggleDrawer('left');
expect(service.drawerState.getValue().left?.open).toBe(false);
});
});
it('should do nothing if drawer is not initialized', () => {
using(createLayoutService(mockElement), (service) => {
service.toggleDrawer('left');
expect(service.drawerState.getValue().left).toBeUndefined();
});
});
it('should toggle right drawer independently', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.initDrawer('right', { open: false, width: '200px', variant: 'temporary' });
service.toggleDrawer('right');
expect(service.drawerState.getValue().left?.open).toBe(true);
expect(service.drawerState.getValue().right?.open).toBe(true);
});
});
});
describe('setDrawerOpen', () => {
it('should set drawer open state', () => {
using(createLayoutService(mockElement), (service) => {
service.setDrawerOpen('left', true);
expect(service.drawerState.getValue().left?.open).toBe(true);
});
});
it('should create drawer entry with default width if not exists', () => {
using(createLayoutService(mockElement), (service) => {
service.setDrawerOpen('left', true);
expect(service.drawerState.getValue().left?.width).toBe('240px');
});
});
it('should create drawer entry with default variant if not exists', () => {
using(createLayoutService(mockElement), (service) => {
service.setDrawerOpen('left', true);
expect(service.drawerState.getValue().left?.variant).toBe('collapsible');
});
});
it('should preserve existing width when setting open state', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: false, width: '300px', variant: 'collapsible' });
service.setDrawerOpen('left', true);
expect(service.drawerState.getValue().left?.width).toBe('300px');
});
});
it('should preserve existing variant when setting open state', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: false, width: '240px', variant: 'temporary' });
service.setDrawerOpen('left', true);
expect(service.drawerState.getValue().left?.variant).toBe('temporary');
});
});
});
describe('setDrawerWidth', () => {
it('should set drawer width', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.setDrawerWidth('left', '300px');
expect(service.drawerState.getValue().left?.width).toBe('300px');
});
});
it('should create drawer entry with default closed state if not exists', () => {
using(createLayoutService(mockElement), (service) => {
service.setDrawerWidth('left', '300px');
expect(service.drawerState.getValue().left?.open).toBe(false);
});
});
it('should create drawer entry with default variant if not exists', () => {
using(createLayoutService(mockElement), (service) => {
service.setDrawerWidth('left', '300px');
expect(service.drawerState.getValue().left?.variant).toBe('collapsible');
});
});
it('should preserve open state when setting width', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.setDrawerWidth('left', '300px');
expect(service.drawerState.getValue().left?.open).toBe(true);
});
});
it('should preserve variant when setting width', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'permanent' });
service.setDrawerWidth('left', '300px');
expect(service.drawerState.getValue().left?.variant).toBe('permanent');
});
});
});
describe('initDrawer', () => {
it('should initialize left drawer', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '250px', variant: 'collapsible' });
expect(service.drawerState.getValue().left).toEqual({ open: true, width: '250px', variant: 'collapsible' });
});
});
it('should initialize right drawer', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('right', { open: false, width: '200px', variant: 'temporary' });
expect(service.drawerState.getValue().right).toEqual({ open: false, width: '200px', variant: 'temporary' });
});
});
it('should initialize both drawers', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'permanent' });
service.initDrawer('right', { open: true, width: '200px', variant: 'temporary' });
expect(service.drawerState.getValue()).toEqual({
left: { open: true, width: '240px', variant: 'permanent' },
right: { open: true, width: '200px', variant: 'temporary' },
});
});
});
it('should overwrite existing drawer config', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.initDrawer('left', { open: false, width: '300px', variant: 'permanent' });
expect(service.drawerState.getValue().left).toEqual({ open: false, width: '300px', variant: 'permanent' });
});
});
it('should initialize drawer with all variant types', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'permanent' });
expect(service.drawerState.getValue().left?.variant).toBe('permanent');
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
expect(service.drawerState.getValue().left?.variant).toBe('collapsible');
service.initDrawer('left', { open: true, width: '240px', variant: 'temporary' });
expect(service.drawerState.getValue().left?.variant).toBe('temporary');
});
});
});
describe('removeDrawer', () => {
it('should remove left drawer state', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.removeDrawer('left');
expect(service.drawerState.getValue().left).toBeUndefined();
});
});
it('should remove right drawer state', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('right', { open: true, width: '200px', variant: 'temporary' });
service.removeDrawer('right');
expect(service.drawerState.getValue().right).toBeUndefined();
});
});
it('should not affect the other drawer when removing one', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service.initDrawer('right', { open: true, width: '200px', variant: 'temporary' });
service.removeDrawer('left');
expect(service.drawerState.getValue().left).toBeUndefined();
expect(service.drawerState.getValue().right).toEqual({ open: true, width: '200px', variant: 'temporary' });
});
});
it('should be a no-op if the drawer does not exist', () => {
using(createLayoutService(mockElement), (service) => {
const stateBefore = service.drawerState.getValue();
service.removeDrawer('left');
expect(service.drawerState.getValue()).toEqual(stateBefore);
});
});
it('should be a no-op if the service is already disposed', () => {
const service = createLayoutService(mockElement);
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
service[Symbol.dispose]();
expect(() => service.removeDrawer('left')).not.toThrow();
});
it('should reset CSS variables after removing a drawer', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
mockSetProperty.mockClear();
service.removeDrawer('left');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-width', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-configured-width', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-left', '0px');
});
});
});
});
describe('CSS Variables', () => {
it('should update CSS variables when drawer opens', () => {
using(createLayoutService(mockElement), (service) => {
mockSetProperty.mockClear();
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-width', '240px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-configured-width', '240px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-left', '240px');
});
});
it('should set drawer width to 0 when closed', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
mockSetProperty.mockClear();
service.setDrawerOpen('left', false);
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-width', '0px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-left', '0px');
});
});
it('should update CSS variables when AppBar height changes', () => {
using(createLayoutService(mockElement), (service) => {
mockSetProperty.mockClear();
service.appBarHeight.setValue('64px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-appbar-height', '64px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-top', '64px');
});
});
it('should update right drawer CSS variables', () => {
using(createLayoutService(mockElement), (service) => {
mockSetProperty.mockClear();
service.initDrawer('right', { open: true, width: '200px', variant: 'collapsible' });
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-right-width', '200px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-right-configured-width', '200px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-right', '200px');
});
});
it('should set content margin to 0 for temporary drawer variant', () => {
using(createLayoutService(mockElement), (service) => {
mockSetProperty.mockClear();
service.initDrawer('right', { open: true, width: '200px', variant: 'temporary' });
// Drawer width is set to 200px (it's open)
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-right-width', '200px');
// Configured width is always set
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-right-configured-width', '200px');
// But content margin is 0 because temporary drawers overlay content
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-right', '0px');
});
});
it('should always set content margin to drawer width for permanent drawer variant', () => {
using(createLayoutService(mockElement), (service) => {
// Test when closed
service.initDrawer('left', { open: false, width: '240px', variant: 'permanent' });
mockSetProperty.mockClear();
// Even when closed, permanent drawers push content
service.setDrawerOpen('left', false);
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-left', '240px');
});
});
it('should set content margin to 0 when collapsible drawer is closed', () => {
using(createLayoutService(mockElement), (service) => {
service.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' });
mockSetProperty.mockClear();
service.setDrawerOpen('left', false);
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-width', '0px');
// Configured width is still set
expect(mockSetProperty).toHaveBeenCalledWith('--layout-drawer-left-configured-width', '240px');
// Content margin is 0 because drawer is closed
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-margin-left', '0px');
});
});
it('should update CSS variables when topGap changes', () => {
using(createLayoutService(mockElement), (service) => {
mockSetProperty.mockClear();
service.setTopGap('16px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-top-gap', '16px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-content-padding-top', 'calc(48px + 16px)');
});
});
it('should update CSS variables when sideGap changes', () => {
using(createLayoutService(mockElement), (service) => {
mockSetProperty.mockClear();
service.setSideGap('24px');
expect(mockSetProperty).toHaveBeenCalledWith('--layout-side-gap', '24px');
});
});
});
describe('LAYOUT_CSS_VARIABLES export', () => {
it('should export all CSS variable names', () => {
expect(LAYOUT_CSS_VARIABLES.appBarHeight).toBe('--layout-appbar-height');
expect(LAYOUT_CSS_VARIABLES.topGap).toBe('--layout-top-gap');
expect(LAYOUT_CSS_VARIABLES.sideGap).toBe('--layout-side-gap');
expect(LAYOUT_CSS_VARIABLES.contentPaddingTop).toBe('--layout-content-padding-top');
expect(LAYOUT_CSS_VARIABLES.drawerLeftWidth).toBe('--layout-drawer-left-width');
expect(LAYOUT_CSS_VARIABLES.drawerRightWidth).toBe('--layout-drawer-right-width');
expect(LAYOUT_CSS_VARIABLES.drawerLeftConfiguredWidth).toBe('--layout-drawer-left-configured-width');
expect(LAYOUT_CSS_VARIABLES.drawerRightConfiguredWidth).toBe('--layout-drawer-right-configured-width');
expect(LAYOUT_CSS_VARIABLES.contentMarginTop).toBe('--layout-content-margin-top');
expect(LAYOUT_CSS_VARIABLES.contentMarginLeft).toBe('--layout-content-margin-left');
expect(LAYOUT_CSS_VARIABLES.contentMarginRight).toBe('--layout-content-margin-right');
});
});
describe('AppBar Visibility', () => {
it('should initialize with visible AppBar', () => {
using(createLayoutService(mockElement), (service) => {
expect(service.appBarVisible.getValue()).toBe(true);
});
});
it('should allow setting AppBar visibility', () => {
using(createLayoutService(mockElement), (service) => {
service.appBarVisible.setValue(false);
expect(service.appBarVisible.getValue()).toBe(false);
});
});
});
describe('AppBar Height', () => {
it('should initialize with default height', () => {
using(createLayoutService(mockElement), (service) => {
expect(service.appBarHeight.getValue()).toBe('48px');
});
});
it('should allow setting AppBar height', () => {
using(createLayoutService(mockElement), (service) => {
service.appBarHeight.setValue('64px');
expect(service.appBarHeight.getValue()).toBe('64px');
});
});
});
describe('Gap Management', () => {
describe('topGap', () => {
it('should initialize with default value', () => {
using(createLayoutService(mockElement), (service) => {
expect(service.topGap.getValue()).toBe('0px');
});
});
it('should allow setting topGap via setTopGap', () => {
using(createLayoutService(mockElement), (service) => {
service.setTopGap('16px');
expect(service.topGap.getValue()).toBe('16px');
});
});
it('should allow setting topGap via observable', () => {
using(createLayoutService(mockElement), (service) => {
service.topGap.setValue('32px');
expect(service.topGap.getValue()).toBe('32px');
});
});
});
describe('sideGap', () => {
it('should initialize with default value', () => {
using(createLayoutService(mockElement), (service) => {
expect(service.sideGap.getValue()).toBe('0px');
});
});
it('should allow setting sideGap via setSideGap', () => {
using(createLayoutService(mockElement), (service) => {
service.setSideGap('24px');
expect(service.sideGap.getValue()).toBe('24px');
});
});
it('should allow setting sideGap via observable', () => {
using(createLayoutService(mockElement), (service) => {
service.sideGap.setValue('48px');
expect(service.sideGap.getValue()).toBe('48px');
});
});
});
});
describe('Disposal', () => {
it('should dispose all observables', () => {
const service = createLayoutService(mockElement);
const drawerStateSpy = vi.spyOn(service.drawerState, Symbol.dispose);
const appBarVisibleSpy = vi.spyOn(service.appBarVisible, Symbol.dispose);
const appBarHeightSpy = vi.spyOn(service.appBarHeight, Symbol.dispose);
const topGapSpy = vi.spyOn(service.topGap, Symbol.dispose);
const sideGapSpy = vi.spyOn(service.sideGap, Symbol.dispose);
service[Symbol.dispose]();
expect(drawerStateSpy).toHaveBeenCalled();
expect(appBarVisibleSpy).toHaveBeenCalled();
expect(appBarHeightSpy).toHaveBeenCalled();
expect(topGapSpy).toHaveBeenCalled();
expect(sideGapSpy).toHaveBeenCalled();
});
});
describe('Observable Subscriptions', () => {
it('should notify subscribers when drawer state changes', () => {
using(createLayoutService(mockElement), (service) => {
const states = [];
service.drawerState.subscribe((state) => {
states.push(state);
});
service.initDrawer('left', { open: false, width: '240px', variant: 'collapsible' });
service.setDrawerOpen('left', true);
expect(states.length).toBe(2); // 2 changes (initDrawer + setDrawerOpen)
expect(states[states.length - 1].left?.open).toBe(true);
expect(states[states.length - 1].left?.variant).toBe('collapsible');
});
});
it('should notify subscribers when topGap changes', () => {
using(createLayoutService(mockElement), (service) => {
const values = [];
service.topGap.subscribe((value) => {
values.push(value);
});
service.setTopGap('16px');
service.setTopGap('32px');
expect(values).toEqual(['16px', '32px']);
});
});
it('should notify subscribers when sideGap changes', () => {
using(createLayoutService(mockElement), (service) => {
const values = [];
service.sideGap.subscribe((value) => {
values.push(value);
});
service.setSideGap('24px');
service.setSideGap('48px');
expect(values).toEqual(['24px', '48px']);
});
});
});
describe('LAYOUT_CSS_VARIABLES', () => {
it('should export all CSS variable names', () => {
expect(LAYOUT_CSS_VARIABLES).toEqual({
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',
});
});
});
});
//# sourceMappingURL=layout-service.spec.js.map