UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

532 lines 25.3 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot, LocationService } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Tabs } from './tabs.js'; describe('Tabs', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; window.location.hash = ''; }); afterEach(() => { document.body.innerHTML = ''; window.location.hash = ''; delete document.startViewTransition; }); const createTabs = () => [ { hash: 'tab1', header: createComponent("span", null, "Tab 1"), component: createComponent("div", { id: "content-1" }, "Content 1"), }, { hash: 'tab2', header: createComponent("span", null, "Tab 2"), component: createComponent("div", { id: "content-2" }, "Content 2"), }, { hash: 'tab3', header: createComponent("span", null, "Tab 3"), component: createComponent("div", { id: "content-3" }, "Content 3"), }, ]; it('should render all tab headers', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); expect(document.body.innerHTML).toContain('Tab 1'); expect(document.body.innerHTML).toContain('Tab 2'); expect(document.body.innerHTML).toContain('Tab 3'); }); }); it('should display the active tab content based on URL hash', async () => { await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab2'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); expect(document.getElementById('content-2')).toBeTruthy(); expect(document.getElementById('content-1')).toBeFalsy(); expect(document.getElementById('content-3')).toBeFalsy(); }); }); it('should not display any tab content when hash does not match', async () => { await usingAsync(createInjector(), async (injector) => { window.location.hash = '#nonexistent'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); expect(document.getElementById('content-1')).toBeFalsy(); expect(document.getElementById('content-2')).toBeFalsy(); expect(document.getElementById('content-3')).toBeFalsy(); }); }); it('should update active tab when hash changes', async () => { await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab1'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); expect(document.getElementById('content-1')).toBeTruthy(); // Change hash window.location.hash = '#tab3'; injector.get(LocationService).updateState(); await flushUpdates(); expect(document.getElementById('content-3')).toBeTruthy(); expect(document.getElementById('content-1')).toBeFalsy(); }); }); it('should render tab headers as anchor elements with correct href', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); // Tab headers extend anchor elements const html = document.body.innerHTML; expect(html).toContain('href="#tab1"'); expect(html).toContain('href="#tab2"'); expect(html).toContain('href="#tab3"'); }); }); it('should mark the active tab header with active class', async () => { await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab2'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); // Verify the active tab content is shown expect(document.getElementById('content-2')).toBeTruthy(); // Check for data-active attribute on the tab-header element containing Tab 2 const tabHeaders = document.querySelectorAll('a[is="shade-tab-header"]'); const activeTab = Array.from(tabHeaders).find((el) => el.hasAttribute('data-active')); expect(activeTab).toBeTruthy(); expect(activeTab?.getAttribute('href')).toBe('#tab2'); }); }); it('should switch active class when hash changes', async () => { await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab1'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); // Verify tab1 is active expect(document.getElementById('content-1')).toBeTruthy(); let tabHeaders = document.querySelectorAll('a[is="shade-tab-header"]'); let activeTab = Array.from(tabHeaders).find((el) => el.hasAttribute('data-active')); expect(activeTab).toBeTruthy(); expect(activeTab?.getAttribute('href')).toBe('#tab1'); // Change hash window.location.hash = '#tab2'; injector.get(LocationService).updateState(); await flushUpdates(); // Verify tab2 is now active expect(document.getElementById('content-2')).toBeTruthy(); expect(document.getElementById('content-1')).toBeFalsy(); tabHeaders = document.querySelectorAll('a[is="shade-tab-header"]'); activeTab = Array.from(tabHeaders).find((el) => el.hasAttribute('data-active')); expect(activeTab).toBeTruthy(); expect(activeTab?.getAttribute('href')).toBe('#tab2'); }); }); it('should apply containerStyle to the element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, containerStyle: { maxWidth: '800px' } }), }); await flushUpdates(); const tabsElement = document.querySelector('shade-tabs'); expect(tabsElement.style.maxWidth).toBe('800px'); }); }); it('should work with empty tabs array', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: [] }), }); await flushUpdates(); const tabHeaders = document.querySelectorAll('shade-tab-header'); expect(tabHeaders.length).toBe(0); }); }); describe('controlled mode', () => { it('should display the active tab based on activeKey', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab2" }), }); await flushUpdates(); expect(document.getElementById('content-2')).toBeTruthy(); expect(document.getElementById('content-1')).toBeFalsy(); expect(document.getElementById('content-3')).toBeFalsy(); }); }); it('should render tab headers as buttons instead of anchors', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1" }), }); await flushUpdates(); const buttons = document.querySelectorAll('.shade-tab-btn'); expect(buttons.length).toBe(3); // No anchor-based tab headers in controlled mode const anchors = document.querySelectorAll('a[is="shade-tab-header"]'); expect(anchors.length).toBe(0); }); }); it('should mark the active tab button with active class', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab2" }), }); await flushUpdates(); const buttons = document.querySelectorAll('.shade-tab-btn'); expect(buttons[0].classList.contains('active')).toBe(false); expect(buttons[1].classList.contains('active')).toBe(true); expect(buttons[2].classList.contains('active')).toBe(false); }); }); it('should fire onTabChange when a controlled tab is clicked', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); const onTabChange = vi.fn(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1", onTabChange: onTabChange }), }); await flushUpdates(); const buttons = document.querySelectorAll('.shade-tab-btn'); buttons[1].click(); expect(onTabChange).toHaveBeenCalledWith('tab2'); }); }); it('should ignore URL hash when activeKey is provided', async () => { await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab3'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1" }), }); await flushUpdates(); // activeKey takes precedence over URL hash expect(document.getElementById('content-1')).toBeTruthy(); expect(document.getElementById('content-3')).toBeFalsy(); }); }); }); describe('type prop', () => { it('should set data-type="card" attribute when type is card', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, type: "card" }), }); await flushUpdates(); const tabsElement = document.querySelector('shade-tabs'); expect(tabsElement.getAttribute('data-type')).toBe('card'); }); }); it('should not set data-type attribute when type is line', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, type: "line" }), }); await flushUpdates(); const tabsElement = document.querySelector('shade-tabs'); expect(tabsElement.getAttribute('data-type')).toBeNull(); }); }); }); describe('orientation prop', () => { it('should set data-orientation="vertical" attribute', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, orientation: "vertical" }), }); await flushUpdates(); const tabsElement = document.querySelector('shade-tabs'); expect(tabsElement.getAttribute('data-orientation')).toBe('vertical'); }); }); it('should not set data-orientation attribute when horizontal', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, orientation: "horizontal" }), }); await flushUpdates(); const tabsElement = document.querySelector('shade-tabs'); expect(tabsElement.getAttribute('data-orientation')).toBeNull(); }); }); }); describe('closable tabs', () => { it('should render close button for closable tabs when onClose is provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = [ { hash: 'tab1', header: createComponent("span", null, "Tab 1"), component: createComponent("div", null, "Content 1"), closable: true }, { hash: 'tab2', header: createComponent("span", null, "Tab 2"), component: createComponent("div", null, "Content 2") }, ]; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1", onClose: () => { } }), }); await flushUpdates(); const closeButtons = document.querySelectorAll('.shade-tab-close'); expect(closeButtons.length).toBe(1); }); }); it('should not render close button when onClose is not provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = [ { hash: 'tab1', header: createComponent("span", null, "Tab 1"), component: createComponent("div", null, "Content 1"), closable: true }, ]; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1" }), }); await flushUpdates(); const closeButtons = document.querySelectorAll('.shade-tab-close'); expect(closeButtons.length).toBe(0); }); }); it('should fire onClose when close button is clicked', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const onClose = vi.fn(); const tabs = [ { hash: 'tab1', header: createComponent("span", null, "Tab 1"), component: createComponent("div", null, "Content 1"), closable: true }, { hash: 'tab2', header: createComponent("span", null, "Tab 2"), component: createComponent("div", null, "Content 2"), closable: true }, ]; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1", onClose: onClose }), }); await flushUpdates(); const closeButtons = document.querySelectorAll('.shade-tab-close'); closeButtons[1].click(); expect(onClose).toHaveBeenCalledWith('tab2'); }); }); it('should not fire onTabChange when close button is clicked', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const onClose = vi.fn(); const onTabChange = vi.fn(); const tabs = [ { hash: 'tab1', header: createComponent("span", null, "Tab 1"), component: createComponent("div", null, "Content 1"), closable: true }, ]; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, activeKey: "tab1", onClose: onClose, onTabChange: onTabChange }), }); await flushUpdates(); const closeButton = document.querySelector('.shade-tab-close'); closeButton.click(); expect(onClose).toHaveBeenCalledWith('tab1'); expect(onTabChange).not.toHaveBeenCalled(); }); }); }); describe('add button', () => { it('should render add button when onAdd is provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, onAdd: () => { } }), }); await flushUpdates(); const addButton = document.querySelector('.shade-tab-add'); expect(addButton).toBeTruthy(); expect(addButton?.textContent?.trim()).toBe('+'); }); }); it('should not render add button when onAdd is not provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); const addButton = document.querySelector('.shade-tab-add'); expect(addButton).toBeFalsy(); }); }); it('should fire onAdd when add button is clicked', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); const onAdd = vi.fn(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, onAdd: onAdd }), }); await flushUpdates(); const addButton = document.querySelector('.shade-tab-add'); addButton.click(); expect(onAdd).toHaveBeenCalledOnce(); }); }); }); describe('onTabChange callback (hash mode)', () => { it('should fire onTabChange when tab header is clicked in hash mode', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const tabs = createTabs(); const onTabChange = vi.fn(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, onTabChange: onTabChange }), }); await flushUpdates(); const tabHeaders = document.querySelectorAll('a[is="shade-tab-header"]'); tabHeaders[1].click(); expect(onTabChange).toHaveBeenCalledWith('tab2'); }); }); }); describe('view transitions', () => { const mockStartViewTransition = () => { const spy = vi.fn((optionsOrCallback) => { const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update; update?.(); return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: vi.fn(), }; }); document.startViewTransition = spy; return spy; }; it('should call startViewTransition when viewTransition is enabled and hash changes', async () => { const spy = mockStartViewTransition(); await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab1'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs, viewTransition: true }), }); await flushUpdates(); spy.mockClear(); window.location.hash = '#tab2'; injector.get(LocationService).updateState(); await flushUpdates(); expect(spy).toHaveBeenCalled(); expect(document.getElementById('content-2')).toBeTruthy(); }); }); it('should not call startViewTransition when viewTransition is not set', async () => { const spy = mockStartViewTransition(); await usingAsync(createInjector(), async (injector) => { window.location.hash = '#tab1'; const rootElement = document.getElementById('root'); const tabs = createTabs(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Tabs, { tabs: tabs }), }); await flushUpdates(); spy.mockClear(); window.location.hash = '#tab2'; injector.get(LocationService).updateState(); await flushUpdates(); expect(spy).not.toHaveBeenCalled(); expect(document.getElementById('content-2')).toBeTruthy(); }); }); }); }); //# sourceMappingURL=tabs.spec.js.map