UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

607 lines 31.8 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot, ScreenService } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LayoutService, createLayoutService } from '../../services/layout-service.js'; import { Drawer } from './index.js'; /** * Creates a mock element for LayoutService */ const createMockElement = () => ({ style: { setProperty: vi.fn(), }, }); describe('Drawer component', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const renderDrawer = async (options = {}) => { const { position = 'left', variant = 'collapsible', children = createComponent("div", null, "Drawer Content"), ...restProps } = options; const injector = createInjector(); const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: position, variant: variant, ...restProps }, children)), }); await flushUpdates(); const drawer = document.querySelector('shade-drawer'); return { injector, drawer, layoutService, screenService: injector.get(ScreenService), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render the shade-drawer custom element', async () => { await usingAsync(await renderDrawer(), async ({ drawer }) => { expect(drawer).not.toBeNull(); expect(drawer.tagName.toLowerCase()).toBe('shade-drawer'); }); }); it('should render children in drawer content', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible" }, createComponent("div", { id: "test-content" }, "Test Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('test-content'); }); }); it('should render left drawer with correct positioning class', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "permanent" }, createComponent("div", null, "Left Drawer"))), }); await flushUpdates(); const container = document.querySelector('.drawer-left'); expect(container).not.toBeNull(); }); }); it('should render right drawer with correct positioning class', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "right", variant: "permanent" }, createComponent("div", null, "Right Drawer"))), }); await flushUpdates(); const container = document.querySelector('.drawer-right'); expect(container).not.toBeNull(); }); }); }); describe('permanent variant', () => { it('should initialize as open', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'permanent', }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should not have closed class when open', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "permanent" }, createComponent("div", null, "Permanent Drawer"))), }); await flushUpdates(); const container = document.querySelector('.drawer-container'); expect(container?.classList.contains('closed')).toBe(false); }); }); it('should set data-variant attribute to permanent', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "permanent" }, createComponent("div", null, "Permanent Drawer"))), }); await flushUpdates(); const container = document.querySelector('.drawer-container'); expect(container?.getAttribute('data-variant')).toBe('permanent'); }); }); }); describe('collapsible variant', () => { it('should initialize as open by default', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'collapsible', }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should initialize as closed when defaultOpen is false', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'collapsible', defaultOpen: false, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); it('should add closed class when drawer is closed', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible", defaultOpen: false }, createComponent("div", null, "Collapsible Drawer"))), }); await flushUpdates(); const container = document.querySelector('.drawer-container'); expect(container?.classList.contains('closed')).toBe(true); }); }); it('should respond to LayoutService toggle', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible" }, createComponent("div", null, "Collapsible Drawer"))), }); await flushUpdates(); // Initially open let container = document.querySelector('.drawer-container'); expect(container?.classList.contains('closed')).toBe(false); // Close via LayoutService layoutService.setDrawerOpen('left', false); await flushUpdates(); container = document.querySelector('.drawer-container'); expect(container?.classList.contains('closed')).toBe(true); }); }); }); describe('temporary variant', () => { it('should initialize as closed by default', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'temporary', }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); it('should initialize as open when defaultOpen is true', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'temporary', defaultOpen: true, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should render backdrop', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "temporary" }, createComponent("div", null, "Temporary Drawer"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('drawer-backdrop'); }); }); it('should show backdrop when drawer is open', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "temporary" }, createComponent("div", null, "Temporary Drawer"))), }); await flushUpdates(); layoutService.setDrawerOpen('left', true); await flushUpdates(); const backdrop = document.querySelector('.drawer-backdrop'); expect(backdrop?.classList.contains('visible')).toBe(true); }); }); it('should close drawer when backdrop is clicked', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "temporary" }, createComponent("div", null, "Temporary Drawer"))), }); await flushUpdates(); layoutService.setDrawerOpen('left', true); await flushUpdates(); const backdrop = document.querySelector('.drawer-backdrop'); backdrop.click(); await flushUpdates(); expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); it('should use transform for temporary drawer animation', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "temporary", width: "300px" }, createComponent("div", null, "Temporary Drawer"))), }); await flushUpdates(); // When closed, left drawer should be translated off-screen let container = document.querySelector('.drawer-container'); expect(container.style.transform).toContain('translateX'); expect(container.style.width).toBe('300px'); // When opened layoutService.setDrawerOpen('left', true); await flushUpdates(); container = document.querySelector('.drawer-container'); expect(container.style.transform).toBe('translateX(0)'); }); }); }); describe('width configuration', () => { it('should use custom width', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'permanent', width: '300px', }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.width).toBe('300px'); }); }); it('should use default width of 240px when not specified', async () => { await usingAsync(await renderDrawer({ position: 'left', variant: 'permanent', }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.width).toBe('240px'); }); }); it('should update width in LayoutService when changed', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); // First render with width layoutService.initDrawer('left', { open: true, width: '200px', variant: 'collapsible' }); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "permanent", width: "300px" }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); // Width should be updated to 300px expect(layoutService.drawerState.getValue().left?.width).toBe('300px'); }); }); }); describe('right drawer', () => { it('should initialize right drawer state correctly', async () => { await usingAsync(await renderDrawer({ position: 'right', variant: 'collapsible', width: '200px', }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().right).toEqual({ open: true, width: '200px', variant: 'collapsible', }); }); }); it('should render right drawer backdrop for temporary variant', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "right", variant: "temporary" }, createComponent("div", null, "Temporary Right Drawer"))), }); await flushUpdates(); layoutService.setDrawerOpen('right', true); await flushUpdates(); const backdrop = document.querySelector('[data-testid="drawer-backdrop-right"]'); expect(backdrop).not.toBeNull(); }); }); }); describe('responsive breakpoint', () => { it('should close collapsible drawer when screen becomes smaller than breakpoint', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); const screenService = injector.get(ScreenService); // Start with large screen screenService.screenSize.atLeast.md.setValue(true); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible", collapseOnBreakpoint: "md" }, createComponent("div", null, "Responsive Drawer"))), }); await flushUpdates(); // Drawer should be open on large screen expect(layoutService.drawerState.getValue().left?.open).toBe(true); // Simulate screen becoming smaller screenService.screenSize.atLeast.md.setValue(false); await flushUpdates(); // Drawer should now be closed expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); it('should open collapsible drawer when screen becomes larger than breakpoint', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); const screenService = injector.get(ScreenService); // Start with large screen screenService.screenSize.atLeast.md.setValue(true); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible", collapseOnBreakpoint: "md" }, createComponent("div", null, "Responsive Drawer"))), }); await flushUpdates(); // Drawer should be open on large screen expect(layoutService.drawerState.getValue().left?.open).toBe(true); // Simulate screen becoming smaller screenService.screenSize.atLeast.md.setValue(false); await flushUpdates(); // Drawer should now be closed expect(layoutService.drawerState.getValue().left?.open).toBe(false); // Simulate screen becoming larger again screenService.screenSize.atLeast.md.setValue(true); await flushUpdates(); // Drawer should now be open again expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should close temporary drawer when screen becomes larger than breakpoint', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); const screenService = injector.get(ScreenService); // Start with small screen and drawer open screenService.screenSize.atLeast.md.setValue(false); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "temporary", collapseOnBreakpoint: "md", defaultOpen: true }, createComponent("div", null, "Temporary Mobile Drawer"))), }); await flushUpdates(); expect(layoutService.drawerState.getValue().left?.open).toBe(true); // Simulate screen becoming larger screenService.screenSize.atLeast.md.setValue(true); await flushUpdates(); // Temporary drawer should close when screen is large (switch to desktop layout) expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); it('should not affect permanent drawers with collapseOnBreakpoint', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); const screenService = injector.get(ScreenService); // Start with large screen screenService.screenSize.atLeast.md.setValue(true); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "permanent", collapseOnBreakpoint: "md" }, createComponent("div", null, "Permanent Drawer"))), }); await flushUpdates(); expect(layoutService.drawerState.getValue().left?.open).toBe(true); // Simulate screen becoming smaller screenService.screenSize.atLeast.md.setValue(false); await flushUpdates(); // Permanent drawer should remain open expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); }); describe('data attributes', () => { it('should set data-variant attribute', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible" }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); const container = document.querySelector('.drawer-container'); expect(container?.getAttribute('data-variant')).toBe('collapsible'); }); }); it('should set data-open attribute', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible" }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); let container = document.querySelector('.drawer-container'); expect(container?.getAttribute('data-open')).toBe('true'); layoutService.setDrawerOpen('left', false); await flushUpdates(); container = document.querySelector('.drawer-container'); expect(container?.getAttribute('data-open')).toBe('false'); }); }); it('should set data-testid attribute', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible" }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); const container = document.querySelector('[data-testid="drawer-left"]'); expect(container).not.toBeNull(); }); }); }); describe('cleanup on disposal', () => { it('should call removeDrawer on LayoutService when the component is removed from DOM', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible" }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); expect(layoutService.drawerState.getValue().left).toBeDefined(); const removeDrawerSpy = vi.spyOn(layoutService, 'removeDrawer'); const drawer = document.querySelector('shade-drawer'); drawer.remove(); await flushUpdates(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(removeDrawerSpy).toHaveBeenCalledWith('left'); }); }); it('should not re-add drawer state via ghost render during disposal', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible", defaultOpen: true }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); expect(layoutService.drawerState.getValue().left).toBeDefined(); expect(layoutService.drawerState.getValue().left?.open).toBe(true); const drawer = document.querySelector('shade-drawer'); drawer.remove(); await flushUpdates(); await new Promise((resolve) => setTimeout(resolve, 10)); // Drawer state must remain cleared — a ghost re-render must not re-add it expect(layoutService.drawerState.getValue().left).toBeUndefined(); }); }); it('should only clean up its own drawer position on disposal', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); layoutService.initDrawer('left', { open: true, width: '240px', variant: 'collapsible' }); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "right", variant: "temporary" }, createComponent("div", null, "Right Drawer"))), }); await flushUpdates(); expect(layoutService.drawerState.getValue().right).toBeDefined(); expect(layoutService.drawerState.getValue().left).toBeDefined(); const removeDrawerSpy = vi.spyOn(layoutService, 'removeDrawer'); const drawer = document.querySelector('shade-drawer'); drawer.remove(); await flushUpdates(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(removeDrawerSpy).toHaveBeenCalledWith('right'); expect(removeDrawerSpy).not.toHaveBeenCalledWith('left'); }); }); }); describe('preserving user interactions', () => { it('should not reset drawer state if already initialized', async () => { await usingAsync(createInjector(), async (injector) => { const layoutService = createLayoutService(createMockElement()); injector.bind(LayoutService, () => layoutService); const rootElement = document.getElementById('root'); // Pre-initialize drawer as closed layoutService.initDrawer('left', { open: false, width: '240px', variant: 'collapsible' }); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Drawer, { position: "left", variant: "collapsible", defaultOpen: true }, createComponent("div", null, "Drawer"))), }); await flushUpdates(); // Should preserve the closed state, not reset to defaultOpen expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); }); }); //# sourceMappingURL=index.spec.js.map