From 48789ebc28a2cb9d78d175585c4a7836c0d2a9fe Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 26 Aug 2026 22:06:41 +0200 Subject: [PATCH 01/29] Add the v2 component review as the decision record Record the full inventory of the v2 public surface with the per-component keep, drop, rename and rewrite decisions. This document is the plan of record for the ui v2 major version and outranks the js-toolkit migration port wherever the two disagree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R --- V2-COMPONENTS-REVIEW.md | 303 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 V2-COMPONENTS-REVIEW.md diff --git a/V2-COMPONENTS-REVIEW.md b/V2-COMPONENTS-REVIEW.md new file mode 100644 index 00000000..f89c6eb1 --- /dev/null +++ b/V2-COMPONENTS-REVIEW.md @@ -0,0 +1,303 @@ +# v2 component review + +Full inventory of the public surface of the `ui` packages, for the js-toolkit v4 / ui v2 major version. + +Legend: + +- `[deprecated]` — already marked `@deprecated` in v1 +- `[twig]` — Twig template only, no TypeScript class +- `[ts+twig]` — TypeScript class with a Twig template +- `[abstract]` — not a declarative component, only a base class to extend +- `[shopify]` / `[twicpics]` — third party integration variant + +## Review decisions (round 2) + +Answers to the round-1 comments, verified against source: + +- **Accordion family** (Accordion, AccordionItem) — checked against `Disclosure`/`DisclosureGroup`: clean supersession. `Disclosure` covers everything `AccordionItem` does (open/close, transitions, ARIA sync) plus independent self-registration and `Transition`/`ViewTransition` integration `AccordionItem` never got, and `DisclosureGroup`'s `multiple`/`collapsible` options already express "accordion" as one configuration (`multiple: false, collapsible: true`) rather than a separate component. **Drop**, superseded by `Disclosure` + `DisclosureGroup`. +- **AnchorNav family** — still relevant: it's a scrollspy/table-of-contents pattern (highlight nav link for the section in view), nothing else in the catalog covers it. **Keep.** +- **AnchorScrollTo** — **Rename to `ScrollTo`.** +- **Cursor** — **Keep, redesign for a more generic/customizable API.** +- **Frame family** (Frame, FrameAnchor, FrameForm, FrameLoader, FrameTarget, FrameTriggerLoader) — **Drop**, superseded by `Fetch`. +- **LargeText** — **Keep, redesign for more generic usage.** +- **LazyInclude** — checked against `Fetch` + `Action` + `InViewOnce`: composable in theory, but `Fetch`'s DOM update is `[id]`-selector matching (built for partial-page nav), not raw-HTML injection, so the fetched fragment would need an extra `id` wrapper to satisfy it. `LazyInclude` is ~80 lines and does `innerHTML = content` directly. **Keep** as the lightweight primitive, **renamed to `Defer`**. Naming survey against comparable prior art (Unpoly's `up-defer`, Remix's `defer()`/``, htmx's "Lazy Loading" trigger pattern, Turbo's `loading="lazy"`, the community `` element) — "defer" is the established web-platform term for "fetch and inject after initial load" (`', - ); - - expect(spy.mock.calls.flat()).toEqual(['old', 'inert', 'new']); - expect(container.innerHTML).toMatchInlineSnapshot( - `""`, - ); - spy.mockRestore(); - - container.remove(); - globalThis.document = oldDocument; - }); - - it('should not reevaluate existing ', - ); - - expect(spy.mock.calls.flat()).toEqual(['old', 'inert', 'new']); - spy.mockRestore(); - - container.remove(); - globalThis.document = oldDocument; - }); - - it('should use View Transition API if supported', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - - await mount(fetch); - - const updateDOMSpy = vi.spyOn(fetch, '__updateDOM'); - const transitionSpy = vi.fn((callback: () => void) => { - callback(); - return { - ready: Promise.resolve(), - finished: Promise.resolve(), - }; - }); - Object.defineProperty(document, 'startViewTransition', { - value: transitionSpy, - configurable: true, - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(transitionSpy).toHaveBeenCalled(); - expect(updateDOMSpy).toHaveBeenCalled(); - - // Clean up - delete (document as any).startViewTransition; - container.remove(); - }); - - it('should not use View Transition API if disabled', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com', dataOptionNoViewTransition: '' }); - const fetch = new Fetch(anchor); - - await mount(fetch); - - const updateDOMSpy = vi.spyOn(fetch, '__updateDOM'); - const transitionSpy = vi.fn((callback: () => void) => { - callback(); - return { - ready: Promise.resolve(), - finished: Promise.resolve(), - }; - }); - Object.defineProperty(document, 'startViewTransition', { - value: transitionSpy, - configurable: true, - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(transitionSpy).not.toHaveBeenCalled(); - expect(updateDOMSpy).toHaveBeenCalled(); - - // Clean up - delete (document as any).startViewTransition; - container.remove(); - }); - - it('should batch simultaneous updates into a single view transition', async () => { - const container = h('div', { id: 'container' }, [ - h('div', { id: 'test' }, ['old content']), - h('div', { id: 'other' }, ['old other']), - ]); - document.body.appendChild(container); - - const fetchA = new Fetch(h('a', { href: 'https://example.com' })); - const fetchB = new Fetch(h('a', { href: 'https://example.com' })); - await mount(fetchA, fetchB); - - const transitionSpy = vi.fn((callback: () => void | Promise) => { - callback(); - return { - ready: Promise.resolve(), - finished: Promise.resolve(), - }; - }); - Object.defineProperty(document, 'startViewTransition', { - value: transitionSpy, - configurable: true, - }); - - await Promise.all([ - fetchA.update(new URL('https://example.com'), {}, '
new content
'), - fetchB.update(new URL('https://example.com'), {}, '
new other
'), - ]); - - // The shared scheduler flushed both updates in ONE view transition. - expect(transitionSpy).toHaveBeenCalledTimes(1); - expect(document.getElementById('test')?.textContent).toBe('new content'); - expect(document.getElementById('other')?.textContent).toBe('new other'); - - // Clean up - delete (document as any).startViewTransition; - container.remove(); - }); - - it('should let a `dom-update` runner substitute the default transition runner', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - - await mount(fetch); - - const transitionSpy = vi.fn((callback: () => void) => { - callback(); - return { - ready: Promise.resolve(), - finished: Promise.resolve(), - }; - }); - Object.defineProperty(document, 'startViewTransition', { - value: transitionSpy, - configurable: true, - }); - - let contentBeforeApply: string | undefined; - let contentAfterApply: string | undefined; - fetch.$on('dom-update', (event: CustomEvent) => { - event.detail.wrap((apply: () => void) => { - contentBeforeApply = document.getElementById('test')?.textContent; - apply(); - contentAfterApply = document.getElementById('test')?.textContent; - }); - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(contentBeforeApply).toBe('old content'); - expect(contentAfterApply).toBe('new content'); - expect(transitionSpy).not.toHaveBeenCalled(); - - // Clean up - delete (document as any).startViewTransition; - container.remove(); - }); - - it('should let a `dom-update` transitioner run the update through its `update` method', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - - await mount(fetch); - - let contentAfterMutate: string | undefined; - const update = vi.fn((mutate: () => void) => { - mutate(); - contentAfterMutate = document.getElementById('test')?.textContent; - }); - fetch.$on('dom-update', (event: CustomEvent) => { - event.detail.wrap({ update }); - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(update).toHaveBeenCalledOnce(); - expect(update).toHaveBeenCalledWith(expect.any(Function)); - expect(contentAfterMutate).toBe('new content'); - expect(document.getElementById('test')?.textContent).toBe('new content'); - - container.remove(); - }); - - it('should keep the last `wrap` runner registered during dispatch', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - - await mount(fetch); - - const firstRunner = vi.fn((apply: () => void) => apply()); - const lastRunner = vi.fn((apply: () => void) => apply()); - fetch.$on('dom-update', (event: CustomEvent) => { - event.detail.wrap(firstRunner); - event.detail.wrap(lastRunner); - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(firstRunner).not.toHaveBeenCalled(); - expect(lastRunner).toHaveBeenCalledOnce(); - expect(document.getElementById('test')?.textContent).toBe('new content'); - - container.remove(); - }); - - it('should ignore and warn on `wrap` calls after the `dom-update` event dispatched', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const warnFn = vi.fn(); - Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn }); - - await mount(fetch); - - let wrap: (runner: (apply: () => void) => void) => void; - fetch.$on('dom-update', (event: CustomEvent) => { - wrap = event.detail.wrap; - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - // The default path ran since no runner was registered during dispatch. - expect(document.getElementById('test')?.textContent).toBe('new content'); - - const lateRunner = vi.fn(); - wrap(lateRunner); - - expect(lateRunner).not.toHaveBeenCalled(); - expect(warnFn).toHaveBeenCalledWith( - '`wrap` must be called synchronously while the `dom-update` event dispatches.', - ); - - container.remove(); - }); - - it('should apply the content and warn when a `wrap` runner rejects', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const warnFn = vi.fn(); - Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn }); - const fn = vi.fn(); - fetch.$on('fetch-update-after', () => fn()); - - await mount(fetch); - - const error = new Error('Runner failed'); - fetch.$on('dom-update', (event: CustomEvent) => { - event.detail.wrap(() => Promise.reject(error)); - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(document.getElementById('test')?.textContent).toBe('new content'); - expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error); - expect(fn).toHaveBeenCalled(); - - container.remove(); - }); - - it('should apply the content and warn when a `wrap` runner throws synchronously', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const warnFn = vi.fn(); - Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn }); - const fn = vi.fn(); - fetch.$on('fetch-update-after', () => fn()); - - await mount(fetch); - - const error = new Error('Runner failed'); - fetch.$on('dom-update', (event: CustomEvent) => { - event.detail.wrap(() => { - throw error; - }); - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(document.getElementById('test')?.textContent).toBe('new content'); - expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error); - expect(fn).toHaveBeenCalled(); - - container.remove(); - }); - - it('should not apply the content twice when a `wrap` runner rejects after applying', async () => { - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const warnFn = vi.fn(); - Object.defineProperty(fetch, '$warn', { configurable: true, get: () => warnFn }); - const updateDOMSpy = vi.spyOn(fetch, '__updateDOM'); - - await mount(fetch); - - const error = new Error('Runner failed'); - fetch.$on('dom-update', (event: CustomEvent) => { - event.detail.wrap((apply: () => void) => { - apply(); - return Promise.reject(error); - }); - }); - - await fetch.update(new URL('https://example.com'), {}, '
new content
'); - - expect(document.getElementById('test')?.textContent).toBe('new content'); - expect(updateDOMSpy).toHaveBeenCalledOnce(); - expect(warnFn).toHaveBeenCalledWith('The `dom-update` runner rejected.', error); - - container.remove(); - }); - }); - - describe('error handling', () => { - it('should emit error event on fetch failure', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const fn = vi.fn(); - fetch.$on('fetch-error', (event: CustomEvent) => fn(...event.detail)); - - const fetchError = new Error('Network error'); - const clientSpy = vi.fn(() => Promise.reject(fetchError)); - vi.spyOn(fetch, 'client', 'get').mockImplementation(() => clientSpy); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(fn).toHaveBeenCalledWith({ - error: fetchError, - instance: expect.any(Fetch), - url: expect.any(URL), - requestInit: expect.any(Object), - }); - }); - - it('should emit error event on response ko', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const fn = vi.fn(); - fetch.$on('fetch-error', (event: CustomEvent) => fn(...event.detail)); - - const fetchResponse = new Response('Network error', { status: 404 }); - const clientSpy = vi.fn(() => Promise.resolve(fetchResponse)); - vi.spyOn(fetch, 'client', 'get').mockImplementation(() => clientSpy); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(fn).toHaveBeenCalledWith({ - error: expect.any(Error), - instance: expect.any(Fetch), - url: expect.any(URL), - requestInit: expect.any(Object), - }); - }); - - it('should call error method on fetch failure', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const errorSpy = vi.spyOn(fetch, 'error'); - - const fetchError = new Error('Network error'); - const clientSpy = vi.fn(() => Promise.reject(fetchError)); - vi.spyOn(fetch, 'client', 'get').mockImplementation(() => clientSpy); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(errorSpy).toHaveBeenCalledOnce(); - }); - - it('should still emit after-fetch on error', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const fn = vi.fn(); - fetch.$on('fetch-after', (event: CustomEvent) => fn(...event.detail)); - - const fetchError = new Error('Network error'); - const clientSpy = vi.fn(() => Promise.reject(fetchError)); - vi.spyOn(fetch, 'client', 'get').mockImplementation(() => clientSpy); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(fn).toHaveBeenCalled(); - }); - - it('should emit fetch-abort on abort', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const fn = vi.fn(); - fetch.$on('fetch-abort', (event: CustomEvent) => fn(...event.detail)); - - const clientSpy = vi.fn( - (url, { signal }) => - new Promise((resolve, reject) => { - signal.addEventListener('abort', () => { - reject(new DOMException('Aborted', 'AbortError')); - }); - }), - ); - vi.spyOn(fetch, 'client', 'get').mockImplementation(() => clientSpy); - - await mount(fetch); - setTimeout(() => fetch.abort(), 1); - await fetch.fetch(new URL('https://example.com')); - - expect(fn).toHaveBeenCalled(); - }); - }); - - describe('events', () => { - it('should emit all expected events in order', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const eventLog: string[] = []; - - for (const event of Object.values(Fetch.FETCH_EVENTS)) { - fetch.$on(event as string, () => eventLog.push(event)); - } - - const clientSpy = vi.spyOn(fetch, 'client', 'get'); - clientSpy.mockImplementation(() => () => Promise.resolve(new Response('content'))); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - // `fetch()` does not await `update()`, and the scheduled view transition - // resolves in a later microtask: flush before asserting. - await wait(0); - - expect(eventLog).toContain('fetch-before'); - expect(eventLog).toContain('fetch-fetch'); - expect(eventLog).toContain('fetch-after'); - expect(eventLog).toContain('fetch-update-before'); - expect(eventLog).toContain('fetch-update'); - expect(eventLog).toContain('fetch-update-after'); - }); - - it('should emit bubbling events', async () => { - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new Fetch(anchor); - const fn = vi.fn(); - - document.body.appendChild(anchor); - document.body.addEventListener('fetch-before', fn); - - const clientSpy = vi.spyOn(fetch, 'client', 'get'); - clientSpy.mockImplementation(() => Promise.resolve(new Response('content'))); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(fn).toHaveBeenCalled(); - - document.body.removeEventListener('fetch-before', fn); - anchor.remove(); - }); - }); - - describe('header handling', () => { - it('should merge headers from option, requestInit, and input elements', async () => { - const headerInput = h('input', { - dataRef: 'headers[]', - dataName: 'x-custom', - value: 'custom-value', - }); - const otherInput = h('input', { - dataRef: 'headers[]', - value: 'other-value', - }); - const form = h( - 'form', - { - action: 'https://example.com', - method: 'post', - dataOptionHeaders: { 'x-option': 'option-value' }, - }, - [headerInput, otherInput], - ); - const fetch = new Fetch(form); - - await mount(fetch); - const requestInit = fetch.requestInit; - - expect(requestInit.headers['x-custom']).toBe('custom-value'); - expect(requestInit.headers['x-option']).toBe('option-value'); - expect(requestInit.headers['user-agent']).toContain('@studiometa/ui/Fetch'); - }); - }); -}); diff --git a/packages/tests/Fetch/FetchShopifyPartial.spec.ts b/packages/tests/Fetch/FetchShopifyPartial.spec.ts deleted file mode 100644 index fcb8b6d5..00000000 --- a/packages/tests/Fetch/FetchShopifyPartial.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { FetchShopifyPartial } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -const FAKE_UPDATE = { partials: [{ name: 'product-grid', html: '
updated
' }] }; - -const originalLoader = FetchShopifyPartial.__loadPartialsModule; - -/** - * Build a fake partials API and wire it into the static loader. - */ -function useFakePartials(overrides: Record = {}) { - const fakePartials = { - fetch: vi.fn().mockResolvedValue(FAKE_UPDATE), - apply: vi.fn(), - ...overrides, - }; - FetchShopifyPartial.__loadPartialsModule = async () => ({ partials: fakePartials }) as any; - return fakePartials; -} - -describe('The FetchShopifyPartial class', () => { - afterEach(() => { - FetchShopifyPartial.__loadPartialsModule = originalLoader; - vi.restoreAllMocks(); - }); - - it('should use Shopify partials when the `partials` option is set', async () => { - const fakePartials = useFakePartials(); - const anchor = h('a', { - href: 'https://example.com/collections/all', - dataOptionPartials: 'product-grid,product-count', - }); - const fetch = new FetchShopifyPartial(anchor); - - await mount(fetch); - anchor.dispatchEvent(new MouseEvent('click', { button: 0 })); - // Wait for the async fetch lifecycle to settle. - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(fakePartials.fetch).toHaveBeenCalledOnce(); - const args = fakePartials.fetch.mock.calls[0]; - expect(args.slice(0, 2)).toEqual(['product-grid', 'product-count']); - const options = args[args.length - 1]; - expect(options.url).toBe('https://example.com/collections/all'); - expect(options.signal).toBeInstanceOf(AbortSignal); - - expect(fakePartials.apply).toHaveBeenCalledWith(FAKE_UPDATE); - }); - - it('should fall back to base Fetch when no `partials` option is set', async () => { - const fakePartials = useFakePartials(); - const windowFetchSpy = vi.spyOn(window, 'fetch'); - windowFetchSpy.mockImplementation(() => Promise.resolve(new Response('
ok
'))); - - const anchor = h('a', { href: 'https://example.com' }); - const fetch = new FetchShopifyPartial(anchor); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(fakePartials.fetch).not.toHaveBeenCalled(); - expect(windowFetchSpy).toHaveBeenCalledOnce(); - }); - - it('should fall back to base Fetch when the module fails to resolve', async () => { - FetchShopifyPartial.__loadPartialsModule = async () => { - throw new Error('Cannot find module @shopify/partial-rendering'); - }; - const windowFetchSpy = vi.spyOn(window, 'fetch'); - windowFetchSpy.mockImplementation(() => - Promise.resolve(new Response('
new content
')), - ); - - const container = h('div', { id: 'container' }, [h('div', { id: 'test' }, ['old content'])]); - document.body.appendChild(container); - - const anchor = h('a', { - href: 'https://example.com', - dataOptionPartials: 'product-grid', - }); - const fetch = new FetchShopifyPartial(anchor); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - // Wait for the fire-and-forget update phase to settle. - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(windowFetchSpy).toHaveBeenCalledOnce(); - // The base id-based full-page swap must actually run on the fallback path. - expect(document.getElementById('test')?.textContent).toBe('new content'); - - container.remove(); - }); - - it('should fall back to base Fetch for a POST form (body cannot be carried)', async () => { - const fakePartials = useFakePartials(); - const windowFetchSpy = vi.spyOn(window, 'fetch'); - windowFetchSpy.mockImplementation(() => Promise.resolve(new Response('
ok
'))); - - const form = h('form', { - action: 'https://example.com/cart/add', - method: 'post', - dataOptionPartials: 'cart-items', - }); - const fetch = new FetchShopifyPartial(form); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com/cart/add')); - - expect(fakePartials.fetch).not.toHaveBeenCalled(); - expect(windowFetchSpy).toHaveBeenCalledOnce(); - }); - - it('should fall back to base Fetch when custom headers are configured', async () => { - const fakePartials = useFakePartials(); - const windowFetchSpy = vi.spyOn(window, 'fetch'); - windowFetchSpy.mockImplementation(() => Promise.resolve(new Response('
ok
'))); - - const anchor = h('a', { - href: 'https://example.com', - dataOptionPartials: 'product-grid', - dataOptionHeaders: { 'x-foo': 'bar' }, - }); - const fetch = new FetchShopifyPartial(anchor); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(fakePartials.fetch).not.toHaveBeenCalled(); - expect(windowFetchSpy).toHaveBeenCalledOnce(); - }); - - it('should fall back to base Fetch when a per-call request init carries unsupported options', async () => { - const fakePartials = useFakePartials(); - const windowFetchSpy = vi.spyOn(window, 'fetch'); - windowFetchSpy.mockImplementation(() => Promise.resolve(new Response('
ok
'))); - - const anchor = h('a', { - href: 'https://example.com', - dataOptionPartials: 'product-grid', - }); - const fetch = new FetchShopifyPartial(anchor); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com'), { credentials: 'include' }); - - expect(fakePartials.fetch).not.toHaveBeenCalled(); - expect(windowFetchSpy).toHaveBeenCalledOnce(); - }); - - it('should fall back to base Fetch when the module has no partials export', async () => { - FetchShopifyPartial.__loadPartialsModule = async () => ({}) as any; - const windowFetchSpy = vi.spyOn(window, 'fetch'); - windowFetchSpy.mockImplementation(() => Promise.resolve(new Response('
ok
'))); - - const anchor = h('a', { - href: 'https://example.com', - dataOptionPartials: 'product-grid', - }); - const fetch = new FetchShopifyPartial(anchor); - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(windowFetchSpy).toHaveBeenCalledOnce(); - }); - - it('should emit lifecycle events in order without emitting RESPONSE', async () => { - useFakePartials(); - const anchor = h('a', { - href: 'https://example.com', - dataOptionPartials: 'product-grid', - }); - const fetch = new FetchShopifyPartial(anchor); - const eventLog: string[] = []; - - for (const event of Object.values(FetchShopifyPartial.FETCH_EVENTS)) { - fetch.$on(event as string, () => eventLog.push(event as string)); - } - - await mount(fetch); - await fetch.fetch(new URL('https://example.com')); - - expect(eventLog).toEqual([ - FetchShopifyPartial.FETCH_EVENTS.BEFORE_FETCH, - FetchShopifyPartial.FETCH_EVENTS.FETCH, - FetchShopifyPartial.FETCH_EVENTS.AFTER_FETCH, - FetchShopifyPartial.FETCH_EVENTS.BEFORE_UPDATE, - FetchShopifyPartial.FETCH_EVENTS.UPDATE, - FetchShopifyPartial.FETCH_EVENTS.AFTER_UPDATE, - ]); - expect(eventLog).not.toContain(FetchShopifyPartial.FETCH_EVENTS.RESPONSE); - }); -}); diff --git a/packages/tests/Fetch/FetchShopifySection.spec.ts b/packages/tests/Fetch/FetchShopifySection.spec.ts deleted file mode 100644 index 2e06ec10..00000000 --- a/packages/tests/Fetch/FetchShopifySection.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { FetchShopifySection } from '@studiometa/ui'; -import { h, mount, wait } from '#test-utils'; - -/** - * Build a JSON `Response` shaped like the Shopify Section Rendering API output. - */ -function sectionsResponse(sections: Record) { - return new Response(JSON.stringify(sections), { - headers: { 'content-type': 'application/json' }, - }); -} - -describe('The FetchShopifySection class', () => { - afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - it('should have the correct config', () => { - expect(FetchShopifySection.config.name).toBe('FetchShopifySection'); - expect(FetchShopifySection.config.options.sections).toBe(String); - // The JSON unwrapping lives in `__parseResponse`, not a `response` option override, so the - // base text-response default is inherited untouched. - expect(FetchShopifySection.config.options.response.default).toBe('response.text()'); - }); - - it('should append the `sections` option to the request URL without touching the href', async () => { - const anchor = h('a', { - href: 'https://example.com/collections/all?sort_by=price', - dataOptionSections: 'product-grid,product-count', - }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - - // The element's href is left clean for the no-JS fallback… - expect(anchor.getAttribute('href')).toBe('https://example.com/collections/all?sort_by=price'); - // …while the request URL carries the sections parameter alongside existing params. - expect(fetch.url.searchParams.get('sort_by')).toBe('price'); - expect(fetch.url.searchParams.get('sections')).toBe('product-grid,product-count'); - }); - - it('should parse the comma-separated `sections` option, trimming whitespace', async () => { - const anchor = h('a', { - href: 'https://example.com/collections/all', - // Authored with incidental whitespace and a trailing comma. - dataOptionSections: ' product-grid , product-count , ', - }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - - expect(fetch.url.searchParams.get('sections')).toBe('product-grid,product-count'); - }); - - it('should unwrap the JSON response and swap each section by id', async () => { - document.body.innerHTML = '
old
'; - const spy = vi.spyOn(window, 'fetch'); - spy.mockResolvedValue(sectionsResponse({ price: '
new
' })); - - const anchor = h('a', { - href: 'https://example.com/products/foo', - dataOptionSections: 'price', - }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - await fetch.fetch(); - await wait(10); - - expect(spy).toHaveBeenCalledOnce(); - expect((spy.mock.calls[0][0] as URL).searchParams.get('sections')).toBe('price'); - expect(document.getElementById('price')?.textContent).toBe('new'); - }); - - it('should drop sections returned as null via `filter(Boolean)`', async () => { - document.body.innerHTML = '
old-a
old-b
'; - const spy = vi.spyOn(window, 'fetch'); - spy.mockResolvedValue(sectionsResponse({ a: '
new-a
', b: null })); - - const anchor = h('a', { href: 'https://example.com/x', dataOptionSections: 'a,b' }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - await fetch.fetch(); - await wait(10); - - expect(document.getElementById('a')?.textContent).toBe('new-a'); - // The null section is skipped, leaving its element untouched. - expect(document.getElementById('b')?.textContent).toBe('old-b'); - }); - - it('should degrade to the base text response when no sections are configured', async () => { - document.body.innerHTML = '
old
'; - const spy = vi.spyOn(window, 'fetch'); - // A plain HTML page, as returned when no `sections` parameter is sent. - spy.mockResolvedValue( - new Response('
new
', { headers: { 'content-type': 'text/html' } }), - ); - - const anchor = h('a', { href: 'https://example.com/products/foo' }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - await fetch.fetch(); - await wait(10); - - // No `sections` parameter is appended… - expect((spy.mock.calls[0][0] as URL).searchParams.has('sections')).toBe(false); - // …and the HTML is swapped in place via the inherited id-based behaviour, not rejected. - expect(document.getElementById('price')?.textContent).toBe('new'); - }); - - it('should honour a custom `response` option instead of unwrapping JSON', async () => { - document.body.innerHTML = '
old
'; - const spy = vi.spyOn(window, 'fetch'); - // The response is JSON, but the custom `response` option ignores it and returns fixed HTML. - spy.mockResolvedValue(sectionsResponse({ price: '
json
' })); - - const anchor = h('a', { - href: 'https://example.com/products/foo', - dataOptionSections: 'price', - dataOptionResponse: 'Promise.resolve(\'
custom
\')', - }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - await fetch.fetch(); - await wait(10); - - // The custom option wins over the default Section Rendering JSON unwrap. - expect(document.getElementById('price')?.textContent).toBe('custom'); - }); - - it('should re-append the `sections` parameter on popstate navigation', async () => { - const spy = vi.spyOn(window, 'fetch'); - spy.mockResolvedValue(sectionsResponse({ price: '
new
' })); - - const anchor = h('a', { - href: 'https://example.com/products/foo', - dataOptionSections: 'price', - dataOptionHistory: '', - }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - - // Simulate the inherited popstate handler replaying the clean, section-free location URL. - fetch.fetch(new URL('https://example.com/products/foo'), { - headers: { 'x-triggered-by': 'popstate' }, - }); - await wait(10); - - expect((spy.mock.calls[0][0] as URL).searchParams.get('sections')).toBe('price'); - }); - - it('should coerce a string URL passed to fetch() and append `sections`', async () => { - const spy = vi.spyOn(window, 'fetch'); - spy.mockResolvedValue(sectionsResponse({ price: '
new
' })); - - const anchor = h('a', { href: 'https://example.com/products/foo', dataOptionSections: 'price' }); - const fetch = new FetchShopifySection(anchor); - await mount(fetch); - - // A string argument exercises the `url instanceof URL ? … : new URL(url, …)` branch… - fetch.fetch('https://example.com/collections/all?sort_by=price'); - await wait(10); - - const requested = spy.mock.calls[0][0] as URL; - expect(requested).toBeInstanceOf(URL); - expect(requested.pathname).toBe('/collections/all'); - // …and the sections parameter is appended on top of the existing query. - expect(requested.searchParams.get('sort_by')).toBe('price'); - expect(requested.searchParams.get('sections')).toBe('price'); - }); - - it('should append `sections` onto a GET form’s derived query string', async () => { - const form = h('form', { - action: 'https://example.com/collections/all', - method: 'get', - dataOptionSections: 'main-collection-product-grid', - }); - form.append(h('input', { type: 'hidden', name: 'sort_by', value: 'price-ascending' })); - const fetch = new FetchShopifySection(form); - await mount(fetch); - - // The form field and the sections parameter coexist on the request URL… - expect(fetch.url.searchParams.get('sort_by')).toBe('price-ascending'); - expect(fetch.url.searchParams.get('sections')).toBe('main-collection-product-grid'); - // …while the form's own action stays clean for the no-JS fallback. - expect(form.getAttribute('action')).toBe('https://example.com/collections/all'); - }); - - it('should emit a `fetch-error` event on a malformed JSON section response', async () => { - const spy = vi.spyOn(window, 'fetch'); - // A non-JSON body: `response.json()` in `__parseResponse` rejects. - spy.mockResolvedValue(new Response('', { headers: { 'content-type': 'text/html' } })); - - const anchor = h('a', { href: 'https://example.com/products/foo', dataOptionSections: 'price' }); - const fetch = new FetchShopifySection(anchor); - - let error: Error | undefined; - fetch.$on(FetchShopifySection.FETCH_EVENTS.ERROR, (event) => { - error = (event as CustomEvent).detail[0].error; - }); - - await mount(fetch); - await fetch.fetch(); - await wait(10); - - // The rejection is caught by the base fetch lifecycle rather than left unhandled. - expect(error).toBeInstanceOf(Error); - }); - - it('should keep the `sections` parameter out of the history / update URL', async () => { - const spy = vi.spyOn(window, 'fetch'); - spy.mockResolvedValue(sectionsResponse({ price: '
new
' })); - - const anchor = h('a', { - href: 'https://example.com/products/foo?variant=42', - dataOptionSections: 'price', - dataOptionHistory: '', - }); - const fetch = new FetchShopifySection(anchor); - - let updateUrl: URL | undefined; - fetch.$on(FetchShopifySection.FETCH_EVENTS.BEFORE_UPDATE, (event) => { - updateUrl = (event as CustomEvent).detail[0].url; - }); - - await mount(fetch); - await fetch.fetch(); - await wait(10); - - // The request went out with the sections parameter… - expect((spy.mock.calls[0][0] as URL).searchParams.get('sections')).toBe('price'); - // …but the URL handed to update()/history is the clean, human-facing one. - expect(updateUrl?.searchParams.has('sections')).toBe(false); - expect(updateUrl?.searchParams.get('variant')).toBe('42'); - }); -}); diff --git a/packages/tests/Figure/Figure.spec.ts b/packages/tests/Figure/Figure.spec.ts deleted file mode 100644 index 4ead12dd..00000000 --- a/packages/tests/Figure/Figure.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { it, describe, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; -import { Figure } from '@studiometa/ui'; -import { - wait, - hConnected as h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, - mockImageLoad, - mockImageLoadError, - unmockImageLoad, -} from '#test-utils'; - -beforeAll(() => { - intersectionObserverBeforeAllCallback(); -}); - -beforeEach(() => { - mockImageLoad(); -}); - -afterEach(() => { - intersectionObserverAfterEachCallback(); - unmockImageLoad(); -}); - -describe('The Figure component', () => { - it('should load the original image lazily and terminate the instance', async () => { - const src = 'http://localhost/img.jpg'; - const img = h('img', { - dataRef: 'img', - src: 'data:image/svg+xml,', - dataSrc: src, - }); - const figure = h('figure', { dataOptionLazy: '' }, [img]); - - const instance = new Figure(figure); - const fn = vi.fn(); - instance.$on('terminated', fn); - expect(img.src).not.toBe(src); - mockIsIntersecting(figure, true); - await wait(100); - expect(img.src).toBe(src); - expect(fn).toHaveBeenCalledOnce(); - }); - - it('should warn and not terminate when the image fails to load', async () => { - unmockImageLoad(); - mockImageLoadError(); - - const src = 'http://localhost/broken.jpg'; - const img = h('img', { - dataRef: 'img', - src: 'data:image/svg+xml,', - dataSrc: src, - }); - const figure = h('figure', { dataOptionLazy: '' }, [img]); - - const instance = new Figure(figure); - const warnSpy = vi.spyOn(instance, '$warn', 'get'); - const terminated = vi.fn(); - instance.$on('terminated', terminated); - - mockIsIntersecting(figure, true); - await wait(100); - - expect(img.src).not.toBe(src); - expect(warnSpy).toHaveBeenCalledOnce(); - expect(terminated).not.toHaveBeenCalled(); - }); - - it('should warn if the `img` ref is misconfigured', async () => { - const div = h('div'); - const instance = new Figure(div); - const warnSpy = vi.spyOn(instance, '$warn', 'get'); - mockIsIntersecting(div, true); - await wait(10); - expect(warnSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/Figure/FigureShopify.spec.ts b/packages/tests/Figure/FigureShopify.spec.ts deleted file mode 100644 index 6c8ebb2d..00000000 --- a/packages/tests/Figure/FigureShopify.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { it, describe, vi, expect, beforeAll, afterEach } from 'vitest'; -import { FigureShopify } from '@studiometa/ui'; -import { - wait, - hConnected as h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, -} from '#test-utils'; - -beforeAll(() => { - intersectionObserverBeforeAllCallback(); -}); - -afterEach(() => { - intersectionObserverAfterEachCallback(); -}); - -async function getContext({ figureAttributes = {} } = {}) { - const img = h('img', { - dataRef: 'img', - src: 'data:image/svg+xml,', - dataSrc: 'https://localhost/image.jpg', - }); - const figure = h('figure', { dataOptionLazy: '', ...figureAttributes }, [img]); - - const widthSpy = vi.spyOn(img, 'offsetWidth', 'get'); - widthSpy.mockImplementation(() => 100); - const heightSpy = vi.spyOn(img, 'offsetHeight', 'get'); - heightSpy.mockImplementation(() => 100); - - const instance = new FigureShopify(figure); - mockIsIntersecting(figure, true); - await wait(100); - - return { - img, - figure, - instance, - setSize({ width, height }: { width?: number; height?: number } = {}) { - if (width) { - widthSpy.mockImplementation(() => width); - } - if (height) { - heightSpy.mockImplementation(() => height); - } - }, - }; -} - -describe('The FigureShopify component', () => { - it('should override the original image', async () => { - const { instance, setSize } = await getContext(); - expect(instance.original).toBe('https://localhost/image.jpg?width=100&height=100'); - setSize({ width: 200, height: 200 }); - expect(instance.original).toBe('https://localhost/image.jpg?width=200&height=200'); - }); - - it('should not override the original image when disabled', async () => { - const { instance } = await getContext(); - instance.$options.disable = true; - expect(instance.original).toBe('https://localhost/image.jpg'); - }); - - it('should add a crop parameter', async () => { - const { instance, setSize } = await getContext({ - figureAttributes: { dataOptionCrop: 'center' }, - }); - - expect(instance.original).toBe('https://localhost/image.jpg?width=100&height=100&crop=center'); - setSize({ width: 200, height: 200 }); - expect(instance.original).toBe('https://localhost/image.jpg?width=200&height=200&crop=center'); - }); -}); diff --git a/packages/tests/Figure/FigureTwicpics.spec.ts b/packages/tests/Figure/FigureTwicpics.spec.ts deleted file mode 100644 index dfc40304..00000000 --- a/packages/tests/Figure/FigureTwicpics.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { it, describe, vi, expect, beforeAll, afterEach, beforeEach } from 'vitest'; -import { FigureTwicpics } from '@studiometa/ui'; -import { - wait, - hConnected as h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, - unmockImageLoad, - mockImageLoad, - mockImageLoadError, - resizeWindow, -} from '#test-utils'; - -beforeAll(() => { - intersectionObserverBeforeAllCallback(); -}); - -beforeEach(() => { - mockImageLoad(); -}); - -afterEach(() => { - intersectionObserverAfterEachCallback(); - unmockImageLoad(); -}); - -async function getContext({ figureAttributes = {} } = {}) { - const img = h('img', { - dataRef: 'img', - src: 'data:image/svg+xml,', - dataSrc: 'https://localhost/image.jpg', - }); - const figure = h('figure', { dataOptionLazy: '', ...figureAttributes }, [img]); - - const widthSpy = vi.spyOn(img, 'offsetWidth', 'get'); - widthSpy.mockImplementation(() => 100); - const heightSpy = vi.spyOn(img, 'offsetHeight', 'get'); - heightSpy.mockImplementation(() => 100); - - const instance = new FigureTwicpics(figure); - mockIsIntersecting(figure, true); - await wait(100); - - return { - img, - figure, - instance, - setSize({ width, height }: { width?: number; height?: number } = {}) { - if (width) { - widthSpy.mockImplementation(() => width); - } - if (height) { - heightSpy.mockImplementation(() => height); - } - }, - }; -} - -describe('The FigureTwicpics component', () => { - it('should override the original image', async () => { - const { instance, setSize } = await getContext(); - expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); - setSize({ width: 200, height: 200 }); - expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); - }); - - it('should update the image source on resize', async () => { - const { instance, setSize, img } = await getContext(); - expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); - expect(img.src).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); - setSize({ width: 200, height: 200 }); - await resizeWindow(); - expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); - expect(img.src).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); - }); - - it('should set the domain and path', async () => { - const { instance } = await getContext({ - figureAttributes: { - dataOptionDomain: 'twic.pics', - dataOptionPath: 'path', - } - }); - - expect(instance.$options.domain).toBe('twic.pics'); - expect(instance.domain).toBe('twic.pics'); - expect(instance.$options.path).toBe('path'); - expect(instance.path).toBe('path'); - expect(instance.original).toBe('https://twic.pics/path/image.jpg?twic=v1/cover=100x100'); - - instance.$el.removeAttribute('data-option-domain'); - - expect(instance.$options.domain).toBe(''); - expect(instance.domain).toBe('localhost'); - expect(instance.original).toBe('https://localhost/path/image.jpg?twic=v1/cover=100x100'); - }); - - it('should warn and keep the source when the image fails to load on resize', async () => { - const { instance, setSize, img } = await getContext(); - const src = img.src; - const warnSpy = vi.spyOn(instance, '$warn', 'get'); - - unmockImageLoad(); - mockImageLoadError(); - setSize({ width: 200, height: 200 }); - await resizeWindow(); - - expect(warnSpy).toHaveBeenCalledOnce(); - expect(img.src).toBe(src); - }); - - it('should take the device pixel ratio into account', async () => { - const { instance } = await getContext(); - - window.devicePixelRatio = 2; - - expect(instance.devicePixelRatio).toBe(2); - expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=200x200'); - - instance.$el.setAttribute('data-option-no-dpr', ''); - - expect(instance.devicePixelRatio).toBe(1); - expect(instance.original).toBe('https://localhost/image.jpg?twic=v1/cover=100x100'); - }); -}); diff --git a/packages/tests/FigureVideo/FigureVideo.spec.ts b/packages/tests/FigureVideo/FigureVideo.spec.ts deleted file mode 100644 index 85056351..00000000 --- a/packages/tests/FigureVideo/FigureVideo.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { it, describe, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; -import { FigureVideo } from '@studiometa/ui'; -import { - wait, - hConnected as h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, - mockImageLoad, - mockImageLoadError, - unmockImageLoad, - mockVideoLoad, - unmockVideoLoad, -} from '#test-utils'; - -beforeAll(() => { - intersectionObserverBeforeAllCallback(); -}); - -beforeEach(() => { - mockImageLoad(); - mockVideoLoad(); -}); - -afterEach(() => { - intersectionObserverAfterEachCallback(); - unmockImageLoad(); - unmockVideoLoad(); -}); - -function getContext({ poster = 'http://localhost/poster.jpg' } = {}) { - const source = h('source', { dataSrc: 'http://localhost/video.mp4' }); - const video = h('video', poster ? { dataRef: 'video', dataPoster: poster } : { dataRef: 'video' }, [ - source, - ]); - const figure = h('figure', { dataOptionLazy: '' }, [video]); - - return { source, video, figure, instance: new FigureVideo(figure) }; -} - -describe('The FigureVideo component', () => { - it('should lazily load the poster and sources then emit load', async () => { - const { video, source, figure, instance } = getContext(); - const load = vi.fn(); - instance.$on('load', load); - - mockIsIntersecting(figure, true); - await wait(100); - - expect(video.poster).toBe('http://localhost/poster.jpg'); - expect(source.src).toBe('http://localhost/video.mp4'); - expect(load).toHaveBeenCalledOnce(); - }); - - it('should resolve the poster silently when no poster is defined', async () => { - const { video, figure, instance } = getContext({ poster: '' }); - const load = vi.fn(); - instance.$on('load', load); - - mockIsIntersecting(figure, true); - await wait(100); - - expect(video.poster).toBeFalsy(); - expect(load).toHaveBeenCalledOnce(); - }); - - it('should warn when the poster fails to load', async () => { - unmockImageLoad(); - mockImageLoadError(); - - const { figure, instance } = getContext(); - const warnSpy = vi.spyOn(instance, '$warn', 'get'); - - mockIsIntersecting(figure, true); - await wait(100); - - expect(warnSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/FigureVideo/FigureVideoTwicpics.spec.ts b/packages/tests/FigureVideo/FigureVideoTwicpics.spec.ts deleted file mode 100644 index f92a19ab..00000000 --- a/packages/tests/FigureVideo/FigureVideoTwicpics.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { it, describe, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; -import { FigureVideoTwicpics } from '@studiometa/ui'; -import { - wait, - hConnected as h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, - mockImageLoad, - mockImageLoadError, - unmockImageLoad, - mockVideoLoad, - unmockVideoLoad, - resizeWindow, -} from '#test-utils'; - -beforeAll(() => { - intersectionObserverBeforeAllCallback(); -}); - -beforeEach(() => { - mockImageLoad(); - mockVideoLoad(); -}); - -afterEach(() => { - intersectionObserverAfterEachCallback(); - unmockImageLoad(); - unmockVideoLoad(); -}); - -async function getContext({ figureAttributes = {}, poster = 'poster.jpg' } = {}) { - const source = h('source', { dataSrc: 'https://localhost/video.mp4' }); - const video = h('video', { dataRef: 'video', dataPoster: poster }, [source]); - const figure = h('figure', { dataOptionLazy: '', ...figureAttributes }, [video]); - - const widthSpy = vi.spyOn(video, 'offsetWidth', 'get').mockImplementation(() => 100); - const heightSpy = vi.spyOn(video, 'offsetHeight', 'get').mockImplementation(() => 100); - - const instance = new FigureVideoTwicpics(figure); - mockIsIntersecting(figure, true); - await wait(100); - - return { - source, - video, - figure, - instance, - setSize({ width, height }: { width?: number; height?: number } = {}) { - if (width) { - widthSpy.mockImplementation(() => width); - } - if (height) { - heightSpy.mockImplementation(() => height); - } - }, - }; -} - -describe('The FigureVideoTwicpics component', () => { - it('should format the sources with the normalized size', async () => { - const { source } = await getContext(); - expect(source.src).toBe('https://localhost/video.mp4?twic=v1/cover=100x100'); - }); - - it('should format the poster with the normalized size', async () => { - const { video } = await getContext(); - expect(video.poster).toBe('https://localhost/poster.jpg?twic=v1/cover=100x100'); - }); - - it('should use the domain and path options', async () => { - const { source } = await getContext({ - figureAttributes: { - dataOptionDomain: 'twic.pics', - dataOptionPath: 'path', - }, - }); - expect(source.src).toBe('https://twic.pics/path/video.mp4?twic=v1/cover=100x100'); - }); - - it('should reformat the sources on resize', async () => { - const { source, setSize } = await getContext(); - expect(source.src).toContain('cover=100x100'); - setSize({ width: 200, height: 200 }); - await resizeWindow(); - expect(source.src).toContain('cover=200x200'); - }); - - it('should warn when the poster fails to load', async () => { - unmockImageLoad(); - mockImageLoadError(); - - const { instance } = await getContext(); - const warnSpy = vi.spyOn(instance, '$warn', 'get'); - - // Reload to trigger a fresh poster load with the error mock in place. - await instance.loadPoster(); - - expect(warnSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/Frame/AbstractFrameTrigger.spec.ts b/packages/tests/Frame/AbstractFrameTrigger.spec.ts deleted file mode 100644 index da602db1..00000000 --- a/packages/tests/Frame/AbstractFrameTrigger.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { getInstanceFromElement } from '@studiometa/js-toolkit'; -import { AbstractFrameTrigger, FrameTriggerLoader } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The AbstractFrameTrigger class', () => { - it('should have an `url` getter', async () => { - const triggerA = new AbstractFrameTrigger(h('a', { href: 'https://localhost/foo' })); - expect(triggerA.url.toString()).toBe('https://localhost/foo'); - const triggerB = new AbstractFrameTrigger(h('form', { action: 'https://localhost/foo' })); - expect(triggerB.url.toString()).toBe('https://localhost/foo'); - }); - - it('should emit a `frame-trigger` event when triggered', () => { - const trigger = new AbstractFrameTrigger(h('a', { href: 'http://localhost' })); - const fn = vi.fn(); - trigger.$on('frame-trigger', (event: CustomEvent) => fn(...event.detail)); - trigger.trigger(); - expect(fn).toHaveBeenCalledOnce(); - expect(fn.mock.lastCall).toEqual([ - new URL('http://localhost/'), - { - headers: {}, - trigger, - }, - ]); - }); - - it('should have a `requestInit` option', () => { - const dataOptionRequestInit = { method: 'POST' }; - const trigger = new AbstractFrameTrigger( - h('a', { href: 'http://localhost', dataOptionRequestInit }), - ); - expect(trigger.requestInit.method).toEqual(dataOptionRequestInit.method); - }); - - it('should have a `headers` option', () => { - const dataOptionHeaders = { Accept: 'text/*' }; - const trigger = new AbstractFrameTrigger( - h('a', { href: 'http://localhost', dataOptionHeaders }), - ); - expect(trigger.requestInit.headers).toEqual(dataOptionHeaders); - }); - - it('should trigger its FrameLoader child components', async () => { - const loader = h('div', { dataComponent: 'FrameTriggerLoader' }); - const div = h('div', [loader]); - const frameTrigger = new AbstractFrameTrigger(div); - await mount(frameTrigger); - - const frameTriggerLoader = getInstanceFromElement(loader, FrameTriggerLoader); - const enterSpy = vi.spyOn(frameTriggerLoader, 'enter'); - const leaveSpy = vi.spyOn(frameTriggerLoader, 'leave'); - frameTrigger.$emit('frame-fetch-before'); - expect(enterSpy).toHaveBeenCalledOnce(); - expect(leaveSpy).not.toHaveBeenCalledOnce(); - frameTrigger.$emit('frame-fetch-after'); - expect(enterSpy).toHaveBeenCalledOnce(); - expect(leaveSpy).toHaveBeenCalledOnce(); - }); - - it('should not fail if FrameTriggerLoader componens are not present', async () => { - class Foo extends AbstractFrameTrigger { - static config = { - name: 'Foo', - components: {}, - }; - } - - const form = h('form'); - const frameForm = new Foo(form); - expect(() => frameForm.onFrameFetchBefore()).not.toThrow(); - expect(() => frameForm.onFrameFetchAfter()).not.toThrow(); - }); -}); diff --git a/packages/tests/Frame/Frame.spec.ts b/packages/tests/Frame/Frame.spec.ts deleted file mode 100644 index 6ebd7e1e..00000000 --- a/packages/tests/Frame/Frame.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { Base, getInstanceFromElement } from '@studiometa/js-toolkit'; -import { Frame, FrameAnchor, FrameLoader } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The Frame class', () => { - it('should have an `id` getter', async () => { - const div = h('div', { id: 'foo' }); - const frameTarget = new Frame(div); - await mount(frameTarget); - expect(frameTarget.id).toBe(div.id); - }); - - it('should have a `client` getter', async () => { - const frameTarget = new Frame(h('div')); - const spy = vi.spyOn(window, 'fetch'); - spy.mockImplementation(() => Promise.resolve(new Response('hi'))); - - await frameTarget.client('#'); - expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith('#'); - spy.mockRestore(); - }); - - it('should have a `requestInit` getter', async () => { - const init = { method: 'post' }; - const headers = { 'x-foo': 'bar' }; - const div = h('div', { id: 'foo', dataOptionRequestInit: init, dataOptionHeaders: headers }); - const frameTarget = new Frame(div); - await mount(frameTarget); - expect(frameTarget.requestInit).toEqual({ - method: 'post', - headers: { - accept: 'text/*', - 'user-agent': `${navigator.userAgent} @studiometa/ui/Frame`, - 'x-requested-by': '@studiometa/ui/Frame', - 'x-foo': 'bar', - }, - }); - }); - - it('should be able to get its direct children', async () => { - const nestedAnchor = h('a', { dataComponent: 'FrameAnchor', id: 'nested-anchor' }); - const nestedFrame = h('div', { dataComponent: 'Frame', id: 'nested-frame' }, [nestedAnchor]); - const anchor = h('a', { dataComponent: 'FrameAnchor', id: 'anchor' }); - const frame = h('div', { dataComponent: 'Frame', id: 'frame' }, [anchor, nestedFrame]); - const div = h('div', [frame]); - - class App extends Base { - static config = { - name: 'App', - components: { - Frame, - }, - }; - } - - await mount(new App(div)); - expect(getInstanceFromElement(frame, Frame).getDirectChildren('FrameAnchor')).toHaveLength(1); - }); - - it('should listen to the frame-trigger events', async () => { - const anchor = h('a', { dataComponent: 'FrameAnchor', href: '#' }); - const form = h('form', { dataComponent: 'FrameForm', href: '#' }); - const div = h('div', { id: 'frame' }, [anchor, form]); - const frame = new Frame(div); - const spy = vi.spyOn(frame, 'fetch'); - spy.mockImplementation(() => Promise.resolve()); - - await mount(frame); - anchor.dispatchEvent(new MouseEvent('click')); - expect(spy).toHaveBeenCalledOnce(); - form.dispatchEvent(new SubmitEvent('submit')); - expect(spy).toHaveBeenCalledTimes(2); - }); - - it('should listen to the window popstate events', async () => { - const frame = new Frame(h('div')); - const spy = vi.spyOn(frame, 'fetch'); - spy.mockImplementation(() => Promise.resolve()); - await mount(frame); - - window.dispatchEvent(new PopStateEvent('popstate')); - expect(spy).toHaveBeenCalledOnce(); - expect(spy.mock.lastCall).toEqual([ - new URL(window.location.href), - { - headers: { - [frame.headerNames.X_TRIGGERED_BY]: 'popstate', - }, - }, - ]); - }); - - it('should trigger its FrameLoader child components', async () => { - const loader = h('div', { dataComponent: 'FrameLoader' }); - const div = h('div', [loader]); - const frame = new Frame(div); - await mount(frame); - - const frameLoader = getInstanceFromElement(loader, FrameLoader); - const enterSpy = vi.spyOn(frameLoader, 'enter'); - const leaveSpy = vi.spyOn(frameLoader, 'leave'); - frame.$emit('frame-fetch-before'); - expect(enterSpy).toHaveBeenCalledOnce(); - expect(leaveSpy).not.toHaveBeenCalledOnce(); - frame.$emit('frame-fetch-after'); - expect(enterSpy).toHaveBeenCalledOnce(); - expect(leaveSpy).toHaveBeenCalledOnce(); - }); - - it('should trigger content update on its FrameTarget child components', async () => { - const target = h('div', { dataComponent: 'FrameTarget', id: 'foo' }, ['hello world']); - const div = h('div', { id: 'frame' }, [target]); - const frame = new Frame(div); - await mount(frame); - - await frame.content( - new URL(`http://localhost/?foo=bar`), - {}, - '
Lorem ipsum
', - ); - expect(target.textContent).toBe('Lorem ipsum'); - }); - - it('should have an `history` option', async () => { - const div = h('div', { id: 'frame', dataOptionHistory: true }); - const frame = new Frame(div); - const historySpy = vi.spyOn(window.history, 'pushState'); - historySpy.mockImplementation(() => undefined); - - await mount(frame); - await frame.content( - new URL(`http://localhost/?foo=bar`), - {}, - 'foo
Lorem ipsum
', - ); - - expect(historySpy).toHaveBeenCalledOnce(); - expect(historySpy).toHaveBeenLastCalledWith({}, '', '/?foo=bar'); - expect(document.title).toBe('foo'); - - await frame.content( - new URL(`http://localhost/`), - { - headers: { - [frame.headerNames.X_TRIGGERED_BY]: 'popstate', - }, - }, - 'bar
Lorem ipsum
', - ); - expect(historySpy).toHaveBeenCalledOnce(); - expect(document.title).toBe('bar'); - - historySpy.mockRestore(); - }); - - it('should fetch content', async () => { - const div = h('div', { id: 'frame' }); - const frame = new Frame(div); - const clientSpy = vi.spyOn(frame, 'client'); - clientSpy.mockImplementation(() => Promise.resolve(new Response('hello world'))); - const contentSpy = vi.spyOn(frame, 'content'); - contentSpy.mockImplementation(() => Promise.resolve()); - await mount(frame); - - const url = new URL('https://localhost'); - await frame.fetch(url); - expect(contentSpy).toHaveBeenCalledOnce(); - expect(contentSpy).toHaveBeenLastCalledWith( - url, - { - ...frame.requestInit, - signal: frame.abortController.signal, - }, - 'hello world', - ); - }); - - it('should trigger a root update', async () => { - const div = h('div', { id: 'frame' }); - const frame = new Frame(div); - await mount(frame); - - const updateSpy = vi.spyOn(frame.$root, '$update'); - - await frame.content(new URL('http://localhost/'), {}, '
new content
'); - - expect(updateSpy).toHaveBeenCalledOnce(); - updateSpy.mockRestore(); - }); - - it('should handle errors', async () => { - const div = h('div', { id: 'frame' }); - const frame = new Frame(div); - await mount(frame); - - const errorSpy = vi.spyOn(frame, 'error'); - - const fetchError = new Error('Fetch failed'); - const clientSpy = vi.spyOn(frame, 'client'); - clientSpy.mockImplementation(() => Promise.reject(fetchError)); - - const url = new URL('https://localhost'); - const fn = vi.fn(); - frame.$on('frame-error', (event: CustomEvent) => fn(...event.detail)); - - await frame.fetch(url); - - const params = [ - url, - { ...frame.requestInit, signal: frame.abortController.signal }, - fetchError, - ]; - expect(errorSpy).toHaveBeenCalledOnce(); - expect(errorSpy.mock.lastCall).toEqual(params); - expect(fn).toHaveBeenCalledOnce(); - expect(fn.mock.lastCall).toEqual(params); - - errorSpy.mockRestore(); - clientSpy.mockRestore(); - }); - - it('should dispatch its event to the source trigger instance', async () => { - const anchor = h('a', { dataComponent: 'FrameAnchor' }); - const div = h('div', { id: 'frame' }, [anchor]); - const frame = new Frame(div); - await mount(frame); - - const frameAnchor = getInstanceFromElement(anchor, FrameAnchor); - for (const event of Frame.config.emits) { - const fn = vi.fn(); - frameAnchor.$on(event, (event: CustomEvent) => fn(...event.detail)); - frame.emitSync(event, frameAnchor, 'foo'); - expect(fn).toHaveBeenCalledWith('foo'); - } - }); -}); diff --git a/packages/tests/Frame/FrameAnchor.spec.ts b/packages/tests/Frame/FrameAnchor.spec.ts deleted file mode 100644 index c900cb95..00000000 --- a/packages/tests/Frame/FrameAnchor.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FrameAnchor } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The FrameAnchor class', () => { - it('should trigger when the link is clicked', async () => { - const anchor = h('a', { href: '/#' }); - const frameAnchor = new FrameAnchor(anchor); - const fn = vi.fn(); - frameAnchor.$on('frame-trigger', (event: CustomEvent) => fn(event)); - await mount(frameAnchor); - const event = new MouseEvent('click'); - const spy = vi.spyOn(event, 'preventDefault'); - anchor.dispatchEvent(event); - expect(fn).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledOnce(); - }); - - it('should not trigger when the link target is `_blank`', async () => { - const anchor = h('a', { href: '/#', target: '_blank' }); - const frameAnchor = new FrameAnchor(anchor); - const fn = vi.fn(); - frameAnchor.$on('frame-trigger', (event: CustomEvent) => fn(event)); - await mount(frameAnchor); - const event = new MouseEvent('click'); - const spy = vi.spyOn(event, 'preventDefault'); - anchor.dispatchEvent(event); - expect(fn).not.toHaveBeenCalled(); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should not trigger when a controller key is pressed while clicking', async () => { - const anchor = h('a', { href: '/#' }); - const frameAnchor = new FrameAnchor(anchor); - const fn = vi.fn(); - frameAnchor.$on('frame-trigger', (event: CustomEvent) => fn(event)); - await mount(frameAnchor); - anchor.dispatchEvent(new MouseEvent('click', { metaKey: true })); - anchor.dispatchEvent(new MouseEvent('click', { ctrlKey: true })); - anchor.dispatchEvent(new MouseEvent('click', { altKey: true })); - anchor.dispatchEvent(new MouseEvent('click', { shiftKey: true })); - expect(fn).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/tests/Frame/FrameForm.spec.ts b/packages/tests/Frame/FrameForm.spec.ts deleted file mode 100644 index b3586016..00000000 --- a/packages/tests/Frame/FrameForm.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FrameForm } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The FrameForm class', () => { - it('should have an `method` getter', async () => { - const formA = new FrameForm(h('form')); - expect(formA.method).toBe('get'); - const formB = new FrameForm(h('form', { method: 'POST' })); - expect(formB.method).toBe('post'); - }); - - it('should add form data as url search parameters if the method is get', () => { - const input = h('input', { type: 'text', name: 'foo', value: 'bar' }); - const form = h('form', { action: 'http://localhost/' }, [input]); - const frameForm = new FrameForm(form); - expect(frameForm.url.toString()).toBe('http://localhost/?foo=bar'); - form.method = 'POST'; - expect(frameForm.url.toString()).toBe('http://localhost/'); - }); - - it('should add form data as body if method is post', async () => { - const input = h('input', { type: 'text', name: 'foo', value: 'bar' }); - const form = h('form', { action: 'http://localhost/', method: 'POST' }, [input]); - const frameForm = new FrameForm(form); - await mount(frameForm); - expect(frameForm.requestInit.method).toBe('post'); - expect(frameForm.requestInit.body).toEqual(new FormData(form)); - form.method = 'GET'; - expect(frameForm.requestInit.method).toBeUndefined(); - expect(frameForm.requestInit.body).toBeUndefined(); - }); - - it('should add headers from the headers[] refs', async () => { - const input = h('input', { - type: 'hidden', - dataRef: 'headers[]', - dataName: 'x-custom-header', - value: 'bar', - }); - const form = h('form', { action: 'http://localhost/', method: 'POST' }, [input]); - const frameForm = new FrameForm(form); - await mount(frameForm); - expect(frameForm.requestInit.headers).toEqual({ 'x-custom-header': 'bar' }); - }); - - it('should trigger when the form is submitted in the same target', async () => { - const form = h('form', { action: 'http://localhost/' }); - const frameForm = new FrameForm(form); - const fn = vi.fn(); - frameForm.$on('frame-trigger', (event: CustomEvent) => fn(event)); - await mount(frameForm); - const event = new SubmitEvent('submit'); - const spy = vi.spyOn(event, 'preventDefault'); - form.dispatchEvent(event); - expect(fn).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledOnce(); - form.target = '_blank'; - form.dispatchEvent(event); - expect(fn).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledOnce(); - }); - - it('should not fail if headers[] refs are not present', async () => { - class Foo extends FrameForm { - static config = { - name: 'Foo', - refs: [], - }; - } - - const form = h('form'); - const frameForm = new Foo(form); - expect(() => frameForm.requestInit).not.toThrow(); - }); -}); diff --git a/packages/tests/Frame/FrameLoader.spec.ts b/packages/tests/Frame/FrameLoader.spec.ts deleted file mode 100644 index 76442eee..00000000 --- a/packages/tests/Frame/FrameLoader.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { FrameLoader } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The FrameLoader class', () => { - it('should always have the `...Keep` options active', async () => { - const div = h('div'); - const frameLoader = new FrameLoader(div); - await mount(frameLoader); - expect(frameLoader.$options.enterKeep).toBe(true); - expect(frameLoader.$options.leaveKeep).toBe(true); - div.setAttribute('data-option-no-enter-keep', ''); - div.setAttribute('data-option-no-leave-keep', ''); - expect(frameLoader.$options.enterKeep).toBe(true); - expect(frameLoader.$options.leaveKeep).toBe(true); - }); -}); diff --git a/packages/tests/Frame/FrameTarget.spec.ts b/packages/tests/Frame/FrameTarget.spec.ts deleted file mode 100644 index 364e6871..00000000 --- a/packages/tests/Frame/FrameTarget.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FrameTarget } from '@studiometa/ui'; -import { Window } from 'happy-dom'; -import { h, mount } from '#test-utils'; - -describe('The FrameTarget class', () => { - it('should have an `id` getter', async () => { - const div = h('div', { id: 'foo' }); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - expect(frameTarget.id).toBe(div.id); - }); - - it('should be able to append or prepend content', async () => { - const text = 'Hello world\n'; - const div = h('div', { id: 'foo', dataOptionMode: 'append' }, [text]); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - await frameTarget.updateContent( - h('div', { id: 'foo', dataOptionMode: 'append' }, ['Lorem ipsum\n']), - ); - expect(div.textContent).toBe('Hello world\nLorem ipsum\n'); - - div.setAttribute('data-option-mode', 'prepend'); - await frameTarget.updateContent( - h('div', { id: 'foo', dataOptionMode: 'append' }, ['Lorem ipsum\n']), - ); - expect(div.textContent).toBe('Lorem ipsum\nHello world\nLorem ipsum\n'); - }); - - it('should be able to replace content by default', async () => { - const textA = 'Hello world\n'; - const textB = 'Hello world\n'; - const div = h('div', { id: 'foo' }, [textA]); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - await frameTarget.updateContent(h('div', { id: 'foo' }, [textB])); - expect(div.textContent).toBe(textB); - - await frameTarget.updateContent(h('div', { id: 'foo' }, [textA])); - expect(div.textContent).toBe(textA); - }); - - it('should do nothing if the given content is null', async () => { - const text = 'Hello world\n'; - const div = h('div', { id: 'foo' }, [text]); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - const spy = vi.spyOn(frameTarget, 'leave'); - - await frameTarget.updateContent(null); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should be able to morph content', async () => { - const div = h('div', { id: 'foo', dataOptionMode: 'morph' }, [ - h('p', {}, ['Original content']), - h('span', { class: 'keep' }, ['Keep this']), - ]); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - const newContent = h('div', { id: 'foo', dataOptionMode: 'morph' }, [ - h('p', {}, ['Updated content']), - h('span', { class: 'keep' }, ['Keep this']), - h('div', {}, ['New element']), - ]); - - await frameTarget.updateContent(newContent); - - expect(div.querySelector('p')?.textContent).toBe('Updated content'); - expect(div.querySelector('span.keep')?.textContent).toBe('Keep this'); - expect(div.querySelector('div')?.textContent).toBe('New element'); - }); - - it('should use replaceChildren for replace mode', async () => { - const div = h('div', { id: 'foo', dataOptionMode: 'replace' }, [ - h('p', { id: 'original' }, ['Original content']), - ]); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - const spy = vi.spyOn(div, 'replaceChildren'); - - const newContent = h('p', { id: 'new' }, ['New content']); - const newDiv = h('div', { id: 'foo', dataOptionMode: 'replace' }, [newContent]); - - await frameTarget.updateContent(newDiv); - - expect(spy).toHaveBeenCalledWith(newContent); - expect(div.querySelector('#new')?.textContent).toBe('New content'); - expect(div.querySelector('#original')).toBeNull(); - }); - - it('should replace script element with inline content', async () => { - const { document } = new Window({ - settings: { - enableJavaScriptEvaluation: true, - suppressInsecureJavaScriptEnvironmentWarning: true, - }, - }); - const div = h('div', { id: 'foo' }, ['Hello world']); - // @ts-expect-error HTMLElement is Node. - document.body.appendChild(div); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - await frameTarget.updateContent( - h('div', { id: 'foo' }, [ - h('script', { type: 'text/javascript' }, [ - 'document.querySelector("#foo")?.classList.add("foo")', - ]), - ]), - ); - - expect(div.classList.contains('foo')).toBe(true); - }); - - it('should replace script element with src', async () => { - const { document } = new Window({ - settings: { - enableJavaScriptEvaluation: true, - suppressInsecureJavaScriptEnvironmentWarning: true, - }, - }); - const div = h('div', { id: 'foo' }, ['Hello world']); - // @ts-expect-error HTMLElement is Node. - document.body.appendChild(div); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - await frameTarget.updateContent( - h('div', { id: 'foo' }, [ - h('script', { - type: 'text/javascript', - src: 'data:text/javascript,document.querySelector("#foo")?.classList.add("foo")', - }), - ]), - ); - - expect(div.classList.contains('foo')).toBe(true); - }); - - it('should append or prepend script element', async () => { - const spy = vi.spyOn(console, 'log'); - spy.mockImplementation(() => {}); - const { document } = new Window({ - console, - settings: { - enableJavaScriptEvaluation: true, - suppressInsecureJavaScriptEnvironmentWarning: true, - }, - }); - const div = h('div', { id: 'foo', dataOptionMode: 'append' }, ['Hello world']); - // @ts-expect-error HTMLElement is Node. - document.body.appendChild(div); - const frameTarget = new FrameTarget(div); - await mount(frameTarget); - - await frameTarget.updateContent( - h('div', { id: 'foo' }, [h('script', { type: 'text/javascript' }, ['console.log("one")'])]), - ); - - expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith('one'); - spy.mockClear() - - await frameTarget.updateContent( - h('div', { id: 'foo' }, [h('script', { type: 'text/javascript' }, ['console.log("two")'])]), - ); - expect(new Set(spy.mock.calls.flat())).toMatchInlineSnapshot(` - Set { - "two", - } - `); - spy.mockRestore() - }); -}); diff --git a/packages/tests/Hoverable/Hoverable.spec.ts b/packages/tests/Hoverable/Hoverable.spec.ts deleted file mode 100644 index 1ffa509d..00000000 --- a/packages/tests/Hoverable/Hoverable.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { PointerServiceProps } from '@studiometa/js-toolkit'; -import { Hoverable } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -function pointerProgress(x: number, y: number) { - return { - progress: { x, y }, - } as PointerServiceProps; -} - -describe('The Hoverable component', () => { - it('should have target and parent getters', async () => { - const target = h('div', { dataRef: 'target' }); - const div = h('div', [target]); - const hoverable = new Hoverable(div); - await mount(hoverable); - expect(hoverable.target).toBe(target); - expect(hoverable.parent).toBe(div); - }); - - it('should keep x and y in bounds', async () => { - const target = h('div', { dataRef: 'target' }); - const div = h('div', [target]); - const hoverable = new Hoverable(div); - const spy = vi.spyOn(hoverable, 'bounds', 'get'); - spy.mockImplementation(() => ({ - xMin: 0, - xMax: 100, - yMin: 0, - yMax: 100, - })); - await mount(hoverable); - - hoverable.movedrelative(pointerProgress(0, 0)); - expect(hoverable.props.x).toBe(0); - expect(hoverable.props.y).toBe(0); - hoverable.movedrelative(pointerProgress(0.5, 0.5)); - expect(hoverable.props.x).toBe(50); - expect(hoverable.props.y).toBe(50); - hoverable.movedrelative(pointerProgress(1, 1)); - expect(hoverable.props.x).toBe(100); - expect(hoverable.props.y).toBe(100); - hoverable.movedrelative(pointerProgress(1.5, 1.5)); - expect(hoverable.props.x).toBe(100); - expect(hoverable.props.y).toBe(100); - }); - - it('should reverse x and y position when reversed option is used', async () => { - const target = h('div', { dataRef: 'target' }); - const div = h('div', { dataOptionReversed: true }, [target]); - const hoverable = new Hoverable(div); - const spy = vi.spyOn(hoverable, 'bounds', 'get'); - spy.mockImplementation(() => ({ - xMin: 0, - xMax: 100, - yMin: 0, - yMax: 100, - })); - await mount(hoverable); - - hoverable.movedrelative(pointerProgress(0, 0)); - expect(hoverable.props.x).toBe(100); - expect(hoverable.props.y).toBe(100); - hoverable.movedrelative(pointerProgress(0.5, 0.5)); - expect(hoverable.props.x).toBe(50); - expect(hoverable.props.y).toBe(50); - hoverable.movedrelative(pointerProgress(1, 1)); - expect(hoverable.props.x).toBe(0); - expect(hoverable.props.y).toBe(0); - hoverable.movedrelative(pointerProgress(1.5, 1.5)); - expect(hoverable.props.x).toBe(0); - expect(hoverable.props.y).toBe(0); - }); - - it('should stop update x and y position when contained option is used and mouse position is out of bounds', async () => { - const target = h('div', { dataRef: 'target' }); - const div = h('div', { dataOptionContained: true }, [target]); - const hoverable = new Hoverable(div); - const spy = vi.spyOn(hoverable, 'bounds', 'get'); - spy.mockImplementation(() => ({ - xMin: 0, - xMax: 100, - yMin: 0, - yMax: 100, - })); - await mount(hoverable); - - hoverable.movedrelative(pointerProgress(0, 0)); - expect(hoverable.props.x).toBe(0); - expect(hoverable.props.y).toBe(0); - hoverable.movedrelative(pointerProgress(0.5, 1.5)); - expect(hoverable.props.x).toBe(0); - expect(hoverable.props.y).toBe(0); - }); - - it('should correctly calculate bounds', async () => { - const target = h('div', { dataRef: 'target' }); - const div = h('div', [target]); - - const hoverable = new Hoverable(div); - await mount(hoverable); - - const parentSpies = {}; - const parentOffsets = { - offsetTop: 0, - offsetLeft: 0, - offsetHeight: 100, - offsetWidth: 100, - }; - - for (const [name, value] of Object.entries(parentOffsets) as [ - keyof typeof parentOffsets, - number, - ][]) { - const mock = vi.spyOn(hoverable.parent, name, 'get'); - mock.mockImplementation(() => value); - parentSpies[name] = mock; - } - - const targetSpies = {}; - const targetOffsets = { - offsetTop: 10, - offsetHeight: 10, - offsetLeft: 10, - offsetWidth: 10, - }; - - for (const [name, value] of Object.entries(targetOffsets) as [ - keyof typeof targetOffsets, - number, - ][]) { - const mock = vi.spyOn(hoverable.target, name, 'get'); - mock.mockImplementation(() => value); - targetSpies[name] = mock; - } - - // @ts-expect-error - hoverable.target.offsetParent = div; - - expect(hoverable.bounds.xMin).toBe(-10); - expect(hoverable.bounds.yMin).toBe(-10); - expect(hoverable.bounds.xMax).toBe(80); - expect(hoverable.bounds.yMax).toBe(80); - - // @ts-expect-error - hoverable.target.offsetParent = document.body; - - expect(hoverable.bounds.xMin).toBe(-10); - expect(hoverable.bounds.yMin).toBe(-10); - expect(hoverable.bounds.xMax).toBe(80); - expect(hoverable.bounds.yMax).toBe(80); - }); -}); diff --git a/packages/tests/InView/index.spec.ts b/packages/tests/InView/index.spec.ts deleted file mode 100644 index 0ad76aa2..00000000 --- a/packages/tests/InView/index.spec.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; -import { InView, InViewOnce } from '@studiometa/ui'; -import type { Base } from '@studiometa/js-toolkit'; -import { - h, - mount, - mockIsIntersecting, - intersectionMockInstance, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, -} from '#test-utils'; - -/** - * Instantiate and mount a single component (`InView` or `InViewOnce`) from an - * HTML string. - */ -async function mountComponent( - Ctor: new (el: HTMLElement) => T, - html: string, -): Promise<{ root: HTMLElement; el: HTMLElement; instance: T }> { - const root = h('div'); - root.innerHTML = html; - const el = root.querySelector('[data-component]') as HTMLElement; - const instance = new Ctor(el); - await mount(instance); - - return { root, el, instance }; -} - -/** - * Simulate the element leaving the viewport, tolerating the case where its - * observer has already been disconnected (e.g. after a one-shot terminate). - */ -async function tryLeave(el: HTMLElement) { - try { - await mockIsIntersecting(el, false); - } catch { - // The observer was disconnected: the leave can never be delivered, which - // is exactly the guarantee we are asserting. - } -} - -describe('InView component', () => { - beforeAll(() => { - intersectionObserverBeforeAllCallback(); - }); - - afterEach(() => { - intersectionObserverAfterEachCallback(); - }); - - it('should have the correct config', () => { - expect(InView.config.name).toBe('InView'); - expect(InView.config.emits).toEqual(['in-view', 'out-of-view']); - }); - - it('should emit `in-view` when the element enters the viewport', async () => { - const { el, instance } = await mountComponent(InView, `
`); - const fn = vi.fn(); - instance.$on('in-view', fn); - - await mockIsIntersecting(el, true); - - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should emit `out-of-view` when the element leaves the viewport', async () => { - const { el, instance } = await mountComponent(InView, `
`); - const fn = vi.fn(); - instance.$on('out-of-view', fn); - - await mockIsIntersecting(el, true); - await mockIsIntersecting(el, false); - - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('should re-emit `in-view` on each re-entry (repeating)', async () => { - const { el, instance } = await mountComponent(InView, `
`); - const inView = vi.fn(); - const outOfView = vi.fn(); - instance.$on('in-view', inView); - instance.$on('out-of-view', outOfView); - - await mockIsIntersecting(el, true); - await mockIsIntersecting(el, false); - await mockIsIntersecting(el, true); - - expect(inView).toHaveBeenCalledTimes(2); - expect(outOfView).toHaveBeenCalledTimes(1); - }); - - it('should expose the configurable `intersectionObserver` option', async () => { - const { el } = await mountComponent( - InView, - `
`, - ); - - const observer = intersectionMockInstance(el); - - // The option is forwarded to the IntersectionObserver instance. - expect(observer.rootMargin).toBe('100px'); - }); -}); - -describe('InViewOnce component', () => { - beforeAll(() => { - intersectionObserverBeforeAllCallback(); - }); - - afterEach(() => { - intersectionObserverAfterEachCallback(); - }); - - it('should have the correct config, emitting only `in-view`', () => { - expect(InViewOnce.config.name).toBe('InViewOnce'); - expect(InViewOnce.config.emits).toEqual(['in-view']); - }); - - it('should emit `in-view` exactly once and terminate (disconnect the observer)', async () => { - const { el, instance } = await mountComponent( - InViewOnce, - `
`, - ); - const observer = intersectionMockInstance(el); - const terminateSpy = vi.spyOn(instance, '$terminate'); - const fn = vi.fn(); - instance.$on('in-view', fn); - - await mockIsIntersecting(el, true); - - expect(fn).toHaveBeenCalledTimes(1); - expect(terminateSpy).toHaveBeenCalledTimes(1); - // The decorator disconnects the observer on `terminated`. - expect(observer.disconnect).toHaveBeenCalled(); - }); - - it('should NOT emit `out-of-view`, even after leaving the viewport', async () => { - const { el, instance } = await mountComponent( - InViewOnce, - `
`, - ); - const outOfView = vi.fn(); - instance.$on('out-of-view', outOfView); - - await mockIsIntersecting(el, true); - // A leave after termination must not resurface an `out-of-view` event. - // The observer is disconnected on terminate, so the leave can never even - // be delivered — swallow the resulting "not observed" error. - await tryLeave(el); - - expect(outOfView).not.toHaveBeenCalled(); - // The observer was disconnected, so no further crossing can be delivered. - expect(() => intersectionMockInstance(el)).toThrow( - 'Failed to find IntersectionObserver for element', - ); - }); - - it('should not re-emit `in-view` on a later intersection', async () => { - const { el, instance } = await mountComponent( - InViewOnce, - `
`, - ); - const inView = vi.fn(); - instance.$on('in-view', inView); - - await mockIsIntersecting(el, true); - await tryLeave(el); - // Any further intersection cannot be delivered to a disconnected observer. - await tryLeave(el); - - expect(inView).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/tests/Indexable/Indexable.spec.ts b/packages/tests/Indexable/Indexable.spec.ts deleted file mode 100644 index 1f137b8b..00000000 --- a/packages/tests/Indexable/Indexable.spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Indexable } from '@studiometa/ui'; -import { h } from '#test-utils'; - -class TestIndexable extends Indexable { - #length = 3; - - get length() { - return this.#length; - } - - set length(value: number) { - this.#length = value; - } -} - -describe('The Indexable class', () => { - let indexable: TestIndexable; - let element: HTMLElement; - - beforeEach(() => { - element = h('div'); - indexable = new TestIndexable(element); - }); - - describe(`"${Indexable.BOUNDARIES.CLAMP}" boundary`, () => { - beforeEach(() => { - indexable.boundary = Indexable.BOUNDARIES.CLAMP; - }); - - it('should stay in bounds indexes', () => { - indexable.currentIndex = 0; - expect(indexable.nextIndex).toBe(1); - expect(indexable.prevIndex).toBe(0); - - indexable.currentIndex = 2; - expect(indexable.nextIndex).toBe(2); - expect(indexable.prevIndex).toBe(1); - }); - }); - - describe(`"${Indexable.BOUNDARIES.LOOP}" boundary`, () => { - beforeEach(() => { - indexable.boundary = Indexable.BOUNDARIES.LOOP; - }); - - it('should wrap around indexes', () => { - indexable.currentIndex = 0; - expect(indexable.nextIndex).toBe(1); - expect(indexable.prevIndex).toBe(2); // (0 - 1 + 3) % 3 = 2 - - indexable.currentIndex = 2; - expect(indexable.nextIndex).toBe(0); // (2 + 1) % 3 = 0 - expect(indexable.prevIndex).toBe(1); - }); - - it('should handle out of bounds indexes', () => { - indexable.currentIndex = -1; - expect(indexable.currentIndex).toBe(2); - - indexable.currentIndex = 5; - expect(indexable.currentIndex).toBe(2); - }); - - it('should handle large negative indexes', () => { - indexable.currentIndex = -5; - expect(indexable.currentIndex).toBe(1); // ((-5 % 3) + 3) % 3 = 1 - - indexable.currentIndex = -7; - expect(indexable.currentIndex).toBe(2); // ((-7 % 3) + 3) % 3 = 2 - }); - }); - - describe(`"${Indexable.BOUNDARIES.BOUNCE}" boundary`, () => { - beforeEach(() => { - indexable.boundary = Indexable.BOUNDARIES.BOUNCE; - }); - - it('should bounce back when reaching bounds', () => { - indexable.currentIndex = 0; - expect(indexable.nextIndex).toBe(1); - expect(indexable.prevIndex).toBe(1); - - indexable.currentIndex = 2; - expect(indexable.nextIndex).toBe(1); - expect(indexable.prevIndex).toBe(1); - }); - - it('should handle out of bounds indexes', () => { - indexable.currentIndex = -1; - expect(indexable.currentIndex).toBe(1); - - indexable.currentIndex = 5; - expect(indexable.currentIndex).toBe(1); - }); - - it('should not change direction when setting an out of bounds index', () => { - expect(indexable.isReverse).toBe(false); - - indexable.currentIndex = 5; - expect(indexable.currentIndex).toBe(1); - expect(indexable.isReverse).toBe(false); - - indexable.currentIndex = -1; - expect(indexable.currentIndex).toBe(1); - expect(indexable.isReverse).toBe(false); - }); - - it('should not change direction when going to an out of bounds index', async () => { - expect(indexable.isReverse).toBe(false); - - await indexable.goTo(10); - expect(indexable.currentIndex).toBe(2); - expect(indexable.isReverse).toBe(false); - - await indexable.goTo(-5); - expect(indexable.currentIndex).toBe(1); - expect(indexable.isReverse).toBe(false); - }); - - it('should keep the travel direction after a no-op out of bounds assignment', async () => { - indexable.currentIndex = 1; - - // -1 reflects back to the current index: no index change, no event, - // and the direction must stay untouched. - indexable.currentIndex = -1; - expect(indexable.currentIndex).toBe(1); - expect(indexable.isReverse).toBe(false); - - await indexable.goNext(); - expect(indexable.currentIndex).toBe(2); - }); - - it('should ping-pong through indices with goNext', async () => { - indexable.currentIndex = 0; - - await indexable.goNext(); - expect(indexable.currentIndex).toBe(1); - - await indexable.goNext(); - expect(indexable.currentIndex).toBe(2); - - await indexable.goNext(); - expect(indexable.currentIndex).toBe(1); // bounce back instead of oscillating - - await indexable.goNext(); - expect(indexable.currentIndex).toBe(0); - - await indexable.goNext(); - expect(indexable.currentIndex).toBe(1); // bounce again - }); - - it('should reverse the direction back with goPrev', async () => { - indexable.currentIndex = 0; - - await indexable.goNext(); - await indexable.goNext(); - expect(indexable.currentIndex).toBe(2); - - await indexable.goPrev(); - expect(indexable.currentIndex).toBe(1); - }); - }); - - describe('default length', () => { - it('should default to 0 and pin the index at 0', async () => { - const instance = new Indexable(h('div')); - expect(instance.length).toBe(0); - expect(instance.maxIndex).toBe(0); - - await instance.goTo(5); - expect(instance.currentIndex).toBe(0); - - instance.boundary = Indexable.BOUNDARIES.LOOP; - await instance.goTo(5); - expect(instance.currentIndex).toBe(0); - - instance.boundary = Indexable.BOUNDARIES.BOUNCE; - await instance.goTo(5); - expect(instance.currentIndex).toBe(0); - }); - }); - - describe('total option', () => { - it('should derive the length from the `total` option when used standalone', async () => { - const instance = new Indexable(h('div', { dataOptionTotal: 3 })); - expect(instance.length).toBe(3); - expect(instance.maxIndex).toBe(2); - }); - - it('should let a standalone instance navigate within the configured bounds', async () => { - const instance = new Indexable(h('div', { dataOptionTotal: 3 })); - - await instance.goTo(5); - expect(instance.currentIndex).toBe(2); // clamped to maxIndex - - instance.boundary = Indexable.BOUNDARIES.LOOP; - await instance.goTo(3); - expect(instance.currentIndex).toBe(0); // wraps around - }); - }); - - describe('goTo method', () => { - it('should go to specific index', async () => { - const emitSpy = vi.spyOn(indexable, '$emit'); - - await indexable.goTo(1); - expect(indexable.currentIndex).toBe(1); - expect(emitSpy).toHaveBeenCalledWith('index', 1); - }); - - it(`should handle "${Indexable.INSTRUCTIONS.NEXT}" instruction`, async () => { - await indexable.goTo(Indexable.INSTRUCTIONS.NEXT); - expect(indexable.currentIndex).toBe(1); - }); - - it(`should handle "${Indexable.INSTRUCTIONS.PREVIOUS}" instruction`, async () => { - indexable.currentIndex = 1; - await indexable.goTo(Indexable.INSTRUCTIONS.PREVIOUS); - expect(indexable.currentIndex).toBe(0); - }); - - it(`should handle "${Indexable.INSTRUCTIONS.FIRST}" instruction`, async () => { - indexable.currentIndex = 2; - await indexable.goTo(Indexable.INSTRUCTIONS.FIRST); - expect(indexable.currentIndex).toBe(0); - }); - - it(`should handle "${Indexable.INSTRUCTIONS.LAST}" instruction`, async () => { - await indexable.goTo(Indexable.INSTRUCTIONS.LAST); - expect(indexable.currentIndex).toBe(2); - }); - - it(`should handle "${Indexable.INSTRUCTIONS.RANDOM}" instruction`, async () => { - const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); - - await indexable.goTo(Indexable.INSTRUCTIONS.RANDOM); - expect(indexable.currentIndex).toBeGreaterThanOrEqual(0); - expect(indexable.currentIndex).toBeLessThanOrEqual(2); - - randomSpy.mockRestore(); - }); - - it('should handle reverse with instructions', async () => { - indexable.isReverse = true; - - indexable.currentIndex = 1; - await indexable.goTo(Indexable.INSTRUCTIONS.PREVIOUS); - expect(indexable.currentIndex).toBe(2); - - indexable.currentIndex = 1; - await indexable.goTo(Indexable.INSTRUCTIONS.NEXT); - expect(indexable.currentIndex).toBe(0); - - await indexable.goTo(Indexable.INSTRUCTIONS.FIRST); - expect(indexable.currentIndex).toBe(2); - - await indexable.goTo(Indexable.INSTRUCTIONS.LAST); - expect(indexable.currentIndex).toBe(0); - }); - - it('should warn and keep the current index for an invalid instruction', async () => { - const warnSpy = vi.spyOn(indexable, '$warn', 'get'); - - await expect(indexable.goTo('invalid' as any)).resolves.toBeUndefined(); - expect(warnSpy).toHaveBeenCalledOnce(); - expect(indexable.currentIndex).toBe(0); - }); - - it.each([undefined, null, true, {}, NaN, Infinity, -Infinity])( - 'should warn and keep the current index for an invalid numeric index: %s', - async (value) => { - indexable.currentIndex = 1; - indexable.boundary = Indexable.BOUNDARIES.LOOP; - const warnSpy = vi.spyOn(indexable, '$warn', 'get'); - const emitSpy = vi.spyOn(indexable, '$emit'); - - await expect(indexable.goTo(value as any)).resolves.toBeUndefined(); - expect(warnSpy).toHaveBeenCalledOnce(); - expect(emitSpy).not.toHaveBeenCalled(); - expect(indexable.currentIndex).toBe(1); - }, - ); - - it('should not emit when the index does not change', async () => { - const emitSpy = vi.spyOn(indexable, '$emit'); - - await indexable.goTo(0); // already at index 0 - expect(emitSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/tests/Menu/Menu.spec.ts b/packages/tests/Menu/Menu.spec.ts deleted file mode 100644 index beccbc92..00000000 --- a/packages/tests/Menu/Menu.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { getInstanceFromElement } from '@studiometa/js-toolkit'; -import { Menu, MenuBtn, MenuList } from '@studiometa/ui'; -import { h, mount, wait } from '#test-utils'; - -async function getContext({ mode = 'click' } = {}) { - const menuBtn = h('button', { dataComponent: 'MenuBtn' }, ['Click me']); - const menuList = h('div', { dataComponent: 'MenuList' }); - const root = h('div', { dataOptionMode: mode }, [menuBtn, menuList]); - const menu = new Menu(root); - await mount(menu); - return { - menuBtn, - menuList, - root, - menu, - }; -} - -describe('The Menu component', () => { - it('should not mount if no menuList or menuBtn child', async () => { - const menu = new Menu(h('div')); - await mount(menu); - expect(menu.menuList).toBeUndefined(); - expect(menu.menuBtn).toBeUndefined(); - expect(menu.$isMounted).toBe(false); - }); - - it('should have a shouldReactOnClick getter based on its mode option', async () => { - const menu = new Menu(h('div')); - await mount(menu); - expect(menu.$options.mode).toBe('click'); - expect(menu.shouldReactOnClick).toBe(true); - menu.$el.dataset.optionMode = 'hover'; - expect(menu.$options.mode).toBe('hover'); - expect(menu.shouldReactOnClick).toBe(false); - }); - - it('should add aria-attributes to its menu list and menu btn', async () => { - const { menu, menuBtn, menuList } = await getContext(); - expect(menu.menuBtn).toBeInstanceOf(MenuBtn); - expect(menu.menuList).toBeInstanceOf(MenuList); - - expect(menuBtn.getAttribute('aria-controls')).toBe(menu.$id); - expect(menuList.id).toBe(menu.$id); - }); - - it('should delegate the open, close and toggle methods to menuList', async () => { - const { menu } = await getContext(); - const openSpy = vi.spyOn(menu.menuList, 'open'); - const closeSpy = vi.spyOn(menu.menuList, 'close'); - const toggleSpy = vi.spyOn(menu.menuList, 'toggle'); - menu.open(); - expect(openSpy).toHaveBeenCalledOnce(); - menu.close(); - expect(closeSpy).toHaveBeenCalledOnce(); - menu.toggle(); - expect(toggleSpy).toHaveBeenCalledOnce(); - }); - - it('should close on escape', async () => { - const { menu } = await getContext(); - const closeSpy = vi.spyOn(menu, 'close'); - // @ts-expect-error - menu.keyed({ ENTER: false, ESC: true, isUp: true }); - expect(closeSpy).toHaveBeenCalledOnce(); - }); - - it('should toggle on ENTER on the menu btn when in hover mode', async () => { - const { menu, menuBtn, root } = await getContext({ mode: 'hover' }); - document.body.append(root); - const toggleSpy = vi.spyOn(menu, 'toggle'); - menuBtn.focus(); - expect(document.activeElement).toBe(menuBtn); - // @ts-expect-error - menu.keyed({ isUp: false, ENTER: true }); - expect(toggleSpy).toHaveBeenCalledTimes(0); - // @ts-expect-error - menu.keyed({ isUp: true, ENTER: true }); - expect(toggleSpy).toHaveBeenCalledTimes(1); - // @ts-expect-error - menu.keyed({ isUp: true, ENTER: true }); - expect(toggleSpy).toHaveBeenCalledTimes(2); - }); - - it('should toggle on btn click when in click mode', async () => { - const { menu, menuBtn } = await getContext(); - const toggleSpy = vi.spyOn(menu, 'toggle'); - let event = new MouseEvent('click'); - let preventDefaultSpy = vi.spyOn(event, 'preventDefault'); - preventDefaultSpy.mockImplementation(() => null); - menuBtn.dispatchEvent(event); - expect(toggleSpy).toHaveBeenCalledTimes(1); - expect(preventDefaultSpy).toHaveBeenCalledTimes(1); - event = new MouseEvent('click', { bubbles: true }); - preventDefaultSpy = vi.spyOn(event, 'preventDefault'); - menuBtn.dispatchEvent(event); - expect(toggleSpy).toHaveBeenCalledTimes(2); - expect(preventDefaultSpy).toHaveBeenCalledTimes(1); - }); - - it('should NOT toggle on btn click when NOT in click mode', async () => { - const { menu, menuBtn } = await getContext({ mode: 'hover' }); - const toggleSpy = vi.spyOn(menu, 'toggle'); - let event = new MouseEvent('click'); - let preventDefaultSpy = vi.spyOn(event, 'preventDefault'); - preventDefaultSpy.mockImplementation(() => null); - menuBtn.dispatchEvent(event); - expect(toggleSpy).toHaveBeenCalledTimes(0); - expect(preventDefaultSpy).toHaveBeenCalledTimes(0); - event = new MouseEvent('click', { bubbles: true }); - preventDefaultSpy = vi.spyOn(event, 'preventDefault'); - menuBtn.dispatchEvent(event); - expect(toggleSpy).toHaveBeenCalledTimes(0); - expect(preventDefaultSpy).toHaveBeenCalledTimes(0); - }); - - it('should open on btn mouseenter when in hover mode', async () => { - const { menu, menuBtn } = await getContext({ mode: 'hover' }); - const openSpy = vi.spyOn(menu, 'open'); - let event = new MouseEvent('mouseenter'); - menuBtn.dispatchEvent(event); - expect(openSpy).toHaveBeenCalledTimes(1); - }); - - it('should close on btn or list mouseleave when in hover mode', async () => { - const { menu, menuBtn, menuList } = await getContext({ mode: 'hover' }); - const closeSpy = vi.spyOn(menu, 'close'); - let event = new MouseEvent('mouseleave'); - menuBtn.dispatchEvent(event); - await wait(1); - expect(closeSpy).toHaveBeenCalledTimes(1); - event = new MouseEvent('mouseleave'); - menuList.dispatchEvent(event); - await wait(1); - expect(closeSpy).toHaveBeenCalledTimes(2); - }); - - it('should NOT close on btn or list mouseleave when NOT in hover mode', async () => { - const { menu, menuBtn, menuList } = await getContext(); - const closeSpy = vi.spyOn(menu, 'close'); - let event = new MouseEvent('mouseleave'); - menuBtn.dispatchEvent(event); - await wait(1); - expect(closeSpy).toHaveBeenCalledTimes(0); - event = new MouseEvent('mouseleave'); - menuList.dispatchEvent(event); - await wait(1); - expect(closeSpy).toHaveBeenCalledTimes(0); - }); - - it('should close when clicking outside of its elements', async () => { - const { menu, root } = await getContext(); - document.body.append(root); - const closeSpy = vi.spyOn(menu, 'close'); - menu.open(); - document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(closeSpy).toHaveBeenCalledOnce(); - - root.remove(); - }); - - it('should close other MenuList instance when one is open', async () => { - const menuListA = h('div', { dataComponent: 'MenuList' }); - const menuListB = h('div', { dataComponent: 'MenuList' }); - const root = h('div', [menuListA, menuListB]); - const menu = new Menu(root); - await mount(menu); - - const menuListAInstance = getInstanceFromElement(menuListA, MenuList); - const menuListBInstance = getInstanceFromElement(menuListB, MenuList); - const closeSpyA = vi.spyOn(menuListAInstance, 'close'); - const closeSpyB = vi.spyOn(menuListBInstance, 'close'); - - menu.onMenuListItemsOpen({ target: menuListAInstance }) - - expect(closeSpyA).toHaveBeenCalledTimes(0); - expect(closeSpyB).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/tests/Menu/MenuBtn.spec.ts b/packages/tests/Menu/MenuBtn.spec.ts deleted file mode 100644 index 703c3e74..00000000 --- a/packages/tests/Menu/MenuBtn.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { MenuBtn } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The MenuBtn component', () => { - it('should switch its `isHover` state', async () => { - const btn = h('button'); - const menuBtn = new MenuBtn(btn); - await mount(menuBtn); - expect(menuBtn.isHover).toBe(false); - const mouseenterEvent = new MouseEvent('mouseenter'); - const mouseenterPropagationSpy = vi.spyOn(mouseenterEvent, 'stopPropagation'); - btn.dispatchEvent(mouseenterEvent); - expect(menuBtn.isHover).toBe(true); - expect(mouseenterPropagationSpy).toHaveBeenCalledOnce(); - const mouseleaveEvent = new MouseEvent('mouseleave'); - const mouseleavePropagationSpy = vi.spyOn(mouseleaveEvent, 'stopPropagation'); - btn.dispatchEvent(mouseleaveEvent); - expect(menuBtn.isHover).toBe(false); - expect(mouseleavePropagationSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/Menu/MenuList.spec.ts b/packages/tests/Menu/MenuList.spec.ts deleted file mode 100644 index 93030f65..00000000 --- a/packages/tests/Menu/MenuList.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { getInstanceFromElement } from '@studiometa/js-toolkit'; -import { MenuList } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The MenuList component', () => { - it('should switch its `isHover` state', async () => { - const div = h('div'); - const menuList = new MenuList(div); - await mount(menuList); - expect(menuList.isHover).toBe(false); - div.dispatchEvent(new MouseEvent('mouseenter')); - expect(menuList.isHover).toBe(true); - div.dispatchEvent(new MouseEvent('mouseleave')); - expect(menuList.isHover).toBe(false); - }); - - it('should force its enterKeep and leaveKeep options', async () => { - const div = h('div'); - const menuList = new MenuList(div); - await mount(menuList); - expect(menuList.$options.enterKeep).toBe(true); - expect(menuList.$options.leaveKeep).toBe(true); - }); - - it('should have open an close methods', async () => { - const div = h('div'); - const menuList = new MenuList(div); - await mount(menuList); - - const openFn = vi.fn(); - const closeFn = vi.fn(); - menuList.$on('items-open', openFn); - menuList.$on('items-close', closeFn); - - menuList.open(); - expect(openFn).toHaveBeenCalledTimes(1); - expect(closeFn).toHaveBeenCalledTimes(0); - expect(div.getAttribute('aria-hidden')).toBe('false'); - expect(menuList.isOpen).toBe(true); - - menuList.close(); - expect(openFn).toHaveBeenCalledTimes(1); - expect(closeFn).toHaveBeenCalledTimes(1); - expect(div.getAttribute('aria-hidden')).toBe('true'); - expect(menuList.isOpen).toBe(false); - }); - - it('should have a toggle method', async () => { - const div = h('div'); - const menuList = new MenuList(div); - await mount(menuList); - const openSpy = vi.spyOn(menuList, 'open'); - const closeSpy = vi.spyOn(menuList, 'close'); - - menuList.toggle(); - expect(openSpy).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalledTimes(0); - menuList.toggle(); - expect(openSpy).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalledTimes(1); - menuList.toggle(); - expect(openSpy).toHaveBeenCalledTimes(2); - expect(closeSpy).toHaveBeenCalledTimes(1); - }); - - it('should remove focus from any focused element', async () => { - const a = h('a', { href: '#' }); - const div = h('div', [a]); - document.body.append(div); - const menuList = new MenuList(div); - await mount(menuList); - menuList.open(); - a.focus(); - expect(document.activeElement).toBe(a); - const blurSpy = vi.spyOn(a, 'blur'); - menuList.close(); - expect(blurSpy).toHaveBeenCalledOnce(); - div.remove(); - }); - - it('should close its children MenuList', async () => { - const nestedDiv = h('div', { dataComponent: 'MenuList' }); - const section = h('section', [nestedDiv]); - const div = h('div', [section]); - const menuList = new MenuList(div); - await mount(menuList); - const nestedMenuList = getInstanceFromElement(nestedDiv, MenuList); - - menuList.open(); - expect(menuList.isOpen).toBe(true); - expect(nestedMenuList.isOpen).toBe(false); - - nestedMenuList.open(); - expect(menuList.isOpen).toBe(true); - expect(nestedMenuList.isOpen).toBe(true); - - nestedMenuList.close(); - expect(menuList.isOpen).toBe(true); - expect(nestedMenuList.isOpen).toBe(false); - - nestedMenuList.open(); - expect(menuList.isOpen).toBe(true); - expect(nestedMenuList.isOpen).toBe(true); - - menuList.close(); - expect(menuList.isOpen).toBe(false); - expect(nestedMenuList.isOpen).toBe(false); - }); - - it('should update its focusable children element tabindex attribute', async () => { - const nestedA = h('a', { href: '#' }); - const nestedDiv = h('div', { dataComponent: 'MenuList' }, [nestedA]); - const section = h('section', [nestedDiv]); - const a = h('a', { href: '#' }); - const div = h('div', [a, section]); - const menuList = new MenuList(div); - await mount(menuList); - const nestedMenuList = getInstanceFromElement(nestedDiv, MenuList); - expect(nestedMenuList.$isMounted).toBe(true); - expect(a.getAttribute('tabindex')).toBe('-1'); - expect(nestedA.getAttribute('tabindex')).toBe('-1'); - - menuList.open(); - expect(menuList.isOpen).toBe(true); - expect(nestedMenuList.isOpen).toBe(false); - expect(a.getAttribute('tabindex')).toBeNull(); - expect(nestedA.getAttribute('tabindex')).toBe('-1'); - - nestedMenuList.open(); - expect(menuList.isOpen).toBe(true); - expect(nestedMenuList.isOpen).toBe(true); - expect(a.getAttribute('tabindex')).toBeNull(); - expect(nestedA.getAttribute('tabindex')).toBeNull(); - - menuList.close(); - expect(menuList.isOpen).toBe(false); - expect(nestedMenuList.isOpen).toBe(false); - expect(a.getAttribute('tabindex')).toBe('-1'); - expect(nestedA.getAttribute('tabindex')).toBe('-1'); - }); - - it('should prevent multiple call of open and close', async () => { - const div = h('div'); - const menuList = new MenuList(div); - await mount(menuList); - - const enterSpy = vi.spyOn(menuList, 'enter'); - const leaveSpy = vi.spyOn(menuList, 'leave'); - - menuList.open(); - menuList.open(); - expect(enterSpy).toHaveBeenCalledOnce(); - menuList.close(); - menuList.close(); - expect(leaveSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/Modal/Modal.spec.ts b/packages/tests/Modal/Modal.spec.ts deleted file mode 100644 index 326c979e..00000000 --- a/packages/tests/Modal/Modal.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { nextFrame } from '@studiometa/js-toolkit/utils'; -import { Modal } from '@studiometa/ui'; -import { h } from '#test-utils'; -import template from './Modal.template.html.js'; - -async function getContext({ move = '' } = {}) { - const consoleSpy = vi.spyOn(console, 'warn'); - const target = h('div', { id: 'target' }); - const root = h('div'); - root.innerHTML = template; - root.append(target); - const modal = new Modal(root.firstElementChild as HTMLElement); - if (move) { - modal.$options.move = move; - } - - vi.useFakeTimers(); - modal.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - - return { - root, - modal, - target, - consoleSpy, - consoleSpyRestor: () => consoleSpy.mockRestore(), - }; -} - -describe('The Modal component', () => { - it('should be closed on instantiation', async () => { - const { modal, root } = await getContext(); - expect(modal.$el.outerHTML).toBe(root.firstElementChild.outerHTML); - expect(modal.isOpen).toBe(false); - }); - - it('should warn that it is deprecated when mounted', async () => { - const { consoleSpy, consoleSpyRestor } = await getContext(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('The Modal component is deprecated'), - ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('https://ui.studiometa.dev/reference/items/Dialog/'), - ); - consoleSpyRestor(); - }); - - it('should emit events when opening and closing', async () => { - const { modal } = await getContext(); - const fn = vi.fn(); - modal.$on('open', fn); - modal.$on('close', fn); - - await modal.open(); - expect(fn).toHaveBeenCalledTimes(1); - await modal.close(); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('should update aria-attributes when opening and closing.', async () => { - const { modal } = await getContext(); - expect(modal.$refs.modal.getAttribute('aria-hidden')).toBe('true'); - await modal.open(); - expect(modal.$refs.modal.getAttribute('aria-hidden')).toBe('false'); - await modal.close(); - }); - - it('should update refs classes and styles when opening and closing.', async () => { - const { modal } = await getContext(); - await modal.open(); - await nextFrame(); - expect(modal.$refs.modal.getAttribute('style')).toBe(null); - await modal.close(); - await nextFrame(); - expect(modal.$refs.modal.getAttribute('style')).toBe( - 'opacity: 0; pointer-events: none; visibility: hidden;', - ); - }); - - it('should set the focus to the `autofocus` element when opening.', async () => { - const { modal } = await getContext(); - const autofocus = modal.$refs.modal.querySelector('[autofocus]'); - vi.spyOn(autofocus, 'focus'); - await modal.open(); - expect(autofocus.focus).toHaveBeenCalledTimes(1); - await modal.close(); - }); - - it.skip('should trap the focus when open.', async () => { - const { modal } = await getContext(); - const tabKeydown = new KeyboardEvent('keydown', { keyCode: 9, bubbles: true }); - const closeButton = modal.$refs.modal.querySelector('[data-ref="close[]"]'); - const openButton = modal.$el.querySelector('[data-ref="open[]"]'); - openButton.focus(); - - vi.spyOn(closeButton, 'focus'); - vi.spyOn(openButton, 'focus'); - - await modal.open(); - document.dispatchEvent(tabKeydown); - expect(closeButton.focus).toHaveBeenCalledTimes(1); - expect(openButton.focus).toHaveBeenCalledTimes(0); - document.dispatchEvent(tabKeydown); - document.dispatchEvent(tabKeydown); - expect(openButton.focus).toHaveBeenCalledTimes(0); - expect(closeButton.focus).toHaveBeenCalledTimes(1); - - await modal.close(); - expect(openButton.focus).toHaveBeenCalledTimes(1); - }); - - it('should open when clicking the open button.', async () => { - const { modal, root } = await getContext(); - const btn = root.querySelector('[data-ref="open[]"]'); - await modal.close(); - expect(modal.isOpen).toBe(false); - btn.click(); - expect(modal.isOpen).toBe(true); - }); - - it('should close when pressing the escape key.', async () => { - const { modal } = await getContext(); - const escapeKeyup = new KeyboardEvent('keyup', { keyCode: 27 }); - await modal.open(); - expect(modal.isOpen).toBe(true); - document.dispatchEvent(escapeKeyup); - expect(modal.isOpen).toBe(false); - document.dispatchEvent(escapeKeyup); - expect(modal.isOpen).toBe(false); - }); - - it('should close when clicking the overlay.', async () => { - const { modal, root } = await getContext(); - const overlay = root.querySelector('[data-ref="overlay"]'); - await modal.open(); - expect(modal.isOpen).toBe(true); - overlay.click(); - expect(modal.isOpen).toBe(false); - }); - - it('should close when clicking the close button.', async () => { - const { modal, root } = await getContext(); - const btn = root.querySelector('[data-ref="close[]"]'); - - await modal.open(); - expect(modal.isOpen).toBe(true); - btn.click(); - expect(modal.isOpen).toBe(false); - }); - - it('should close on destroy.', async () => { - const { modal } = await getContext(); - await modal.open(); - expect(modal.isOpen).toBe(true); - vi.useFakeTimers(); - modal.$destroy(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - expect(modal.isOpen).toBe(false); - }); -}); - -describe('The Modal component with the `move` option', () => { - it('should move the `modal` ref to the `#target` element on mounted.', async () => { - const { modal, root, target } = await getContext({ move: '#target' }); - // Append root to the document so querySelector('#target') can find it - document.body.append(root); - // Re-mount so the move logic can find #target in the document - vi.useFakeTimers(); - modal.$destroy(); - await vi.advanceTimersByTimeAsync(100); - modal.$mount(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - expect(target.firstElementChild).toBe(modal.$refs.modal); - // Clean up - modal.$destroy(); - root.remove(); - }); - - it.skip('should move the `modal` ref back to its previous place.', async () => { - const { modal, root } = await getContext({ move: '#target' }); - document.body.append(root); - vi.useFakeTimers(); - modal.$destroy(); - await vi.advanceTimersByTimeAsync(100); - vi.useRealTimers(); - expect(document.body.firstElementChild).toBeNull(); - root.remove(); - }); -}); diff --git a/packages/tests/Modal/Modal.template.html.ts b/packages/tests/Modal/Modal.template.html.ts deleted file mode 100644 index f97620d0..00000000 --- a/packages/tests/Modal/Modal.template.html.ts +++ /dev/null @@ -1,42 +0,0 @@ -export default `
- - - - -
-` diff --git a/packages/tests/Prefetch/AbstractPrefetch.spec.ts b/packages/tests/Prefetch/AbstractPrefetch.spec.ts deleted file mode 100644 index 309d8b51..00000000 --- a/packages/tests/Prefetch/AbstractPrefetch.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AbstractPrefetch } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -beforeEach(() => { - AbstractPrefetch.prefetchedUrls = new Set(); -}); - -describe('The AbstractPrefetch class', () => { - it('should validate if a given URL is prefetchable', async () => { - const anchor = h('a', { href: 'http://fqdn.com/' }); - const prefetch = new AbstractPrefetch(anchor); - await mount(prefetch); - - const location = new URL('http://fqdn.com'); - const locationSpy = vi.spyOn(window, 'location', 'get'); - locationSpy.mockImplementation(() => location); - - const urls = [ - ['http://fqdn.com/', false], - ['http://fqdn.com/#foo', false], - ['http://fqdn.com/foo/bar', true], - ['http://fqdn.net/foo/bar', false], - ] as [string, boolean][]; - - for (const [url, expected] of urls) { - anchor.setAttribute('href', url); - expect(prefetch.isPrefetchable).toBe(expected); - } - - prefetch.$options.prefetch = false; - expect(prefetch.isPrefetchable).toBe(false); - - anchor.removeAttribute('href'); - expect(prefetch.isPrefetchable).toBe(false); - - locationSpy.mockRestore(); - }); - - it('should append a link prefetch element when prefetchable', async () => { - const anchor = h('a', { href: 'http://fqdn.com/foo/bar' }); - const prefetch = new AbstractPrefetch(anchor); - await mount(prefetch); - - const location = new URL('http://fqdn.com'); - const locationSpy = vi.spyOn(window, 'location', 'get'); - locationSpy.mockImplementation(() => location); - - prefetch.prefetch(); - const link = document.head.querySelector('link[rel="prefetch"]'); - expect(link).not.toBeNull(); - expect(link.href).toBe(anchor.href); - - prefetch.prefetch(); - expect(document.head.querySelectorAll('link[rel="prefetch"]')).toHaveLength(1); - - link.remove(); - - anchor.href = 'https://fqdn.com/baz'; - const spy = vi.spyOn(prefetch, 'isPrefetchable', 'get'); - spy.mockImplementation(() => false); - prefetch.prefetch(); - expect(document.head.querySelector('link[rel="prefetch"]')).toBeNull(); - locationSpy.mockRestore(); - }); - - it('should NOT append a link prefetch element when NOT prefetchable', async () => { - const anchor = h('a', { href: 'http://fqdn.com/foo/bar' }); - const prefetch = new AbstractPrefetch(anchor); - await mount(prefetch); - - const location = new URL('http://fqdn.net'); - const locationSpy = vi.spyOn(window, 'location', 'get'); - locationSpy.mockImplementation(() => location); - - prefetch.prefetch(); - const link = document.head.querySelector('link[rel="prefetch"]'); - expect(link).toBeNull(); - - locationSpy.mockRestore(); - }); - - it('should emit a prefetched event when the prefetch has been done', async () => { - const anchor = h('a', { href: 'http://fqdn.com/foo/bar' }); - const prefetch = new AbstractPrefetch(anchor); - const fn = vi.fn(); - prefetch.$on('prefetched', ({ detail: [url] }) => fn(url)); - await mount(prefetch); - - const location = new URL('http://fqdn.com'); - const locationSpy = vi.spyOn(window, 'location', 'get'); - locationSpy.mockImplementation(() => location); - - prefetch.prefetch(); - const link = document.head.querySelector('link[rel="prefetch"]'); - expect(link).not.toBeNull(); - expect(link.href).toBe(anchor.href); - link.dispatchEvent(new Event('load')); - expect(fn).toHaveBeenCalledWith(prefetch.url); - - link.remove(); - locationSpy.mockRestore(); - }); -}); diff --git a/packages/tests/Prefetch/PrefetchWhenOver.spec.ts b/packages/tests/Prefetch/PrefetchWhenOver.spec.ts deleted file mode 100644 index 8725626c..00000000 --- a/packages/tests/Prefetch/PrefetchWhenOver.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { PrefetchWhenOver } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -describe('The PrefetchWhenOver class', () => { - it('should prefetch on mouseenter', async () => { - const anchor = h('a', { href: 'http://fqdn.com/' }); - const prefetch = new PrefetchWhenOver(anchor); - await mount(prefetch); - - const spy = vi.spyOn(prefetch, 'prefetch'); - spy.mockImplementation(() => undefined) - anchor.dispatchEvent(new MouseEvent('mouseenter')); - expect(spy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/Prefetch/PrefetchWhenVisible.spec.ts b/packages/tests/Prefetch/PrefetchWhenVisible.spec.ts deleted file mode 100644 index ef6559ef..00000000 --- a/packages/tests/Prefetch/PrefetchWhenVisible.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; -import { PrefetchWhenVisible } from '@studiometa/ui'; -import { - h, - intersectionObserverAfterEachCallback, - intersectionObserverBeforeAllCallback, - mockIsIntersecting, - wait, -} from '#test-utils'; - -beforeAll(() => { - intersectionObserverBeforeAllCallback(); -}); - -afterEach(() => { - intersectionObserverAfterEachCallback(); -}); - -describe('The PrefetchWhenVisible class', () => { - it('should prefetch on mouseenter', async () => { - const anchor = h('a', { href: 'http://fqdn.com/' }); - const prefetch = new PrefetchWhenVisible(anchor); - const spy = vi.spyOn(prefetch, 'prefetch'); - spy.mockImplementation(() => undefined); - - mockIsIntersecting(anchor, true); - await wait(16); - expect(spy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts b/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts deleted file mode 100644 index f8ac1b51..00000000 --- a/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AbstractScrollAnimation } from '@studiometa/ui'; -import { nextTick } from '@studiometa/js-toolkit/utils'; -import { h, mount, destroy } from '#test-utils'; - -class TestScrollAnimation extends AbstractScrollAnimation { - static config = { - ...AbstractScrollAnimation.config, - name: 'TestScrollAnimation', - }; -} - -describe('AbstractScrollAnimation', () => { - let element: HTMLDivElement; - let animation: TestScrollAnimation; - - beforeEach(async () => { - element = h('div'); - animation = new TestScrollAnimation(element); - await mount(animation); - }); - - afterEach(async () => { - await destroy(animation); - }); - - it('should have the correct default config', () => { - expect(TestScrollAnimation.config.name).toBe('TestScrollAnimation'); - expect(TestScrollAnimation.config.options.playRange.default()).toEqual([0, 1]); - expect(TestScrollAnimation.config.options.from.default()).toEqual({}); - expect(TestScrollAnimation.config.options.to.default()).toEqual({}); - expect(TestScrollAnimation.config.options.easing.default()).toEqual([0, 0, 1, 1]); - }); - - it('should return the element as target', () => { - expect(animation.target).toBe(element); - }); - - it('should create animation with keyframes from from/to options', async () => { - const testElement = h('div', { - dataOptionFrom: JSON.stringify({ opacity: 0 }), - dataOptionTo: JSON.stringify({ opacity: 1 }), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - expect(testAnimation.animation).toBeDefined(); - await destroy(testAnimation); - }); - - it('should create animation with keyframes option', async () => { - const testElement = h('div', { - dataOptionKeyframes: JSON.stringify([{ opacity: 0 }, { opacity: 0.5 }, { opacity: 1 }]), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - expect(testAnimation.animation).toBeDefined(); - await destroy(testAnimation); - }); - - it('should calculate play range correctly with 2-element array', async () => { - const testElement = h('div', { - dataOptionPlayRange: JSON.stringify([0.2, 0.8]), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - expect(testAnimation.playRange).toEqual([0.2, 0.8]); - await destroy(testAnimation); - }); - - it('should calculate play range correctly with 3-element array (staggered)', async () => { - const testElement = h('div', { - dataOptionPlayRange: JSON.stringify([1, 3, 0.5]), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - const [start, end] = testAnimation.playRange; - expect(start).toBe(0.5); - expect(end).toBe(0.5); - await destroy(testAnimation); - }); - - it('should handle scrolledInView with damped progress', () => { - const renderSpy = vi.spyOn(animation, 'render'); - - animation.scrolledInView({ - current: { x: 0, y: 0.5 }, - dampedCurrent: { x: 0, y: 0.5 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0, y: 0.5 }, - progress: { x: 0, y: 0.5 }, - }); - - expect(renderSpy).toHaveBeenCalledWith(0.5); - }); - - it('should handle scrolledInView with custom play range', async () => { - const testElement = h('div', { - dataOptionPlayRange: JSON.stringify([0.25, 0.75]), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - const renderSpy = vi.spyOn(testAnimation, 'render'); - - testAnimation.scrolledInView({ - current: { x: 0, y: 0.5 }, - dampedCurrent: { x: 0, y: 0.5 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0, y: 0.5 }, - progress: { x: 0, y: 0.5 }, - }); - - expect(renderSpy).toHaveBeenCalledWith(0.5); - await destroy(testAnimation); - }); - - it('should render animation with given progress', () => { - const progressSpy = vi.fn(); - const mockAnimation = { progress: progressSpy }; - - Object.defineProperty(animation, 'animation', { - value: mockAnimation, - configurable: true, - }); - - animation.render(0.75); - expect(progressSpy).toHaveBeenCalledWith(0.75); - }); - - it('should track progress in render method', () => { - const progressSpy = vi.fn(); - const mockAnimation = { progress: progressSpy }; - - Object.defineProperty(animation, 'animation', { - value: mockAnimation, - configurable: true, - }); - - expect(animation.progress).toBe(0); - animation.render(0.75); - expect(animation.progress).toBe(0.75); - }); - - it('should restore animation state on mount', async () => { - const testElement = h('div', { - dataOptionFrom: JSON.stringify({ opacity: 0 }), - dataOptionTo: JSON.stringify({ opacity: 1 }), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - - // Simulate partial progress closer to end - testAnimation.render(0.6); - expect(testAnimation.progress).toBe(0.6); - - // Destroy rounds progress to nearest boundary (1) - await destroy(testAnimation); - await nextTick(); - expect(testAnimation.progress).toBe(1); - - // Remount restores the completed progress - await mount(testAnimation); - expect(testAnimation.progress).toBe(1); - }); - - it('should complete animation to nearest boundary on destroy', async () => { - const testElement = h('div', { - dataOptionFrom: JSON.stringify({ opacity: 0 }), - dataOptionTo: JSON.stringify({ opacity: 1 }), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - - // Simulate partial progress closer to 1 - testAnimation.render(0.8); - expect(testAnimation.progress).toBe(0.8); - - // Destroy should trigger completion to nearest boundary (1) - await destroy(testAnimation); - await nextTick(); - - expect(testAnimation.progress).toBe(1); - }); - - it('should complete animation to 0 when progress is closer to start', async () => { - const testElement = h('div', { - dataOptionFrom: JSON.stringify({ opacity: 0 }), - dataOptionTo: JSON.stringify({ opacity: 1 }), - }); - const testAnimation = new TestScrollAnimation(testElement); - await mount(testAnimation); - - // Simulate partial progress closer to 0 - testAnimation.render(0.3); - expect(testAnimation.progress).toBe(0.3); - - // Destroy should trigger completion to nearest boundary (0) - await destroy(testAnimation); - await nextTick(); - - expect(testAnimation.progress).toBe(0); - }); -}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts deleted file mode 100644 index df8652b0..00000000 --- a/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'; -import { ScrollAnimation } from '@studiometa/ui'; -import { - h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, -} from '#test-utils'; - -describe('ScrollAnimation (deprecated)', () => { - let element: HTMLDivElement; - let targetElement: HTMLDivElement; - let animation: ScrollAnimation; - - beforeAll(() => { - intersectionObserverBeforeAllCallback(); - }); - - afterEach(() => { - intersectionObserverAfterEachCallback(); - }); - - beforeEach(async () => { - targetElement = h('div', { dataRef: 'target' }); - element = h('div', [targetElement]); - animation = new ScrollAnimation(element); - await mockIsIntersecting(element, true); - }); - - afterEach(async () => { - await mockIsIntersecting(element, false); - }); - - it('should have the correct config', () => { - expect(ScrollAnimation.config.name).toBe('ScrollAnimation'); - expect(ScrollAnimation.config.refs).toEqual(['target']); - }); - - it('should use the target ref as animation target', async () => { - expect(animation.$refs.target).toBe(targetElement); - expect(animation.target).toBe(targetElement); - }); - - it('should inherit from AbstractScrollAnimation', () => { - expect(animation).toBeInstanceOf(ScrollAnimation); - expect(animation.scrolledInView).toBeDefined(); - expect(animation.render).toBeDefined(); - }); - - it('should have default playRange', () => { - expect(animation.playRange).toEqual([0, 1]); - }); - - it('should create animation lazily', () => { - expect(() => animation.animation).not.toThrow(); - }); -}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts deleted file mode 100644 index 82078703..00000000 --- a/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { ScrollAnimationChild } from '@studiometa/ui'; -import { h, mount, destroy } from '#test-utils'; - -describe('ScrollAnimationChild (deprecated)', () => { - let element: HTMLDivElement; - let animation: ScrollAnimationChild; - - beforeEach(async () => { - element = h('div'); - animation = new ScrollAnimationChild(element); - await mount(animation); - }); - - afterEach(async () => { - await destroy(animation); - }); - - it('should have the correct config', () => { - expect(ScrollAnimationChild.config.name).toBe('AbstractScrollAnimation'); - expect(ScrollAnimationChild.config.options.dampFactor.default).toBe(0.1); - expect(ScrollAnimationChild.config.options.dampPrecision.default).toBe(0.001); - }); - - it('should initialize with correct default damped values', () => { - expect(animation.dampedCurrent).toEqual({ x: 0, y: 0 }); - expect(animation.dampedProgress).toEqual({ x: 0, y: 0 }); - }); - - it('should have damping options accessible', () => { - expect(animation.$options.dampFactor).toBe(0.1); - expect(animation.$options.dampPrecision).toBe(0.001); - }); - - it('should override scrolledInView method', () => { - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - expect(() => animation.scrolledInView(mockProps)).not.toThrow(); - }); - - it('should inherit from AbstractScrollAnimation', () => { - expect(animation.render).toBeDefined(); - expect(animation.target).toBe(element); - expect(animation.playRange).toEqual([0, 1]); - }); -}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimationChildWithEase.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationChildWithEase.spec.ts deleted file mode 100644 index 78b2e465..00000000 --- a/packages/tests/ScrollAnimation/ScrollAnimationChildWithEase.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ScrollAnimationChildWithEase } from '@studiometa/ui'; -import { h, mount, destroy } from '#test-utils'; - -describe('ScrollAnimationChildWithEase (deprecated)', () => { - let element: HTMLDivElement; - let animation: ScrollAnimationChildWithEase; - - beforeEach(async () => { - element = h('div'); - animation = new ScrollAnimationChildWithEase(element); - await mount(animation); - }); - - afterEach(async () => { - await destroy(animation); - }); - - it('should have the correct config', () => { - expect(ScrollAnimationChildWithEase.config.name).toBe('ScrollAnimationChildWithEase'); - expect(ScrollAnimationChildWithEase.config.options.dampFactor.default).toBe(0.1); - expect(ScrollAnimationChildWithEase.config.options.dampPrecision.default).toBe(0.001); - }); - - it('should initialize with correct default damped values', () => { - expect(animation.dampedCurrent).toEqual({ x: 0, y: 0 }); - expect(animation.dampedProgress).toEqual({ x: 0, y: 0 }); - }); - - it('should have damping options accessible', () => { - expect(animation.$options.dampFactor).toBe(0.1); - expect(animation.$options.dampPrecision).toBe(0.001); - }); - - it('should override scrolledInView method', () => { - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - expect(() => animation.scrolledInView(mockProps)).not.toThrow(); - }); - - it('should inherit from AbstractScrollAnimation', () => { - expect(animation.render).toBeDefined(); - expect(animation.target).toBe(element); - expect(animation.playRange).toEqual([0, 1]); - }); -}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts deleted file mode 100644 index b8d7df61..00000000 --- a/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; -import { ScrollAnimationParent, ScrollAnimationChild } from '@studiometa/ui'; -import { - h, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, -} from '#test-utils'; - -describe('ScrollAnimationParent (deprecated)', () => { - let parentElement: HTMLDivElement; - let childElement1: HTMLDivElement; - let childElement2: HTMLDivElement; - let parent: ScrollAnimationParent; - - beforeAll(() => { - intersectionObserverBeforeAllCallback(); - }); - - afterEach(() => { - intersectionObserverAfterEachCallback(); - }); - - beforeEach(async () => { - parentElement = h('div'); - childElement1 = h('div', { 'data-component': 'ScrollAnimationChild' }); - childElement2 = h('div', { 'data-component': 'ScrollAnimationChild' }); - - parentElement.appendChild(childElement1); - parentElement.appendChild(childElement2); - - parent = new ScrollAnimationParent(parentElement); - await mockIsIntersecting(parentElement, true); - }); - - afterEach(async () => { - await mockIsIntersecting(parentElement, false); - }); - - it('should have the correct config', () => { - expect(ScrollAnimationParent.config.name).toBe('ScrollAnimationParent'); - expect(ScrollAnimationParent.config.components.ScrollAnimationChild).toBe(ScrollAnimationChild); - }); - - it('should have ScrollAnimationChild components', () => { - expect(parent.$children.ScrollAnimationChild).toHaveLength(2); - expect(parent.$children.ScrollAnimationChild[0]).toBeInstanceOf(ScrollAnimationChild); - expect(parent.$children.ScrollAnimationChild[1]).toBeInstanceOf(ScrollAnimationChild); - }); - - it('should propagate scrolledInView to all children', () => { - const child1Spy = vi.spyOn(parent.$children.ScrollAnimationChild[0], 'scrolledInView'); - const child2Spy = vi.spyOn(parent.$children.ScrollAnimationChild[1], 'scrolledInView'); - - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - parent.scrolledInView(mockProps); - - expect(child1Spy).toHaveBeenCalledWith(mockProps); - expect(child2Spy).toHaveBeenCalledWith(mockProps); - }); - - it('should work with no children', async () => { - const emptyParent = new ScrollAnimationParent(h('div')); - await mockIsIntersecting(emptyParent.$el, true); - - expect(emptyParent.$children.ScrollAnimationChild).toHaveLength(0); - - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - expect(() => emptyParent.scrolledInView(mockProps)).not.toThrow(); - - await mockIsIntersecting(emptyParent.$el, false); - }); - - it('should be extended from withScrolledInView(Base)', () => { - expect(parent.scrolledInView).toBeDefined(); - }); -}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts deleted file mode 100644 index 1c851c64..00000000 --- a/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { ScrollAnimationTarget } from '@studiometa/ui'; -import { h, mount, destroy } from '#test-utils'; - -describe('ScrollAnimationTarget', () => { - let element: HTMLDivElement; - let animation: ScrollAnimationTarget; - - beforeEach(async () => { - element = h('div'); - animation = new ScrollAnimationTarget(element); - await mount(animation); - }); - - afterEach(async () => { - await destroy(animation); - }); - - it('should have the correct config', () => { - expect(ScrollAnimationTarget.config.name).toBe('ScrollAnimationTarget'); - expect(ScrollAnimationTarget.config.options.dampFactor.default).toBe(0.1); - expect(ScrollAnimationTarget.config.options.dampPrecision.default).toBe(0.001); - }); - - it('should initialize with correct default damped values', () => { - expect(animation.dampedCurrent).toEqual({ x: 0, y: 0 }); - expect(animation.dampedProgress).toEqual({ x: 0, y: 0 }); - }); - - it('should have damping options accessible', () => { - expect(animation.$options.dampFactor).toBe(0.1); - expect(animation.$options.dampPrecision).toBe(0.001); - }); - - it('should override scrolledInView method', () => { - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - expect(() => animation.scrolledInView(mockProps)).not.toThrow(); - }); - - it('should inherit from AbstractScrollAnimation', () => { - expect(animation.render).toBeDefined(); - expect(animation.target).toBe(element); - expect(animation.playRange).toEqual([0, 1]); - }); -}); diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts deleted file mode 100644 index bbbb9a49..00000000 --- a/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; -import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui'; -import { domScheduler } from '@studiometa/js-toolkit/utils'; -import { - h, - destroy, - mockIsIntersecting, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, -} from '#test-utils'; - -describe('ScrollAnimationTimeline', () => { - let parentElement: HTMLDivElement; - let childElement1: HTMLDivElement; - let childElement2: HTMLDivElement; - let parent: ScrollAnimationTimeline; - - beforeAll(() => { - intersectionObserverBeforeAllCallback(); - }); - - afterEach(() => { - intersectionObserverAfterEachCallback(); - }); - - beforeEach(async () => { - parentElement = h('div'); - childElement1 = h('div', { 'data-component': 'ScrollAnimationTarget' }); - childElement2 = h('div', { 'data-component': 'ScrollAnimationTarget' }); - - parentElement.appendChild(childElement1); - parentElement.appendChild(childElement2); - - parent = new ScrollAnimationTimeline(parentElement); - await mockIsIntersecting(parentElement, true); - }); - - afterEach(async () => { - await mockIsIntersecting(parentElement, false); - }); - - it('should have the correct config', () => { - expect(ScrollAnimationTimeline.config.name).toBe('ScrollAnimationTimeline'); - expect(ScrollAnimationTimeline.config.components.ScrollAnimationTarget).toBe(ScrollAnimationTarget); - }); - - it('should have ScrollAnimationTarget components', () => { - expect(parent.$children.ScrollAnimationTarget).toHaveLength(2); - expect(parent.$children.ScrollAnimationTarget[0]).toBeInstanceOf(ScrollAnimationTarget); - expect(parent.$children.ScrollAnimationTarget[1]).toBeInstanceOf(ScrollAnimationTarget); - }); - - it('should propagate scrolledInView to all children', () => { - const child1Spy = vi.spyOn(parent.$children.ScrollAnimationTarget[0], 'scrolledInView'); - const child2Spy = vi.spyOn(parent.$children.ScrollAnimationTarget[1], 'scrolledInView'); - - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - parent.scrolledInView(mockProps); - - expect(child1Spy).toHaveBeenCalledWith(mockProps); - expect(child2Spy).toHaveBeenCalledWith(mockProps); - }); - - it('should work with no children', async () => { - const emptyParent = new ScrollAnimationTimeline(h('div')); - await mockIsIntersecting(emptyParent.$el, true); - - expect(emptyParent.$children.ScrollAnimationTarget).toHaveLength(0); - - const mockProps = { - current: { x: 0.5, y: 0.8 }, - dampedCurrent: { x: 0.4, y: 0.7 }, - start: { x: 0, y: 0 }, - end: { x: 1, y: 1 }, - dampedProgress: { x: 0.4, y: 0.7 }, - progress: { x: 0.5, y: 0.8 }, - }; - - expect(() => emptyParent.scrolledInView(mockProps)).not.toThrow(); - - await mockIsIntersecting(emptyParent.$el, false); - }); - - it('should be extended from withScrolledInView(Base)', () => { - expect(parent.scrolledInView).toBeDefined(); - }); - - it('should not share dampedProgress between children', async () => { - parentElement = h('div'); - childElement1 = h('div', { - 'data-component': 'ScrollAnimationTarget', - 'data-option-damp-factor': '0.1', - }); - childElement2 = h('div', { - 'data-component': 'ScrollAnimationTarget', - 'data-option-damp-factor': '1', - }); - - parentElement.appendChild(childElement1); - parentElement.appendChild(childElement2); - - const timeline = new ScrollAnimationTimeline(parentElement); - await mockIsIntersecting(parentElement, true); - - const child1 = timeline.$children.ScrollAnimationTarget[0]; - const child2 = timeline.$children.ScrollAnimationTarget[1]; - - const child1RenderSpy = vi.spyOn(child1, 'render'); - const child2RenderSpy = vi.spyOn(child2, 'render'); - - const mockProps = { - current: { x: 0, y: 100 }, - start: { x: 0, y: 0 }, - end: { x: 0, y: 1000 }, - progress: { x: 0, y: 0.1 }, - dampedCurrent: { x: 0, y: 0 }, - dampedProgress: { x: 0, y: 0 }, - }; - - timeline.scrolledInView(mockProps as any); - - // Wait for domScheduler - await new Promise((resolve) => domScheduler.read(() => domScheduler.write(resolve))); - - expect(child1RenderSpy).toHaveBeenCalledWith(0.01); - expect(child2RenderSpy).toHaveBeenCalledWith(0.1); - - await mockIsIntersecting(parentElement, false); - await destroy(timeline); - }); -}); diff --git a/packages/tests/ScrollAnimation/index.spec.ts b/packages/tests/ScrollAnimation/index.spec.ts deleted file mode 100644 index f9659a72..00000000 --- a/packages/tests/ScrollAnimation/index.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - AbstractScrollAnimation, - ScrollAnimationTimeline, - ScrollAnimationTarget, - // Deprecated exports - ScrollAnimation, - ScrollAnimationChild, - ScrollAnimationChildWithEase, - ScrollAnimationParent, - ScrollAnimationWithEase, - animationScrollWithEase, -} from '@studiometa/ui'; - -describe('ScrollAnimation exports', () => { - it('should export AbstractScrollAnimation', () => { - expect(AbstractScrollAnimation).toBeDefined(); - expect(AbstractScrollAnimation.config.name).toBe('AbstractScrollAnimation'); - }); - - it('should export ScrollAnimationTimeline', () => { - expect(ScrollAnimationTimeline).toBeDefined(); - expect(ScrollAnimationTimeline.config.name).toBe('ScrollAnimationTimeline'); - }); - - it('should export ScrollAnimationTarget', () => { - expect(ScrollAnimationTarget).toBeDefined(); - expect(ScrollAnimationTarget.config.name).toBe('ScrollAnimationTarget'); - }); - - // Deprecated exports - kept for backward compatibility - it('should export ScrollAnimation (deprecated)', () => { - expect(ScrollAnimation).toBeDefined(); - expect(ScrollAnimation.config.name).toBe('ScrollAnimation'); - }); - - it('should export ScrollAnimationChild (deprecated)', () => { - expect(ScrollAnimationChild).toBeDefined(); - expect(ScrollAnimationChild.config.name).toBe('AbstractScrollAnimation'); - }); - - it('should export ScrollAnimationChildWithEase (deprecated)', () => { - expect(ScrollAnimationChildWithEase).toBeDefined(); - expect(ScrollAnimationChildWithEase.config.name).toBe('ScrollAnimationChildWithEase'); - }); - - it('should export ScrollAnimationParent (deprecated)', () => { - expect(ScrollAnimationParent).toBeDefined(); - expect(ScrollAnimationParent.config.name).toBe('ScrollAnimationParent'); - }); - - it('should export ScrollAnimationWithEase (deprecated)', () => { - expect(ScrollAnimationWithEase).toBeDefined(); - expect(ScrollAnimationWithEase.config.name).toBe('ScrollAnimationWithEase'); - }); - - it('should export animationScrollWithEase decorator (deprecated)', () => { - expect(animationScrollWithEase).toBeDefined(); - expect(typeof animationScrollWithEase).toBe('function'); - }); -}); diff --git a/packages/tests/Timer/Timer.spec.ts b/packages/tests/Timer/Timer.spec.ts deleted file mode 100644 index 0f2cd37c..00000000 --- a/packages/tests/Timer/Timer.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Timer } from '@studiometa/ui'; -import { h, useFakeTimers, useRealTimers, advanceTimersByTimeAsync } from '#test-utils'; - -/** - * Record how many times each named event fires on the element, keeping the - * `detail` payloads so bubbling `CustomEvent`s can be asserted like an `Action` - * would consume them. - */ -function listen(el: HTMLElement, ...names: string[]) { - const calls: Record = {}; - const details: Record = {}; - - for (const name of names) { - calls[name] = 0; - details[name] = []; - el.addEventListener(name, (event) => { - calls[name] += 1; - details[name].push((event as CustomEvent).detail); - }); - } - - return { calls, details }; -} - -/** - * Build a `Timer` element, attach listeners, then mount and flush the mount - * queue so `mounted()` (and its `autostart`) has run. - */ -async function mountTimer(attributes: Record = {}, events: string[] = []) { - const el = h('div', { dataComponent: 'Timer', ...attributes }); - const recorder = listen(el, ...events); - const instance = new Timer(el); - instance.$mount(); - await advanceTimersByTimeAsync(50); - - return { el, instance, ...recorder }; -} - -describe('Timer component', () => { - beforeEach(() => { - useFakeTimers(); - }); - - afterEach(() => { - useRealTimers(); - }); - - it('should have the correct config', () => { - expect(Timer.config.name).toBe('Timer'); - expect(Timer.config.emits).toEqual([ - 'timer-start', - 'timer-end', - 'timer-tick', - 'timer-pause', - 'timer-resume', - 'timer-stop', - ]); - }); - - it('should start on mount and emit `timer-start`', async () => { - const { calls } = await mountTimer({ dataOptionDelay: '2' }, ['timer-start', 'timer-end']); - expect(calls['timer-start']).toBe(1); - expect(calls['timer-end']).toBe(0); - }); - - it('should emit `timer-end` once the delay (in seconds) elapsed', async () => { - const { calls } = await mountTimer({ dataOptionDelay: '2' }, ['timer-end']); - await advanceTimersByTimeAsync(1000); - expect(calls['timer-end']).toBe(0); - await advanceTimersByTimeAsync(1500); - expect(calls['timer-end']).toBe(1); - }); - - it('should dispatch bubbling events', async () => { - const el = h('div', { dataComponent: 'Timer', dataOptionDelay: '1' }); - const parent = h('div', [el]); - let bubbled = 0; - parent.addEventListener('timer-end', () => { - bubbled += 1; - }); - const instance = new Timer(el); - instance.$mount(); - await advanceTimersByTimeAsync(1050); - expect(bubbled).toBe(1); - }); - - it('should re-arm and keep emitting when `repeat` is enabled', async () => { - const { calls } = await mountTimer({ dataOptionDelay: '1', dataOptionRepeat: '' }, [ - 'timer-end', - 'timer-tick', - ]); - await advanceTimersByTimeAsync(3200); - expect(calls['timer-end']).toBe(3); - expect(calls['timer-tick']).toBe(3); - }); - - it('should not autostart when disabled, and start on demand', async () => { - const { instance, calls } = await mountTimer( - { dataOptionDelay: '1', dataOptionNoAutostart: '' }, - ['timer-start', 'timer-end'], - ); - expect(instance.$options.autostart).toBe(false); - expect(calls['timer-start']).toBe(0); - - instance.start(); - await advanceTimersByTimeAsync(1050); - expect(calls['timer-start']).toBe(1); - expect(calls['timer-end']).toBe(1); - }); - - it('should stop without completing', async () => { - const { instance, calls } = await mountTimer({ dataOptionDelay: '2' }, [ - 'timer-stop', - 'timer-end', - ]); - instance.stop(); - expect(calls['timer-stop']).toBe(1); - await advanceTimersByTimeAsync(3000); - expect(calls['timer-end']).toBe(0); - }); - - it('should pause and resume, preserving the remaining time', async () => { - const { instance, calls } = await mountTimer({ dataOptionDelay: '2' }, [ - 'timer-pause', - 'timer-resume', - 'timer-end', - ]); - - await advanceTimersByTimeAsync(1000); - instance.pause(); - expect(calls['timer-pause']).toBe(1); - - // Time passes while paused: the timer must not complete. - await advanceTimersByTimeAsync(5000); - expect(calls['timer-end']).toBe(0); - - instance.resume(); - expect(calls['timer-resume']).toBe(1); - - // Only the ~1s that was left should be needed to complete. - await advanceTimersByTimeAsync(1100); - expect(calls['timer-end']).toBe(1); - }); - - it('should restart from the beginning', async () => { - const { instance, calls } = await mountTimer({ dataOptionDelay: '2' }, [ - 'timer-start', - 'timer-end', - ]); - - await advanceTimersByTimeAsync(1500); - instance.restart(); - expect(calls['timer-start']).toBe(2); - - // The elapsed 1.5s must have been discarded: no completion yet. - await advanceTimersByTimeAsync(1500); - expect(calls['timer-end']).toBe(0); - - await advanceTimersByTimeAsync(600); - expect(calls['timer-end']).toBe(1); - }); - - it('should not resume after being stopped', async () => { - const { instance, calls } = await mountTimer({ dataOptionDelay: '2' }, [ - 'timer-resume', - 'timer-end', - ]); - - instance.stop(); - instance.resume(); - - expect(calls['timer-resume']).toBe(0); - await advanceTimersByTimeAsync(3000); - expect(calls['timer-end']).toBe(0); - }); - - it('should not resume after completing', async () => { - const { instance, calls } = await mountTimer({ dataOptionDelay: '1' }, [ - 'timer-resume', - 'timer-end', - ]); - - await advanceTimersByTimeAsync(1100); - expect(calls['timer-end']).toBe(1); - - instance.resume(); - expect(calls['timer-resume']).toBe(0); - await advanceTimersByTimeAsync(2000); - expect(calls['timer-end']).toBe(1); - }); - - it('should cancel a pending countdown on destroy', async () => { - const { instance, calls } = await mountTimer({ dataOptionDelay: '2' }, ['timer-end']); - await instance.$destroy(); - await advanceTimersByTimeAsync(3000); - expect(calls['timer-end']).toBe(0); - }); -}); diff --git a/packages/tests/Timer/TimerProgress.spec.ts b/packages/tests/Timer/TimerProgress.spec.ts deleted file mode 100644 index f6c57aeb..00000000 --- a/packages/tests/Timer/TimerProgress.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Timer, TimerProgress } from '@studiometa/ui'; -import { h, useFakeTimers, useRealTimers, advanceTimersByTimeAsync } from '#test-utils'; - -function listen(el: HTMLElement, ...names: string[]) { - const calls: Record = {}; - const details: Record = {}; - - for (const name of names) { - calls[name] = 0; - details[name] = []; - el.addEventListener(name, (event) => { - calls[name] += 1; - details[name].push((event as CustomEvent).detail); - }); - } - - return { calls, details }; -} - -async function mountTimerProgress( - attributes: Record = {}, - events: string[] = [], -) { - const el = h('div', { dataComponent: 'TimerProgress', ...attributes }); - const recorder = listen(el, ...events); - const instance = new TimerProgress(el); - instance.$mount(); - await advanceTimersByTimeAsync(50); - - return { el, instance, ...recorder }; -} - -/** Flatten recorded single-argument `detail` arrays into their ratio values. */ -function ratios(details: unknown[][]): number[] { - return details.map((detail) => (detail as number[])[0]); -} - -describe('TimerProgress component', () => { - beforeEach(() => { - useFakeTimers(); - }); - - afterEach(() => { - useRealTimers(); - }); - - it('should have the correct config and extend Timer', () => { - expect(TimerProgress.config.name).toBe('TimerProgress'); - expect(TimerProgress.config.emits).toEqual(['timer-progress']); - expect(TimerProgress.prototype).toBeInstanceOf(Timer); - }); - - it('should merge the parent lifecycle events into its config', async () => { - const { instance } = await mountTimerProgress({ dataOptionDelay: '1' }); - expect(instance.$config.emits).toContain('timer-start'); - expect(instance.$config.emits).toContain('timer-end'); - expect(instance.$config.emits).toContain('timer-progress'); - }); - - it('should emit an increasing progress ratio during the countdown', async () => { - const { details } = await mountTimerProgress({ dataOptionDelay: '2' }, ['timer-progress']); - await advanceTimersByTimeAsync(1000); - - const values = ratios(details['timer-progress']); - expect(values.length).toBeGreaterThan(1); - // Values stay within bounds and progress forward. - expect(Math.min(...values)).toBeGreaterThanOrEqual(0); - expect(values.at(-1)).toBeGreaterThan(values[0]); - expect(values.at(-1)).toBeLessThanOrEqual(1); - }); - - it('should report a final ratio of 1 when the countdown completes', async () => { - const { details, calls } = await mountTimerProgress({ dataOptionDelay: '1' }, [ - 'timer-progress', - 'timer-end', - ]); - await advanceTimersByTimeAsync(1100); - - expect(calls['timer-end']).toBe(1); - expect(ratios(details['timer-progress'])).toContain(1); - }); - - it('should reset progress to 0 on stop', async () => { - const { instance, details } = await mountTimerProgress({ dataOptionDelay: '2' }, [ - 'timer-progress', - ]); - await advanceTimersByTimeAsync(500); - instance.stop(); - - expect(ratios(details['timer-progress']).at(-1)).toBe(0); - }); - - it('should stop the progress loop when a listener stops it mid-dispatch', async () => { - const el = h('div', { dataComponent: 'TimerProgress', dataOptionDelay: '5' }); - const instance = new TimerProgress(el); - let count = 0; - // A listener that tears the timer down synchronously on the first frame. - el.addEventListener('timer-progress', () => { - count += 1; - if (count === 1) { - instance.stop(); - } - }); - instance.$mount(); - - await advanceTimersByTimeAsync(2000); - - // Without the guard the loop would resurrect itself and keep counting up; - // `stop()` also emits a final progress of 0, so at most two events fire. - expect(count).toBeLessThanOrEqual(2); - }); - - it('should still emit the base lifecycle events', async () => { - const { calls } = await mountTimerProgress({ dataOptionDelay: '1' }, [ - 'timer-start', - 'timer-end', - ]); - expect(calls['timer-start']).toBe(1); - await advanceTimersByTimeAsync(1100); - expect(calls['timer-end']).toBe(1); - }); -}); diff --git a/packages/tests/Toaster/Toast.spec.ts b/packages/tests/Toaster/Toast.spec.ts deleted file mode 100644 index 17062494..00000000 --- a/packages/tests/Toaster/Toast.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Toast } from '@studiometa/ui'; -import { h, mount, wait } from '#test-utils'; - -/** - * Build a Toast element with a `[data-ref=close]` control and mount a `Toast` - * on it. `delay` is in seconds (Timer's option); `autostart` follows Timer's - * default of `true` unless disabled. - */ -async function createToast({ delay, autostart = true } = {} as { - delay?: number; - autostart?: boolean; -}) { - const el = h('div', { - dataComponent: 'Toast', - class: 'toast', - ...(delay === undefined ? {} : { dataOptionDelay: String(delay) }), - ...(autostart ? {} : { 'data-option-no-autostart': '' }), - }) as HTMLElement; - el.innerHTML = '

Message

'; - document.body.append(el); - const toast = new Toast(el); - await mount(toast); - return { toast, el }; -} - -afterEach(() => { - document.body.innerHTML = ''; -}); - -describe('The Toast component', () => { - it('should auto-dismiss after its delay and emit `dismiss`', async () => { - const { toast, el } = await createToast({ delay: 0.02 }); - const dismissFn = vi.fn(); - toast.$on('dismiss', dismissFn); - expect(el.isConnected).toBe(true); - await wait(80); - expect(el.isConnected).toBe(false); - expect(dismissFn).toHaveBeenCalledTimes(1); - expect(dismissFn.mock.calls[0][0].detail).toEqual([el]); - }); - - it('should dismiss when the close ref is clicked', async () => { - const { el } = await createToast({ autostart: false }); - (el.querySelector('[data-ref=close]') as HTMLElement).click(); - await wait(20); - expect(el.isConnected).toBe(false); - }); - - it('should pause the countdown while hovered and resume on leave', async () => { - const { el } = await createToast({ delay: 0.02 }); - el.dispatchEvent(new Event('mouseenter')); - await wait(80); - expect(el.isConnected).toBe(true); - el.dispatchEvent(new Event('mouseleave')); - await wait(80); - expect(el.isConnected).toBe(false); - }); - - it('should pause the countdown while focus is inside and resume on blur', async () => { - const { el } = await createToast({ delay: 0.02 }); - el.dispatchEvent(new Event('focusin')); - await wait(80); - expect(el.isConnected).toBe(true); - el.dispatchEvent(new Event('focusout')); - await wait(80); - expect(el.isConnected).toBe(false); - }); - - it('should stay put when autostart is disabled (sticky)', async () => { - const { el } = await createToast({ delay: 0.02, autostart: false }); - await wait(80); - expect(el.isConnected).toBe(true); - (el.querySelector('[data-ref=close]') as HTMLElement).click(); - await wait(20); - expect(el.isConnected).toBe(false); - }); - - it('should be idempotent to dismiss twice', async () => { - const { toast, el } = await createToast({ autostart: false }); - const dismissFn = vi.fn(); - toast.$on('dismiss', dismissFn); - toast.dismiss(); - toast.dismiss(); - await wait(20); - expect(dismissFn).toHaveBeenCalledTimes(1); - expect(el.isConnected).toBe(false); - }); - - it('should cancel the countdown on destroy (no dismiss after teardown)', async () => { - const { toast, el } = await createToast({ delay: 0.02 }); - const dismissFn = vi.fn(); - toast.$on('dismiss', dismissFn); - await toast.$destroy(); - await wait(80); - expect(dismissFn).not.toHaveBeenCalled(); - expect(el.isConnected).toBe(true); - }); -}); diff --git a/packages/tests/Toaster/Toaster.spec.ts b/packages/tests/Toaster/Toaster.spec.ts deleted file mode 100644 index d5db5a91..00000000 --- a/packages/tests/Toaster/Toaster.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Toaster } from '@studiometa/ui'; -import { h, mount, wait } from '#test-utils'; - -/** - * Install a fake `document.startViewTransition` that runs the update callback - * and resolves its promises, recording every call. Returns the call list. - */ -function mockStartViewTransition() { - const calls: Array<() => void | Promise> = []; - // @ts-expect-error — happy-dom does not implement the View Transitions API. - document.startViewTransition = (update: () => void | Promise) => { - calls.push(update); - const done = Promise.resolve().then(() => update()); - return { finished: done, ready: done, updateCallbackDone: done }; - }; - return calls; -} - -/** - * Build a Toaster with its two live regions and a `Toast` template. `Toast` is - * intentionally not registered here, so appended toasts stay inert markup and - * these tests assert the DOM the factory produces (Toast's own behaviour lives - * in Toast.spec.ts). - */ -async function createToaster({ duration, assertive = true } = {} as { - duration?: number; - assertive?: boolean; -}) { - const el = h('div', { - dataComponent: 'Toaster', - ...(duration === undefined ? {} : { dataOptionDuration: String(duration) }), - }); - el.innerHTML = ` -
- ${assertive ? '
' : ''} - - `; - document.body.append(el); - const toaster = new Toaster(el); - await mount(toaster); - return { toaster, el }; -} - -afterEach(() => { - document.body.innerHTML = ''; - // @ts-expect-error — reset the API mock between tests. - delete document.startViewTransition; -}); - -describe('The Toaster component', () => { - it('should start with no toast', async () => { - const { el } = await createToaster(); - expect(el.querySelectorAll('.toast')).toHaveLength(0); - }); - - it('should append a Toast holding the message to the polite region', async () => { - const { toaster } = await createToaster(); - toaster.show('Saved.'); - await wait(0); - const polite = toaster.$refs.polite; - const toast = polite.querySelector('.toast'); - expect(toast?.getAttribute('data-component')).toBe('Toast'); - expect(polite.querySelector('[data-message]')?.textContent).toBe('Saved.'); - }); - - it('should route error toasts to the assertive region', async () => { - const { toaster } = await createToaster(); - toaster.show('Boom.', { type: 'error' }); - await wait(0); - expect(toaster.$refs.assertive.querySelectorAll('.toast')).toHaveLength(1); - expect(toaster.$refs.polite.querySelectorAll('.toast')).toHaveLength(0); - }); - - it('should fall back to the polite region when there is no assertive one', async () => { - const { toaster } = await createToaster({ assertive: false }); - toaster.show('Boom.', { type: 'error' }); - await wait(0); - expect(toaster.$refs.polite.querySelectorAll('.toast')).toHaveLength(1); - }); - - it('should mirror the type and assign a unique view-transition-name', async () => { - const { toaster } = await createToaster(); - const a = toaster.show('a', { type: 'success' }); - const b = toaster.show('b'); - expect(a.dataset.type).toBe('success'); - expect(b.dataset.type).toBe('info'); - const nameA = a.style.getPropertyValue('view-transition-name'); - const nameB = b.style.getPropertyValue('view-transition-name'); - expect(nameA).toMatch(/^toaster-\d+$/); - expect(nameB).toMatch(/^toaster-\d+$/); - expect(nameA).not.toBe(nameB); - }); - - it('should write the duration onto the toast as the Timer `delay`', async () => { - const { toaster } = await createToaster({ duration: 5 }); - const a = toaster.show('a'); - expect(a.dataset.optionDelay).toBe('5'); - const b = toaster.show('b', { duration: 8 }); - expect(b.dataset.optionDelay).toBe('8'); - }); - - it('should make a 0-duration toast sticky by disabling its autostart', async () => { - const { toaster } = await createToaster({ duration: 0 }); - const toast = toaster.show('Saved.'); - expect(toast.hasAttribute('data-option-no-autostart')).toBe(true); - expect(toast.dataset.optionDelay).toBeUndefined(); - }); - - it('should emit a `show` event with the toast, message and type', async () => { - const { toaster } = await createToaster(); - const showFn = vi.fn(); - toaster.$on('show', showFn); - const toast = toaster.show('Saved.', { type: 'error' }); - expect(showFn).toHaveBeenCalledTimes(1); - expect(showFn.mock.calls[0][0].detail).toEqual([toast, 'Saved.', 'error']); - }); - - it('should batch toasts fired in the same tick into a single view transition', async () => { - const calls = mockStartViewTransition(); - const { toaster } = await createToaster(); - - toaster.show('one'); - toaster.show('two'); - toaster.show('three'); - await wait(0); - - expect(calls).toHaveLength(1); - expect(toaster.$refs.polite.querySelectorAll('.toast')).toHaveLength(3); - }); -}); diff --git a/packages/tests/Track/TrackContext.spec.ts b/packages/tests/Track/TrackContext.spec.ts deleted file mode 100644 index 9aab9283..00000000 --- a/packages/tests/Track/TrackContext.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Track, TrackContext } from '@studiometa/ui'; -import type { Base } from '@studiometa/js-toolkit'; -import { h, mount } from '#test-utils'; - -const COMPONENTS: Record Base> = { - Track, - TrackContext, -}; - -async function mountTree(html: string) { - const root = h('div'); - root.innerHTML = html; - - const instances: Base[] = []; - for (const el of Array.from(root.querySelectorAll('[data-component]'))) { - const name = el.getAttribute('data-component'); - const Ctor = COMPONENTS[name]; - instances.push(new Ctor(el as HTMLElement)); - } - - await mount(...instances); - - return { root, instances }; -} - -/** - * Resolve the merged context seen by a Track nested under the given HTML by - * clicking it and reading the payload pushed to `window.dataLayer`. - */ -async function resolveContext(contextHtml: string) { - window.dataLayer = []; - const { root } = await mountTree(` - ${contextHtml.replace( - '', - ``, - )} - `); - (root.querySelector('button') as HTMLButtonElement).click(); - const payload = { ...(window.dataLayer.at(-1) as Record) }; - delete payload.event; - return payload; -} - -beforeEach(() => { - window.dataLayer = []; -}); - -describe('TrackContext component', () => { - it('should have the correct config', () => { - expect(TrackContext.config.name).toBe('TrackContext'); - expect(TrackContext.config.refs).toEqual(['context']); - }); - - describe('context sources', () => { - it('should read the context from the `context` script ref only', async () => { - const context = await resolveContext(` -
- -
- `); - - expect(context).toEqual({ page_type: 'product', id: '123' }); - }); - - it('should read the context from the `data-option-context` attribute only', async () => { - const context = await resolveContext(` -
-
- `); - - expect(context).toEqual({ page_type: 'cart' }); - }); - - it('should merge both sources, the attribute overriding the script ref', async () => { - const context = await resolveContext(` -
- -
- `); - - expect(context).toEqual({ - page_type: 'override', - from_attr: true, - from_script: true, - }); - }); - }); - - describe('ancestor chain', () => { - it('should deep-merge every ancestor TrackContext, the nearest winning', async () => { - // Emulate a PDP > Variant chain around the probe button. - window.dataLayer = []; - const { root } = await mountTree(` -
-
- -
-
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - const payload = window.dataLayer.at(-1) as Record; - - expect(payload).toEqual({ - page_type: 'product', - currency: 'EUR', - // Deep-merged: the inner context only overrides `product.id` while - // `product.brand` is preserved from the outer context. - product: { id: 'variant', brand: 'ACME' }, - event: 'probe', - }); - }); - }); - - describe('array merge (replace, not concat)', () => { - it('should replace an ancestor array with the nearer context array', async () => { - window.dataLayer = []; - const { root } = await mountTree(` -
-
- -
-
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - const payload = window.dataLayer.at(-1) as Record; - - // Nearer context replaces the array instead of concatenating to 3 items. - expect(payload.items).toEqual([{ id: 'c' }]); - }); - - it('should replace the script-ref array with the attribute array on the same context', async () => { - const context = await resolveContext(` -
- -
- `); - - expect(context.items).toEqual([{ id: 'attr' }]); - }); - }); - - describe('malformed JSON tolerance', () => { - it('should not throw and fall back to {} when the `context` ref is invalid JSON', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const context = await resolveContext(` -
- -
- `); - - expect(context).toEqual({}); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('should not throw and fall back to {} when `data-option-context` is invalid JSON', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const context = await resolveContext(` -
-
- `); - - expect(context).toEqual({}); - spy.mockRestore(); - }); - }); -}); diff --git a/packages/tests/Track/TrackEvent.spec.ts b/packages/tests/Track/TrackEvent.spec.ts deleted file mode 100644 index 2ab4d545..00000000 --- a/packages/tests/Track/TrackEvent.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Track } from '@studiometa/ui'; -import { h } from '#test-utils'; - -async function mountTrack(el: HTMLElement) { - const track = new Track(el); - await track.$mount(); - return track; -} - -function lastPush() { - return window.dataLayer?.at(-1); -} - -beforeEach(() => { - window.dataLayer = []; -}); - -describe('TrackEvent custom event handling', () => { - it('should resolve `$detail.*` placeholders from the event detail', async () => { - const el = h('div', { - dataComponent: 'Track', - 'data-track:form-submitted': JSON.stringify({ - event: 'form_submitted', - email: '$detail.email', - name: '$detail.user.name', - }), - }); - await mountTrack(el); - - el.dispatchEvent( - new CustomEvent('form-submitted', { - detail: { email: 'test@example.com', user: { name: 'John' } }, - }), - ); - - expect(lastPush()).toEqual({ - event: 'form_submitted', - email: 'test@example.com', - name: 'John', - }); - }); - - it('should resolve `$detail.*` placeholders nested inside arrays', async () => { - const el = h('div', { - dataComponent: 'Track', - 'data-track:add-to-cart': JSON.stringify({ - event: 'add_to_cart', - ecommerce: { - items: [{ item_id: '$detail.id', price: '$detail.price' }], - }, - }), - }); - await mountTrack(el); - - el.dispatchEvent(new CustomEvent('add-to-cart', { detail: { id: 'SKU1', price: 29.9 } })); - - expect(lastPush()).toEqual({ - event: 'add_to_cart', - ecommerce: { items: [{ item_id: 'SKU1', price: 29.9 }] }, - }); - }); - - it('should treat a falsy CustomEvent detail as empty (no literal placeholder leak)', async () => { - const el = h('div', { - dataComponent: 'Track', - 'data-track:ping': JSON.stringify({ event: 'ping', value: '$detail.value' }), - }); - await mountTrack(el); - - el.dispatchEvent(new CustomEvent('ping', { detail: 0 })); - - // The placeholder resolves to undefined, never the literal "$detail.value". - const push = lastPush() as Record; - expect(push.event).toBe('ping'); - expect(push.value).toBeUndefined(); - }); - - it('should merge the full event detail with the `.detail` modifier', async () => { - const el = h('div', { - dataComponent: 'Track', - 'data-track:custom-event.detail': JSON.stringify({ event: 'custom' }), - }); - await mountTrack(el); - - el.dispatchEvent( - new CustomEvent('custom-event', { - detail: { foo: 'bar', count: 2 }, - }), - ); - - expect(lastPush()).toEqual({ event: 'custom', foo: 'bar', count: 2 }); - }); -}); - -describe('TrackEvent timing modifiers', () => { - it('should debounce the dispatch with a custom delay', async () => { - const el = h('div', { - dataComponent: 'Track', - 'data-track:input.debounce500': JSON.stringify({ event: 'search_input' }), - }); - await mountTrack(el); - - vi.useFakeTimers(); - el.dispatchEvent(new Event('input')); - el.dispatchEvent(new Event('input')); - el.dispatchEvent(new Event('input')); - - // Nothing dispatched before the delay elapses. - expect(window.dataLayer).toHaveLength(0); - - vi.advanceTimersByTime(500); - - // Only a single, trailing dispatch after the delay. - expect(window.dataLayer).toHaveLength(1); - expect(lastPush()).toEqual({ event: 'search_input' }); - vi.useRealTimers(); - }); - - it('should throttle the dispatch with a custom delay', async () => { - const el = h('div', { - dataComponent: 'Track', - 'data-track:scroll.throttle200': JSON.stringify({ event: 'scroll_depth' }), - }); - await mountTrack(el); - - vi.useFakeTimers(); - el.dispatchEvent(new Event('scroll')); - // Leading edge dispatches immediately. - expect(window.dataLayer).toHaveLength(1); - - el.dispatchEvent(new Event('scroll')); - el.dispatchEvent(new Event('scroll')); - // Still throttled within the window. - expect(window.dataLayer).toHaveLength(1); - - vi.advanceTimersByTime(200); - el.dispatchEvent(new Event('scroll')); - expect(window.dataLayer.length).toBeGreaterThanOrEqual(2); - vi.useRealTimers(); - }); -}); diff --git a/packages/tests/Track/TrackShopify.spec.ts b/packages/tests/Track/TrackShopify.spec.ts deleted file mode 100644 index ae3717b8..00000000 --- a/packages/tests/Track/TrackShopify.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { TrackShopify, TrackContext } from '@studiometa/ui'; -import type { Base } from '@studiometa/js-toolkit'; -import { h, mount } from '#test-utils'; - -const COMPONENTS: Record Base> = { - TrackShopify, - TrackContext, -}; - -async function mountTree(html: string) { - const root = h('div'); - root.innerHTML = html; - - const instances: Base[] = []; - for (const el of Array.from(root.querySelectorAll('[data-component]'))) { - const name = el.getAttribute('data-component'); - const Ctor = COMPONENTS[name]; - instances.push(new Ctor(el as HTMLElement)); - } - - await mount(...instances); - - return { root, instances }; -} - -beforeEach(() => { - delete window.Shopify; -}); - -afterEach(() => { - delete window.Shopify; -}); - -describe('TrackShopify component', () => { - it('should have the correct config', () => { - expect(TrackShopify.config.name).toBe('TrackShopify'); - }); - - it('should publish through window.Shopify.analytics.publish with the event name and payload', async () => { - const publish = vi.fn(); - const analytics = { publish }; - window.Shopify = { analytics }; - - const { root } = await mountTree(` -
- -
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(publish).toHaveBeenCalledTimes(1); - expect(publish).toHaveBeenCalledWith('add_to_cart', { - page_type: 'product', - event: 'add_to_cart', - product_id: '123', - }); - // The first argument is the payload's `event` key. - const [name, payload] = publish.mock.calls[0]; - expect(name).toBe((payload as { event: string }).event); - }); - - it('should call publish bound to window.Shopify.analytics', async () => { - const publish = vi.fn(); - const analytics = { publish }; - window.Shopify = { analytics }; - - const { root } = await mountTree( - ``, - ); - - (root.querySelector('button') as HTMLButtonElement).click(); - - // `this` inside publish must be the analytics object. - expect(publish.mock.instances[0]).toBe(analytics); - }); - - it('should warn and not throw when the Shopify analytics API is absent', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - // window.Shopify is undefined (cleared in beforeEach). - const { root } = await mountTree( - ``, - ); - - const button = root.querySelector('button') as HTMLButtonElement; - expect(() => button.click()).not.toThrow(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('should warn and not publish when the payload has no string `event` name', async () => { - const publish = vi.fn(); - window.Shopify = { analytics: { publish } }; - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const { root } = await mountTree( - ``, - ); - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(publish).not.toHaveBeenCalled(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('should warn and not throw when publish is not a function', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - window.Shopify = { analytics: {} }; - - const { root } = await mountTree( - ``, - ); - - const button = root.querySelector('button') as HTMLButtonElement; - expect(() => button.click()).not.toThrow(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); -}); diff --git a/packages/tests/Track/index.spec.ts b/packages/tests/Track/index.spec.ts deleted file mode 100644 index daffabdb..00000000 --- a/packages/tests/Track/index.spec.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { describe, it, expect, beforeEach, beforeAll, afterEach, vi } from 'vitest'; -import { Track, TrackContext } from '@studiometa/ui'; -import type { Base } from '@studiometa/js-toolkit'; -import { - h, - wait, - mount, - mockIsIntersecting, - intersectionMockInstance, - intersectionObserverBeforeAllCallback, - intersectionObserverAfterEachCallback, -} from '#test-utils'; - -const COMPONENTS: Record Base> = { - Track, - TrackContext, -}; - -/** - * Build a DOM tree from an HTML string, instantiate every `[data-component]` - * element with its matching class and mount them all. - */ -async function mountTree(html: string) { - const root = h('div'); - root.innerHTML = html; - - const instances: Base[] = []; - for (const el of Array.from(root.querySelectorAll('[data-component]'))) { - const name = el.getAttribute('data-component'); - const Ctor = COMPONENTS[name]; - instances.push(new Ctor(el as HTMLElement)); - } - - await mount(...instances); - - return { root, instances }; -} - -/** - * Get the last payload pushed to `window.dataLayer`. - */ -function lastPush() { - return window.dataLayer?.at(-1); -} - -beforeEach(() => { - window.dataLayer = []; -}); - -describe('Track component', () => { - it('should have the correct config', () => { - expect(Track.config.name).toBe('Track'); - expect(Track.config.refs).toEqual(['payload']); - }); - - it('should push the resolved payload to window.dataLayer on click', async () => { - const { root } = await mountTree( - ``, - ); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(window.dataLayer).toHaveLength(1); - expect(lastPush()).toEqual({ event: 'cta_click', location: 'header' }); - }); - - it('should keep the event name under the `event` key', async () => { - const { root } = await mountTree( - ``, - ); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toHaveProperty('event', 'add_to_cart'); - }); - - describe('bare event name attribute', () => { - it('should treat a non-JSON attribute value as the event name', async () => { - const { root } = await mountTree( - ``, - ); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ event: 'add_to_cart' }); - }); - - it('should merge the bare event name with the context and base payload', async () => { - const { root } = await mountTree(` -
- -
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ - page_type: 'product', - location: 'footer', - event: 'add_to_cart', - }); - }); - }); - - describe('data-option-payload', () => { - it('should use the `payload` option as the base payload', async () => { - const { root } = await mountTree( - ``, - ); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ event: 'cta', location: 'header', id: '1' }); - }); - - it('should let the `payload` option override the `payload` ref', async () => { - const { root } = await mountTree(` - - `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - // Option wins on conflicts; non-conflicting ref keys are kept. - expect(lastPush()).toEqual({ event: 'cta', source: 'option', kept: true }); - }); - - it('should let the per-event attribute override the `payload` option', async () => { - const { root } = await mountTree( - ``, - ); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ event: 'cta', location: 'event' }); - }); - }); - - it('should merge the deep-merged context of every ancestor TrackContext, nearer wins', async () => { - // PDP > Variant > Track tree. - const { root } = await mountTree(` -
-
- -
-
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ - page_type: 'product', - currency: 'EUR', - // The nearer TrackContext (Variant) overrides the outer one (PDP). - product_id: 'variant', - variant_id: 'v1', - event: 'add_to_cart', - }); - }); - - it('should REPLACE arrays on merge instead of concatenating them', async () => { - const { root } = await mountTree(` -
- -
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - const { ecommerce } = lastPush() as { ecommerce: { items: unknown[] } }; - expect(ecommerce.items).toEqual([{ id: 'from-event' }]); - expect(ecommerce.items).toHaveLength(1); - }); - - it('should apply payload precedence: context < payload ref < per-event attribute', async () => { - const { root } = await mountTree(` -
- -
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ - // The per-event attribute wins over the payload ref and the context. - value: 'event', - from_context: true, - from_payload: true, - event: 'x', - }); - }); - - it('should let the payload ref win over the context', async () => { - const { root } = await mountTree(` -
- -
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toHaveProperty('value', 'payload'); - }); - - it('should fire every data-track:* event declared on one element', async () => { - const { root } = await mountTree( - ``, - ); - - const button = root.querySelector('button') as HTMLButtonElement; - button.dispatchEvent(new Event('mousedown')); - button.click(); - - expect(window.dataLayer).toHaveLength(2); - expect(window.dataLayer?.map((entry) => entry.event)).toEqual([ - 'mousedown_event', - 'click_event', - ]); - }); - - it('should fire an event with an empty data-track: value', async () => { - const { root } = await mountTree(` -
- -
- `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ page_type: 'home' }); - }); - - describe('mounted event', () => { - it('should dispatch on mount with the resolved context', async () => { - await mountTree(` -
-
-
- `); - - // The mounted dispatch is deferred to the next frame. - await wait(50); - - expect(lastPush()).toEqual({ page_type: 'home', event: 'page_view' }); - }); - - it('should still see an ancestor TrackContext mounted just after the child (mount-order tolerance)', async () => { - const root = h('div'); - root.innerHTML = ` -
-
-
`; - - const contextEl = root.querySelector('[data-component="TrackContext"]') as HTMLElement; - const trackEl = root.querySelector('[data-component="Track"]') as HTMLElement; - - // Mount the child Track BEFORE its ancestor TrackContext instance even - // exists, so the context can only be resolved through the next-frame - // deferral (a synchronous dispatch would find no TrackContext). - const track = new Track(trackEl); - await track.$mount(); - - const context = new TrackContext(contextEl); - await context.$mount(); - - // The next-frame deferral lets the late ancestor be resolved. - await wait(50); - - expect(lastPush()).toEqual({ page_type: 'product', event: 'page_view' }); - }); - - it('should apply timing modifiers to the mounted event', async () => { - window.dataLayer = []; - const root = h('div'); - root.innerHTML = `
`; - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - - vi.useFakeTimers(); - const track = new Track(el); - track.$mount(); - - // Flush the mounted deferral (nextFrame → rAF → setTimeout 16), but not - // yet the 500ms debounce window. - await vi.advanceTimersByTimeAsync(50); - expect(window.dataLayer).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(500); - expect(window.dataLayer).toHaveLength(1); - expect(lastPush()).toEqual({ event: 'page_view' }); - vi.useRealTimers(); - }); - - it('should not dispatch when destroyed before the deferred mounted frame', async () => { - const root = h('div'); - root.innerHTML = `
`; - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - - const track = new Track(el); - await track.$mount(); - // Destroy within the same frame, before the deferred send runs. - await track.$destroy(); - - await wait(50); - - // The $isMounted guard prevents a dispatch for the unmounted component. - expect(window.dataLayer ?? []).toHaveLength(0); - }); - }); - - describe('view event (IntersectionObserver)', () => { - beforeAll(() => { - intersectionObserverBeforeAllCallback(); - }); - - afterEach(() => { - intersectionObserverAfterEachCallback(); - }); - - it('should dispatch when the element becomes visible', async () => { - const { root } = await mountTree( - `
`, - ); - - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - await mockIsIntersecting(el, true); - - expect(lastPush()).toEqual({ event: 'product_impression', id: '123' }); - }); - - it('should dispatch on every intersection without the .once modifier', async () => { - const { root } = await mountTree( - `
`, - ); - - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - await mockIsIntersecting(el, true); - await mockIsIntersecting(el, false); - await mockIsIntersecting(el, true); - - expect(window.dataLayer).toHaveLength(2); - }); - - it('should dispatch once and disconnect with the .once modifier', async () => { - const { root } = await mountTree( - `
`, - ); - - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - const observer = intersectionMockInstance(el); - - await mockIsIntersecting(el, true); - - expect(window.dataLayer).toHaveLength(1); - expect(observer.disconnect).toHaveBeenCalled(); - }); - - it('should dispatch on any visibility, even a ratio below the threshold (tall element)', async () => { - const { root } = await mountTree( - `
`, - ); - - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - // A tall element that can only ever be 20% visible: isIntersecting is - // true but the ratio stays below the threshold. It must still dispatch. - await mockIsIntersecting(el, true, 0.2); - - expect(window.dataLayer).toHaveLength(1); - }); - - it('should apply timing modifiers to the view event', async () => { - const { root } = await mountTree( - `
`, - ); - - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - // Three intersections well within the throttle window: only the leading - // dispatch goes through. - await mockIsIntersecting(el, true); - await mockIsIntersecting(el, false); - await mockIsIntersecting(el, true); - - expect(window.dataLayer).toHaveLength(1); - }); - }); - - describe('lifecycle', () => { - it('should not dispatch a `.capture` event after destroy, and resume after remount', async () => { - window.dataLayer = []; - const { root, instances } = await mountTree( - `
`, - ); - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - - el.click(); - expect(window.dataLayer).toHaveLength(1); - - await instances[0].$destroy(); - el.click(); - // The capture listener was removed and dispatch is guarded — no new push. - expect(window.dataLayer).toHaveLength(1); - - await instances[0].$mount(); - el.click(); - // Re-attaching after remount resumes dispatching. - expect(window.dataLayer).toHaveLength(2); - }); - - it('should cancel a pending debounced dispatch on destroy, even after a remount', async () => { - const { root, instances } = await mountTree( - `
`, - ); - const el = root.querySelector('[data-component="Track"]') as HTMLElement; - - // Interact (schedules a 50ms debounce), then destroy mid-window and - // remount — which resets the `detached` guard. - el.dispatchEvent(new Event('input')); - await instances[0].$destroy(); - await instances[0].$mount(); - - // Past the original debounce window: the pre-destroy interaction must not - // resurface (it would if the timer were not cancelled on destroy). - await wait(120); - expect(window.dataLayer ?? []).toHaveLength(0); - }); - }); - - describe('payload isolation and memoization', () => { - it('should not share the array instance across dispatches', async () => { - const { root } = await mountTree( - ``, - ); - const button = root.querySelector('button') as HTMLButtonElement; - - button.click(); - // A consumer mutates the pushed array in place. - (window.dataLayer.at(-1) as { items: number[] }).items.push(99); - - button.click(); - // The second dispatch is unaffected by the mutation of the first. - expect((window.dataLayer.at(-1) as { items: number[] }).items).toEqual([1, 2]); - }); - - it('should resolve the ancestor context only once across dispatches', async () => { - const { root, instances } = await mountTree(` -
- -
- `); - const track = instances.find((i) => i instanceof Track) as Track; - const spy = vi.spyOn(track, '$closest'); - - const button = root.querySelector('button') as HTMLButtonElement; - button.click(); - button.click(); - button.click(); - - // Context is memoized: the ancestor chain is walked once, not per click. - expect(spy).toHaveBeenCalledTimes(1); - expect(lastPush()).toEqual({ page_type: 'product', event: 'cta' }); - spy.mockRestore(); - }); - }); - - describe('malformed JSON tolerance', () => { - it('should not throw when the data-track: value is invalid JSON', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { root } = await mountTree( - ``, - ); - - const button = root.querySelector('button') as HTMLButtonElement; - expect(() => button.click()).not.toThrow(); - // The malformed event was dropped, nothing was dispatched. - expect(window.dataLayer).toHaveLength(0); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('should fall back to an empty payload when the payload ref is invalid JSON', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { root } = await mountTree(` - - `); - - (root.querySelector('button') as HTMLButtonElement).click(); - - expect(lastPush()).toEqual({ event: 'x' }); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('should fall back to an empty payload when data-option-payload is invalid JSON', async () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { root } = await mountTree( - ``, - ); - - const button = root.querySelector('button') as HTMLButtonElement; - expect(() => button.click()).not.toThrow(); - expect(lastPush()).toEqual({ event: 'x' }); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - }); -}); diff --git a/packages/tests/Transition/Transition.spec.ts b/packages/tests/Transition/Transition.spec.ts deleted file mode 100644 index d8c011ef..00000000 --- a/packages/tests/Transition/Transition.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { Transition } from '@studiometa/ui'; -import { mount, h } from '#test-utils'; - -describe('The Transition component', () => { - it('should default to its root element as target', async () => { - const div = h('div'); - const transition = new Transition(div); - expect(transition.target).toBe(div); - }); - - it('should dispatch enter and leave method to grouped elements', async () => { - const opts = { dataOptionGroup: 'group' }; - const transitionA = new Transition(h('div', opts)); - const transitionB = new Transition(h('div', opts)); - - await mount(transitionA, transitionB); - expect(transitionA.$options.group).toBe(transitionB.$options.group); - expect(transitionA.targets).toEqual(transitionB.targets); - }); - - it('should toggle between enter and leave states', async () => { - const div = h('div'); - const transition = new Transition(div); - await mount(transition); - - expect(transition.state).toBe(null); - - await transition.toggle(); - expect(transition.state).toBe(transition.constructor.STATES.ENTERING); - - await transition.toggle(); - expect(transition.state).toBe(transition.constructor.STATES.LEAVING); - - await transition.toggle(); - expect(transition.state).toBe(transition.constructor.STATES.ENTERING); - }); - - it('should track state as entering when enter is called', async () => { - const div = h('div'); - const transition = new Transition(div); - await mount(transition); - - await transition.enter(); - expect(transition.state).toBe(transition.constructor.STATES.ENTERING); - }); - - it('should track state as leaving when leave is called', async () => { - const div = h('div'); - const transition = new Transition(div); - await mount(transition); - - await transition.leave(); - expect(transition.state).toBe(transition.constructor.STATES.LEAVING); - }); - - it('should emit transition-toggle event when toggle is called', async () => { - const div = h('div'); - const transition = new Transition(div); - await mount(transition); - - const emitSpy = vi.spyOn(transition, '$emit'); - await transition.toggle(); - - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_TOGGLE, - ); - }); - - it('should emit transition-enter events when enter is called', async () => { - const div = h('div'); - const transition = new Transition(div); - await mount(transition); - - const emitSpy = vi.spyOn(transition, '$emit'); - await transition.enter(); - - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_ENTER, - ); - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_ENTER_START, - ); - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_ENTER_END, - ); - }); - - it('should emit transition-leave events when leave is called', async () => { - const div = h('div'); - const transition = new Transition(div); - await mount(transition); - - const emitSpy = vi.spyOn(transition, '$emit'); - await transition.leave(); - - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_LEAVE, - ); - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_LEAVE_START, - ); - expect(emitSpy).toHaveBeenCalledWith( - transition.constructor.EVENTS.TRANSITION_LEAVE_END, - ); - }); -}); diff --git a/packages/tests/ViewTransition/ViewTransition.spec.ts b/packages/tests/ViewTransition/ViewTransition.spec.ts deleted file mode 100644 index be4b3349..00000000 --- a/packages/tests/ViewTransition/ViewTransition.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { ViewTransition } from '@studiometa/ui'; -import { h, mount } from '#test-utils'; - -/** - * Install a fake `document.startViewTransition` that runs the update callback - * and resolves its promises, recording every call. Returns the call list. - */ -function mockStartViewTransition() { - const calls: Array<() => void | Promise> = []; - // @ts-expect-error — happy-dom does not implement the View Transitions API. - document.startViewTransition = (update: () => void | Promise) => { - calls.push(update); - const done = Promise.resolve().then(() => update()); - return { finished: done, ready: done, updateCallbackDone: done }; - }; - return calls; -} - -afterEach(() => { - // @ts-expect-error — reset between tests. - delete document.startViewTransition; -}); - -describe('The ViewTransition component', () => { - it('should apply the `view-transition-name` on mount', async () => { - const transition = new ViewTransition(h('div', { dataOptionViewTransitionName: 'panel' })); - await mount(transition); - expect(transition.$el.style.getPropertyValue('view-transition-name')).toBe('panel'); - }); - - it('should toggle state classes on enter and leave', async () => { - mockStartViewTransition(); - const transition = new ViewTransition( - h('div', { class: 'hidden', dataOptionLeaveTo: 'hidden', dataOptionEnterTo: 'shown' }), - ); - await mount(transition); - - await transition.enter(); - expect(transition.$el.classList.contains('hidden')).toBe(false); - expect(transition.$el.classList.contains('shown')).toBe(true); - - await transition.leave(); - expect(transition.$el.classList.contains('shown')).toBe(false); - expect(transition.$el.classList.contains('hidden')).toBe(true); - }); - - it('should track its state and toggle between enter and leave', async () => { - mockStartViewTransition(); - const transition = new ViewTransition(h('div', { dataOptionLeaveTo: 'hidden' })); - await mount(transition); - - expect(transition.state).toBe(null); - await transition.toggle(); - expect(transition.state).toBe('entering'); - await transition.toggle(); - expect(transition.state).toBe('leaving'); - await transition.toggle(); - expect(transition.state).toBe('entering'); - }); - - it('should batch concurrent transitions into a single view transition', async () => { - const calls = mockStartViewTransition(); - const a = new ViewTransition(h('div', { class: 'hidden', dataOptionLeaveTo: 'hidden' })); - const b = new ViewTransition(h('div', { class: 'hidden', dataOptionLeaveTo: 'hidden' })); - await mount(a, b); - - // Both fired synchronously in the same tick -> one coordinated transition. - await Promise.all([a.enter(), b.enter()]); - - expect(calls.length).toBe(1); - expect(a.$el.classList.contains('hidden')).toBe(false); - expect(b.$el.classList.contains('hidden')).toBe(false); - }); - - it('should fall back to a synchronous update when the API is unavailable', async () => { - // No mock installed: `document.startViewTransition` is undefined. - const transition = new ViewTransition(h('div', { class: 'hidden', dataOptionLeaveTo: 'hidden' })); - await mount(transition); - - await transition.enter(); - expect(transition.$el.classList.contains('hidden')).toBe(false); - }); -}); diff --git a/packages/ui-mapbox/package.json b/packages/ui-mapbox/package.json index a6c076f0..956b115e 100644 --- a/packages/ui-mapbox/package.json +++ b/packages/ui-mapbox/package.json @@ -141,7 +141,7 @@ "homepage": "https://github.com/studiometa/ui#readme", "peerDependencies": { "@mapbox/mapbox-gl-geocoder": "^5.0.0", - "@studiometa/js-toolkit": "^3.9.0", + "@studiometa/js-toolkit": "^4.0.0-alpha.0", "mapbox-gl": "^3.0.0" }, "peerDependenciesMeta": { @@ -151,7 +151,7 @@ }, "devDependencies": { "@mapbox/mapbox-gl-geocoder": "^5.1.0", - "@studiometa/js-toolkit": "^3.9.0", + "@studiometa/js-toolkit": "^4.0.0-alpha.0", "@types/mapbox__mapbox-gl-geocoder": "^5.0.0", "mapbox-gl": "^3.13.0", "tsdown": "0.21.5" diff --git a/packages/ui-motion/package.json b/packages/ui-motion/package.json index c7c5e138..b948c610 100644 --- a/packages/ui-motion/package.json +++ b/packages/ui-motion/package.json @@ -79,11 +79,11 @@ }, "homepage": "https://github.com/studiometa/ui#readme", "peerDependencies": { - "@studiometa/js-toolkit": "^3.9.0", + "@studiometa/js-toolkit": "^4.0.0-alpha.0", "motion": "^13.0.0" }, "devDependencies": { - "@studiometa/js-toolkit": "^3.9.0", + "@studiometa/js-toolkit": "^4.0.0-alpha.0", "motion": "^13.0.0", "tsdown": "0.21.5" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 4189ac68..631e858d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,25 +22,50 @@ "types": "./dist/autoload.d.ts", "import": "./dist/autoload.js" }, - "./Accordion": { - "typescript": "./src/Accordion/Accordion.ts", - "types": "./dist/Accordion/Accordion.d.ts", - "import": "./dist/Accordion/Accordion.js" + "./AbstractCarouselChild": { + "typescript": "./src/Carousel/AbstractCarouselChild.ts", + "types": "./dist/Carousel/AbstractCarouselChild.d.ts", + "import": "./dist/Carousel/AbstractCarouselChild.js" + }, + "./AbstractCarouselComponent": { + "typescript": "./src/Carousel/AbstractCarouselComponent.ts", + "types": "./dist/Carousel/AbstractCarouselComponent.d.ts", + "import": "./dist/Carousel/AbstractCarouselComponent.js" + }, + "./AbstractFigure": { + "typescript": "./src/Figure/AbstractFigure.ts", + "types": "./dist/Figure/AbstractFigure.d.ts", + "import": "./dist/Figure/AbstractFigure.js" + }, + "./AbstractFigureDynamic": { + "typescript": "./src/Figure/AbstractFigureDynamic.ts", + "types": "./dist/Figure/AbstractFigureDynamic.d.ts", + "import": "./dist/Figure/AbstractFigureDynamic.js" + }, + "./AbstractPrefetch": { + "typescript": "./src/Prefetch/AbstractPrefetch.ts", + "types": "./dist/Prefetch/AbstractPrefetch.d.ts", + "import": "./dist/Prefetch/AbstractPrefetch.js" }, - "./AccordionItem": { - "typescript": "./src/Accordion/AccordionItem.ts", - "types": "./dist/Accordion/AccordionItem.d.ts", - "import": "./dist/Accordion/AccordionItem.js" + "./AbstractSliderChild": { + "typescript": "./src/Slider/AbstractSliderChild.ts", + "types": "./dist/Slider/AbstractSliderChild.d.ts", + "import": "./dist/Slider/AbstractSliderChild.js" + }, + "./AbstractTrack": { + "typescript": "./src/Track/AbstractTrack.ts", + "types": "./dist/Track/AbstractTrack.d.ts", + "import": "./dist/Track/AbstractTrack.js" }, "./Action": { "typescript": "./src/Action/Action.ts", "types": "./dist/Action/Action.d.ts", "import": "./dist/Action/Action.js" }, - "./Target": { - "typescript": "./src/Action/Target.ts", - "types": "./dist/Action/Target.d.ts", - "import": "./dist/Action/Target.js" + "./ActionEvent": { + "typescript": "./src/Action/ActionEvent.ts", + "types": "./dist/Action/ActionEvent.d.ts", + "import": "./dist/Action/ActionEvent.js" }, "./AnchorNav": { "typescript": "./src/AnchorNav/AnchorNav.ts", @@ -57,21 +82,6 @@ "types": "./dist/AnchorNav/AnchorNavTarget.d.ts", "import": "./dist/AnchorNav/AnchorNavTarget.js" }, - "./AnchorScrollTo": { - "typescript": "./src/AnchorScrollTo/AnchorScrollTo.ts", - "types": "./dist/AnchorScrollTo/AnchorScrollTo.d.ts", - "import": "./dist/AnchorScrollTo/AnchorScrollTo.js" - }, - "./AbstractCarouselChild": { - "typescript": "./src/Carousel/AbstractCarouselChild.ts", - "types": "./dist/Carousel/AbstractCarouselChild.d.ts", - "import": "./dist/Carousel/AbstractCarouselChild.js" - }, - "./AbstractCarouselComponent": { - "typescript": "./src/Carousel/AbstractCarouselComponent.ts", - "types": "./dist/Carousel/AbstractCarouselComponent.d.ts", - "import": "./dist/Carousel/AbstractCarouselComponent.js" - }, "./Carousel": { "typescript": "./src/Carousel/Carousel.ts", "types": "./dist/Carousel/Carousel.d.ts", @@ -132,11 +142,21 @@ "types": "./dist/Data/DataModel.d.ts", "import": "./dist/Data/DataModel.js" }, + "./DataRegistry": { + "typescript": "./src/Data/registry.ts", + "types": "./dist/Data/registry.d.ts", + "import": "./dist/Data/registry.js" + }, "./DataScope": { "typescript": "./src/Data/DataScope.ts", "types": "./dist/Data/DataScope.d.ts", "import": "./dist/Data/DataScope.js" }, + "./Defer": { + "typescript": "./src/Defer/Defer.ts", + "types": "./dist/Defer/Defer.d.ts", + "import": "./dist/Defer/Defer.js" + }, "./Dialog": { "typescript": "./src/Dialog/Dialog.ts", "types": "./dist/Dialog/Dialog.d.ts", @@ -197,46 +217,6 @@ "types": "./dist/FigureVideo/FigureVideoTwicpics.d.ts", "import": "./dist/FigureVideo/FigureVideoTwicpics.js" }, - "./AbstractFrameTrigger": { - "typescript": "./src/Frame/AbstractFrameTrigger.ts", - "types": "./dist/Frame/AbstractFrameTrigger.d.ts", - "import": "./dist/Frame/AbstractFrameTrigger.js" - }, - "./Frame": { - "typescript": "./src/Frame/Frame.ts", - "types": "./dist/Frame/Frame.d.ts", - "import": "./dist/Frame/Frame.js" - }, - "./FrameAnchor": { - "typescript": "./src/Frame/FrameAnchor.ts", - "types": "./dist/Frame/FrameAnchor.d.ts", - "import": "./dist/Frame/FrameAnchor.js" - }, - "./FrameForm": { - "typescript": "./src/Frame/FrameForm.ts", - "types": "./dist/Frame/FrameForm.d.ts", - "import": "./dist/Frame/FrameForm.js" - }, - "./FrameLoader": { - "typescript": "./src/Frame/FrameLoader.ts", - "types": "./dist/Frame/FrameLoader.d.ts", - "import": "./dist/Frame/FrameLoader.js" - }, - "./FrameTarget": { - "typescript": "./src/Frame/FrameTarget.ts", - "types": "./dist/Frame/FrameTarget.d.ts", - "import": "./dist/Frame/FrameTarget.js" - }, - "./FrameTriggerLoader": { - "typescript": "./src/Frame/FrameTriggerLoader.ts", - "types": "./dist/Frame/FrameTriggerLoader.d.ts", - "import": "./dist/Frame/FrameTriggerLoader.js" - }, - "./types": { - "typescript": "./src/Frame/types.ts", - "types": "./dist/Frame/types.d.ts", - "import": "./dist/Frame/types.js" - }, "./Hoverable": { "typescript": "./src/Hoverable/Hoverable.ts", "types": "./dist/Hoverable/Hoverable.d.ts", @@ -262,11 +242,6 @@ "types": "./dist/LargeText/LargeText.d.ts", "import": "./dist/LargeText/LargeText.js" }, - "./LazyInclude": { - "typescript": "./src/LazyInclude/LazyInclude.ts", - "types": "./dist/LazyInclude/LazyInclude.d.ts", - "import": "./dist/LazyInclude/LazyInclude.js" - }, "./Menu": { "typescript": "./src/Menu/Menu.ts", "types": "./dist/Menu/Menu.d.ts", @@ -282,101 +257,31 @@ "types": "./dist/Menu/MenuList.d.ts", "import": "./dist/Menu/MenuList.js" }, - "./Modal": { - "typescript": "./src/Modal/Modal.ts", - "types": "./dist/Modal/Modal.d.ts", - "import": "./dist/Modal/Modal.js" - }, - "./ModalWithTransition": { - "typescript": "./src/Modal/ModalWithTransition.ts", - "types": "./dist/Modal/ModalWithTransition.d.ts", - "import": "./dist/Modal/ModalWithTransition.js" - }, - "./Panel": { - "typescript": "./src/Panel/Panel.ts", - "types": "./dist/Panel/Panel.d.ts", - "import": "./dist/Panel/Panel.js" - }, - "./AbstractPrefetch": { - "typescript": "./src/Prefetch/AbstractPrefetch.ts", - "types": "./dist/Prefetch/AbstractPrefetch.d.ts", - "import": "./dist/Prefetch/AbstractPrefetch.js" - }, - "./PrefetchWhenOver": { - "typescript": "./src/Prefetch/PrefetchWhenOver.ts", - "types": "./dist/Prefetch/PrefetchWhenOver.d.ts", - "import": "./dist/Prefetch/PrefetchWhenOver.js" + "./PrefetchOnInteraction": { + "typescript": "./src/Prefetch/PrefetchOnInteraction.ts", + "types": "./dist/Prefetch/PrefetchOnInteraction.d.ts", + "import": "./dist/Prefetch/PrefetchOnInteraction.js" }, "./PrefetchWhenVisible": { "typescript": "./src/Prefetch/PrefetchWhenVisible.ts", "types": "./dist/Prefetch/PrefetchWhenVisible.d.ts", "import": "./dist/Prefetch/PrefetchWhenVisible.js" }, - "./AbstractScrollAnimation": { - "typescript": "./src/ScrollAnimation/AbstractScrollAnimation.ts", - "types": "./dist/ScrollAnimation/AbstractScrollAnimation.d.ts", - "import": "./dist/ScrollAnimation/AbstractScrollAnimation.js" - }, - "./ScrollAnimation": { - "typescript": "./src/ScrollAnimation/ScrollAnimation.ts", - "types": "./dist/ScrollAnimation/ScrollAnimation.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimation.js" - }, - "./ScrollAnimationChild": { - "typescript": "./src/ScrollAnimation/ScrollAnimationChild.ts", - "types": "./dist/ScrollAnimation/ScrollAnimationChild.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimationChild.js" - }, - "./ScrollAnimationChildWithEase": { - "typescript": "./src/ScrollAnimation/ScrollAnimationChildWithEase.ts", - "types": "./dist/ScrollAnimation/ScrollAnimationChildWithEase.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimationChildWithEase.js" - }, - "./ScrollAnimationParent": { - "typescript": "./src/ScrollAnimation/ScrollAnimationParent.ts", - "types": "./dist/ScrollAnimation/ScrollAnimationParent.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimationParent.js" - }, - "./ScrollAnimationTarget": { - "typescript": "./src/ScrollAnimation/ScrollAnimationTarget.ts", - "types": "./dist/ScrollAnimation/ScrollAnimationTarget.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimationTarget.js" - }, - "./ScrollAnimationTimeline": { - "typescript": "./src/ScrollAnimation/ScrollAnimationTimeline.ts", - "types": "./dist/ScrollAnimation/ScrollAnimationTimeline.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimationTimeline.js" - }, - "./ScrollAnimationWithEase": { - "typescript": "./src/ScrollAnimation/ScrollAnimationWithEase.ts", - "types": "./dist/ScrollAnimation/ScrollAnimationWithEase.d.ts", - "import": "./dist/ScrollAnimation/ScrollAnimationWithEase.js" - }, - "./animationScrollWithEase": { - "typescript": "./src/ScrollAnimation/animationScrollWithEase.ts", - "types": "./dist/ScrollAnimation/animationScrollWithEase.d.ts", - "import": "./dist/ScrollAnimation/animationScrollWithEase.js" - }, - "./withScrollAnimationDebug": { - "typescript": "./src/ScrollAnimation/withScrollAnimationDebug.ts", - "types": "./dist/ScrollAnimation/withScrollAnimationDebug.d.ts", - "import": "./dist/ScrollAnimation/withScrollAnimationDebug.js" - }, "./ScrollReveal": { "typescript": "./src/ScrollReveal/ScrollReveal.ts", "types": "./dist/ScrollReveal/ScrollReveal.d.ts", "import": "./dist/ScrollReveal/ScrollReveal.js" }, + "./ScrollTo": { + "typescript": "./src/ScrollTo/ScrollTo.ts", + "types": "./dist/ScrollTo/ScrollTo.d.ts", + "import": "./dist/ScrollTo/ScrollTo.js" + }, "./Sentinel": { "typescript": "./src/Sentinel/Sentinel.ts", "types": "./dist/Sentinel/Sentinel.d.ts", "import": "./dist/Sentinel/Sentinel.js" }, - "./AbstractSliderChild": { - "typescript": "./src/Slider/AbstractSliderChild.ts", - "types": "./dist/Slider/AbstractSliderChild.d.ts", - "import": "./dist/Slider/AbstractSliderChild.js" - }, "./Slider": { "typescript": "./src/Slider/Slider.ts", "types": "./dist/Slider/Slider.d.ts", @@ -422,6 +327,11 @@ "types": "./dist/Tabs/Tabs.d.ts", "import": "./dist/Tabs/Tabs.js" }, + "./Target": { + "typescript": "./src/Action/Target.ts", + "types": "./dist/Action/Target.d.ts", + "import": "./dist/Action/Target.js" + }, "./Timer": { "typescript": "./src/Timer/Timer.ts", "types": "./dist/Timer/Timer.d.ts", @@ -452,6 +362,11 @@ "types": "./dist/Track/TrackContext.d.ts", "import": "./dist/Track/TrackContext.js" }, + "./TrackEvent": { + "typescript": "./src/Track/TrackEvent.ts", + "types": "./dist/Track/TrackEvent.d.ts", + "import": "./dist/Track/TrackEvent.js" + }, "./TrackShopify": { "typescript": "./src/Track/TrackShopify.ts", "types": "./dist/Track/TrackShopify.d.ts", @@ -467,21 +382,11 @@ "types": "./dist/ViewTransition/ViewTransition.d.ts", "import": "./dist/ViewTransition/ViewTransition.js" }, - "./scheduler": { - "typescript": "./src/ViewTransition/scheduler.ts", - "types": "./dist/ViewTransition/scheduler.d.ts", - "import": "./dist/ViewTransition/scheduler.js" - }, "./withDeprecation": { "typescript": "./src/decorators/withDeprecation.ts", "types": "./dist/decorators/withDeprecation.d.ts", "import": "./dist/decorators/withDeprecation.js" }, - "./withIndex": { - "typescript": "./src/decorators/withIndex.ts", - "types": "./dist/decorators/withIndex.d.ts", - "import": "./dist/decorators/withIndex.js" - }, "./withTransition": { "typescript": "./src/decorators/withTransition.ts", "types": "./dist/decorators/withTransition.d.ts", @@ -534,11 +439,11 @@ "morphdom": "^2.7.8" }, "devDependencies": { - "@studiometa/js-toolkit": "^3.9.0" + "@studiometa/js-toolkit": "^4.0.0-alpha.0" }, "peerDependencies": { "@shopify/partial-rendering": "*", - "@studiometa/js-toolkit": "^3.9.0" + "@studiometa/js-toolkit": "^4.0.0-alpha.0" }, "peerDependenciesMeta": { "@shopify/partial-rendering": { diff --git a/packages/ui/src/Accordion/Accordion.ts b/packages/ui/src/Accordion/Accordion.ts deleted file mode 100644 index 17acd27d..00000000 --- a/packages/ui/src/Accordion/Accordion.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; -import { AccordionCore } from './AccordionCore.js'; -import type { AccordionProps } from './AccordionCore.js'; -import { AccordionItem } from './AccordionItem.js'; - -/** - * Accordion class. - * - * The ready-to-use accordion component. It extends `AccordionCore` with the - * default `AccordionItem` child implementation, so declaring `data-component="Accordion"` - * on a container with nested `AccordionItem` elements yields a fully working, - * optionally auto-closing accordion without any custom item class. - * - * @link https://ui.studiometa.dev/reference/items/Accordion/ - */ -export class Accordion extends AccordionCore { - static config: BaseConfig = { - ...AccordionCore.config, - components: { - AccordionItem, - }, - }; -} - -export default Accordion; diff --git a/packages/ui/src/Accordion/Accordion.twig b/packages/ui/src/Accordion/Accordion.twig deleted file mode 100644 index c597614e..00000000 --- a/packages/ui/src/Accordion/Accordion.twig +++ /dev/null @@ -1,54 +0,0 @@ -{# -/** - * @file - * Accordion component. - * - * @param array<{ title: string, content: unknown, attr: array }> $items - * The items of the accordion. - * @param array $attr - * Use it to customize the root element attributes. - * @param array $item_attr - * Use it to customize each item element attributes. - * @param array $item_container_attr - * Use it to customize each item container element attributes. - * - * @block $title - * Use it to customize each item's title. - * @block $content - * Use it to customize each item's content. - */ -#} - -{% set attributes = merge_html_attributes(attr ?? null, { data_component: 'Accordion' }) %} - -
- {% for item in items %} - {% set item_attributes = merge_html_attributes(item_attr ?? null, { data_component: 'AccordionItem' }, item.attr ?? null) %} - {% set is_open = item_attributes.data_option_is_open ?? false %} -
- - {% set item_container_attributes = - merge_html_attributes( - item_container_attr ?? null, - {}, - { - data_ref: 'container', - style: { visibility: is_open ? '' : 'hidden', height: is_open ? '' : '0' }, - class: 'relative overflow-hidden' - } - ) - %} -
- -
-
- {% endfor %} -
diff --git a/packages/ui/src/Accordion/AccordionCore.ts b/packages/ui/src/Accordion/AccordionCore.ts deleted file mode 100644 index a4d4a563..00000000 --- a/packages/ui/src/Accordion/AccordionCore.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import type { AccordionItem, AccordionItemProps } from './AccordionItem'; - -export interface AccordionProps extends BaseProps { - $refs: { - btn: HTMLElement[]; - content: HTMLElement[]; - }; - $options: { - autoclose: boolean; - item: AccordionItemProps['$options']; - }; - $children: { - AccordionItem: AccordionItem[]; - }; -} - -/** - * Accordion class. - * - * The base orchestrator for a group of `AccordionItem` children. It relays each - * item's open/close into the `open` and `close` events and, when the `autoclose` - * option (`data-option-autoclose`) is set, closes every other item as soon as - * one opens. The `item` option is forwarded to each child so item defaults can be - * configured from the parent. It carries no child component itself and is meant - * to be extended (see `Accordion`). - */ -export class AccordionCore extends Base { - /** - * Accordion config. - */ - static config: BaseConfig = { - name: 'Accordion', - emits: ['open', 'close'], - options: { - autoclose: Boolean, - item: { - type: Object, - default: (): Partial => ({}), - }, - }, - }; - - /** - * Synchronize close on open. - */ - onAccordionItemOpen({ index }: { index: number }) { - this.$emit('open', this.$children.AccordionItem[index], index); - if (this.$options.autoclose) { - this.$children.AccordionItem.filter((el, i) => index !== i).forEach((item) => item.close()); - } - } - - /** - * Emit close event. - */ - onAccordionItemClose({ index }: { index: number }) { - this.$emit('close', this.$children.AccordionItem[index], index); - } -} diff --git a/packages/ui/src/Accordion/AccordionItem.ts b/packages/ui/src/Accordion/AccordionItem.ts deleted file mode 100644 index 55c30c59..00000000 --- a/packages/ui/src/Accordion/AccordionItem.ts +++ /dev/null @@ -1,232 +0,0 @@ -import deepmerge from 'deepmerge'; -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseConfig } from '@studiometa/js-toolkit'; -import type { BaseProps } from '@studiometa/js-toolkit'; -import { transition } from '@studiometa/js-toolkit/utils/transition'; -import type { AccordionCore as Accordion } from './AccordionCore.js'; - -type AccordionItemStates = Partial< - Record<'open' | 'active' | 'closed', string | Partial> ->; - -export interface AccordionItemProps extends BaseProps { - $refs: { - btn: HTMLElement; - content: HTMLElement; - container: HTMLElement; - }; - $options: { - isOpen: boolean; - styles: Partial>; - }; -} - -/** - * AccordionItem class. - * - * A single collapsible panel driven by its `btn`, `content` and `container` refs. - * It toggles open and closed on button click, animating the container height (and - * any other ref styles declared via the `styles` option) with the toolkit - * `transition` helper, emits `open`/`close`, and keeps the relevant ARIA - * attributes (`aria-expanded`, `aria-controls`, `aria-hidden`, `aria-labelledby`) - * in sync. Its initial state is set with the `isOpen` option (`data-option-is-open`). - */ -export class AccordionItem extends Base { - /** - * Config. - */ - static config: BaseConfig = { - name: 'AccordionItem', - refs: ['btn', 'content', 'container'], - emits: ['open', 'close'], - options: { - isOpen: Boolean, - styles: { - type: Object, - default: (): AccordionItemProps['$options']['styles'] => ({ - container: { - open: '', - active: '', - closed: '', - }, - }), - merge: true, - }, - }, - }; - - /** - * Add aria-attributes on mounted. - */ - mounted() { - const accordion = this.$closest('Accordion'); - if (accordion && accordion.$options.item) { - Object.entries(accordion.$options.item).forEach(([key, value]) => { - if (key in this.$options) { - // @ts-ignore - const type = AccordionItem.config.options[key].type ?? AccordionItem.config.options[key]; - if (type === Array || type === Object) { - // @ts-ignore - this.$options[key] = deepmerge(this.$options[key], /** @type {any} */ value); - } else { - // @ts-ignore - this.$options[key] = value; - } - } - }); - } - - this.$refs.btn.setAttribute('id', this.$id); - this.$refs.btn.setAttribute('aria-controls', this.contentId); - this.$refs.content.setAttribute('aria-labelledby', this.$id); - this.$refs.content.setAttribute('id', this.contentId); - - const { isOpen } = this.$options; - this.updateAttributes(isOpen); - - // Update refs styles on mount - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { container, ...otherStyles } = this.$options.styles; - - const { $refs } = this; - Object.entries(otherStyles) - .filter(([refName]) => $refs[refName]) - .forEach(([refName, { open, closed } = { open: '', closed: '' }]) => { - transition($refs[refName] as HTMLElement, { to: isOpen ? open : closed }, 'keep'); - }); - } - - /** - * Remove styles on destroy. - */ - destroyed() { - this.$refs.container.style.visibility = ''; - this.$refs.container.style.height = ''; - } - - /** - * Handler for the click event on the `btn` ref. - */ - onBtnClick() { - if (this.$options.isOpen) { - this.close(); - } else { - this.open(); - } - } - - /** - * Get the content ID. - */ - get contentId(): string { - return `content-${this.$id}`; - } - - /** - * Update the refs' attributes according to the given type. - */ - updateAttributes(isOpen: boolean) { - this.$refs.container.style.visibility = isOpen ? '' : 'hidden'; - this.$refs.container.style.height = isOpen ? '' : '0'; - this.$refs.content.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); - this.$refs.btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); - } - - /** - * Open an item. - */ - async open() { - if (this.$options.isOpen) { - return; - } - - this.$log('open'); - this.$emit('open'); - - this.$options.isOpen = true; - this.updateAttributes(this.$options.isOpen); - - this.$refs.container.style.visibility = ''; - const { container, ...otherStyles } = this.$options.styles; - - const { $refs } = this; - - await Promise.all([ - transition($refs.container, { - from: { height: '0' }, - active: container.active, - to: { height: `${$refs.content.offsetHeight}px` }, - }).then(() => { - // Remove style only if the item has not been closed before the end - if (this.$options.isOpen) { - $refs.content.style.position = ''; - } - - return Promise.resolve(); - }), - ...Object.entries(otherStyles) - .filter(([refName]) => $refs[refName]) - .map(([refName, { open, active, closed } = { open: '', active: '', closed: '' }]) => - transition( - $refs[refName] as HTMLElement, - { - from: closed, - active, - to: open, - }, - 'keep', - ), - ), - ]); - } - - /** - * Close an item. - */ - async close() { - if (!this.$options.isOpen) { - return; - } - - this.$log('close'); - this.$emit('close'); - - this.$options.isOpen = false; - - const height = this.$refs.container.offsetHeight; - this.$refs.content.style.position = 'absolute'; - const { container, ...otherStyles } = this.$options.styles; - - /** @type {AccordionItemRefs} */ - const refs = this.$refs; - - await Promise.all([ - transition(refs.container, { - from: { height: `${height}px` }, - active: container.active, - to: { height: '0' }, - }).then(() => { - // Add end styles only if the item has not been re-opened before the end - if (!this.$options.isOpen) { - refs.container.style.height = '0'; - refs.container.style.visibility = 'hidden'; - this.updateAttributes(this.$options.isOpen); - } - return Promise.resolve(); - }), - ...Object.entries(otherStyles) - .filter(([refName]) => refs[refName]) - .map(([refName, { open, active, closed } = { open: '', active: '', closed: '' }]) => - transition( - refs[refName] as HTMLElement, - { - from: open, - active, - to: closed, - }, - 'keep', - ), - ), - ]); - } -} diff --git a/packages/ui/src/Accordion/index.ts b/packages/ui/src/Accordion/index.ts deleted file mode 100644 index 4434a5ba..00000000 --- a/packages/ui/src/Accordion/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Accordion } from './Accordion.js'; -export { AccordionItem, type AccordionItemProps } from './AccordionItem.js'; diff --git a/packages/ui/src/Action/Action.ts b/packages/ui/src/Action/Action.ts deleted file mode 100644 index 0669f9ce..00000000 --- a/packages/ui/src/Action/Action.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; -import { ActionEvent } from './ActionEvent.js'; - -export interface ActionProps extends BaseProps { - $options: { - on: string; - target: string; - selector: string; - effect: string; - }; -} - -/** - * Action class. - * - * A declarative bridge that wires DOM events on its element to effects run on - * targeted components. Bindings come from `data-on:` attributes (e.g. - * `data-on:click="target.$el.textContent = 'Clicked'"`) and/or the `on`, `target` - * and `effect` options, each parsed into an `ActionEvent` that is attached on - * mount and detached on destroy. This lets HTML trigger methods or property - * changes on other components without writing any JavaScript. - * - * @link https://ui.studiometa.dev/reference/items/Action/ - */ -export class Action extends Base { - static config: BaseConfig = { - name: 'Action', - options: { - on: { - type: String, - default: 'click', - }, - target: String, - effect: String, - }, - }; - - /** - * @private - */ - __actionEvents: Set>; - - get actionEvents() { - if (this.__actionEvents) { - return this.__actionEvents; - } - - const { on } = this.$options; - this.__actionEvents = new Set(); - - // @ts-ignore - for (const attribute of this.$el.attributes) { - if (attribute.name.includes('on:')) { - const name = attribute.name.split('on:').pop(); - this.__actionEvents.add(new ActionEvent(this, name, attribute.value)); - } - } - - if (on) { - const { target, effect } = this.$options; - if (effect) { - const effectDefinition = target ? `${target}${ActionEvent.effectSeparator}${effect}` : effect; - this.__actionEvents.add(new ActionEvent(this, on, effectDefinition)); - } - } - - return this.__actionEvents; - } - - /** - * Mounted - */ - mounted() { - for (const actionEvent of this.actionEvents) { - actionEvent.attachEvent(); - } - } - - /** - * Destroyed - */ - destroyed() { - for (const actionEvent of this.actionEvents) { - actionEvent.detachEvent(); - } - } -} - -export default Action; diff --git a/packages/ui/src/Action/ActionEvent.ts b/packages/ui/src/Action/ActionEvent.ts deleted file mode 100644 index c476d0e1..00000000 --- a/packages/ui/src/Action/ActionEvent.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { getInstances } from '@studiometa/js-toolkit/getInstances'; -import type { Base } from '@studiometa/js-toolkit'; -import { isFunction } from '@studiometa/js-toolkit/utils/isFunction'; - -/** - * Extract component name and an optional additional selector from a string. - */ -const TARGET_REGEX = /([a-zA-Z]+)(\((.*)\))?/; - -const effectCache = new Map(); - -export type Modifiers = 'prevent' | 'stop' | 'once' | 'passive' | 'capture' | 'debounce'; - -export class ActionEvent { - static modifierSeparator = '.'; - static targetSeparator = ' '; - static effectSeparator = '->'; - - /** - * Timer for debouncing event handling. - */ - private debounceTimer?: number; - - /** - * The Action instance. - */ - action: T; - - /** - * The event to listen to. - */ - event: string; - - /** - * The modifiers to apply to the event. - */ - modifiers: Modifiers[]; - - /** - * The debounce delay in milliseconds. - */ - debounceDelay: number = 100; - - /** - * Target definition. - * Ex: `Target Target(.selector)`. - */ - targetDefinition: string; - - /** - * The content of the effect callback function. - */ - effectDefinition: string; - - /** - * Class constructor. - * @param {T} action The parent Action instance. - * @param {string} eventDefinition The event with its modifiers: `click.prevent.stop` - * @param {string} effectDefinition The target and effect definition: `Target(.selector)->target.$destroy()` - */ - constructor(action: T, eventDefinition: string, effectDefinition: string) { - this.action = action; - const [event, ...modifiers] = eventDefinition.split(ActionEvent.modifierSeparator); - this.event = event; - - // Process modifiers and extract debounce delay if present - const processedModifiers: Modifiers[] = []; - for (const modifier of modifiers) { - if (modifier.startsWith('debounce')) { - processedModifiers.push('debounce'); - this.debounceDelay = parseInt(modifier.replace('debounce', '') || '100'); - } else { - processedModifiers.push(modifier as Modifiers); - } - } - - this.modifiers = processedModifiers; - - let effect = effectDefinition; - let targetDefinition = ''; - - if (effect.includes(ActionEvent.effectSeparator)) { - [targetDefinition, effect] = effect.split(ActionEvent.effectSeparator); - } - - this.targetDefinition = targetDefinition.trim(); - this.effectDefinition = effect.trim(); - } - - /** - * Get the generated function for the defined effect. - */ - get effect() { - const { effectDefinition } = this; - const keys = Array.from(this.instances.keys()); - const cacheKey = effectDefinition + keys.join(''); - - if (!effectCache.has(cacheKey)) { - const args = [ - 'ctx', - 'event', - 'target', - 'action', - 'self', - '$el', - ...keys, - `return ${effectDefinition}`, - ]; - effectCache.set(cacheKey, new Function(...args)); - } - - return effectCache.get(cacheKey) as Function; - } - - /** - * Get the targets object for the defined targets string. - */ - get targets() { - const { targetDefinition } = this; - - if (!targetDefinition) { - return [{ Action: this.action }]; - } - - // Extract component's names and selectors. - const parts = targetDefinition.split(ActionEvent.targetSeparator).map((part) => { - const [, name, , selector] = part.match(TARGET_REGEX) ?? []; - return [name, selector]; - }); - - const targets = [] as Array>; - - for (const instance of getInstances()) { - const { name } = instance.__config; - - for (const part of parts) { - const shouldPush = - part[0] === name && (!part[1] || (part[1] && instance.$el.matches(part[1]))); - if (shouldPush) { - targets.push({ [instance.__config.name]: instance }); - } - } - } - - return targets; - } - - /** - * Get instances mounted on the action element. - * @internal - */ - get instances() { - const { $el } = this.action; - const instances = new Map(); - for (const instance of getInstances()) { - if (instance.$el === $el) { - instances.set(instance.$config.name, instance); - } - } - - return instances; - } - - /** - * Handle the defined event and trigger the effect for each defined target. - */ - handleEvent(event: Event) { - const { targets, effect, modifiers } = this; - - if (modifiers.includes('prevent')) { - event.preventDefault(); - } - - if (modifiers.includes('stop')) { - event.stopPropagation(); - } - - if (modifiers.includes('debounce')) { - clearTimeout(this.debounceTimer); - this.debounceTimer = window.setTimeout(() => { - this.executeEffect(targets, effect, event); - }, this.debounceDelay); - } else { - this.executeEffect(targets, effect, event); - } - } - - /** - * Execute the effect for all targets. - */ - executeEffect(targets: Array>, effect: Function, event: Event) { - const { action } = this; - - for (const target of targets) { - try { - const [currentTarget] = Object.values(target).flat(); - const args = [ - target, - event, - currentTarget, - action, - action, - currentTarget.$el, - ...this.instances.values(), - ]; - const value = effect.apply(action.$el, args); - if (isFunction(value)) { - value.apply(action.$el, args); - } - } catch (err) { - action.$warn(err); - } - } - } - - /** - * Bind the defined event to the given Action instance root element. - */ - attachEvent() { - const { event, modifiers } = this; - this.action.$el.addEventListener(event, this, { - capture: modifiers.includes('capture'), - once: modifiers.includes('once'), - passive: modifiers.includes('passive'), - }); - } - - /** - * Unbind the event from the given Action instance root element. - */ - detachEvent() { - clearTimeout(this.debounceTimer); - this.action.$el.removeEventListener(this.event, this); - } -} diff --git a/packages/ui/src/Action/Target.ts b/packages/ui/src/Action/Target.ts deleted file mode 100644 index ae5d72f1..00000000 --- a/packages/ui/src/Action/Target.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; - -export interface TargetProps extends BaseProps {} - -/** - * Target class. - * - * A minimal marker component that exposes its element as an addressable target for - * the `Action` component. It defines no behaviour of its own; declaring - * `data-component="Target"` simply lets `Action` bindings resolve and act on this - * element by name. - */ -export class Target extends Base { - /** - * Config. - */ - static config: BaseConfig = { - name: 'Target', - }; -} diff --git a/packages/ui/src/Action/index.ts b/packages/ui/src/Action/index.ts deleted file mode 100644 index 59f66455..00000000 --- a/packages/ui/src/Action/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Action, type ActionProps } from './Action.js'; -export { Target, type TargetProps } from './Target.js'; diff --git a/packages/ui/src/AnchorNav/AnchorNav.ts b/packages/ui/src/AnchorNav/AnchorNav.ts deleted file mode 100644 index 9d4a1f64..00000000 --- a/packages/ui/src/AnchorNav/AnchorNav.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; -import { AnchorNavLink } from './AnchorNavLink.js'; -import { AnchorNavTarget } from './AnchorNavTarget.js'; - -export interface AnchorNavProps extends BaseProps { - $children: { - AnchorNavLink: AnchorNavLink[]; - AnchorNavTarget: AnchorNavTarget[]; - }; -} - -/** - * AnchorNav class. - * - * Coordinates a set of `AnchorNavLink` children with their matching - * `AnchorNavTarget` sections. As each target enters or leaves (its - * mount/destroy is reported to the parent), the links whose `targetId` matches - * the target's element id are toggled active via their `enter()`/`leave()` - * methods, keeping the navigation highlight in sync with the visible section. - * - * @link https://ui.studiometa.dev/reference/items/AnchorNav/ - */ -export class AnchorNav extends Base { - /** - * Config - */ - static config: BaseConfig = { - name: 'AnchorNav', - components: { - AnchorNavLink, - AnchorNavTarget, - }, - }; - - /** - * Listen to the AnchorNavTarget that is mounted - */ - onAnchorNavTargetMounted({ target }: { target: AnchorNavTarget }) { - const { id } = target.$el; - this.$children.AnchorNavLink.forEach((anchorNavLink) => { - if (id === anchorNavLink.targetId) { - anchorNavLink.enter(); - } - }); - } - - /** - * Listen to the AnchorNavTarget that is destroyed - */ - onAnchorNavTargetDestroyed({ target }: { target: AnchorNavTarget }) { - const { id } = target.$el; - this.$children.AnchorNavLink.forEach((anchorNavLink) => { - if (id === anchorNavLink.targetId) { - anchorNavLink.leave(); - } - }); - } -} - -export default AnchorNav; diff --git a/packages/ui/src/AnchorNav/AnchorNavLink.ts b/packages/ui/src/AnchorNav/AnchorNavLink.ts deleted file mode 100644 index 92d975c0..00000000 --- a/packages/ui/src/AnchorNav/AnchorNavLink.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { BaseConfig } from '@studiometa/js-toolkit'; -import { AnchorScrollTo, AnchorScrollToProps } from '../AnchorScrollTo/AnchorScrollTo.js'; -import { withTransition } from '../decorators/index.js'; - -export interface AnchorNavLinkProps extends AnchorScrollToProps { - $options: { - id: string; - }; -} - -/** - * Manage a slider item and its state transition. - */ -export class AnchorNavLink extends withTransition(AnchorScrollTo) { - /** - * Config. - */ - static config: BaseConfig = { - ...AnchorScrollTo.config, - name: 'AnchorNavLink', - }; - - get targetId() { - return this.$el.hash.replace(/^#/, ''); - } -} diff --git a/packages/ui/src/AnchorNav/AnchorNavTarget.ts b/packages/ui/src/AnchorNav/AnchorNavTarget.ts deleted file mode 100644 index 716cdbeb..00000000 --- a/packages/ui/src/AnchorNav/AnchorNavTarget.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import { withMountWhenInView } from '@studiometa/js-toolkit/withMountWhenInView'; -import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; - -/** - * Manage a sticky table section. - */ -export class AnchorNavTarget extends withMountWhenInView(Base) { - /** - * Config. - */ - static config: BaseConfig = { - name: 'AnchorNavTarget', - }; -} diff --git a/packages/ui/src/AnchorNav/index.ts b/packages/ui/src/AnchorNav/index.ts deleted file mode 100644 index 490ed0d6..00000000 --- a/packages/ui/src/AnchorNav/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AnchorNav, type AnchorNavProps } from './AnchorNav.js'; -export { AnchorNavLink, type AnchorNavLinkProps } from './AnchorNavLink.js'; -export { AnchorNavTarget } from './AnchorNavTarget.js'; diff --git a/packages/ui/src/AnchorScrollTo/AnchorScrollTo.ts b/packages/ui/src/AnchorScrollTo/AnchorScrollTo.ts deleted file mode 100644 index 68272f0c..00000000 --- a/packages/ui/src/AnchorScrollTo/AnchorScrollTo.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; -import { scrollTo } from '@studiometa/js-toolkit/utils/scrollTo'; - -export interface AnchorScrollToProps extends BaseProps { - $el: HTMLAnchorElement; -} - -/** - * AnchorScrollTo class. - * - * Enhances an anchor element so that clicking it smoothly scrolls to the element - * referenced by its `href` hash instead of jumping. It reads the target from the - * link's `hash`, delegates the animation to the toolkit `scrollTo` helper and - * prevents the default jump; if the target cannot be resolved the click is left - * untouched. - * - * @link https://ui.studiometa.dev/reference/items/AnchorScrollTo/ - */ -export class AnchorScrollTo extends Base { - static config: BaseConfig = { - name: 'AnchorScrollTo', - }; - - /** - * Get the target selector. - */ - get targetSelector(): Parameters[0] { - return this.$el.hash; - } - - /** - * Scroll to the target selector on click. - */ - onClick({ event }: { event: MouseEvent }) { - try { - scrollTo(this.targetSelector); - event.preventDefault(); - } catch { - // Silence is golden. - } - } -} - -export default AnchorScrollTo; diff --git a/packages/ui/src/AnchorScrollTo/index.ts b/packages/ui/src/AnchorScrollTo/index.ts deleted file mode 100644 index 7f7c360d..00000000 --- a/packages/ui/src/AnchorScrollTo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AnchorScrollTo, type AnchorScrollToProps } from './AnchorScrollTo.js'; diff --git a/packages/ui/src/Carousel/AbstractCarouselChild.ts b/packages/ui/src/Carousel/AbstractCarouselChild.ts deleted file mode 100644 index 19effaf8..00000000 --- a/packages/ui/src/Carousel/AbstractCarouselChild.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { nextFrame } from '@studiometa/js-toolkit/utils/nextFrame'; -import { domScheduler } from '@studiometa/js-toolkit/utils/domScheduler'; -import { isFunction } from '@studiometa/js-toolkit/utils/isFunction'; -import { AbstractCarouselComponent } from './AbstractCarouselComponent.js'; -import type { Carousel } from './Carousel.js'; - -export interface AbstractCarouselChildProps extends BaseProps {} - -/** - * AbstractCarouselChild class. - * - * The shared base for Carousel controls that must reflect the active index - * (items, buttons). It connects to its parent `Carousel` — either handed over - * by the Carousel on mount or resolved through the inherited guarded - * `$closest('Carousel')` lookup, never via the deprecated `$parent` accessor — - * and subscribes to the Carousel's index store, scheduling a call to the - * subclass `update(index)` method whenever the active item changes. Subclasses - * must implement `update` to reflect the current index in the DOM. - */ -export class AbstractCarouselChild extends AbstractCarouselComponent< - T & AbstractCarouselChildProps -> { - /** - * Config. - */ - static config: BaseConfig = { - name: 'AbstractCarouselChild', - }; - - /** - * Unsubscribe callback for the parent Carousel store subscription. - * @private - */ - __unsubscribe: (() => void) | null = null; - - /** - * Connect to the parent Carousel on mount. - */ - mounted() { - this.__connect(); - } - - /** - * Reconnect and refresh with the current index on resize. - */ - resized() { - this.__connect(); - const { carousel } = this; - if (carousel?.store.has('index')) { - nextFrame(() => { - this.__updateWith(carousel.store.get('index', 0)); - }); - } - } - - /** - * Reconnect and refresh on update. - * - * Reconnects (idempotent) then re-runs the subclass `update` against the - * current index. This matters when items are added or removed after mount: - * the index *value* may be unchanged — so the store's change-gated `set` does - * not re-notify — yet `carousel.lastIndex` and an item's own position among - * its siblings have shifted. Without this refresh a `CarouselBtn` could stay - * stuck disabled (e.g. `next` after appending items) or a `CarouselItem` keep - * a stale active state. - */ - updated() { - this.__connect(); - const { carousel } = this; - if (carousel?.store.has('index')) { - this.__updateWith(carousel.store.get('index', 0)); - } - } - - /** - * Remove the store subscription. - */ - destroyed() { - this.__unsubscribe?.(); - this.__unsubscribe = null; - this.__carousel = undefined; - } - - /** - * Subscribe to a Carousel index store. - * - * The subscription never relies on the deprecated `$parent` accessor. The - * Carousel is either handed over by the parent itself — see - * `Carousel.connectChildren`, which connects the children that mounted before - * it — or resolved through a guarded `$closest('Carousel')` lookup. The call - * is idempotent and a no-op once the child is connected or unmounted. - * - * The current index is pulled immediately only when the Carousel has already - * seeded its store, which happens after `Carousel.mounted` runs its initial - * `goTo`. This ensures the `update` callback never runs against a - * not-yet-initialised Carousel and fixes the initial-state race where the - * first item was not marked active and the `prev` button was not disabled on - * load. - * @private - */ - __connect(carousel: Carousel | undefined = this.carousel) { - if (this.__unsubscribe || !this.$isMounted || !carousel) { - return; - } - - this.__carousel = carousel; - this.__unsubscribe = carousel.store.subscribe('index', (index) => { - this.__updateWith(index ?? 0); - }); - - if (carousel.store.has('index')) { - this.__updateWith(carousel.store.get('index', 0)); - } - } - - /** - * Schedule the `update` callback for the given index. - * @private - */ - __updateWith(index: number) { - domScheduler.read(() => { - const callback = this.update(index); - if (isFunction(callback)) { - domScheduler.write(() => { - // @ts-ignore - callback(); - }); - } - }); - } - - /** - * Update the child component with the given index. - */ - update(index: number): void | (() => void) { - throw new Error(`The \`AbstractCarouselChild.update(${index})\` method must be implemented.`); - } -} diff --git a/packages/ui/src/Carousel/AbstractCarouselComponent.ts b/packages/ui/src/Carousel/AbstractCarouselComponent.ts deleted file mode 100644 index c1c4e7fb..00000000 --- a/packages/ui/src/Carousel/AbstractCarouselComponent.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import type { Carousel } from './Carousel.js'; - -export interface AbstractCarouselComponentProps extends BaseProps {} - -/** - * AbstractCarouselComponent class. - * - * The shared, non-subscribing base for every Carousel child component. It - * resolves the parent `Carousel` — either handed over by the Carousel on mount - * or through a guarded `$closest('Carousel')` lookup, never via the deprecated - * `$parent` accessor — and exposes the orientation getters the children rely on. - * - * It does not subscribe to the Carousel index store: components that only need - * to read the carousel (the `CarouselWrapper` scroller and the `CarouselDrag` - * track) extend this base directly, mirroring how `SliderItem`/`SliderDrag` - * extend `Base` rather than `AbstractSliderChild`. Controls that must react to - * index changes extend the subscribing `AbstractCarouselChild` instead. - */ -export class AbstractCarouselComponent extends Base< - T & AbstractCarouselComponentProps -> { - /** - * Config. - */ - static config: BaseConfig = { - name: 'AbstractCarouselComponent', - }; - - /** - * The parent Carousel this component is connected to. - * @private - */ - __carousel: Carousel | undefined; - - /** - * The parent Carousel instance, if any. - * - * Returns the Carousel that connected this component, falling back to a - * guarded `$closest('Carousel')` lookup. Never dereferences the deprecated - * `$parent` accessor; may be `undefined` before the component is connected to - * a Carousel. - */ - get carousel(): Carousel | undefined { - return this.__carousel ?? this.$closest('Carousel'); - } - - /** - * Is the carousel horizontal? Defaults to `true` (the `x` axis) when the - * parent Carousel cannot be resolved yet. - */ - get isHorizontal(): boolean { - return this.carousel?.isHorizontal ?? true; - } - - /** - * Is the carousel vertical? Defaults to `false` when the parent Carousel - * cannot be resolved yet. - */ - get isVertical(): boolean { - return this.carousel?.isVertical ?? false; - } -} diff --git a/packages/ui/src/Carousel/Carousel.ts b/packages/ui/src/Carousel/Carousel.ts deleted file mode 100644 index ac1c3868..00000000 --- a/packages/ui/src/Carousel/Carousel.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { Base, BaseConfig } from '@studiometa/js-toolkit'; -import { createMemoryStorageProvider } from '@studiometa/js-toolkit/utils/createMemoryStorageProvider'; -import { createStorage } from '@studiometa/js-toolkit/utils/createStorage'; -import { isNumber } from '@studiometa/js-toolkit/utils/isNumber'; -import { nextFrame } from '@studiometa/js-toolkit/utils/nextFrame'; -import type { IndexableInstructions, IndexableProps } from '../decorators/index.js'; -import { Indexable } from '../Indexable/index.js'; -import { AbstractCarouselChild } from './AbstractCarouselChild.js'; -import { CarouselBtn } from './CarouselBtn.js'; -import { CarouselDrag } from './CarouselDrag.js'; -import { CarouselItem } from './CarouselItem.js'; -import { CarouselWrapper } from './CarouselWrapper.js'; - -/** - * Shape of the per-instance store shared with the child components. - */ -export type CarouselStore = { index: number }; - -/** - * Props for the Carousel class. - */ -export interface CarouselProps { - $children: { - CarouselBtn: CarouselBtn[]; - CarouselDrag: CarouselDrag[]; - CarouselItem: CarouselItem[]; - CarouselWrapper: CarouselWrapper[]; - }; - $options: { - axis: 'x' | 'y'; - }; -} - -/** - * Carousel class. - */ -export class Carousel extends Indexable { - /** - * Config. - */ - static config: BaseConfig = { - name: 'Carousel', - components: { - CarouselBtn, - CarouselDrag, - CarouselItem, - CarouselWrapper, - }, - options: { - ...Indexable.config.options, - axis: { type: String, default: 'x' }, - }, - emits: ['progress'], - }; - - /** - * Per-instance store used to broadcast the current index to the child - * components. Controls subscribe to it through a guarded `$closest('Carousel')` - * lookup instead of listening to `index`/`progress` events, which removes the - * mount-order race where a child that mounted before the Carousel missed the - * initial index. - * - * The store uses the in-memory provider and lives for the whole lifetime of - * the instance (it is a constructor-time field). It survives `$destroy`/ - * `$mount` cycles of the same instance — a re-mounted Carousel exposes a - * stale-but-consistent seeded index and its children re-subscribe on remount - * and unsubscribe on destroy, so there is no leak and no need to `destroy()` - * the memory store. - */ - store = createStorage({ provider: createMemoryStorageProvider() }); - - /** - * Is the carousel horizontal? - */ - get isHorizontal() { - return !this.isVertical; - } - - /** - * Is the carousel vertical? - */ - get isVertical() { - return this.$options.axis === 'y'; - } - - /** - * Get the carousel's items. - */ - get items() { - return this.$children.CarouselItem; - } - - /** - * Get the carousel's length. - */ - get length() { - return this.items?.length || 0; - } - - /** - * Get the carousel's wrapper. - */ - get wrapper() { - return this.$children.CarouselWrapper?.[0]; - } - - /** - * Previous progress value. - */ - previousProgress = -1; - - /** - * Progress from 0 to 1. - */ - get progress() { - return this.wrapper?.progress ?? 0; - } - - /** - * Get the current index. - * - * The accessor pair is overridden as a whole: defining only the setter would - * shadow the getter inherited from `withIndex` and make reads `undefined`. - */ - get currentIndex(): number { - return super.currentIndex; - } - - /** - * Set the current index and broadcast it to the child components. - * - * `super` runs first so `withIndex` normalises the value (clamp/loop/bounce) - * and assigns `__index` before any subscriber reads `currentIndex`; the store - * is then seeded with the normalised value, never the raw one. Assigning the - * index only reports and stores state — it never scrolls the wrapper. Use - * `goTo()` to navigate (which scrolls); this separation is what lets - * `CarouselWrapper.onScroll` report the scroll-synced index without forming a - * scroll/index feedback loop. - * - * The store write is gated on an actual change (or the store not being seeded - * yet) so the initial `0 -> 0` assignment during `mounted` still seeds the - * store, while same-value scroll updates do not re-run every subscriber. The - * memory store fires subscribers synchronously with no deduplication, so this - * gate is load-bearing. - */ - set currentIndex(value: number) { - super.currentIndex = value; - const index = this.currentIndex; - if (!this.store.has('index') || this.store.get('index') !== index) { - this.store.set('index', index); - } - } - - /** - * Mounted hook. - * - * Seeds the store with the current index (via `goTo`) then connects the - * children — including any that mounted before this Carousel — so they - * synchronise against an already-seeded store. - */ - mounted() { - this.goTo(this.currentIndex); - this.connectChildren(); - } - - /** - * Connect the child components that track the current index, including those - * that mounted before this Carousel. Runs after `goTo` has seeded the store so - * connected children synchronise against an initialised Carousel. Idempotent - * thanks to the child-side `__unsubscribe` guard. - */ - connectChildren() { - for (const children of Object.values(this.$children as Record)) { - for (const child of children) { - if (child instanceof AbstractCarouselChild) { - child.__connect(this as unknown as Carousel); - } - } - } - } - - /** - * Re-normalise the index and reconnect children on update. - * - * Removing items after mount shrinks `length` but leaves `currentIndex` - * untouched, so it can fall outside the new `0…lastIndex` range and leave no - * item active. Reassigning it runs the `withIndex` setter, which re-normalises - * against the current item count and re-seeds the store (a no-op when the - * index is still in range). `connectChildren` then connects any newly-added - * children — it is idempotent for already-connected ones thanks to the - * `__unsubscribe` guard. - */ - updated() { - const { currentIndex } = this; - this.currentIndex = currentIndex; - this.connectChildren(); - // Item changes alter the progress denominator (the children invalidate their - // geometry caches in their own `updated` hooks). Force `ticked` to re-emit - // the refreshed progress on the next frame, otherwise the emitted `progress` - // value and `--carousel-progress` stay stale until the next scroll. - this.previousProgress = -1; - this.$services.enable('ticked'); - } - - /** - * Resized hook. - * - * Re-snaps to the current index. `goTo` scrolls imperatively, reading each - * item's freshly-measured `state`, so the re-snap must run only after the - * children have invalidated their geometry caches (`CarouselItem.state` and - * `CarouselWrapper`'s scroll distance). js-toolkit dispatches resize callbacks - * in mount (registration) order — the parent Carousel registers *before* its - * children — so a synchronous re-snap here would read stale pre-resize - * geometry. Deferring by one frame lets the children's `resized` callbacks - * (which run synchronously later in the same resize tick) invalidate their - * caches first, mirroring how `Slider.resized` defers with `nextFrame`. - */ - resized() { - nextFrame(() => this.goTo(this.currentIndex)); - } - - /** - * Go to the given item. - * - * Navigation is imperative: after updating the index it scrolls the wrapper to - * the matching item. The scroll is triggered only for numeric arguments — - * instruction arguments (`next`, `prev`, …) recurse through `goNext`/`goPrev` - * into a numeric `goTo`, which owns the scroll, so guarding on `isNumber` - * avoids scrolling twice per instruction navigation. - */ - goTo(indexOrInstruction: number | IndexableInstructions) { - this.$log('goTo', indexOrInstruction); - this.$services.enable('ticked'); - const result = super.goTo(indexOrInstruction); - if (isNumber(indexOrInstruction)) { - this.wrapper?.scrollToIndex(this.currentIndex); - } - return result; - } - - ticked() { - if (this.progress !== this.previousProgress) { - this.previousProgress = this.progress; - this.$emit('progress', this.progress); - this.$el.style.setProperty('--carousel-progress', String(this.progress)); - } else { - this.$services.disable('ticked'); - } - } -} - -export default Carousel; diff --git a/packages/ui/src/Carousel/CarouselBtn.ts b/packages/ui/src/Carousel/CarouselBtn.ts deleted file mode 100644 index de201678..00000000 --- a/packages/ui/src/Carousel/CarouselBtn.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { AbstractCarouselChild } from './AbstractCarouselChild.js'; - -/** - * Props for the CarouselBtn class. - */ -export interface CarouselBtnProps extends BaseProps { - $el: HTMLButtonElement; - $options: { - action: 'next' | 'prev' | string; - }; -} - -/** - * CarouselBtn class. - */ -export class CarouselBtn extends AbstractCarouselChild< - T & CarouselBtnProps -> { - /** - * Config. - */ - static config: BaseConfig = { - name: 'CarouselBtn', - options: { action: String }, - }; - - /** - * Go to the next or previous item on click. - */ - onClick() { - const { carousel } = this; - if (!carousel) { - return; - } - - const { action } = this.$options; - switch (action) { - case 'next': - carousel.goNext(); - break; - case 'prev': - carousel.goPrev(); - break; - default: - carousel.goTo(Number(action)); - break; - } - } - - /** - * Update the disabled state for the given index. - */ - update(index: number) { - const { carousel } = this; - if (!carousel) { - return; - } - - const { action } = this.$options; - // Base the disabled state on whether the action would actually move the - // index, so it honours the inherited `Indexable` options: with `boundary` - // `loop`/`bounce` the ends never disable (navigation wraps), and `reverse` - // flips which end is terminal. `prevIndex`/`nextIndex` already encode all of - // that; a numeric action disables only on the slide it points to. - let shouldDisable: boolean; - if (action === 'next') { - shouldDisable = carousel.nextIndex === index; - } else if (action === 'prev') { - shouldDisable = carousel.prevIndex === index; - } else { - shouldDisable = Number(action) === index; - } - - this.$el.disabled = shouldDisable; - } -} diff --git a/packages/ui/src/Carousel/CarouselDrag.ts b/packages/ui/src/Carousel/CarouselDrag.ts deleted file mode 100644 index 4b24f139..00000000 --- a/packages/ui/src/Carousel/CarouselDrag.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { BaseConfig, BaseProps, DragServiceProps } from '@studiometa/js-toolkit'; -import { withDrag } from '@studiometa/js-toolkit/withDrag'; -import { withMountOnMediaQuery } from '@studiometa/js-toolkit/withMountOnMediaQuery'; -import { inertiaFinalValue } from '@studiometa/js-toolkit/utils/inertiaFinalValue'; -import { AbstractCarouselComponent } from './AbstractCarouselComponent.js'; -import { getClosestIndex } from './utils.js'; - -/** - * Props for the CarouselDrag class. - */ -export interface CarouselDragProps extends BaseProps {} - -/** - * CarouselDrag class. - * - * The draggable track of the Carousel. It only reads the carousel (items and - * orientation) and never reacts to index changes, so it extends the - * non-subscribing `AbstractCarouselComponent` rather than - * `AbstractCarouselChild` — mirroring how `SliderDrag` extends `Base`, not - * `AbstractSliderChild`. - */ -export class CarouselDrag< - T extends BaseProps = BaseProps, -> extends withMountOnMediaQuery( - withDrag(AbstractCarouselComponent), - '(pointer: fine)', -) { - /** - * Config. - */ - static config: BaseConfig = { - name: 'CarouselDrag', - }; - - /** - * Dragged hook. - */ - dragged(props: DragServiceProps) { - if (!this.$isMounted) return; - - // do noting on inertia and stop - if (props.mode === 'inertia' || props.mode === 'stop') { - return; - } - - // do nothin while the distance is 0 - if ( - (this.isHorizontal && props.distance.x === 0) || - (this.isVertical && props.distance.y === 0) - ) { - return; - } - - const wrapper = this.$el; - - // @todo wait for the props.delta values to be fixed - // @see https://github.com/studiometa/js-toolkit/pull/533 - if (props.mode === 'drag') { - const left = wrapper.scrollLeft - props.delta.x; - const top = wrapper.scrollTop - props.delta.y; - // We must disable the scroll-snap otherwise we - // cannot programmatically scroll to a position - // that is not a snap-point. This might be easily - // fixed by not using scroll-snap at all. - wrapper.style.scrollSnapType = 'none'; - wrapper.scrollTo({ left, top, behavior: 'instant' }); - return; - } - - // @todo implement inertia with the raf service for a smoother transition than the native smooth scroll - if (props.mode === 'drop') { - const { carousel } = this; - if (!carousel) { - return; - } - - const options: ScrollToOptions = { behavior: 'smooth' }; - - if (this.isHorizontal) { - const finalValue = inertiaFinalValue(wrapper.scrollLeft, props.delta.x * -2.5); - const index = getClosestIndex( - carousel.items.map((item) => item.state.left), - finalValue, - ); - options.left = carousel.items[index]?.state?.left; - } else if (this.isVertical) { - const finalValue = inertiaFinalValue(wrapper.scrollTop, props.delta.y * -2.5); - const index = getClosestIndex( - carousel.items.map((item) => item.state.top), - finalValue, - ); - options.top = carousel.items[index]?.state?.top; - } - - // No target slide to snap to (e.g. an empty carousel): restore scroll-snap - // — which the `drag` branch disabled — and bail instead of scrolling to an - // `undefined` offset. - if (options.left === undefined && options.top === undefined) { - wrapper.style.scrollSnapType = ''; - return; - } - - wrapper.addEventListener( - 'scrollend', - () => { - wrapper.style.scrollSnapType = ''; - }, - { once: true }, - ); - wrapper.scrollTo(options); - } - } -} diff --git a/packages/ui/src/Carousel/CarouselItem.ts b/packages/ui/src/Carousel/CarouselItem.ts deleted file mode 100644 index 99d7e95d..00000000 --- a/packages/ui/src/Carousel/CarouselItem.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import type { ScrollAction } from 'compute-scroll-into-view'; -import { compute } from 'compute-scroll-into-view'; -import { AbstractCarouselChild } from './AbstractCarouselChild.js'; - -/** - * Props for the CarouselItem class. - */ -export interface CarouselItemProps extends BaseProps {} - -/** - * CarouselItem class. - */ -export class CarouselItem extends AbstractCarouselChild< - T & CarouselItemProps -> { - /** - * Config. - */ - static config: BaseConfig = { - name: 'CarouselItem', - }; - - /** - * The item's index in the carousel. - */ - get index() { - return this.carousel?.$children.CarouselItem.indexOf(this) ?? -1; - } - - __state: ScrollAction; - __shouldEvaluateState = true; - - /** - * The item's active state descriptor. - */ - get state(): ScrollAction { - if (this.__shouldEvaluateState) { - const [state] = compute(this.$el, { - block: 'center', - inline: 'center', - boundary: this.carousel?.wrapper?.$el, - }); - this.__state = state; - this.__shouldEvaluateState = false; - } - - return this.__state; - } - - /** - * Invalidate the cached state on resize. - * - * Extends the base reconnect/refresh (`super.resized`) rather than shadowing - * it: the cache is invalidated first so a subsequent `state` read re-measures. - * The active-state `update` does not depend on geometry, so the ordering only - * matters for keeping the base refresh alive for a future edit. - */ - resized() { - this.__shouldEvaluateState = true; - super.resized(); - } - - /** - * Invalidate the cached scroll target when the item list or content changes. - * - * Inserting/removing slides (or changing an item's content) via `$update` - * shifts the offsets returned by `compute-scroll-into-view`, so the cache is - * cleared before the base reconnect/refresh runs — otherwise `scrollToIndex` - * and `onScroll` keep using stale positions until the next resize. - */ - updated() { - this.__shouldEvaluateState = true; - super.updated(); - } - - /** - * Reflect the active state for the given index. - * @todo a11y - */ - update(index: number) { - const isActive = this.index === index; - return () => { - this.$el.style.setProperty('--carousel-item-active', String(Number(isActive))); - }; - } -} diff --git a/packages/ui/src/Carousel/CarouselWrapper.ts b/packages/ui/src/Carousel/CarouselWrapper.ts deleted file mode 100644 index 5f549e84..00000000 --- a/packages/ui/src/Carousel/CarouselWrapper.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { clamp } from '@studiometa/js-toolkit/utils/clamp'; -import { AbstractCarouselComponent } from './AbstractCarouselComponent.js'; -import { getClosestIndex } from './utils.js'; - -/** - * Props for the CarouselWrapper class. - */ -export interface CarouselWrapperProps extends BaseProps {} - -/** - * CarouselWrapper class. - * - * The scrollable track of the Carousel. It scrolls to a given item on demand - * through the imperative `scrollToIndex` (called by `Carousel.goTo`) and, on - * native/touch scroll, merely reports the closest item back to the Carousel via - * `currentIndex`. Because scrolling is only ever initiated by `goTo` and - * `onScroll` only reports, the scroll/index feedback loop that used to hijack a - * smooth scroll cannot form, so no synchronising guard is needed. - */ -export class CarouselWrapper extends AbstractCarouselComponent< - T & CarouselWrapperProps -> { - /** - * Config. - */ - static config: BaseConfig = { - name: 'CarouselWrapper', - }; - - /** - * Cached maximum scroll distances (`scrollWidth - clientWidth` and - * `scrollHeight - clientHeight`). The `progress` getter runs on every frame, - * so these layout-triggering reads are cached and only refreshed on resize. - * @private - */ - __scrollDistance = { x: 0, y: 0 }; - - /** - * Whether the cached scroll distances need to be re-measured. - * @private - */ - __shouldMeasure = true; - - /** - * Current progress between 0 and 1. - */ - get progress() { - if (this.__shouldMeasure) { - const { scrollWidth, clientWidth, scrollHeight, clientHeight } = this.$el; - // Round to integer pixels: browsers report fractional scroll sizes and - // offsets, so the last item can settle a sub-pixel short of the maximum - // and keep progress from ever reaching a clean `1`. - this.__scrollDistance = { - x: Math.round(scrollWidth - clientWidth), - y: Math.round(scrollHeight - clientHeight), - }; - this.__shouldMeasure = false; - } - - if (this.isHorizontal) { - const { x } = this.__scrollDistance; - return x === 0 ? 0 : clamp(Math.round(this.$el.scrollLeft) / x, 0, 1); - } else if (this.isVertical) { - const { y } = this.__scrollDistance; - return y === 0 ? 0 : clamp(Math.round(this.$el.scrollTop) / y, 0, 1); - } - - return 0; - } - - /** - * Invalidate the cached scroll distances on resize. - */ - resized() { - this.__shouldMeasure = true; - } - - /** - * Invalidate the cached scroll distances when the item list changes. - * - * Adding or removing items changes `scrollWidth`, so `progress` (and the - * `--carousel-progress` variable derived from it) would keep dividing by the - * pre-update distance until the next resize otherwise. - */ - updated() { - this.__shouldMeasure = true; - } - - /** - * Scroll to the item at the given index. - * - * Called imperatively by `Carousel.goTo`. Guards against an empty carousel or - * a missing item so the unconditional mount-time seed cannot throw. - */ - scrollToIndex(index: number) { - const state = this.carousel?.items[index]?.state; - if (state) { - this.$el.scrollTo({ left: state.left, top: state.top, behavior: 'smooth' }); - } - } - - /** - * Report the scroll-synced index and keep the progress bar animating. - * - * Assigning `carousel.currentIndex` only stores/reports the index — it never - * scrolls back — so this cannot hijack a `goTo` smooth scroll. The `ticked` - * service must be (re-)enabled here because it self-disables once progress - * stabilises: without it `--carousel-progress` and the `progress` event would - * freeze during any scroll not initiated by `goTo`, including all touch - * scrolling (the `CarouselDrag` track only mounts on `(pointer: fine)`). - */ - onScroll() { - const { isHorizontal, $el, carousel } = this; - if (!carousel) { - return; - } - - const minDiffIndex = getClosestIndex( - carousel.items.map((item) => (isHorizontal ? item.state.left : item.state.top)), - isHorizontal ? $el.scrollLeft : $el.scrollTop, - ); - - carousel.currentIndex = minDiffIndex; - carousel.$services.enable('ticked'); - } -} diff --git a/packages/ui/src/Carousel/index.ts b/packages/ui/src/Carousel/index.ts deleted file mode 100644 index af682949..00000000 --- a/packages/ui/src/Carousel/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { Carousel, type CarouselProps, type CarouselStore } from './Carousel.js'; -export { CarouselBtn, type CarouselBtnProps } from './CarouselBtn.js'; -export { CarouselDrag, type CarouselDragProps } from './CarouselDrag.js'; -export { CarouselItem, type CarouselItemProps } from './CarouselItem.js'; -export { CarouselWrapper, type CarouselWrapperProps } from './CarouselWrapper.js'; -export { - AbstractCarouselComponent, - type AbstractCarouselComponentProps, -} from './AbstractCarouselComponent.js'; -export { AbstractCarouselChild, type AbstractCarouselChildProps } from './AbstractCarouselChild.js'; diff --git a/packages/ui/src/Carousel/utils.ts b/packages/ui/src/Carousel/utils.ts deleted file mode 100644 index aa57acf8..00000000 --- a/packages/ui/src/Carousel/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Get the index of the closest number to the target. - */ -export function getClosestIndex(numbers: number[], target: number): number { - let index = 0; - let min = Number.POSITIVE_INFINITY; - let closestIndex = 0; - - for (const number of numbers) { - const absoluteDiff = Math.abs(number - target); - - if (absoluteDiff < min) { - closestIndex = index; - min = absoluteDiff; - } - - index += 1; - } - - return closestIndex; -} diff --git a/packages/ui/src/ClickOutside/ClickOutside.ts b/packages/ui/src/ClickOutside/ClickOutside.ts deleted file mode 100644 index 6afa771a..00000000 --- a/packages/ui/src/ClickOutside/ClickOutside.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseConfig, BaseProps, BaseEventHookParams } from '@studiometa/js-toolkit'; - -export interface ClickOutsideProps extends BaseProps {} - -/** - * ClickOutside class. - * - * A minimal marker component that reports clicks landing outside its own element. - * Using the built-in `onDocumentClick` hook, it dispatches a native - * `click-outside` `CustomEvent` on its root element whenever a document click - * occurs outside of it. Paired with the `Action` component on the same element, - * this lets HTML react to outside clicks — closing a dropdown or popover for - * example — via `data-on:click-outside="..."` without writing any JavaScript. - * - * @link https://ui.studiometa.dev/reference/items/ClickOutside/ - */ -export class ClickOutside extends Base { - /** - * Config. - */ - static config: BaseConfig = { - name: 'ClickOutside', - }; - - /** - * Dispatch a `click-outside` event when a document click lands outside the element. - */ - onDocumentClick({ event }: BaseEventHookParams) { - if (!event.composedPath().includes(this.$el)) { - this.$el.dispatchEvent(new CustomEvent('click-outside', { detail: { event } })); - } - } -} - -export default ClickOutside; diff --git a/packages/ui/src/ClickOutside/index.ts b/packages/ui/src/ClickOutside/index.ts deleted file mode 100644 index 0763699d..00000000 --- a/packages/ui/src/ClickOutside/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ClickOutside, type ClickOutsideProps } from './ClickOutside.js'; diff --git a/packages/ui/src/Cursor/Cursor.ts b/packages/ui/src/Cursor/Cursor.ts deleted file mode 100644 index ddae994f..00000000 --- a/packages/ui/src/Cursor/Cursor.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import type { BaseConfig, PointerServiceProps, BaseProps } from '@studiometa/js-toolkit'; -import { damp } from '@studiometa/js-toolkit/utils/damp'; -import { matrix } from '@studiometa/js-toolkit/utils/matrix'; - -export interface CursorProps extends BaseProps { - $options: { - growSelectors: string; - shrinkSelectors: string; - scale: number; - growTo: number; - shrinkTo: number; - translateDampFactor: number; - growDampFactor: number; - shrinkDampFactor: number; - }; -} - -/** - * Cursor class. - * - * Custom cursor that follows the pointer, damping its position each frame for a - * smooth trail. It grows over elements matching `growSelectors` and shrinks over - * `shrinkSelectors` or while the pointer is down, interpolating between the - * `scale`, `growTo` and `shrinkTo` factors with per-transition damping options - * and applying the result as a `transform` on the root element. - * - * @link https://ui.studiometa.dev/reference/items/Cursor/ - */ -export class Cursor extends Base { - static config: BaseConfig = { - name: 'Cursor', - options: { - growSelectors: { - type: String, - default: 'a, a *, button, button *, [data-cursor-grow], [data-cursor-grow] *', - }, - shrinkSelectors: { - type: String, - default: '[data-cursor-shrink], [data-cursor-shrink] *', - }, - scale: { - type: Number, - default: 1, - }, - growTo: { - type: Number, - default: 2, - }, - shrinkTo: { - type: Number, - default: 0.5, - }, - translateDampFactor: { - type: Number, - default: 0.25, - }, - growDampFactor: { - type: Number, - default: 0.25, - }, - shrinkDampFactor: { - type: Number, - default: 0.25, - }, - }, - }; - - x = 0; - - y = 0; - - scale = 0; - - pointerX = 0; - - pointerY = 0; - - pointerScale = 0; - - /** - * Mounted hook. - */ - mounted() { - this.x = 0; - this.y = 0; - this.scale = 0; - this.pointerX = 0; - this.pointerY = 0; - this.pointerScale = 0; - this.render({ x: this.x, y: this.y, scale: this.scale }); - } - - /** - * Moved hook. - */ - moved({ event, x, y, isDown }: PointerServiceProps) { - if (!this.$services.has('ticked')) { - this.$services.enable('ticked'); - } - - this.pointerX = x; - this.pointerY = y; - - let { scale } = this.$options; - - if (!event) { - this.pointerScale = scale; - return; - } - - const shouldGrow = - (event.target instanceof Element && event.target.matches(this.$options.growSelectors)) || - false; - const shouldReduce = - isDown || - (event.target instanceof Element && event.target.matches(this.$options.shrinkSelectors)) || - false; - - if (shouldGrow) { - scale = this.$options.growTo; - } - - if (shouldReduce) { - scale = this.$options.shrinkTo; - } - - this.pointerScale = scale; - } - - /** - * RequestAnimationFrame hook. - */ - ticked() { - this.x = damp(this.pointerX, this.x, this.$options.translateDampFactor); - this.y = damp(this.pointerY, this.y, this.$options.translateDampFactor); - this.scale = damp( - this.pointerScale, - this.scale, - this.pointerScale < this.scale - ? this.$options.shrinkDampFactor - : this.$options.growDampFactor, - ); - - this.render({ x: this.x, y: this.y, scale: this.scale }); - - if (this.x === this.pointerX && this.y === this.pointerY && this.scale === this.pointerScale) { - this.$services.disable('ticked'); - } - } - - /** - * Render the cursor. - */ - render({ x, y, scale }: { x: number; y: number; scale: number }) { - const transform = matrix({ - translateX: x, - translateY: y, - scaleX: scale, - scaleY: scale, - }); - this.$el.style.transform = `translateZ(0) ${transform}`; - } -} - -export default Cursor; diff --git a/packages/ui/src/Cursor/index.ts b/packages/ui/src/Cursor/index.ts deleted file mode 100644 index 6f0967d5..00000000 --- a/packages/ui/src/Cursor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Cursor, type CursorProps } from './Cursor.js'; diff --git a/packages/ui/src/Data/DataBind.ts b/packages/ui/src/Data/DataBind.ts deleted file mode 100644 index f2565091..00000000 --- a/packages/ui/src/Data/DataBind.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { Base } from '@studiometa/js-toolkit/Base'; -import { withGroup } from '@studiometa/js-toolkit/withGroup'; -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { nextTick } from '@studiometa/js-toolkit/utils/nextTick'; -import { getDataChannel } from './DataChannel.js'; -import { DataScope, getDataScope, DATA_GROUP_NAMESPACE } from './DataScope.js'; -import type { DataScopeMember, DataValue } from './DataScope.js'; -import { - type DataControlContext, - isCheckbox, - isInput, - readControlValue, - resolvePropertyName, - setProperty, - valuesEqual, - writeControlValue, -} from './formControl.js'; -import { getCallback } from './utils.js'; -import { emitDomUpdate, runWrapped } from '../utils/dom-update.js'; - -export interface DataBindProps extends BaseProps { - $options: { - prop: string; - immediate: boolean; - key: string; - }; -} - -const EMPTY_DATA = Object.freeze({}); - -type VirtualBinding = - | { type: 'text' | 'if'; expression: string } - | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; - -/** - * DataBind class. - * - * Part of the reactive Data* family. It creates a binding between a DOM element - * and a shared value within its Data group — optionally scoped by an enclosing - * `DataScope` — reflecting values published by the other members of the group - * onto the element. The bound target defaults to a form control's value or the - * element's `textContent`, can be overridden with the `prop` option, keyed with - * the `key` option, and propagated on mount with `immediate`; `data-bind:*` - * attributes additionally drive an element's property, attribute, class, style, - * text or — on a `