UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

307 lines 15.3 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot, Shade } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Wizard } from './index.js'; const Step1 = Shade({ customElementName: 'wizard-test-step-1', render: ({ props }) => (createComponent("div", { className: "wizard-step", "data-step-name": "step1" }, createComponent("span", { className: "step-info" }, "Page ", props.currentPage + 1, " of ", props.maxPages), createComponent("span", { className: "step-name" }, "step1"), createComponent("button", { className: "prev-btn", onclick: () => props.onPrev?.() }, "Previous"), createComponent("button", { className: "next-btn", onclick: () => props.onNext?.() }, "Next"))), }); const Step2 = Shade({ customElementName: 'wizard-test-step-2', render: ({ props }) => (createComponent("div", { className: "wizard-step", "data-step-name": "step2" }, createComponent("span", { className: "step-info" }, "Page ", props.currentPage + 1, " of ", props.maxPages), createComponent("span", { className: "step-name" }, "step2"), createComponent("button", { className: "prev-btn", onclick: () => props.onPrev?.() }, "Previous"), createComponent("button", { className: "next-btn", onclick: () => props.onNext?.() }, "Next"))), }); const Step3 = Shade({ customElementName: 'wizard-test-step-3', render: ({ props }) => (createComponent("div", { className: "wizard-step", "data-step-name": "step3" }, createComponent("span", { className: "step-info" }, "Page ", props.currentPage + 1, " of ", props.maxPages), createComponent("span", { className: "step-name" }, "step3"), createComponent("button", { className: "prev-btn", onclick: () => props.onPrev?.() }, "Previous"), createComponent("button", { className: "next-btn", onclick: () => props.onNext?.() }, "Next"))), }); describe('Wizard', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; delete document.startViewTransition; }); const renderWizard = async (steps, onFinish, options) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: (createComponent(Wizard, { steps: steps, onFinish: onFinish, stepLabels: options?.stepLabels, showProgress: options?.showProgress, viewTransition: options?.viewTransition })), }); await flushUpdates(); const wizard = root.querySelector('shades-wizard'); const getStepElement = () => { return wizard.querySelector('wizard-test-step-1, wizard-test-step-2, wizard-test-step-3'); }; return { injector, wizard, getStepName: () => getStepElement()?.querySelector('.step-name')?.textContent, getStepInfo: () => getStepElement()?.querySelector('.step-info')?.textContent, clickNext: async () => { const btn = getStepElement()?.querySelector('.next-btn'); btn?.click(); await flushUpdates(); }, clickPrev: async () => { const btn = getStepElement()?.querySelector('.prev-btn'); btn?.click(); await flushUpdates(); }, [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render the wizard container', async () => { await usingAsync(await renderWizard([Step1]), async ({ wizard }) => { expect(wizard).toBeTruthy(); expect(wizard.tagName.toLowerCase()).toBe('shades-wizard'); }); }); it('should render the first step initially', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ getStepName }) => { expect(getStepName()).toBe('step1'); }); }); it('should pass correct props to the first step', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3]), async ({ getStepInfo }) => { expect(getStepInfo()).toBe('Page 1 of 3'); }); }); }); describe('navigation', () => { it('should navigate to next step when onNext is called', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ getStepName, clickNext }) => { expect(getStepName()).toBe('step1'); await clickNext(); expect(getStepName()).toBe('step2'); }); }); it('should navigate to previous step when onPrev is called', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ getStepName, clickNext, clickPrev }) => { await clickNext(); expect(getStepName()).toBe('step2'); await clickPrev(); expect(getStepName()).toBe('step1'); }); }); it('should not navigate before first step', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ getStepName, clickPrev }) => { expect(getStepName()).toBe('step1'); await clickPrev(); expect(getStepName()).toBe('step1'); }); }); it('should update step info when navigating', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ getStepInfo, clickNext }) => { expect(getStepInfo()).toBe('Page 1 of 2'); await clickNext(); expect(getStepInfo()).toBe('Page 2 of 2'); }); }); it('should navigate through multiple steps', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3]), async ({ getStepName, getStepInfo, clickNext, clickPrev }) => { expect(getStepName()).toBe('step1'); expect(getStepInfo()).toBe('Page 1 of 3'); await clickNext(); expect(getStepName()).toBe('step2'); expect(getStepInfo()).toBe('Page 2 of 3'); await clickNext(); expect(getStepName()).toBe('step3'); expect(getStepInfo()).toBe('Page 3 of 3'); await clickPrev(); expect(getStepName()).toBe('step2'); expect(getStepInfo()).toBe('Page 2 of 3'); }); }); }); describe('onFinish', () => { it('should call onFinish when clicking next on the last step', async () => { const onFinish = vi.fn(); await usingAsync(await renderWizard([Step1], onFinish), async ({ clickNext }) => { expect(onFinish).not.toHaveBeenCalled(); await clickNext(); expect(onFinish).toHaveBeenCalledOnce(); }); }); it('should call onFinish after navigating to the last step', async () => { const onFinish = vi.fn(); await usingAsync(await renderWizard([Step1, Step2], onFinish), async ({ clickNext }) => { await clickNext(); expect(onFinish).not.toHaveBeenCalled(); await clickNext(); expect(onFinish).toHaveBeenCalledOnce(); }); }); it('should work without onFinish callback', async () => { await usingAsync(await renderWizard([Step1]), async ({ getStepName, clickNext }) => { expect(getStepName()).toBe('step1'); await clickNext(); expect(getStepName()).toBe('step1'); }); }); }); describe('step indicator', () => { it('should not render step indicator when stepLabels is not provided', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ wizard }) => { const indicator = wizard.querySelector('[data-testid="wizard-step-indicator"]'); expect(indicator).toBeNull(); }); }); it('should render step indicator when stepLabels is provided', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { stepLabels: ['First', 'Second', 'Third'], }), async ({ wizard }) => { const indicator = wizard.querySelector('[data-testid="wizard-step-indicator"]'); expect(indicator).toBeTruthy(); const circles = indicator?.querySelectorAll('.wizard-step-circle'); expect(circles?.length).toBe(3); }); }); it('should show step labels', async () => { await usingAsync(await renderWizard([Step1, Step2], undefined, { stepLabels: ['Setup', 'Confirm'], }), async ({ wizard }) => { const labels = wizard.querySelectorAll('.wizard-step-label'); expect(labels[0]?.textContent).toBe('Setup'); expect(labels[1]?.textContent).toBe('Confirm'); }); }); it('should mark the current step as active', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { stepLabels: ['A', 'B', 'C'], }), async ({ wizard }) => { const circles = wizard.querySelectorAll('.wizard-step-circle'); expect(circles[0]?.hasAttribute('data-active')).toBe(true); expect(circles[1]?.hasAttribute('data-active')).toBe(false); }); }); it('should update active step on navigation', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { stepLabels: ['A', 'B', 'C'], }), async ({ wizard, clickNext }) => { await clickNext(); const circles = wizard.querySelectorAll('.wizard-step-circle'); expect(circles[0]?.hasAttribute('data-completed')).toBe(true); expect(circles[1]?.hasAttribute('data-active')).toBe(true); expect(circles[2]?.hasAttribute('data-active')).toBe(false); }); }); }); describe('progress bar', () => { it('should not render progress bar when showProgress is false', async () => { await usingAsync(await renderWizard([Step1, Step2]), async ({ wizard }) => { const progressBar = wizard.querySelector('[data-testid="wizard-progress-bar"]'); expect(progressBar).toBeNull(); }); }); it('should render progress bar when showProgress is true', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { showProgress: true }), async ({ wizard }) => { const progressBar = wizard.querySelector('[data-testid="wizard-progress-bar"]'); expect(progressBar).toBeTruthy(); }); }); it('should start at 0% on first step', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { showProgress: true }), async ({ wizard }) => { const fill = wizard.querySelector('.wizard-progress-fill'); expect(fill?.style.width).toBe('0%'); }); }); it('should update progress on navigation', async () => { await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { showProgress: true }), async ({ wizard, clickNext }) => { await clickNext(); const fill = wizard.querySelector('.wizard-progress-fill'); expect(fill?.style.width).toBe('50%'); await clickNext(); const fill2 = wizard.querySelector('.wizard-progress-fill'); expect(fill2?.style.width).toBe('100%'); }); }); }); describe('Paper container', () => { it('should render step inside a Paper component', async () => { await usingAsync(await renderWizard([Step1]), async ({ wizard }) => { const paper = wizard.querySelector('div[is="shade-paper"]'); expect(paper).toBeTruthy(); }); }); it('should have elevation 3 on the Paper', async () => { await usingAsync(await renderWizard([Step1]), async ({ wizard }) => { const paper = wizard.querySelector('div[is="shade-paper"]'); expect(paper?.getAttribute('data-elevation')).toBe('3'); }); }); }); 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 on next when viewTransition is enabled', async () => { const spy = mockStartViewTransition(); await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { viewTransition: true }), async ({ clickNext, getStepName }) => { spy.mockClear(); await clickNext(); expect(spy).toHaveBeenCalledTimes(1); expect(getStepName()).toBe('step2'); }); }); it('should call startViewTransition on prev when viewTransition is enabled', async () => { const spy = mockStartViewTransition(); await usingAsync(await renderWizard([Step1, Step2, Step3], undefined, { viewTransition: true }), async ({ clickNext, clickPrev, getStepName }) => { await clickNext(); spy.mockClear(); await clickPrev(); expect(spy).toHaveBeenCalledTimes(1); expect(getStepName()).toBe('step1'); }); }); it('should not call startViewTransition when viewTransition is not set', async () => { const spy = mockStartViewTransition(); await usingAsync(await renderWizard([Step1, Step2, Step3]), async ({ clickNext, getStepName }) => { spy.mockClear(); await clickNext(); expect(spy).not.toHaveBeenCalled(); expect(getStepName()).toBe('step2'); }); }); }); }); //# sourceMappingURL=index.spec.js.map