UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

610 lines (479 loc) 24.9 kB
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: ReturnType<typeof vi.fn> let mockElement: { style: { setProperty: ReturnType<typeof vi.fn> } } beforeEach(() => { mockSetProperty = vi.fn() mockElement = { style: { setProperty: mockSetProperty, }, } }) afterEach(() => { vi.unstubAllGlobals() }) describe('Constructor', () => { it('should initialize with default values', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (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 as unknown as HTMLElement), () => { 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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (service) => { service.toggleDrawer('left') expect(service.drawerState.getValue().left).toBeUndefined() }) }) it('should toggle right drawer independently', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (service) => { service.setDrawerWidth('left', '300px') expect(service.drawerState.getValue().left?.variant).toBe('collapsible') }) }) it('should preserve open state when setting width', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement) 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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (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 as unknown as HTMLElement), (service) => { expect(service.appBarVisible.getValue()).toBe(true) }) }) it('should allow setting AppBar visibility', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { service.appBarVisible.setValue(false) expect(service.appBarVisible.getValue()).toBe(false) }) }) }) describe('AppBar Height', () => { it('should initialize with default height', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { expect(service.appBarHeight.getValue()).toBe('48px') }) }) it('should allow setting AppBar height', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (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 as unknown as HTMLElement), (service) => { expect(service.topGap.getValue()).toBe('0px') }) }) it('should allow setting topGap via setTopGap', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { service.setTopGap('16px') expect(service.topGap.getValue()).toBe('16px') }) }) it('should allow setting topGap via observable', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { service.topGap.setValue('32px') expect(service.topGap.getValue()).toBe('32px') }) }) }) describe('sideGap', () => { it('should initialize with default value', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { expect(service.sideGap.getValue()).toBe('0px') }) }) it('should allow setting sideGap via setSideGap', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { service.setSideGap('24px') expect(service.sideGap.getValue()).toBe('24px') }) }) it('should allow setting sideGap via observable', () => { using(createLayoutService(mockElement as unknown as HTMLElement), (service) => { service.sideGap.setValue('48px') expect(service.sideGap.getValue()).toBe('48px') }) }) }) }) describe('Disposal', () => { it('should dispose all observables', () => { const service = createLayoutService(mockElement as unknown as HTMLElement) 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 as unknown as HTMLElement), (service) => { const states: Array<{ left?: { open: boolean; width: string; variant: string } }> = [] 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 as unknown as HTMLElement), (service) => { const values: string[] = [] 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 as unknown as HTMLElement), (service) => { const values: string[] = [] 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', }) }) }) })