UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

251 lines 12.5 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot, LocationService } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { Breadcrumb, createBreadcrumb } from './breadcrumb.js'; describe('Breadcrumb', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); describe('Runtime behavior', () => { it('Should render basic breadcrumb trail with static routes', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/home', label: 'Home' }, { path: '/users', label: 'Users' }, ] })), }); await flushUpdates(); const component = rootElement.querySelector('nav[is="shade-breadcrumb"]'); expect(component).toBeTruthy(); expect(component?.textContent).toContain('Home'); expect(component?.textContent).toContain('Users'); }); }); it('Should compile dynamic route parameters correctly', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/users', label: 'Users' }, { path: '/users/:id', label: 'User Details', params: { id: '123' } }, ], lastItemClickable: true })), }); await flushUpdates(); const link = rootElement.querySelector('a[href="/users/123"]'); expect(link).toBeTruthy(); expect(link?.textContent).toBe('User Details'); }); }); it('Should highlight active breadcrumb based on current URL', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const locationService = injector.get(LocationService); history.pushState('', '', '/users'); locationService.updateState(); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/home', label: 'Home' }, { path: '/users', label: 'Users' }, ] })), }); await flushUpdates(); // The active item is the last non-clickable span with data-active attribute // (boolean values are rendered as empty string by the framework's setProp) const activeItem = rootElement.querySelector('[data-non-clickable="true"]'); expect(activeItem).toBeTruthy(); expect(activeItem?.textContent).toBe('Users'); expect(activeItem?.hasAttribute('data-active')).toBe(true); }); }); it('Should render custom separator as string', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/home', label: 'Home' }, { path: '/users', label: 'Users' }, ], separator: " > " })), }); await flushUpdates(); const separator = rootElement.querySelector('[data-separator="true"]'); expect(separator?.textContent).toBe(' > '); }); }); it('Should render optional home item', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { homeItem: { path: '/', label: 'Home' }, items: [{ path: '/users', label: 'Users' }] })), }); await flushUpdates(); const breadcrumb = rootElement.querySelector('nav'); expect(breadcrumb?.textContent).toContain('Home'); expect(breadcrumb?.textContent).toContain('Users'); }); }); it('Should make last item non-clickable when lastItemClickable is false', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/home', label: 'Home' }, { path: '/users', label: 'Users' }, ], lastItemClickable: false })), }); await flushUpdates(); const links = rootElement.querySelectorAll('a'); const spans = rootElement.querySelectorAll('[data-non-clickable="true"]'); expect(links.length).toBe(1); // Only first item expect(spans.length).toBe(1); // Last item }); }); it('Should make last item clickable when lastItemClickable is true', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/home', label: 'Home' }, { path: '/users', label: 'Users' }, ], lastItemClickable: true })), }); await flushUpdates(); const links = rootElement.querySelectorAll('a'); expect(links.length).toBe(2); // Both items }); }); it('Should trigger SPA navigation on click', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const onRouteChange = vi.fn(); injector.get(LocationService).onLocationPathChanged.subscribe(onRouteChange); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [ { path: '/home', label: 'Home' }, { path: '/users', label: 'Users' }, ] })), }); await flushUpdates(); expect(onRouteChange).not.toBeCalled(); const firstLink = rootElement.querySelector('a'); firstLink?.click(); expect(onRouteChange).toBeCalledTimes(1); }); }); it('Should apply custom className and style props', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [{ path: '/home', label: 'Home' }], className: "custom-breadcrumb", style: { padding: '16px' } })), }); await flushUpdates(); const breadcrumb = rootElement.querySelector('nav[is="shade-breadcrumb"]'); expect(breadcrumb?.classList.contains('custom-breadcrumb')).toBe(true); expect(breadcrumb?.style.padding).toBe('16px'); }); }); it('Should handle empty items array gracefully', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Breadcrumb, { items: [] }), }); const nav = rootElement.querySelector('nav'); expect(nav).toBeTruthy(); }); }); it('Should render home item when items array is empty', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Breadcrumb, { homeItem: { path: '/', label: 'Home' }, items: [] }), }); await flushUpdates(); const breadcrumb = rootElement.querySelector('nav'); expect(breadcrumb?.textContent).toContain('Home'); }); }); it('Should compile multiple parameters correctly', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Breadcrumb, { items: [{ path: '/users/:userId/posts/:postId', label: 'Post', params: { userId: '1', postId: '99' } }], lastItemClickable: true })), }); await flushUpdates(); const link = rootElement.querySelector('a[href="/users/1/posts/99"]'); expect(link).toBeTruthy(); expect(link?.textContent).toBe('Post'); }); }); }); describe('Type safety', () => { describe('BreadcrumbItem', () => { it('Should require params when path has parameters', () => { expectTypeOf().toExtend(); }); it('Should make params optional when path has no parameters', () => { expectTypeOf().toExtend(); }); it('Should extract multiple params from path', () => { expectTypeOf().toExtend(); }); }); describe('ExtractRouteParams utility', () => { it('Should return Record<string, never> for paths without params', () => { expectTypeOf().toEqualTypeOf(); }); it('Should extract a single param', () => { expectTypeOf().toEqualTypeOf(); }); it('Should extract multiple params', () => { expectTypeOf().toEqualTypeOf(); }); }); describe('createBreadcrumb', () => { it('Should constrain paths to valid route tree paths', () => { const AppBreadcrumb = createBreadcrumb(); expectTypeOf(AppBreadcrumb).parameter(0).toHaveProperty('items'); }); it('Should enforce required params for dynamic routes', () => { createBreadcrumb(); // Verify the type has the items property expectTypeOf().toHaveProperty('items'); }); it('Should require combined params from nested routes', () => { createBreadcrumb(); // Verify the type has the items property expectTypeOf().toHaveProperty('items'); }); }); }); }); //# sourceMappingURL=breadcrumb.spec.js.map