@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
251 lines • 12.5 kB
JavaScript
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