UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

770 lines 38.4 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { LayoutService } from '../../services/layout-service.js'; import { PageLayout } from './index.js'; describe('PageLayout component', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const renderPageLayout = async (options = {}) => { const injector = createInjector(); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: options.appBar, drawer: options.drawer }, options.children ?? createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); return { injector, pageLayout, layoutService: pageLayout.injector.get(LayoutService), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render the shade-page-layout custom element', async () => { await usingAsync(await renderPageLayout(), async ({ pageLayout }) => { expect(pageLayout).not.toBeNull(); expect(pageLayout.tagName.toLowerCase()).toBe('shade-page-layout'); }); }); it('should render children in content area', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", { id: "test-content" }, "Test Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-content'); }); }); }); describe('positioning', () => { it('should have fixed positioning', async () => { await usingAsync(await renderPageLayout(), async ({ pageLayout }) => { const computedStyle = window.getComputedStyle(pageLayout); expect(computedStyle.position).toBe('fixed'); }); }); it('should have full width', async () => { await usingAsync(await renderPageLayout(), async ({ pageLayout }) => { const computedStyle = window.getComputedStyle(pageLayout); expect(computedStyle.width).toBe('100%'); }); }); it('should have full height', async () => { await usingAsync(await renderPageLayout(), async ({ pageLayout }) => { const computedStyle = window.getComputedStyle(pageLayout); expect(computedStyle.height).toBe('100%'); }); }); }); describe('AppBar', () => { it('should render AppBar when configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'permanent', component: createComponent("div", { id: "my-appbar" }, "AppBar Content"), } }, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-appbar'); expect(document.body.innerHTML).toContain('my-appbar'); }); }); it('should not render AppBar when not configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).not.toContain('page-layout-appbar'); }); }); it('should use custom AppBar height', async () => { await usingAsync(await renderPageLayout({ appBar: { variant: 'permanent', height: '64px', component: createComponent("div", null, "AppBar"), }, }), async ({ layoutService }) => { expect(layoutService.appBarHeight.getValue()).toBe('64px'); }); }); it('should use default AppBar height when not specified', async () => { await usingAsync(await renderPageLayout({ appBar: { variant: 'permanent', component: createComponent("div", null, "AppBar"), }, }), async ({ layoutService }) => { expect(layoutService.appBarHeight.getValue()).toBe('48px'); }); }); it('should add appbar-auto-hide class to host for auto-hide variant', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'auto-hide', component: createComponent("div", null, "AppBar"), } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-appbar-auto-hide')).toBe(true); }); }); it('should not add appbar-auto-hide class to host for permanent variant', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'permanent', component: createComponent("div", null, "AppBar"), } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-appbar-auto-hide')).toBe(false); }); }); it('should not have appbar-visible class initially for auto-hide variant (starts hidden)', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'auto-hide', component: createComponent("div", null, "AppBar"), } }, createComponent("div", null, "Content"))), }); await flushUpdates(); // Auto-hide appbars should start hidden (no data-appbar-visible attribute) const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-appbar-visible')).toBe(false); }); }); }); describe('Left Drawer', () => { it('should render left drawer when configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'permanent', component: createComponent("div", { id: "left-drawer" }, "Left Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-drawer-left'); expect(document.body.innerHTML).toContain('left-drawer'); }); }); it('should not render left drawer when not configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).not.toContain('page-layout-drawer-left'); }); }); it('should initialize permanent drawer as open', async () => { await usingAsync(await renderPageLayout({ drawer: { left: { variant: 'permanent', component: createComponent("div", null, "Left Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should initialize collapsible drawer as open by default', async () => { await usingAsync(await renderPageLayout({ drawer: { left: { variant: 'collapsible', component: createComponent("div", null, "Left Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should initialize collapsible drawer as closed when defaultOpen is false', async () => { await usingAsync(await renderPageLayout({ drawer: { left: { variant: 'collapsible', defaultOpen: false, component: createComponent("div", null, "Left Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.open).toBe(false); }); }); it('should use custom drawer width', async () => { await usingAsync(await renderPageLayout({ drawer: { left: { variant: 'permanent', width: '300px', component: createComponent("div", null, "Left Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.width).toBe('300px'); }); }); it('should use default drawer width when not specified', async () => { await usingAsync(await renderPageLayout({ drawer: { left: { variant: 'permanent', component: createComponent("div", null, "Left Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().left?.width).toBe('240px'); }); }); it('should add drawer-left-closed class to host when drawer is closed', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'collapsible', defaultOpen: false, component: createComponent("div", null, "Left Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-drawer-left-closed')).toBe(true); }); }); }); describe('Right Drawer', () => { it('should render right drawer when configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { right: { variant: 'permanent', component: createComponent("div", { id: "right-drawer" }, "Right Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-drawer-right'); expect(document.body.innerHTML).toContain('right-drawer'); }); }); it('should not render right drawer when not configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).not.toContain('page-layout-drawer-right'); }); }); it('should initialize right drawer state correctly', async () => { await usingAsync(await renderPageLayout({ drawer: { right: { variant: 'permanent', width: '200px', component: createComponent("div", null, "Right Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue().right).toEqual({ open: true, width: '200px', variant: 'permanent', }); }); }); }); describe('Both Drawers', () => { it('should render both drawers when configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'permanent', component: createComponent("div", { id: "left-drawer" }, "Left Drawer"), }, right: { variant: 'permanent', component: createComponent("div", { id: "right-drawer" }, "Right Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-drawer-left'); expect(document.body.innerHTML).toContain('page-layout-drawer-right'); }); }); it('should initialize both drawer states correctly', async () => { await usingAsync(await renderPageLayout({ drawer: { left: { variant: 'permanent', width: '240px', component: createComponent("div", null, "Left Drawer"), }, right: { variant: 'collapsible', width: '200px', defaultOpen: false, component: createComponent("div", null, "Right Drawer"), }, }, }), async ({ layoutService }) => { expect(layoutService.drawerState.getValue()).toEqual({ left: { open: true, width: '240px', variant: 'permanent' }, right: { open: false, width: '200px', variant: 'collapsible' }, }); }); }); }); describe('Temporary Drawer Backdrop', () => { it('should render backdrop element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'temporary', component: createComponent("div", null, "Temporary Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-drawer-backdrop'); }); }); it('should add backdrop-visible class to host when temporary drawer is open', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'temporary', defaultOpen: true, component: createComponent("div", null, "Temporary Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-backdrop-visible')).toBe(true); }); }); it('should close temporary drawer when backdrop is clicked', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'temporary', defaultOpen: true, component: createComponent("div", null, "Temporary Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-drawer-left-closed')).toBe(false); const backdrop = document.querySelector('.page-layout-drawer-backdrop'); backdrop.click(); await flushUpdates(); expect(pageLayout?.hasAttribute('data-drawer-left-closed')).toBe(true); }); }); }); describe('Content Area', () => { it('should render content area', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-content'); }); }); it('should set data-nav-section="content" on the content area for spatial navigation scoping', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); const contentArea = document.querySelector('.page-layout-content'); expect(contentArea?.getAttribute('data-nav-section')).toBe('content'); }); }); it('should set CSS variable for zero paddingTop when no AppBar is configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); // AppBar height is 0 when not configured, so contentPaddingTop = calc(0px + 0px) expect(pageLayout.style.getPropertyValue('--layout-appbar-height')).toBe('0px'); expect(pageLayout.style.getPropertyValue('--layout-content-padding-top')).toBe('calc(0px + 0px)'); }); }); it('should set CSS variable for paddingTop equal to AppBar height when AppBar is configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'permanent', component: createComponent("div", null, "AppBar"), height: '64px', } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); // Content padding top CSS variable should be calculated from appBarHeight + topGap expect(pageLayout.style.getPropertyValue('--layout-appbar-height')).toBe('64px'); expect(pageLayout.style.getPropertyValue('--layout-content-padding-top')).toBe('calc(64px + 0px)'); }); }); it('should set CSS variable for topGap when configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'permanent', component: createComponent("div", null, "AppBar"), height: '48px', }, topGap: "16px" }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout.style.getPropertyValue('--layout-top-gap')).toBe('16px'); expect(pageLayout.style.getPropertyValue('--layout-content-padding-top')).toBe('calc(48px + 16px)'); }); }); it('should set CSS variable for sideGap when configured', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { sideGap: "24px" }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout.style.getPropertyValue('--layout-side-gap')).toBe('24px'); }); }); }); describe('LayoutService Integration', () => { it('should update AppBar height to 0 when no AppBar is configured', async () => { await usingAsync(await renderPageLayout(), async ({ layoutService }) => { expect(layoutService.appBarHeight.getValue()).toBe('0px'); }); }); it('should respond to drawer state changes from LayoutService', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { drawer: { left: { variant: 'collapsible', component: createComponent("div", null, "Left Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); const layoutService = pageLayout.injector.get(LayoutService); // Initially open expect(pageLayout.hasAttribute('data-drawer-left-closed')).toBe(false); // Close via LayoutService layoutService.setDrawerOpen('left', false); await flushUpdates(); expect(pageLayout.hasAttribute('data-drawer-left-closed')).toBe(true); }); }); it('should respond to appBarVisible changes for auto-hide variant', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'auto-hide', component: createComponent("div", null, "AppBar"), } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); const layoutService = pageLayout.injector.get(LayoutService); // Initially hidden for auto-hide variant expect(pageLayout.hasAttribute('data-appbar-visible')).toBe(false); expect(layoutService.appBarVisible.getValue()).toBe(false); // Setting appBarVisible to true should persist across re-renders layoutService.appBarVisible.setValue(true); await flushUpdates(); expect(layoutService.appBarVisible.getValue()).toBe(true); expect(pageLayout.hasAttribute('data-appbar-visible')).toBe(true); }); }); }); describe('Full Layout', () => { it('should render complete layout with AppBar and both drawers', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { appBar: { variant: 'permanent', height: '64px', component: createComponent("div", { id: "appbar" }, "AppBar"), }, drawer: { left: { variant: 'collapsible', width: '240px', component: createComponent("div", { id: "left-drawer" }, "Left Sidebar"), }, right: { variant: 'permanent', width: '200px', component: createComponent("div", { id: "right-drawer" }, "Right Panel"), }, } }, createComponent("div", { id: "main-content" }, "Main Content"))), }); await flushUpdates(); expect(document.body.innerHTML).toContain('page-layout-appbar'); expect(document.body.innerHTML).toContain('page-layout-drawer-left'); expect(document.body.innerHTML).toContain('page-layout-drawer-right'); expect(document.body.innerHTML).toContain('page-layout-content'); const pageLayout = document.querySelector('shade-page-layout'); const layoutService = pageLayout.injector.get(LayoutService); expect(layoutService.appBarHeight.getValue()).toBe('64px'); expect(layoutService.drawerState.getValue()).toEqual({ left: { open: true, width: '240px', variant: 'collapsible' }, right: { open: true, width: '200px', variant: 'permanent' }, }); }); }); }); describe('Contained Mode', () => { it('should set data-contained attribute on host when contained is true', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { contained: true }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-contained')).toBe(true); }); }); it('should not set data-contained attribute when contained is not set', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, null, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-contained')).toBe(false); }); }); it('should have absolute positioning when contained', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { contained: true }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); const computedStyle = window.getComputedStyle(pageLayout); expect(computedStyle.position).toBe('absolute'); }); }); it('should work with AppBar and drawers in contained mode', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { contained: true, appBar: { variant: 'permanent', component: createComponent("div", null, "AppBar"), }, drawer: { left: { variant: 'collapsible', component: createComponent("div", null, "Left Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout.hasAttribute('data-contained')).toBe(true); expect(document.body.innerHTML).toContain('page-layout-appbar'); expect(document.body.innerHTML).toContain('page-layout-drawer-left'); expect(document.body.innerHTML).toContain('page-layout-content'); const layoutService = pageLayout.injector.get(LayoutService); expect(layoutService.drawerState.getValue().left?.open).toBe(true); }); }); it('should support drawer toggle in contained mode', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { contained: true, drawer: { left: { variant: 'collapsible', component: createComponent("div", null, "Left Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); const layoutService = pageLayout.injector.get(LayoutService); expect(pageLayout.hasAttribute('data-drawer-left-closed')).toBe(false); layoutService.setDrawerOpen('left', false); await flushUpdates(); expect(pageLayout.hasAttribute('data-drawer-left-closed')).toBe(true); }); }); it('should support temporary drawer backdrop click in contained mode', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(PageLayout, { contained: true, drawer: { left: { variant: 'temporary', defaultOpen: true, component: createComponent("div", null, "Temporary Drawer"), }, } }, createComponent("div", null, "Content"))), }); await flushUpdates(); const pageLayout = document.querySelector('shade-page-layout'); expect(pageLayout?.hasAttribute('data-backdrop-visible')).toBe(true); const backdrop = document.querySelector('.page-layout-drawer-backdrop'); backdrop.click(); await flushUpdates(); expect(pageLayout?.hasAttribute('data-drawer-left-closed')).toBe(true); }); }); }); }); //# sourceMappingURL=index.spec.js.map