@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
532 lines • 25.3 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, 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