From 7243571a67c1d1a1c5114673939cc9ce569eb1df Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:07:29 +0200 Subject: [PATCH 01/24] feat(v4): port Sentinel onto withInView Sentinel forwards the raw IntersectionObserverEntry via `withInView`, unlike InView's collapsed in/out boolean. Sticky needs the entry's boundingClientRect to tell "scrolled above the viewport" apart from "scrolled below it". --- .../v4/migration/Sentinel/Sentinel.spec.ts | 88 +++++++++++++++++++ packages/v4/migration/Sentinel/Sentinel.ts | 23 +++++ packages/v4/migration/Sentinel/index.ts | 1 + packages/v4/migration/index.ts | 1 + 4 files changed, 113 insertions(+) create mode 100644 packages/v4/migration/Sentinel/Sentinel.spec.ts create mode 100644 packages/v4/migration/Sentinel/Sentinel.ts create mode 100644 packages/v4/migration/Sentinel/index.ts diff --git a/packages/v4/migration/Sentinel/Sentinel.spec.ts b/packages/v4/migration/Sentinel/Sentinel.spec.ts new file mode 100644 index 00000000..6d3ae16a --- /dev/null +++ b/packages/v4/migration/Sentinel/Sentinel.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents, type InViewProps } from '../../src/index.js'; +import { resetDom, settle } from '../../src/test-utils.js'; +import { Sentinel } from './Sentinel.js'; + +const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; +const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; + +registerComponents(Sentinel); + +afterEach(resetDom); + +/** Give the observer a few frames to deliver. */ +async function observed(): Promise { + for (let i = 0; i < 6; i += 1) { + await settle(); + } +} + +function render(style: string): HTMLElement { + const el = document.createElement('div'); + el.setAttribute('data-component', 'Sentinel'); + el.setAttribute('style', style); + document.body.append(el); + return el; +} + +describe('Sentinel', () => { + it('emits `intersected` with the initial entry as soon as it observes, like the v3 decorator', async () => { + const el = render(OFFSCREEN); + const events: InViewProps[] = []; + el.addEventListener('intersected', (event) => { + events.push((event as CustomEvent).detail); + }); + await observed(); + + expect(events).toHaveLength(1); + expect(events[0].isInView).toBe(false); + }); + + it('emits `intersected` with the raw entry when it enters the viewport', async () => { + const el = render(OFFSCREEN); + const events: InViewProps[] = []; + el.addEventListener('intersected', (event) => { + events.push((event as CustomEvent).detail); + }); + await observed(); + + el.setAttribute('style', ONSCREEN); + await observed(); + + const last = events.at(-1); + expect(last?.isInView).toBe(true); + expect(last?.entry?.isIntersecting).toBe(true); + }); + + it('emits `intersected` with the raw entry when it leaves the viewport', async () => { + const el = render(ONSCREEN); + const events: InViewProps[] = []; + el.addEventListener('intersected', (event) => { + events.push((event as CustomEvent).detail); + }); + await observed(); + + el.setAttribute('style', OFFSCREEN); + await observed(); + + const last = events.at(-1); + expect(last?.isInView).toBe(false); + }); + + /** + * The whole point of `Sentinel` over `InView`: the entry's geometry survives, + * so a consumer such as `Sticky` can tell "scrolled above the viewport top" + * apart from "scrolled below the viewport bottom". + */ + it('exposes `boundingClientRect` on the entry, which `InView` discards', async () => { + const el = render(ONSCREEN); + let lastProps: InViewProps | undefined; + el.addEventListener('intersected', (event) => { + lastProps = (event as CustomEvent).detail; + }); + await observed(); + + expect(lastProps?.entry).toBeTruthy(); + expect(typeof lastProps?.entry?.boundingClientRect.y).toBe('number'); + }); +}); diff --git a/packages/v4/migration/Sentinel/Sentinel.ts b/packages/v4/migration/Sentinel/Sentinel.ts new file mode 100644 index 00000000..32f19d00 --- /dev/null +++ b/packages/v4/migration/Sentinel/Sentinel.ts @@ -0,0 +1,23 @@ +import { Base, component, withInView, type BaseProps, type InViewProps } from '../../src/index.js'; + +export type SentinelProps = BaseProps & { + $emits: { intersected: InViewProps }; +}; + +/** + * A minimal marker element that reports its own viewport intersection: the + * raw `IntersectionObserverEntry`, not `InView`'s collapsed in/out boolean. + * `Sticky` needs `entry.boundingClientRect.y` to tell "scrolled above the + * viewport" apart from "scrolled below it", which the collapsed boolean + * cannot express. + * + * @link https://ui.studiometa.dev/reference/items/Sentinel/ + */ +@component({ name: 'Sentinel' }) +export class Sentinel extends withInView(Base, { + threshold: [0, 1], +}) { + intersected(props: InViewProps): void { + this.$emit('intersected', props); + } +} diff --git a/packages/v4/migration/Sentinel/index.ts b/packages/v4/migration/Sentinel/index.ts new file mode 100644 index 00000000..d7c6988a --- /dev/null +++ b/packages/v4/migration/Sentinel/index.ts @@ -0,0 +1 @@ +export { Sentinel, type SentinelProps } from './Sentinel.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index ffbc5b2f..10f84c10 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -13,6 +13,7 @@ export * from './InView/index.js'; export * from './LazyInclude/index.js'; export * from './Prefetch/index.js'; export * from './ScrollAnimation/index.js'; +export * from './Sentinel/index.js'; export * from './Slider/index.js'; export * from './Track/index.js'; export * from './Transition/index.js'; From 42f6db1ed4965efaa44033c40abe3561429c3fd1 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:10:28 +0200 Subject: [PATCH 02/24] feat(v4): port Sticky onto withScroll/withResize and a Sentinel child The static `Set` instance registry v3 kept in sync by hand from mounted()/destroyed() is gone: `getInstances('Sticky')` already answers it live. Sizing the sentinel moved from mounted() to $watchChildren's `added` callback, since a v4 mount carries no ordering guarantee and the sentinel may not exist yet on the first cycle. --- packages/v4/migration/Sticky/Sticky.spec.ts | 155 ++++++++++++++++++ packages/v4/migration/Sticky/Sticky.ts | 172 ++++++++++++++++++++ packages/v4/migration/Sticky/index.ts | 1 + packages/v4/migration/index.ts | 1 + 4 files changed, 329 insertions(+) create mode 100644 packages/v4/migration/Sticky/Sticky.spec.ts create mode 100644 packages/v4/migration/Sticky/Sticky.ts create mode 100644 packages/v4/migration/Sticky/index.ts diff --git a/packages/v4/migration/Sticky/Sticky.spec.ts b/packages/v4/migration/Sticky/Sticky.spec.ts new file mode 100644 index 00000000..fae7a66d --- /dev/null +++ b/packages/v4/migration/Sticky/Sticky.spec.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents, type InViewProps, type ScrollProps } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Sentinel } from '../Sentinel/index.js'; +import { Sticky } from './Sticky.js'; + +registerComponents(Sticky, Sentinel); + +afterEach(resetDom); + +// A `Sentinel` is positioned out of flow in real markup (`absolute bottom-full`), +// so its programmatically set height never adds to the `Sticky` element's own size. +const SENTINEL = '
'; + +function sticky(): string { + return ` +
+ ${SENTINEL} +
+
`; +} + +async function render(markup: string): Promise { + const root = document.createElement('div'); + root.innerHTML = markup; + document.body.append(root); + await settle(); + return root; +} + +function dispatchIntersected(sentinelEl: Element, isInView: boolean, y: number): void { + const props: InViewProps = { + isInView, + entry: { + isIntersecting: isInView, + boundingClientRect: { y } as DOMRectReadOnly, + } as IntersectionObserverEntry, + }; + sentinelEl.dispatchEvent(new CustomEvent('intersected', { detail: props, bubbles: true })); +} + +const BASE_SCROLL_PROPS: ScrollProps = { + x: 0, + y: 0, + deltaX: 0, + deltaY: 0, + maxX: 0, + maxY: 0, + progressX: 0, + progressY: 0, + directionX: 0, + directionY: 0, + isScrolling: true, +}; + +describe('Sticky', () => { + it('sizes its sentinel from the earlier instances sharing its relative ancestor', async () => { + const root = await render(`
${sticky()}${sticky()}
`); + const [first, second] = root.querySelectorAll('[data-component="Sticky"]'); + const firstInstance = getInstance(first, 'Sticky'); + const secondInstance = getInstance(second, 'Sticky'); + + expect(firstInstance.sentinel?.$el.style.height).toBe('1px'); + expect((first as HTMLElement).style.top).toBe('0px'); + expect((first as HTMLElement).style.zIndex).toBe('100'); + + expect(secondInstance.sentinel?.$el.style.height).toBe('51px'); + expect((second as HTMLElement).style.top).toBe('50px'); + expect((second as HTMLElement).style.zIndex).toBe('99'); + }); + + it('sets `isSticky` from the sentinel entry and clears the transform once it is not sticky', async () => { + const root = await render(sticky()); + const el = root.querySelector('[data-component="Sticky"]') as HTMLElement; + const sentinelEl = root.querySelector('[data-component="Sentinel"]') as HTMLElement; + const instance = getInstance(el, 'Sticky'); + const inner = el.querySelector('[data-ref="inner"]') as HTMLElement; + + dispatchIntersected(sentinelEl, true, -5); + expect(instance.isSticky).toBe(true); + + dispatchIntersected(sentinelEl, false, -5); + expect(instance.isSticky).toBe(false); + expect(inner.style.transform).toBe('translateY(0px) translateZ(0px)'); + }); + + it('does not consider a sentinel intersecting from below sticky, only one that has scrolled past the top', async () => { + const root = await render(sticky()); + const sentinelEl = root.querySelector('[data-component="Sentinel"]') as HTMLElement; + const el = root.querySelector('[data-component="Sticky"]') as HTMLElement; + const instance = getInstance(el, 'Sticky'); + + // Still fully visible, entering from below: `y` is positive. + dispatchIntersected(sentinelEl, true, 5); + expect(instance.isSticky).toBe(false); + }); + + it('stacks onto an earlier instance that hid itself on scroll, and unstacks once it reappears', async () => { + const root = await render(`
${sticky()}${sticky()}
`); + const [firstEl, secondEl] = [ + ...root.querySelectorAll('[data-component="Sticky"]'), + ] as HTMLElement[]; + const [firstSentinel, secondSentinel] = [ + ...root.querySelectorAll('[data-component="Sentinel"]'), + ] as HTMLElement[]; + const first = getInstance(firstEl, 'Sticky'); + const second = getInstance(secondEl, 'Sticky'); + const secondInner = secondEl.querySelector('[data-ref="inner"]') as HTMLElement; + + dispatchIntersected(firstSentinel, true, -5); + dispatchIntersected(secondSentinel, true, -5); + expect(first.isSticky).toBe(true); + expect(second.isSticky).toBe(true); + + first.hide(); + expect(firstEl.classList.contains('pointer-events-none')).toBe(true); + expect(secondInner.style.transform).toBe('translateY(-50px) translateZ(0px)'); + + first.show(); + expect(firstEl.classList.contains('pointer-events-none')).toBe(false); + expect(secondInner.style.transform).toBe('translateY(0px) translateZ(0px)'); + }); + + it('hides on scroll direction when `hideWhenDown` is set, and shows again on the way up', async () => { + const root = await render(` +
+ ${SENTINEL} +
+
`); + const el = root.querySelector('[data-component="Sticky"]') as HTMLElement; + const sentinelEl = root.querySelector('[data-component="Sentinel"]') as HTMLElement; + const instance = getInstance(el, 'Sticky'); + + dispatchIntersected(sentinelEl, true, -5); + expect(instance.isSticky).toBe(true); + + instance.scrolled({ ...BASE_SCROLL_PROPS, deltaY: 10, directionY: 1 }); + expect(instance.isVisible).toBe(false); + + instance.scrolled({ ...BASE_SCROLL_PROPS, deltaY: -10, directionY: -1 }); + expect(instance.isVisible).toBe(true); + }); + + it('ignores a scroll update with no movement', async () => { + const root = await render(sticky()); + const el = root.querySelector('[data-component="Sticky"]') as HTMLElement; + const sentinelEl = root.querySelector('[data-component="Sentinel"]') as HTMLElement; + const instance = getInstance(el, 'Sticky'); + + dispatchIntersected(sentinelEl, true, -5); + instance.scrolled({ ...BASE_SCROLL_PROPS, deltaY: 0, directionY: 0 }); + + expect(instance.isVisible).toBe(true); + }); +}); diff --git a/packages/v4/migration/Sticky/Sticky.ts b/packages/v4/migration/Sticky/Sticky.ts new file mode 100644 index 00000000..947da1aa --- /dev/null +++ b/packages/v4/migration/Sticky/Sticky.ts @@ -0,0 +1,172 @@ +import { + Base, + component, + getInstances, + withResize, + withScroll, + type BaseProps, + type ChildrenCollection, + type DelegatedEvent, + type ScrollProps, +} from '../../src/index.js'; +import { Sentinel } from '../Sentinel/index.js'; + +export type StickyProps = BaseProps & { + $refs: { inner: HTMLElement }; + $options: { + zIndex: number; + hideWhenUp: boolean; + hideWhenDown: boolean; + }; +}; + +/** + * A sticky-positioning primitive that stacks multiple sticky elements + * without overlap. A child `Sentinel` detects when the element becomes + * stuck, then each instance offsets and z-indexes itself against the others + * sharing its positioning context. `hideWhenUp`/`hideWhenDown` hide on + * scroll direction, and `zIndex` sets the base stacking order. + * + * @link https://ui.studiometa.dev/reference/items/Sticky/ + */ +@component({ + name: 'Sticky', + refs: ['inner'], + components: { Sentinel }, + options: { + zIndex: { type: Number, default: 100 }, + hideWhenUp: Boolean, + hideWhenDown: Boolean, + }, +}) +export class Sticky extends withResize( + withScroll(Base), +) { + isSticky = false; + + isVisible = true; + + /** + * Live collection rather than a constructed reference: a v4 mount carries + * no ordering guarantee, so the sentinel this reads may not exist yet on + * the first `mounted()` cycle. Measuring on `added` instead of on mount + * covers the case a v3 `$children` read could not. + */ + sentinels: ChildrenCollection = this.$watchChildren('Sentinel', { + added: () => this.setSentinelSize(), + }); + + get sentinel(): Sentinel | undefined { + return this.sentinels.items[0]; + } + + /** + * Every mounted `Sticky` on the page, in DOM order. Replaces v3's + * `static instances: Set` manually kept in sync from `mounted()` + * and `destroyed()` — core's registry already answers this live. + */ + get instances(): Sticky[] { + return getInstances('Sticky'); + } + + set y(value: number) { + this.$refs.inner.style.transform = `translateY(${value}px) translateZ(0px)`; + } + + resized(): void { + this.setSentinelSize(); + } + + scrolled(props: ScrollProps): void { + if (!this.isSticky || props.deltaY === 0) { + return; + } + + if ( + (props.directionY === 1 && this.$options.hideWhenDown) || + (props.directionY === -1 && this.$options.hideWhenUp) + ) { + this.hide(); + } else { + this.show(); + } + } + + onSentinelIntersected({ payload }: DelegatedEvent): void { + const { entry } = payload; + this.isSticky = Boolean(entry && entry.isIntersecting && entry.boundingClientRect.y < 0); + this.setPosition(); + } + + /** Hide the sticky component when another one is sticky. */ + hide(): void { + if (!this.isVisible) { + return; + } + + this.isVisible = false; + this.$el.classList.add('pointer-events-none'); + + this.instances.forEach((instance, index) => instance.setPosition(index)); + } + + /** Show the sticky component when the other one is not sticky anymore. */ + show(): void { + if (this.isVisible) { + return; + } + + this.isVisible = true; + this.$el.classList.remove('pointer-events-none'); + + this.instances.forEach((instance, index) => instance.setPosition(index)); + } + + /** Set the sentinel height based on the previous instances. */ + setSentinelSize(): void { + const { instances, sentinel } = this; + if (!sentinel) { + return; + } + + const index = instances.indexOf(this); + const height = instances + .slice(0, index) + .filter((instance) => this.closestRelativeElement(instance.$el).contains(this.$el)) + .reduce((acc, instance) => acc + instance.$el.offsetHeight, 0); + + sentinel.$el.style.height = `${height + 1}px`; + this.$el.style.top = `${height}px`; + this.$el.style.zIndex = String(this.$options.zIndex - index); + } + + /** Set the component's position. */ + setPosition(index?: number): void { + if (!this.isSticky) { + this.y = 0; + return; + } + + const { instances } = this; + const at = index ?? instances.indexOf(this); + + this.y = instances + .slice(0, at) + .filter((instance) => instance.isSticky && !instance.isVisible) + .reduce( + (y, instance) => y - instance.$refs.inner.offsetHeight, + this.isVisible ? 0 : this.$refs.inner.offsetHeight * -1, + ); + } + + /** Find the first parent which has a relative position. */ + closestRelativeElement(element: HTMLElement): HTMLElement { + let parent = element.parentElement as HTMLElement; + + while (parent.parentElement && getComputedStyle(parent).position !== 'relative') { + parent = parent.parentElement; + } + + return parent; + } +} diff --git a/packages/v4/migration/Sticky/index.ts b/packages/v4/migration/Sticky/index.ts new file mode 100644 index 00000000..48184415 --- /dev/null +++ b/packages/v4/migration/Sticky/index.ts @@ -0,0 +1 @@ +export { Sticky, type StickyProps } from './Sticky.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 10f84c10..0ea79581 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -15,6 +15,7 @@ export * from './Prefetch/index.js'; export * from './ScrollAnimation/index.js'; export * from './Sentinel/index.js'; export * from './Slider/index.js'; +export * from './Sticky/index.js'; export * from './Track/index.js'; export * from './Transition/index.js'; From 555e8d90bdc1b060232af4a63a001a6c3edcac0c Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:14:06 +0200 Subject: [PATCH 03/24] feat(v4): port Hoverable onto withPointer/withRaf withRelativePointer's per-target progress maps onto withPointer's ElementPointerProps.relativeProgress{X,Y} directly, which already carries the outside-the-box range v1's contained/clamp logic needs. --- .../v4/migration/Hoverable/Hoverable.spec.ts | 96 +++++++++++++++ packages/v4/migration/Hoverable/Hoverable.ts | 116 ++++++++++++++++++ packages/v4/migration/Hoverable/index.ts | 1 + packages/v4/migration/index.ts | 1 + 4 files changed, 214 insertions(+) create mode 100644 packages/v4/migration/Hoverable/Hoverable.spec.ts create mode 100644 packages/v4/migration/Hoverable/Hoverable.ts create mode 100644 packages/v4/migration/Hoverable/index.ts diff --git a/packages/v4/migration/Hoverable/Hoverable.spec.ts b/packages/v4/migration/Hoverable/Hoverable.spec.ts new file mode 100644 index 00000000..3204a534 --- /dev/null +++ b/packages/v4/migration/Hoverable/Hoverable.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerComponents, type ElementPointerProps, type RafProps } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Hoverable } from './Hoverable.js'; + +registerComponents(Hoverable); + +afterEach(resetDom); + +const PARENT = 'position:absolute;top:0;left:0;width:100px;height:100px'; +const TARGET = 'position:absolute;top:10px;left:10px;width:20px;height:20px'; + +async function render(attributes = ''): Promise<{ el: HTMLElement; instance: Hoverable }> { + const root = document.createElement('div'); + root.innerHTML = ` +
+
+
`; + document.body.append(root); + await settle(); + const el = root.firstElementChild as HTMLElement; + return { el, instance: getInstance(el, 'Hoverable') }; +} + +function progress(x: number, y: number): ElementPointerProps { + return { relativeProgressX: x, relativeProgressY: y } as ElementPointerProps; +} + +function stubBounds(instance: Hoverable): void { + vi.spyOn(instance, 'bounds', 'get').mockReturnValue({ xMin: 0, xMax: 100, yMin: 0, yMax: 100 }); +} + +describe('Hoverable', () => { + it('exposes `target` and `parent` getters', async () => { + const { el, instance } = await render(); + expect(instance.target).toBe(el.querySelector('[data-ref="target"]')); + expect(instance.parent).toBe(el); + }); + + it('computes bounds from the real target and parent boxes', async () => { + const { instance } = await render(); + expect(instance.bounds).toEqual({ xMin: -10, yMin: -10, xMax: 70, yMax: 70 }); + }); + + it('maps pointer progress into bounds, clamped to 0–1', async () => { + const { instance } = await render(); + stubBounds(instance); + + instance.moved(progress(0, 0)); + expect(instance.props).toMatchObject({ x: 0, y: 0 }); + + instance.moved(progress(0.5, 0.5)); + expect(instance.props).toMatchObject({ x: 50, y: 50 }); + + instance.moved(progress(1, 1)); + expect(instance.props).toMatchObject({ x: 100, y: 100 }); + + // Past the box, progress is clamped rather than extrapolated. + instance.moved(progress(1.5, 1.5)); + expect(instance.props).toMatchObject({ x: 100, y: 100 }); + }); + + it('reverses direction when `reversed` is set', async () => { + const { instance } = await render('data-option-reversed="true"'); + stubBounds(instance); + + instance.moved(progress(0, 0)); + expect(instance.props).toMatchObject({ x: 100, y: 100 }); + + instance.moved(progress(1, 1)); + expect(instance.props).toMatchObject({ x: 0, y: 0 }); + }); + + it('stops updating once the pointer leaves the box when `contained` is set', async () => { + const { instance } = await render('data-option-contained="true"'); + stubBounds(instance); + + instance.moved(progress(0, 0)); + expect(instance.props).toMatchObject({ x: 0, y: 0 }); + + instance.moved(progress(0.5, 1.5)); + expect(instance.props).toMatchObject({ x: 0, y: 0 }); + }); + + it('damps toward the target position each frame and writes the transform', async () => { + const { instance } = await render(); + stubBounds(instance); + instance.moved(progress(0.5, 0.5)); + + // A large elapsed time closes the gap past `damp()`'s snap precision. + instance.ticked({ time: 0, delta: 5000 } as RafProps); + await settle(); + + expect(instance.target.style.transform).toBe('translate3d(50px, 50px, 0px)'); + }); +}); diff --git a/packages/v4/migration/Hoverable/Hoverable.ts b/packages/v4/migration/Hoverable/Hoverable.ts new file mode 100644 index 00000000..5ccc0bb5 --- /dev/null +++ b/packages/v4/migration/Hoverable/Hoverable.ts @@ -0,0 +1,116 @@ +import { + Base, + component, + withPointer, + withRaf, + type BaseProps, + type ElementPointerProps, + type RafProps, +} from '../../src/index.js'; +import { getOffsetSizes } from '../../src/utils/dom.js'; +import { clamp01, damp, map } from '../../src/utils/maths.js'; +import { transform } from '../../src/utils/transform.js'; + +export interface HoverableBounds { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +export type HoverableProps = BaseProps & { + $refs: { target: HTMLElement }; + $options: { + /** A number in `0–1` that smoothens the transition between each position. */ + sensitivity: number; + /** Reverse the movement of the target. */ + reversed: boolean; + /** Stop moving the target once the pointer leaves the root element. */ + contained: boolean; + }; +}; + +/** + * Moves a `target` ref in response to the pointer's position over the root + * element. The target is mapped across its available bounds and damped each + * frame by the `sensitivity` option; `reversed` inverts the movement and + * `contained` stops it once the pointer leaves the element. + * + * @link https://ui.studiometa.dev/reference/items/Hoverable/ + */ +@component({ + name: 'Hoverable', + refs: ['target'], + options: { + sensitivity: { type: Number, default: 0.1 }, + reversed: Boolean, + contained: Boolean, + }, +}) +export class Hoverable extends withRaf( + withPointer(Base), +) { + props = { + x: 0, + y: 0, + dampedX: 0, + dampedY: 0, + }; + + /** The hoverable element, defaults to `this.$refs.target`. */ + get target(): HTMLElement { + return this.$refs.target; + } + + /** The bounding element, defaults to `this.$el`. */ + get parent(): HTMLElement { + return this.$el; + } + + /** The bounds in which the target can move. */ + get bounds(): HoverableBounds { + const targetSizes = getOffsetSizes(this.target); + const parentSizes = getOffsetSizes(this.parent); + const xMin = targetSizes.x - parentSizes.x; + const yMin = targetSizes.y - parentSizes.y; + const xMax = xMin + targetSizes.width - parentSizes.width; + const yMax = yMin + targetSizes.height - parentSizes.height; + + return { + yMin: yMin * -1, + yMax: yMax * -1, + xMin: xMin * -1, + xMax: xMax * -1, + }; + } + + moved({ relativeProgressX, relativeProgressY }: ElementPointerProps): void { + const { bounds, props } = this; + const { reversed, contained } = this.$options; + + // Stop updating when the pointer is outside the parent bounds. + if ( + contained && + (relativeProgressY < 0 || relativeProgressX < 0 || relativeProgressY > 1 || relativeProgressX > 1) + ) { + return; + } + + const from = reversed ? 1 : 0; + const to = reversed ? 0 : 1; + + props.y = map(clamp01(relativeProgressY), from, to, bounds.yMin, bounds.yMax); + props.x = map(clamp01(relativeProgressX), from, to, bounds.xMin, bounds.xMax); + } + + ticked({ delta }: RafProps): void { + const { props, target } = this; + const { sensitivity } = this.$options; + props.dampedY = damp(props.y, props.dampedY, sensitivity, delta); + props.dampedX = damp(props.x, props.dampedX, sensitivity, delta); + + this.$write(() => { + target.style.transform = transform({ x: props.dampedX, y: props.dampedY }); + }); + } +} diff --git a/packages/v4/migration/Hoverable/index.ts b/packages/v4/migration/Hoverable/index.ts new file mode 100644 index 00000000..3a8c3c9f --- /dev/null +++ b/packages/v4/migration/Hoverable/index.ts @@ -0,0 +1 @@ +export { Hoverable, type HoverableBounds, type HoverableProps } from './Hoverable.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 0ea79581..eb18ff4f 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -9,6 +9,7 @@ export * from './Data/index.js'; export * from './Draggable/index.js'; export * from './Dialog/index.js'; export * from './Fetch/index.js'; +export * from './Hoverable/index.js'; export * from './InView/index.js'; export * from './LazyInclude/index.js'; export * from './Prefetch/index.js'; From 4bbcdd7c7b1f3b58c67ac5daba013196896af171 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:21:36 +0200 Subject: [PATCH 04/24] feat(v4): port AnchorScrollTo as ScrollTo v4's scrollTo() is a no-op on a missing target rather than throwing, so the existence check that decided whether to preventDefault() moves into the component instead of a try/catch around the call. --- .../v4/migration/ScrollTo/ScrollTo.spec.ts | 79 +++++++++++++++++++ packages/v4/migration/ScrollTo/ScrollTo.ts | 36 +++++++++ packages/v4/migration/ScrollTo/index.ts | 1 + packages/v4/migration/index.ts | 1 + 4 files changed, 117 insertions(+) create mode 100644 packages/v4/migration/ScrollTo/ScrollTo.spec.ts create mode 100644 packages/v4/migration/ScrollTo/ScrollTo.ts create mode 100644 packages/v4/migration/ScrollTo/index.ts diff --git a/packages/v4/migration/ScrollTo/ScrollTo.spec.ts b/packages/v4/migration/ScrollTo/ScrollTo.spec.ts new file mode 100644 index 00000000..fca99842 --- /dev/null +++ b/packages/v4/migration/ScrollTo/ScrollTo.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { ScrollTo } from './ScrollTo.js'; + +registerComponents(ScrollTo); + +afterEach(async () => { + await resetDom(); + document.body.removeAttribute('style'); + // A later `vi.spyOn(window, 'scrollTo')` reuses the same mock and its call + // history otherwise, since nothing here ever restores the original. + vi.restoreAllMocks(); +}); + +// The test viewport's `` has no natural height past the viewport, so a +// `position: absolute` target does not extend `scrollHeight` the way it would +// on a real page: an explicit body height is what `scrollTo`'s own spec uses +// to get a genuinely scrollable page in this harness. +async function render(href: string): Promise<{ el: HTMLAnchorElement; instance: ScrollTo }> { + document.body.style.cssText = 'margin:0;height:3000px'; + const root = document.createElement('div'); + root.innerHTML = ` + Jump +
`; + document.body.append(root); + await settle(); + const el = root.querySelector('[data-component="ScrollTo"]') as HTMLAnchorElement; + return { el, instance: getInstance(el, 'ScrollTo') }; +} + +// Calling `onClick` directly, rather than dispatching a real click on a live +// ``, is deliberate: an untrusted `dispatchEvent` still runs an +// anchor's default navigation in a real browser when not prevented, which +// would tear down the test iframe (`href="/other-page"` proved it). +function clickEvent(): MouseEvent { + return new MouseEvent('click', { cancelable: true }); +} + +describe('ScrollTo', () => { + it('reads the target selector from the hash', async () => { + const { instance } = await render('#target'); + expect(instance.targetSelector).toBe('#target'); + }); + + it('prevents the jump and scrolls to the hash target when it exists', async () => { + const { instance } = await render('#target'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const event = clickEvent(); + + instance.onClick(event); + + expect(event.defaultPrevented).toBe(true); + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0][0]).toMatchObject({ top: 2000 }); + }); + + it('leaves the click alone when the hash target does not exist', async () => { + const { instance } = await render('#missing'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const event = clickEvent(); + + instance.onClick(event); + + expect(event.defaultPrevented).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + it('leaves the click alone when the anchor has no hash', async () => { + const { instance } = await render('/other-page'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const event = clickEvent(); + + instance.onClick(event); + + expect(event.defaultPrevented).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/v4/migration/ScrollTo/ScrollTo.ts b/packages/v4/migration/ScrollTo/ScrollTo.ts new file mode 100644 index 00000000..9e58cff8 --- /dev/null +++ b/packages/v4/migration/ScrollTo/ScrollTo.ts @@ -0,0 +1,36 @@ +import { Base, component, type BaseProps } from '../../src/index.js'; +import { scrollTo } from '../../src/utils/scrollTo.js'; + +export type ScrollToProps = BaseProps & { $el: HTMLAnchorElement }; + +/** + * Enhances an anchor so that clicking it smoothly scrolls to the element its + * `href` hash points to, instead of jumping. Renamed from `AnchorScrollTo`. + * + * @link https://ui.studiometa.dev/reference/items/ScrollTo/ + */ +@component({ name: 'ScrollTo' }) +export class ScrollTo extends Base { + /** The target selector, read from the link's hash. */ + get targetSelector(): string { + return this.$el.hash; + } + + /** + * v1 let `scrollTo()` throw for a missing target and left the click alone + * in that case. v4's `scrollTo()` is a no-op on a target the document does + * not contain rather than throwing, so the existence check moves here: + * `preventDefault()` only once a target is confirmed. + */ + onClick(event: MouseEvent): void { + const { targetSelector } = this; + const target = targetSelector ? document.querySelector(targetSelector) : null; + + if (!target) { + return; + } + + event.preventDefault(); + scrollTo(target); + } +} diff --git a/packages/v4/migration/ScrollTo/index.ts b/packages/v4/migration/ScrollTo/index.ts new file mode 100644 index 00000000..8a0fb2fd --- /dev/null +++ b/packages/v4/migration/ScrollTo/index.ts @@ -0,0 +1 @@ +export { ScrollTo, type ScrollToProps } from './ScrollTo.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index eb18ff4f..53df40e6 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -14,6 +14,7 @@ export * from './InView/index.js'; export * from './LazyInclude/index.js'; export * from './Prefetch/index.js'; export * from './ScrollAnimation/index.js'; +export * from './ScrollTo/index.js'; export * from './Sentinel/index.js'; export * from './Slider/index.js'; export * from './Sticky/index.js'; From 1dcabb51b1283f602fb7148ea8bce16a0eb1f22f Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:26:28 +0200 Subject: [PATCH 05/24] feat(v4): port the AnchorNav family onto in-view mount and ScrollTo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnchorNavTarget's mount/unmount replaces withMountWhenInView with the in-view mount strategy. AnchorNav no longer listens for the target's mounted/destroyed lifecycle events under their plain v3 names — v4 dispatches those under a namespaced type magic-name delegation can't bind to — and reacts through $watchChildren's added/removed callbacks instead, which already fire on exactly that transition. AnchorNavLink extends the newly-ported ScrollTo and calls the same enterTransition/leaveTransition utilities Transition itself calls, since v3's withTransition mixin has no v4 equivalent to mix onto an unrelated base class anymore. --- .../v4/migration/AnchorNav/AnchorNav.spec.ts | 84 +++++++++++++++++++ packages/v4/migration/AnchorNav/AnchorNav.ts | 41 +++++++++ .../migration/AnchorNav/AnchorNavLink.spec.ts | 84 +++++++++++++++++++ .../v4/migration/AnchorNav/AnchorNavLink.ts | 66 +++++++++++++++ .../AnchorNav/AnchorNavTarget.spec.ts | 48 +++++++++++ .../v4/migration/AnchorNav/AnchorNavTarget.ts | 14 ++++ packages/v4/migration/AnchorNav/index.ts | 3 + packages/v4/migration/index.ts | 1 + 8 files changed, 341 insertions(+) create mode 100644 packages/v4/migration/AnchorNav/AnchorNav.spec.ts create mode 100644 packages/v4/migration/AnchorNav/AnchorNav.ts create mode 100644 packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts create mode 100644 packages/v4/migration/AnchorNav/AnchorNavLink.ts create mode 100644 packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts create mode 100644 packages/v4/migration/AnchorNav/AnchorNavTarget.ts create mode 100644 packages/v4/migration/AnchorNav/index.ts diff --git a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts new file mode 100644 index 00000000..2917eda1 --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { AnchorNav } from './AnchorNav.js'; +import { AnchorNavLink } from './AnchorNavLink.js'; +import { AnchorNavTarget } from './AnchorNavTarget.js'; + +const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; +const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; + +registerComponents(AnchorNav, AnchorNavLink, AnchorNavTarget); + +afterEach(resetDom); + +async function observed(): Promise { + for (let i = 0; i < 6; i += 1) { + await settle(); + } +} + +async function render(): Promise<{ root: HTMLElement; target: HTMLElement }> { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
+
`; + document.body.append(root); + await settle(); + return { root, target: root.querySelector('#one') as HTMLElement }; +} + +describe('AnchorNav', () => { + it('enters the matching link once its target scrolls into view', async () => { + const { root, target } = await render(); + const link = getInstance( + root.querySelector('[data-component="AnchorNavLink"]'), + 'AnchorNavLink', + ); + + target.setAttribute('style', ONSCREEN); + await observed(); + + expect(link.state).toBe('entering'); + expect(link.$el.classList.contains('active')).toBe(true); + }); + + it('leaves the matching link once its target scrolls back out of view', async () => { + const { root, target } = await render(); + const link = getInstance( + root.querySelector('[data-component="AnchorNavLink"]'), + 'AnchorNavLink', + ); + + target.setAttribute('style', ONSCREEN); + await observed(); + target.setAttribute('style', OFFSCREEN); + await observed(); + + expect(link.state).toBe('leaving'); + expect(link.$el.classList.contains('active')).toBe(false); + }); + + it('ignores a link whose targetId does not match any target', async () => { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
+
`; + document.body.append(root); + await settle(); + const link = getInstance( + root.querySelector('[data-component="AnchorNavLink"]'), + 'AnchorNavLink', + ); + const target = root.querySelector('#one') as HTMLElement; + + target.setAttribute('style', ONSCREEN); + await observed(); + + expect(link.state).toBeNull(); + }); +}); diff --git a/packages/v4/migration/AnchorNav/AnchorNav.ts b/packages/v4/migration/AnchorNav/AnchorNav.ts new file mode 100644 index 00000000..03c11c27 --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNav.ts @@ -0,0 +1,41 @@ +import { Base, component, type BaseProps, type ChildrenCollection } from '../../src/index.js'; +import { AnchorNavLink } from './AnchorNavLink.js'; +import { AnchorNavTarget } from './AnchorNavTarget.js'; + +export type AnchorNavProps = BaseProps; + +/** + * Coordinates `AnchorNavLink` children with their matching `AnchorNavTarget` + * sections. v3 reacted to the target's `mounted`/`destroyed` lifecycle events + * bubbling with their plain names; v4 dispatches those under a namespaced + * event type instead (`js-toolkit:component:mounted`), so magic-name + * delegation (`onAnchorNavTargetMounted`) cannot bind to them directly. + * `$watchChildren`'s `added`/`removed` callbacks answer the same question — + * they already fire exactly on a matching child's mount/unmount transitions. + * + * @link https://ui.studiometa.dev/reference/items/AnchorNav/ + */ +@component({ + name: 'AnchorNav', + components: { AnchorNavLink, AnchorNavTarget }, +}) +export class AnchorNav extends Base { + links: ChildrenCollection = this.$watchChildren('AnchorNavLink'); + + targets: ChildrenCollection = this.$watchChildren( + 'AnchorNavTarget', + { + added: (target) => this.#toggleLinksFor(target, 'enter'), + removed: (target) => this.#toggleLinksFor(target, 'leave'), + }, + ); + + #toggleLinksFor(target: AnchorNavTarget, action: 'enter' | 'leave'): void { + const { id } = target.$el; + for (const link of this.links) { + if (link.targetId === id) { + void link[action](); + } + } + } +} diff --git a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts new file mode 100644 index 00000000..81d7770d --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { AnchorNavLink } from './AnchorNavLink.js'; + +registerComponents(AnchorNavLink); + +afterEach(resetDom); + +const OPTIONS_ATTRS = [ + 'data-option-enter-from="enter-from"', + 'data-option-enter-active="enter-active"', + 'data-option-enter-to="enter-to"', + 'data-option-enter-keep="true"', + 'data-option-leave-from="leave-from"', + 'data-option-leave-active="leave-active"', + 'data-option-leave-to="leave-to"', + 'data-option-leave-keep="true"', +].join(' '); + +async function render(): Promise { + const root = document.createElement('div'); + root.innerHTML = ``; + document.body.append(root); + await settle(); + return getInstance(root.firstElementChild, 'AnchorNavLink'); +} + +describe('AnchorNavLink', () => { + it('reads the target id from the hash, without the `#`', async () => { + const instance = await render(); + expect(instance.targetId).toBe('section-one'); + }); + + it('runs the enter transition and emits its lifecycle events', async () => { + const instance = await render(); + const events: string[] = []; + instance.$el.addEventListener('transition-enter', () => events.push('transition-enter')); + instance.$el.addEventListener('transition-enter-start', () => + events.push('transition-enter-start'), + ); + instance.$el.addEventListener('transition-enter-end', () => + events.push('transition-enter-end'), + ); + + await instance.enter(); + + expect(instance.state).toBe('entering'); + expect(instance.$el.className).toBe('enter-to'); + expect(events).toEqual(['transition-enter', 'transition-enter-start', 'transition-enter-end']); + }); + + it('runs the leave transition, clearing the enter end state first', async () => { + const instance = await render(); + await instance.enter(); + + await instance.leave(); + + expect(instance.state).toBe('leaving'); + expect(instance.$el.className).toBe('leave-to'); + }); + + it('toggles between enter and leave depending on its last state', async () => { + const instance = await render(); + + await instance.toggle(); + expect(instance.state).toBe('entering'); + expect(instance.$el.className).toBe('enter-to'); + + await instance.toggle(); + expect(instance.state).toBe('leaving'); + expect(instance.$el.className).toBe('leave-to'); + }); + + it('still runs onClick for the inherited ScrollTo behaviour', async () => { + const instance = await render(); + const event = new MouseEvent('click', { cancelable: true }); + + instance.onClick(event); + + // No `#section-one` element in the document: the click is left alone. + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/v4/migration/AnchorNav/AnchorNavLink.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.ts new file mode 100644 index 00000000..a773d78f --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNavLink.ts @@ -0,0 +1,66 @@ +import { component, type BaseProps } from '../../src/index.js'; +import { + enterTransition, + leaveTransition, + TRANSITION_OPTIONS, + type TransitionOptions, +} from '../../src/utils/transition.js'; +import { ScrollTo } from '../ScrollTo/index.js'; + +export type AnchorNavLinkProps = BaseProps & { + $options: TransitionOptions; + $emits: { + 'transition-enter': void; + 'transition-enter-start': void; + 'transition-enter-end': void; + 'transition-leave': void; + 'transition-leave-start': void; + 'transition-leave-end': void; + }; +}; + +/** + * A `ScrollTo` link that also enters/leaves a CSS transition on itself, + * driven by `AnchorNav` as its matching `AnchorNavTarget` mounts and + * unmounts. v3 mixed a `withTransition` decorator onto `AnchorScrollTo`; + * v4's `Transition` is a standalone component rather than a mixin (the + * `Dialog` port collapsed the two), so this calls the same + * `enterTransition`/`leaveTransition` utilities `Transition` itself calls, + * the way `SliderDots` does for the same reason. + * + * @link https://ui.studiometa.dev/reference/items/AnchorNav/ + */ +@component({ + name: 'AnchorNavLink', + options: { ...TRANSITION_OPTIONS }, +}) +export class AnchorNavLink extends ScrollTo< + AnchorNavLinkProps & T +> { + state: 'entering' | 'leaving' | null = null; + + /** The target section id, read from the link's hash. */ + get targetId(): string { + return this.$el.hash.replace(/^#/, ''); + } + + async enter(): Promise { + this.state = 'entering'; + this.$emit('transition-enter'); + this.$emit('transition-enter-start'); + await enterTransition(this.$el, this.$options); + this.$emit('transition-enter-end'); + } + + async leave(): Promise { + this.state = 'leaving'; + this.$emit('transition-leave'); + this.$emit('transition-leave-start'); + await leaveTransition(this.$el, this.$options); + this.$emit('transition-leave-end'); + } + + toggle(): Promise { + return this.state === 'entering' ? this.leave() : this.enter(); + } +} diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts new file mode 100644 index 00000000..e7dc5a0c --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { INSTANCES } from '../../src/protocol-symbols.js'; +import { resetDom, settle } from '../../src/test-utils.js'; +import { AnchorNavTarget } from './AnchorNavTarget.js'; + +const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; +const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; + +registerComponents(AnchorNavTarget); + +afterEach(resetDom); + +async function observed(): Promise { + for (let i = 0; i < 6; i += 1) { + await settle(); + } +} + +function render(style: string): HTMLElement { + const el = document.createElement('div'); + el.setAttribute('data-component', 'AnchorNavTarget'); + el.setAttribute('style', style); + document.body.append(el); + return el; +} + +describe('AnchorNavTarget', () => { + it('mounts once scrolled into view', async () => { + const el = render(OFFSCREEN); + await observed(); + expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBeUndefined(); + + el.setAttribute('style', ONSCREEN); + await observed(); + expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(true); + }); + + it('unmounts once scrolled back out of view', async () => { + const el = render(ONSCREEN); + await observed(); + expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(true); + + el.setAttribute('style', OFFSCREEN); + await observed(); + expect(el[INSTANCES]?.get('AnchorNavTarget')?.$isMounted).toBe(false); + }); +}); diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.ts new file mode 100644 index 00000000..89fbe889 --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNavTarget.ts @@ -0,0 +1,14 @@ +import { Base, type BaseConfig } from '../../src/index.js'; + +/** + * Marks a section `AnchorNav` tracks: mounts once scrolled into view and + * unmounts once it leaves, so `AnchorNav` can toggle the matching link. + * + * @link https://ui.studiometa.dev/reference/items/AnchorNav/ + */ +export class AnchorNavTarget extends Base { + static config: BaseConfig = { + name: 'AnchorNavTarget', + mountStrategy: 'in-view', + }; +} diff --git a/packages/v4/migration/AnchorNav/index.ts b/packages/v4/migration/AnchorNav/index.ts new file mode 100644 index 00000000..490ed0d6 --- /dev/null +++ b/packages/v4/migration/AnchorNav/index.ts @@ -0,0 +1,3 @@ +export { AnchorNav, type AnchorNavProps } from './AnchorNav.js'; +export { AnchorNavLink, type AnchorNavLinkProps } from './AnchorNavLink.js'; +export { AnchorNavTarget } from './AnchorNavTarget.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 53df40e6..24703950 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -2,6 +2,7 @@ export * from './Accordion/index.js'; export * from './Action/index.js'; +export * from './AnchorNav/index.js'; export * from './Carousel/index.js'; export * from './ClickOutside/index.js'; export * from './Cursor/index.js'; From 79e6d6b4c6f6bd0842cc543f28a5664945fc5312 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:41:03 +0200 Subject: [PATCH 06/24] feat(v4): port the Menu family onto withKey, $closest and $watchChildren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $closest('Menu') replaces getClosestParent(target, this.constructor) for both the menuBtn/menuList getters and the click/hover guards, since $watchChildren collects every matching descendant regardless of depth and a nested submenu's own button/list would otherwise match too. Menu no longer destroys itself in mounted() when a required child is missing — v4 gives no ordering guarantee for child vs. parent mount, so the button/list wiring moves to $watchChildren's added callback, and a Menu with no list is inert rather than a hard failure. MenuList implements Transitionable directly instead of extending the ported Transition class: the latter is a plain, non-generic Base subclass, and MenuList already overrides every one of its methods to force enterKeep/leaveKeep true (the v3 $options-getter override this needed has no v4 equivalent, since $options is a read-only own property with no override point). keyed() maps onto withKey's KeyProps one for one. The nextTick-deferred close on mouseleave uses defaultScheduler.background(), the documented v4 replacement. --- packages/v4/migration/Menu/Menu.spec.ts | 146 ++++++++++++++++ packages/v4/migration/Menu/Menu.ts | 154 ++++++++++++++++ packages/v4/migration/Menu/MenuBtn.spec.ts | 41 +++++ packages/v4/migration/Menu/MenuBtn.ts | 28 +++ packages/v4/migration/Menu/MenuList.spec.ts | 120 +++++++++++++ packages/v4/migration/Menu/MenuList.ts | 183 ++++++++++++++++++++ packages/v4/migration/Menu/index.ts | 3 + packages/v4/migration/index.ts | 1 + 8 files changed, 676 insertions(+) create mode 100644 packages/v4/migration/Menu/Menu.spec.ts create mode 100644 packages/v4/migration/Menu/Menu.ts create mode 100644 packages/v4/migration/Menu/MenuBtn.spec.ts create mode 100644 packages/v4/migration/Menu/MenuBtn.ts create mode 100644 packages/v4/migration/Menu/MenuList.spec.ts create mode 100644 packages/v4/migration/Menu/MenuList.ts create mode 100644 packages/v4/migration/Menu/index.ts diff --git a/packages/v4/migration/Menu/Menu.spec.ts b/packages/v4/migration/Menu/Menu.spec.ts new file mode 100644 index 00000000..b61b2ed8 --- /dev/null +++ b/packages/v4/migration/Menu/Menu.spec.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Menu } from './Menu.js'; +import { MenuBtn } from './MenuBtn.js'; +import { MenuList } from './MenuList.js'; + +registerComponents(Menu, MenuBtn, MenuList); + +afterEach(resetDom); + +function menuMarkup(mode?: string): string { + return ` +
+ + +
`; +} + +// Off-screen: this test environment's real Chromium can deliver a genuine +// `mouseenter` wherever the cursor happens to rest by default, and content +// rendered at the top of `document.body` is where it lands. +async function render(mode?: string): Promise<{ root: HTMLElement; menu: Menu }> { + const root = document.createElement('div'); + root.setAttribute('style', 'position:absolute;top:300vh;left:0'); + root.innerHTML = menuMarkup(mode); + document.body.append(root); + await settle(); + return { root, menu: getInstance(root.querySelector('[data-component="Menu"]'), 'Menu') }; +} + +describe('Menu', () => { + it('wires up aria-controls on the button and the id on the list', async () => { + const { root, menu } = await render(); + const btn = root.querySelector('#btn') as HTMLElement; + + expect(btn.getAttribute('aria-controls')).toBe(menu.$id); + // Menu overwrites the list's own id with its own, same as v3 did. + expect(menu.menuList?.$el.getAttribute('id')).toBe(menu.$id); + }); + + it('closes its list on mount', async () => { + const { menu } = await render(); + expect(menu.menuList?.isOpen).toBe(false); + }); + + it('toggles the list on button click in click mode (the default)', async () => { + const { root, menu } = await render(); + const btn = root.querySelector('#btn') as HTMLElement; + + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(menu.menuList?.isOpen).toBe(true); + + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(menu.menuList?.isOpen).toBe(false); + }); + + it('closes on a document click outside, in click mode', async () => { + const { root, menu } = await render(); + menu.open(); + expect(menu.menuList?.isOpen).toBe(true); + + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(menu.menuList?.isOpen).toBe(false); + }); + + it('does not close on outside click in hover mode', async () => { + const { menu } = await render('hover'); + menu.open(); + + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(menu.menuList?.isOpen).toBe(true); + }); + + it('opens on button mouseenter and closes on mouseleave, in hover mode', async () => { + const { root, menu } = await render('hover'); + const btn = root.querySelector('#btn') as HTMLElement; + + // Real `mouseenter`/`mouseleave` do not bubble; the framework's own + // delegation reaches them through the capture phase instead (they are + // registered in `CAPTURED_EVENTS`), which fires regardless of `.bubbles`. + btn.dispatchEvent(new MouseEvent('mouseenter')); + expect(menu.menuList?.isOpen).toBe(true); + + btn.dispatchEvent(new MouseEvent('mouseleave')); + await settle(); + + expect(menu.menuList?.isOpen).toBe(false); + }); + + it('closes on Escape', async () => { + const { menu } = await render(); + menu.open(); + + menu.keyed({ ESC: true, ENTER: false, isUp: true } as never); + + expect(menu.menuList?.isOpen).toBe(false); + }); + + it('toggles on Enter when the button has focus, in hover mode', async () => { + const { root, menu } = await render('hover'); + const btn = root.querySelector('#btn') as HTMLElement; + btn.focus(); + + menu.keyed({ ENTER: true, ESC: false, isUp: true } as never); + + expect(menu.menuList?.isOpen).toBe(true); + }); + + it('closes sibling submenus when one of them opens', async () => { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
`; + document.body.append(root); + await settle(); + const subA = getInstance(root.querySelector('#sub-a'), 'Menu'); + const subB = getInstance(root.querySelector('#sub-b'), 'Menu'); + + subA.open(); + expect(subA.menuList?.isOpen).toBe(true); + + subB.open(); + expect(subB.menuList?.isOpen).toBe(true); + expect(subA.menuList?.isOpen).toBe(false); + }); +}); diff --git a/packages/v4/migration/Menu/Menu.ts b/packages/v4/migration/Menu/Menu.ts new file mode 100644 index 00000000..6480423b --- /dev/null +++ b/packages/v4/migration/Menu/Menu.ts @@ -0,0 +1,154 @@ +import { + Base, + component, + withKey, + type BaseProps, + type ChildrenCollection, + type DelegatedEvent, + type GlobalEvent, + type KeyProps, +} from '../../src/index.js'; +import { defaultScheduler } from '../../src/scheduler.js'; +import { MenuBtn } from './MenuBtn.js'; +import { MenuList } from './MenuList.js'; + +export type MenuProps = BaseProps & { + $options: { mode: 'click' | 'hover' }; +}; + +/** + * A disclosure menu orchestrating a `MenuBtn` toggle button and a + * collapsible `MenuList`. The `mode` option chooses whether it opens on + * click or on hover, and it wires up ARIA attributes, keyboard handling + * (Enter/Escape), click-outside dismissal and mutual closing of sibling + * submenus. + * + * `menuBtn`/`menuList` filter their `$watchChildren` collections down to + * the child whose nearest `Menu` ancestor is this one — `$closest('Menu')` + * replaces v3's `getClosestParent(target, this.constructor)`, since a + * nested submenu's own button/list would otherwise match too. + * + * v3 destroyed itself in `mounted()` when either child was missing; v4 + * gives no ordering guarantee for when a child mounts relative to its + * parent, so `$watchChildren`'s `added` callback — not `mounted()` — is + * where the button and list are wired up, and a `Menu` with no list is + * simply inert rather than a hard failure. + * + * @link https://ui.studiometa.dev/reference/items/Menu/ + */ +@component({ + name: 'Menu', + components: { MenuBtn, MenuList }, + options: { mode: { type: String, default: 'click' } }, +}) +export class Menu extends withKey(Base) { + menuBtns: ChildrenCollection = this.$watchChildren('MenuBtn', { + added: (btn) => { + if (btn.$closest('Menu') === this) { + btn.$el.setAttribute('aria-controls', this.$id); + } + }, + }); + + menuLists: ChildrenCollection = this.$watchChildren('MenuList', { + added: (list) => { + if (list.$closest('Menu') === this) { + list.$el.setAttribute('id', this.$id); + list.close(); + } + }, + }); + + /** The `MenuBtn` this `Menu` owns, a nested submenu's own button excluded. */ + get menuBtn(): MenuBtn | undefined { + return this.menuBtns.items.find((btn) => btn.$closest('Menu') === this); + } + + /** The `MenuList` this `Menu` owns, a nested submenu's own list excluded. */ + get menuList(): MenuList | undefined { + return this.menuLists.items.find((list) => list.$closest('Menu') === this); + } + + get shouldReactOnClick(): boolean { + return this.$options.mode === 'click'; + } + + get isHover(): boolean { + return Boolean(this.menuBtn?.isHover || this.menuList?.isHover); + } + + keyed({ ENTER, ESC, isUp }: KeyProps): void { + if (!isUp) { + return; + } + + if (ESC) { + this.close(); + return; + } + + if (!this.shouldReactOnClick && ENTER && document.activeElement === this.menuBtn?.$el) { + this.toggle(); + } + } + + onDocumentClick({ event }: GlobalEvent): void { + if (this.shouldReactOnClick && !this.$el.contains(event.target as Node)) { + this.close(); + } + } + + onMenuBtnClick({ event, target }: DelegatedEvent): void { + if (!this.shouldReactOnClick || target.$closest('Menu') !== this) { + return; + } + event.preventDefault(); + this.toggle(); + } + + onMenuBtnMouseenter({ target }: DelegatedEvent): void { + if (target === this.menuBtn && !this.shouldReactOnClick) { + this.open(); + } + } + + onMenuBtnMouseleave(): void { + this.#closeIfNotHoveredOnNextTurn(); + } + + onMenuListMouseleave(): void { + this.#closeIfNotHoveredOnNextTurn(); + } + + onMenuListItemsOpen({ target }: DelegatedEvent): void { + for (const list of this.menuLists) { + if (!list.$el.contains(target.$el)) { + list.close(); + } + } + } + + close(): void { + this.menuList?.close(); + } + + open(): void { + this.menuList?.open(); + } + + toggle(): void { + void this.menuList?.toggle(); + } + + #closeIfNotHoveredOnNextTurn(): void { + if (this.shouldReactOnClick) { + return; + } + + defaultScheduler.background(() => { + if (this.$isMounted && !this.isHover) { + this.close(); + } + }); + } +} diff --git a/packages/v4/migration/Menu/MenuBtn.spec.ts b/packages/v4/migration/Menu/MenuBtn.spec.ts new file mode 100644 index 00000000..04588e81 --- /dev/null +++ b/packages/v4/migration/Menu/MenuBtn.spec.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { MenuBtn } from './MenuBtn.js'; + +registerComponents(MenuBtn); + +afterEach(resetDom); + +async function render(): Promise<{ el: HTMLElement; instance: MenuBtn }> { + const root = document.createElement('div'); + root.innerHTML = ``; + document.body.append(root); + await settle(); + const el = root.firstElementChild as HTMLElement; + return { el, instance: getInstance(el, 'MenuBtn') }; +} + +describe('MenuBtn', () => { + it('tracks its own hover state', async () => { + const { instance } = await render(); + + instance.onMouseenter(new MouseEvent('mouseenter')); + expect(instance.isHover).toBe(true); + + instance.onMouseleave(new MouseEvent('mouseleave')); + expect(instance.isHover).toBe(false); + }); + + it('stops propagation so a wrapping list does not also count the hover', async () => { + const { instance } = await render(); + const enter = new MouseEvent('mouseenter', { bubbles: true, cancelable: true }); + const leave = new MouseEvent('mouseleave', { bubbles: true, cancelable: true }); + + instance.onMouseenter(enter); + instance.onMouseleave(leave); + + expect(enter.cancelBubble).toBe(true); + expect(leave.cancelBubble).toBe(true); + }); +}); diff --git a/packages/v4/migration/Menu/MenuBtn.ts b/packages/v4/migration/Menu/MenuBtn.ts new file mode 100644 index 00000000..2f1929d0 --- /dev/null +++ b/packages/v4/migration/Menu/MenuBtn.ts @@ -0,0 +1,28 @@ +import { Base, type BaseConfig, type BaseProps } from '../../src/index.js'; + +export type MenuBtnProps = BaseProps; + +/** + * The toggle button child of a `Menu`. It tracks its own hover state and + * stops propagation of `mouseenter`/`mouseleave` so a wrapping `MenuList` + * does not also count the button as hovered. + * + * @link https://ui.studiometa.dev/reference/items/Menu/ + */ +export class MenuBtn extends Base { + static config: BaseConfig = { + name: 'MenuBtn', + }; + + isHover = false; + + onMouseenter(event: MouseEvent): void { + this.isHover = true; + event.stopPropagation(); + } + + onMouseleave(event: MouseEvent): void { + this.isHover = false; + event.stopPropagation(); + } +} diff --git a/packages/v4/migration/Menu/MenuList.spec.ts b/packages/v4/migration/Menu/MenuList.spec.ts new file mode 100644 index 00000000..2245f42e --- /dev/null +++ b/packages/v4/migration/Menu/MenuList.spec.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { MenuList } from './MenuList.js'; + +registerComponents(MenuList); + +afterEach(resetDom); + +async function render(): Promise<{ + root: HTMLElement; + outer: MenuList; + nested: MenuList; + outerLink: HTMLElement; + nestedLink: HTMLElement; +}> { + const root = document.createElement('div'); + root.innerHTML = ` + `; + document.body.append(root); + await settle(); + return { + root, + outer: getInstance(root.querySelector('#outer-list'), 'MenuList'), + nested: getInstance(root.querySelector('#nested-list'), 'MenuList'), + outerLink: root.querySelector('#outer-link') as HTMLElement, + nestedLink: root.querySelector('#nested-link') as HTMLElement, + }; +} + +describe('MenuList', () => { + it('closes tabindex on its own focusable elements on mount, nested lists excluded from double-handling', async () => { + const { outerLink, nestedLink } = await render(); + expect(outerLink.getAttribute('tabindex')).toBe('-1'); + expect(nestedLink.getAttribute('tabindex')).toBe('-1'); + }); + + it('opens: restores tabindex on its own items only, sets aria-hidden, keeps the enter-to class, emits items-open', async () => { + const { outer, outerLink, nestedLink } = await render(); + const events: string[] = []; + outer.$el.addEventListener('items-open', () => events.push('items-open')); + + outer.open(); + await settle(); + + expect(outer.isOpen).toBe(true); + expect(outer.$el.getAttribute('aria-hidden')).toBe('false'); + expect(outer.$el.classList.contains('open')).toBe(true); + expect(outerLink.hasAttribute('tabindex')).toBe(false); + // A nested list's own item is untouched by the outer list's open(). + expect(nestedLink.getAttribute('tabindex')).toBe('-1'); + expect(events).toEqual(['items-open']); + }); + + it('closes: re-applies tabindex, sets aria-hidden, keeps the leave-to class, emits items-close, and closes nested lists', async () => { + const { outer, nested, outerLink } = await render(); + outer.open(); + nested.open(); + await settle(); + + // Closing recursively closes the nested list too, which also emits + // `items-close` and bubbles to this same listener: filter to the outer + // list's own event. + const events: string[] = []; + outer.$el.addEventListener('items-close', (event) => { + if (event.target === outer.$el) { + events.push('items-close'); + } + }); + + outer.close(); + await settle(); + + expect(outer.isOpen).toBe(false); + expect(outer.$el.getAttribute('aria-hidden')).toBe('true'); + expect(outer.$el.classList.contains('closed')).toBe(true); + expect(outerLink.getAttribute('tabindex')).toBe('-1'); + expect(events).toEqual(['items-close']); + // Closing the outer list recursively closes the nested one too. + expect(nested.isOpen).toBe(false); + }); + + it('blurs the focused element within it when it closes', async () => { + const { outer, outerLink } = await render(); + outer.open(); + outerLink.focus(); + expect(document.activeElement).toBe(outerLink); + + outer.close(); + + expect(document.activeElement).not.toBe(outerLink); + }); + + it('toggles between open and closed', async () => { + const { outer } = await render(); + + outer.toggle(); + expect(outer.isOpen).toBe(true); + + outer.toggle(); + expect(outer.isOpen).toBe(false); + }); + + it('is a no-op to close an already-closed list', async () => { + const { outer } = await render(); + const events: string[] = []; + outer.$el.addEventListener('items-close', () => events.push('items-close')); + + outer.close(); + + expect(events).toEqual([]); + }); +}); diff --git a/packages/v4/migration/Menu/MenuList.ts b/packages/v4/migration/Menu/MenuList.ts new file mode 100644 index 00000000..f32ee2a5 --- /dev/null +++ b/packages/v4/migration/Menu/MenuList.ts @@ -0,0 +1,183 @@ +import { + Base, + getInstances, + type BaseConfig, + type BaseProps, + type ChildrenCollection, +} from '../../src/index.js'; +import { enterTransition, leaveTransition, type TransitionOptions } from '../../src/utils/transition.js'; +import type { Transitionable } from '../Transition/index.js'; + +const FOCUSABLE_ELEMENTS = [ + 'a[href]:not([inert])', + 'area[href]:not([inert])', + 'input:not([disabled]):not([inert])', + 'select:not([disabled]):not([inert])', + 'textarea:not([disabled]):not([inert])', + 'button:not([disabled]):not([inert])', + 'iframe:not([inert])', + 'audio:not([inert])', + 'video:not([inert])', + '[contenteditable]:not([inert])', + '[tabindex]:not([inert])', +].join(','); + +export type MenuListProps = BaseProps & { + $options: Omit; + $emits: { + 'transition-enter': void; + 'transition-enter-start': void; + 'transition-enter-end': void; + 'transition-leave': void; + 'transition-leave-start': void; + 'transition-leave-end': void; + 'items-open': void; + 'items-close': void; + }; +}; + +/** The nearest `MenuList` instance at or above `el`, or `null`. */ +function closestMenuList(el: Element | null): MenuList | null { + let node = el; + while (node) { + const instance = getInstances(node).find((i) => i.$config.name === 'MenuList'); + if (instance) { + return instance; + } + node = node.parentElement; + } + return null; +} + +/** + * The collapsible list child of a `Menu`. It implements `Transitionable` + * (the same contract `Dialog` fans its own children out to) to animate its + * reveal, exposes `open()`, `close()` and `toggle()`, keeps `aria-hidden` + * and the `tabindex` of its focusable elements in sync with its visibility, + * recursively closes nested lists, and emits `items-open`/`items-close`. + * + * v3 extended a `withTransition`-mixed class and forced `enterKeep`/ + * `leaveKeep` to `true` by overriding the `$options` getter — a menu's open + * state must stay visible, not revert once the transition ends. v4's + * `$options` is a read-only own property with no override point, and its + * ported `Transition` is a plain, non-generic `Base` subclass rather than a + * mixin, so extending it here would fix `MenuList`'s own props to + * `Transition`'s. Since every one of `Transition`'s methods is overridden + * below anyway to force the two flags, implementing `Transitionable` + * directly avoids both problems at once. + * + * @link https://ui.studiometa.dev/reference/items/Menu/ + */ +export class MenuList + extends Base + implements Transitionable +{ + static config: BaseConfig = { + name: 'MenuList', + options: { + enterFrom: String, + enterActive: String, + enterTo: String, + leaveFrom: String, + leaveActive: String, + leaveTo: String, + }, + components: { MenuList }, + }; + + state: 'entering' | 'leaving' | null = null; + + isOpen = false; + + isHover = false; + + #lists: ChildrenCollection = this.$watchChildren('MenuList'); + + mounted(): void { + this.#updateTabIndexes('close'); + } + + onMouseenter(): void { + this.isHover = true; + } + + onMouseleave(): void { + this.isHover = false; + } + + async enter(): Promise { + this.state = 'entering'; + this.$emit('transition-enter'); + this.$emit('transition-enter-start'); + await enterTransition(this.$el, { ...this.$options, enterKeep: true, leaveKeep: true }); + this.$emit('transition-enter-end'); + } + + async leave(): Promise { + this.state = 'leaving'; + this.$emit('transition-leave'); + this.$emit('transition-leave-start'); + await leaveTransition(this.$el, { ...this.$options, enterKeep: true, leaveKeep: true }); + this.$emit('transition-leave-end'); + } + + /** Display the menu items. */ + open(): void { + if (this.isOpen) { + return; + } + + this.#updateTabIndexes('open'); + this.$el.setAttribute('aria-hidden', 'false'); + this.isOpen = true; + void this.enter(); + this.$emit('items-open'); + } + + /** Hide the menu items. */ + close(): void { + if (!this.isOpen) { + return; + } + + for (const list of this.#lists) { + list.close(); + } + + if ( + document.activeElement instanceof HTMLElement && + this.$el.contains(document.activeElement) + ) { + document.activeElement.blur(); + } + + this.$el.setAttribute('aria-hidden', 'true'); + this.#updateTabIndexes('close'); + this.isOpen = false; + void this.leave(); + this.$emit('items-close'); + } + + toggle(): Promise { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + return Promise.resolve(); + } + + /** Set the `tabindex` of this list's own focusable elements, nested lists excluded. */ + #updateTabIndexes(mode: 'open' | 'close' = 'open'): void { + for (const item of this.$el.querySelectorAll(FOCUSABLE_ELEMENTS)) { + if (closestMenuList(item.parentElement) !== this) { + continue; + } + if (mode === 'close') { + item.setAttribute('tabindex', '-1'); + } else { + item.removeAttribute('tabindex'); + } + } + } +} diff --git a/packages/v4/migration/Menu/index.ts b/packages/v4/migration/Menu/index.ts new file mode 100644 index 00000000..b916ba11 --- /dev/null +++ b/packages/v4/migration/Menu/index.ts @@ -0,0 +1,3 @@ +export { Menu, type MenuProps } from './Menu.js'; +export { MenuBtn, type MenuBtnProps } from './MenuBtn.js'; +export { MenuList, type MenuListProps } from './MenuList.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 24703950..00fb6ae1 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -13,6 +13,7 @@ export * from './Fetch/index.js'; export * from './Hoverable/index.js'; export * from './InView/index.js'; export * from './LazyInclude/index.js'; +export * from './Menu/index.js'; export * from './Prefetch/index.js'; export * from './ScrollAnimation/index.js'; export * from './ScrollTo/index.js'; From 20e29c4ed55987895c43ed8136992ae4c737592d Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:45:21 +0200 Subject: [PATCH 07/24] feat(v4): port Timer and TimerProgress onto withRaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timer's internal timing fields stay plain fields rather than #private: TimerProgress reads them from a subclass, and a JS private field is invisible to a subclass entirely — there is no v4 equivalent of "protected". Positional array-detail dispatch (__dispatch(name, ...detail)) becomes a named payload object, matching $emit's contract. withRaf(Timer, { manual: true }) replaces v3's $services.disable ('ticked') workaround for the RafService auto-enabling on any class that declares ticked() — manual mode never auto-starts, so there is nothing left to neutralize in mounted(). --- packages/v4/migration/Timer/Timer.spec.ts | 142 +++++++++++++++++ packages/v4/migration/Timer/Timer.ts | 144 ++++++++++++++++++ .../v4/migration/Timer/TimerProgress.spec.ts | 89 +++++++++++ packages/v4/migration/Timer/TimerProgress.ts | 64 ++++++++ packages/v4/migration/Timer/index.ts | 2 + packages/v4/migration/index.ts | 1 + 6 files changed, 442 insertions(+) create mode 100644 packages/v4/migration/Timer/Timer.spec.ts create mode 100644 packages/v4/migration/Timer/Timer.ts create mode 100644 packages/v4/migration/Timer/TimerProgress.spec.ts create mode 100644 packages/v4/migration/Timer/TimerProgress.ts create mode 100644 packages/v4/migration/Timer/index.ts diff --git a/packages/v4/migration/Timer/Timer.spec.ts b/packages/v4/migration/Timer/Timer.spec.ts new file mode 100644 index 00000000..42ee1760 --- /dev/null +++ b/packages/v4/migration/Timer/Timer.spec.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Timer } from './Timer.js'; + +registerComponents(Timer); + +afterEach(resetDom); + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Creates the element and starts recording events on it before mounting + * settles: `mounted()`'s autostart fires `timer-start` synchronously during + * that first cycle, which a listener attached only after `await settle()` + * would already have missed. + */ +function renderUnmounted(attributes = ''): HTMLElement { + const root = document.createElement('div'); + root.innerHTML = `
`; + document.body.append(root); + return root.firstElementChild as HTMLElement; +} + +async function render(attributes = ''): Promise<{ el: HTMLElement; instance: Timer }> { + const el = renderUnmounted(attributes); + await settle(); + return { el, instance: getInstance(el, 'Timer') }; +} + +function record(el: HTMLElement, ...types: string[]): string[] { + const events: string[] = []; + for (const type of types) { + el.addEventListener(type, () => events.push(type)); + } + return events; +} + +describe('Timer', () => { + it('starts on mount by default, then ends after the delay', async () => { + const el = renderUnmounted('data-option-delay="0.02"'); + const events = record(el, 'timer-start', 'timer-end'); + await settle(); + + await wait(60); + + expect(events).toEqual(['timer-start', 'timer-end']); + }); + + it('does not start on mount when autostart is disabled', async () => { + const { el, instance } = await render('data-option-delay="0.02" data-option-autostart="false"'); + const events = record(el, 'timer-start', 'timer-end'); + + await wait(60); + expect(events).toEqual([]); + + instance.start(); + await wait(60); + expect(events).toEqual(['timer-start', 'timer-end']); + }); + + it('pauses and resumes, preserving the remaining time', async () => { + const { el, instance } = await render('data-option-delay="0.1" data-option-autostart="false"'); + const events = record(el, 'timer-pause', 'timer-resume', 'timer-end'); + + instance.start(); + await wait(30); + instance.pause(); + const remainingAtPause = instance.remaining; + + await wait(50); + expect(events).toEqual(['timer-pause']); + expect(instance.timerId).toBeNull(); + + instance.resume(); + expect(events).toEqual(['timer-pause', 'timer-resume']); + expect(instance.remaining).toBeCloseTo(remainingAtPause, 0); + + await wait(120); + expect(events).toEqual(['timer-pause', 'timer-resume', 'timer-end']); + }); + + it('is a no-op to pause an idle timer or resume a running one', async () => { + const { el, instance } = await render('data-option-delay="0.1"'); + const events = record(el, 'timer-pause', 'timer-resume'); + + instance.resume(); + expect(events).toEqual([]); + + instance.stop(); + instance.pause(); + expect(events).toEqual([]); + }); + + it('stops without completing', async () => { + const { el, instance } = await render('data-option-delay="0.02"'); + const events = record(el, 'timer-stop', 'timer-end'); + + instance.stop(); + await wait(60); + + expect(events).toEqual(['timer-stop']); + expect(instance.remaining).toBe(0); + }); + + it('restarts from the beginning', async () => { + const { el, instance } = await render('data-option-delay="0.1" data-option-autostart="false"'); + const events = record(el, 'timer-start'); + + instance.start(); + await wait(30); + instance.restart(); + + expect(events).toEqual(['timer-start', 'timer-start']); + expect(instance.remaining).toBeCloseTo(100, 0); + }); + + it('re-arms itself and emits timer-tick when repeat is set', async () => { + // `settle()` itself takes real time (its own polling waits), so the delay + // must be comfortably longer than that to observe exactly one full cycle + // rather than however many `settle()`'s own wait already consumed. + const el = renderUnmounted('data-option-delay="0.15" data-option-repeat="true"'); + const events = record(el, 'timer-start', 'timer-end', 'timer-tick'); + await settle(); + + await wait(150); + + expect(events).toEqual(['timer-start', 'timer-end', 'timer-tick', 'timer-start']); + }); + + it('cancels the pending countdown when destroyed', async () => { + const { el, instance } = await render('data-option-delay="0.02"'); + const events = record(el, 'timer-end'); + + instance.$destroy(); + await wait(60); + + expect(events).toEqual([]); + }); +}); diff --git a/packages/v4/migration/Timer/Timer.ts b/packages/v4/migration/Timer/Timer.ts new file mode 100644 index 00000000..e094cb09 --- /dev/null +++ b/packages/v4/migration/Timer/Timer.ts @@ -0,0 +1,144 @@ +import { Base, type BaseConfig, type BaseProps, type MountedReturn } from '../../src/index.js'; + +export type TimerProps = BaseProps & { + $options: { + delay: number; + repeat: boolean; + autostart: boolean; + }; + $emits: { + 'timer-start': void; + 'timer-end': void; + 'timer-tick': void; + 'timer-pause': void; + 'timer-resume': void; + 'timer-stop': void; + }; +}; + +/** + * A headless, composable countdown primitive. It emits bubbling events for + * its lifecycle and exposes imperative methods, holding no UI state of its + * own — combine it with `Action` (to react to `timer-*` events or call its + * methods) and the `Data*` family (to turn those events into reactive + * state). + * + * The timing fields below are regular fields rather than `#private`: v3 + * declared them `@protected` because `TimerProgress` reads them directly, + * and a JS private field is invisible to a subclass entirely, not merely + * hidden from the outside — there is no v4 equivalent of "protected". + * + * @link https://ui.studiometa.dev/reference/items/Timer/ + */ +export class Timer extends Base { + static config: BaseConfig = { + name: 'Timer', + options: { + delay: { type: Number, default: 0 }, + repeat: Boolean, + autostart: { type: Boolean, default: true }, + }, + }; + + /** The pending `setTimeout` id, or `null` when idle. */ + timerId: number | null = null; + + /** Timestamp (ms) at which the current countdown segment was armed. */ + armedAt = 0; + + /** Time (ms) already consumed before the current segment, preserved across pauses. */ + elapsed = 0; + + /** Time (ms) left to wait before the countdown completes. */ + remaining = 0; + + /** Whether a countdown is currently paused and can be resumed. */ + paused = false; + + /** The total countdown duration in milliseconds (the `delay` option is in seconds). */ + get duration(): number { + return this.$options.delay * 1000; + } + + /** Start the countdown on mount unless `autostart` is disabled, cancel it on destroy. */ + mounted(): MountedReturn { + if (this.$options.autostart) { + this.start(); + } + return () => this.clear(); + } + + /** Start — or restart — the countdown from the beginning. */ + start(): void { + this.clear(); + this.paused = false; + this.elapsed = 0; + this.remaining = this.duration; + this.$emit('timer-start'); + this.arm(this.remaining); + } + + /** Alias for `start()`, restarting the countdown from zero. */ + restart(): void { + this.start(); + } + + /** Stop the countdown without completing it. */ + stop(): void { + this.clear(); + this.paused = false; + this.remaining = 0; + this.$emit('timer-stop'); + } + + /** Pause the countdown, preserving the remaining time. */ + pause(): void { + if (this.timerId === null) { + return; + } + + const consumed = performance.now() - this.armedAt; + this.elapsed += consumed; + this.remaining -= consumed; + this.clear(); + this.paused = true; + this.$emit('timer-pause'); + } + + /** Resume a paused countdown from where it left off. */ + resume(): void { + if (!this.paused) { + return; + } + + this.paused = false; + this.$emit('timer-resume'); + this.arm(this.remaining); + } + + /** Arm the countdown for the given duration in milliseconds. */ + arm(delay: number): void { + this.armedAt = performance.now(); + this.timerId = window.setTimeout(() => this.complete(), delay); + } + + /** Handle the countdown reaching zero, re-arming when `repeat` is enabled. */ + complete(): void { + this.timerId = null; + this.$emit('timer-end'); + + if (this.$options.repeat) { + this.$emit('timer-tick'); + this.start(); + } + } + + /** Cancel any pending countdown. Subclasses extend this to release extra resources. */ + clear(): void { + if (this.timerId !== null) { + window.clearTimeout(this.timerId); + } + + this.timerId = null; + } +} diff --git a/packages/v4/migration/Timer/TimerProgress.spec.ts b/packages/v4/migration/Timer/TimerProgress.spec.ts new file mode 100644 index 00000000..e75f3a06 --- /dev/null +++ b/packages/v4/migration/Timer/TimerProgress.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { TimerProgress } from './TimerProgress.js'; + +registerComponents(TimerProgress); + +afterEach(resetDom); + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function render(attributes = ''): Promise<{ el: HTMLElement; instance: TimerProgress }> { + const root = document.createElement('div'); + root.innerHTML = `
`; + document.body.append(root); + await settle(); + const el = root.firstElementChild as HTMLElement; + return { el, instance: getInstance(el, 'TimerProgress') }; +} + +function recordProgress(el: HTMLElement): number[] { + const ratios: number[] = []; + el.addEventListener('timer-progress', (event) => { + ratios.push((event as CustomEvent<{ ratio: number }>).detail.ratio); + }); + return ratios; +} + +describe('TimerProgress', () => { + it('does not run the frame loop before the countdown is armed', async () => { + const { el } = await render('data-option-autostart="false"'); + const ratios = recordProgress(el); + + await wait(60); + + expect(ratios).toEqual([]); + }); + + it('reports increasing progress while armed, ending at 1', async () => { + const { el } = await render('data-option-delay="0.08"'); + const ratios = recordProgress(el); + + await wait(150); + + expect(ratios.length).toBeGreaterThan(1); + expect(ratios.at(-1)).toBe(1); + expect([...ratios]).toEqual([...ratios].sort((a, b) => a - b)); + }); + + it('stops the frame loop once complete', async () => { + const { el } = await render('data-option-delay="0.02"'); + const ratios = recordProgress(el); + + await wait(60); + const countAtComplete = ratios.length; + await wait(60); + + expect(ratios.length).toBe(countAtComplete); + }); + + it('resets progress to 0 when stopped', async () => { + const { el, instance } = await render('data-option-delay="0.2"'); + const ratios = recordProgress(el); + + await wait(30); + instance.stop(); + + expect(ratios.at(-1)).toBe(0); + }); + + it('stops the frame loop while paused and resumes it', async () => { + const { el, instance } = await render('data-option-delay="0.1"'); + const ratios = recordProgress(el); + + await wait(20); + instance.pause(); + const countAtPause = ratios.length; + await wait(40); + + expect(ratios.length).toBe(countAtPause); + + instance.resume(); + await wait(150); + + expect(ratios.at(-1)).toBe(1); + }); +}); diff --git a/packages/v4/migration/Timer/TimerProgress.ts b/packages/v4/migration/Timer/TimerProgress.ts new file mode 100644 index 00000000..2e965337 --- /dev/null +++ b/packages/v4/migration/Timer/TimerProgress.ts @@ -0,0 +1,64 @@ +import { withRaf, type BaseConfig, type BaseProps } from '../../src/index.js'; +import { Timer, type TimerProps } from './Timer.js'; + +export type TimerProgressProps = TimerProps & { + $emits: TimerProps['$emits'] & { + 'timer-progress': { ratio: number }; + }; +}; + +/** + * A `Timer` that additionally emits a smooth, pause-aware `timer-progress` + * event on every animation frame, carrying a `0 → 1` ratio in its detail. It + * is a separate component so the base `Timer` never pays the per-frame + * cost: mount `TimerProgress` only where a continuous indicator (e.g. a + * progress bar) is needed. + * + * v3 mounted `ticked` on the shared `RafService` unconditionally (any class + * declaring the method got it) and had to `$services.disable('ticked')` in + * `mounted()` to neutralize that, since progress must only run while a + * countdown is armed. `withRaf(Timer, { manual: true })` never auto-starts + * in the first place — `arm()`/`clear()` own the toggle from there on, and + * there is nothing left to neutralize. + * + * @link https://ui.studiometa.dev/reference/items/Timer/ + */ +export class TimerProgress extends withRaf(Timer, { + manual: true, +}) { + static config: BaseConfig = { + name: 'TimerProgress', + }; + + /** Emit the current progress ratio on every animation frame while running. */ + ticked(): void { + const consumed = this.elapsed + (performance.now() - this.armedAt); + const ratio = Math.min(1, Math.max(0, consumed / (this.duration || 1))); + this.$emit('timer-progress', { ratio }); + } + + /** Enable the progress loop alongside the countdown. */ + arm(delay: number): void { + super.arm(delay); + this.$services.ticked.start(); + } + + /** Report full progress and stop the loop before the countdown completes. */ + complete(): void { + this.$services.ticked.stop(); + this.$emit('timer-progress', { ratio: 1 }); + super.complete(); + } + + /** Disable the progress loop together with the countdown. */ + clear(): void { + super.clear(); + this.$services.ticked.stop(); + } + + /** Stop the countdown and reset the reported progress to zero. */ + stop(): void { + super.stop(); + this.$emit('timer-progress', { ratio: 0 }); + } +} diff --git a/packages/v4/migration/Timer/index.ts b/packages/v4/migration/Timer/index.ts new file mode 100644 index 00000000..b1f41020 --- /dev/null +++ b/packages/v4/migration/Timer/index.ts @@ -0,0 +1,2 @@ +export { Timer, type TimerProps } from './Timer.js'; +export { TimerProgress, type TimerProgressProps } from './TimerProgress.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 00fb6ae1..6e15ca79 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -20,6 +20,7 @@ export * from './ScrollTo/index.js'; export * from './Sentinel/index.js'; export * from './Slider/index.js'; export * from './Sticky/index.js'; +export * from './Timer/index.js'; export * from './Track/index.js'; export * from './Transition/index.js'; From e4c5b3ab2867ca548618d1971fa2115e57ef6251 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 18:56:08 +0200 Subject: [PATCH 08/24] feat(v4): port Toast and Toaster onto Timer and viewTransition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real finding along the way: a boolean option's DOM presence is its value regardless of the string written — data-option-x="false" reads true — so turning off a true-default option (Toast's autostart) takes the negated attribute name (data-option-no-autostart), not ="false". Fixed in Toaster's sticky-toast branch and audited across every other family this session for the same mistake. $emit only takes one payload object, so Toast/Toaster's positional v3 emits (__dispatch(name, ...detail), $emit('show', toast, message, type)) become named payloads, the same adaptation Timer needed. Tests poll for a viewTransition()-driven DOM mutation rather than trusting settle() or a fixed wait: the scheduler's write task that flushes the transition queue returns as soon as document.startViewTransition(...).finished is requested, not once it settles, and a real headless compositor can take longer than usual to finish one. --- packages/v4/migration/Toaster/Toast.spec.ts | 101 ++++++++++++++++ packages/v4/migration/Toaster/Toast.ts | 85 +++++++++++++ packages/v4/migration/Toaster/Toaster.spec.ts | 114 ++++++++++++++++++ packages/v4/migration/Toaster/Toaster.ts | 113 +++++++++++++++++ packages/v4/migration/Toaster/index.ts | 2 + packages/v4/migration/index.ts | 1 + 6 files changed, 416 insertions(+) create mode 100644 packages/v4/migration/Toaster/Toast.spec.ts create mode 100644 packages/v4/migration/Toaster/Toast.ts create mode 100644 packages/v4/migration/Toaster/Toaster.spec.ts create mode 100644 packages/v4/migration/Toaster/Toaster.ts create mode 100644 packages/v4/migration/Toaster/index.ts diff --git a/packages/v4/migration/Toaster/Toast.spec.ts b/packages/v4/migration/Toaster/Toast.spec.ts new file mode 100644 index 00000000..c7ab5c31 --- /dev/null +++ b/packages/v4/migration/Toaster/Toast.spec.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Toast } from './Toast.js'; + +registerComponents(Toast); + +afterEach(resetDom); + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * `viewTransition()` chains onto a module-level tail the scheduler does not + * track: `defaultScheduler`'s write task that flushes it returns as soon as + * `document.startViewTransition(...).finished` is *requested*, not once it + * settles, so `settle()` gives no guarantee the DOM mutation inside it has + * run yet. A real headless compositor can also take longer than usual to + * finish one. Poll instead of trusting a fixed wait. + */ +async function waitFor(predicate: () => boolean, timeout = 1000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor: timed out'); + } + await wait(10); + } +} + +function renderUnmounted(attributes = ''): HTMLElement { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
`; + document.body.append(root); + return root.firstElementChild as HTMLElement; +} + +async function render(attributes = ''): Promise<{ el: HTMLElement; instance: Toast }> { + const el = renderUnmounted(attributes); + await settle(); + return { el, instance: getInstance(el, 'Toast') }; +} + +describe('Toast', () => { + it('pauses on mouseenter/focusin and resumes on mouseleave/focusout', async () => { + const { instance } = await render('data-option-delay="1"'); + + instance.onMouseenter(); + expect(instance.paused).toBe(true); + instance.onMouseleave(); + expect(instance.paused).toBe(false); + + instance.onFocusin(); + expect(instance.paused).toBe(true); + instance.onFocusout(); + expect(instance.paused).toBe(false); + }); + + it('dismisses when the close control is activated, emitting dismiss and removing itself', async () => { + const { el, instance } = await render('data-option-no-autostart'); + const events: HTMLElement[] = []; + el.addEventListener('dismiss', (event) => { + events.push((event as CustomEvent<{ el: HTMLElement }>).detail.el); + }); + + instance.onCloseClick(); + await waitFor(() => !el.isConnected); + + expect(events).toEqual([el]); + }); + + it('is idempotent: a second dismiss is a no-op', async () => { + const { el, instance } = await render('data-option-no-autostart'); + const events: unknown[] = []; + el.addEventListener('dismiss', () => events.push(1)); + + instance.dismiss(); + instance.dismiss(); + await settle(); + + expect(events).toEqual([1]); + }); + + it('auto-dismisses once the countdown completes', async () => { + // Listener attached before mounting settles: `autostart` fires the + // countdown synchronously during that first cycle, and a short delay + // could complete before a listener added only afterwards ever saw it. + const el = renderUnmounted('data-option-delay="0.02"'); + const events: unknown[] = []; + el.addEventListener('dismiss', () => events.push(1)); + await settle(); + + await waitFor(() => !el.isConnected); + + expect(events).toEqual([1]); + }); +}); diff --git a/packages/v4/migration/Toaster/Toast.ts b/packages/v4/migration/Toaster/Toast.ts new file mode 100644 index 00000000..aff7c3a5 --- /dev/null +++ b/packages/v4/migration/Toaster/Toast.ts @@ -0,0 +1,85 @@ +import { viewTransition, type BaseConfig } from '../../src/index.js'; +import { Timer, type TimerProps } from '../Timer/index.js'; + +export type ToastProps = TimerProps & { + $refs: { close: HTMLElement }; + $emits: TimerProps['$emits'] & { + dismiss: { el: HTMLElement }; + }; +}; + +/** + * A single, self-contained toast built on the `Timer` primitive: `Timer` + * provides the pausable auto-dismiss countdown (its `delay` option is the + * toast lifetime, in seconds), `autostart` begins it on mount and its + * `mounted()` cleanup clears it for free. This class adds the interaction — + * pausing while hovered or focused, an optional close control — and + * animates itself out through the shared `viewTransition` scheduler when + * dismissed. + * + * It is mounted automatically when a `Toaster` inserts it, and destroyed + * automatically when it removes itself — so a `Toaster` never has to track + * or tear down individual toasts. + * + * @link https://ui.studiometa.dev/reference/items/Toaster/ + */ +export class Toast extends Timer { + /** Merges with `Timer`'s (the `delay`/`autostart`/`repeat` options are inherited). */ + static config: BaseConfig = { + name: 'Toast', + refs: ['close'], + }; + + /** Whether the toast is already leaving, so a click + timer race dismisses once. */ + #dismissed = false; + + /** Pause the countdown while the pointer is over the toast. */ + onMouseenter(): void { + this.pause(); + } + + /** Resume the countdown when the pointer leaves. */ + onMouseleave(): void { + this.resume(); + } + + /** + * Pause while the focus is anywhere inside the toast (`focusin`/`focusout` + * bubble, unlike `focus`/`blur`, so this covers the close control too). + */ + onFocusin(): void { + this.pause(); + } + + /** Resume when the focus leaves the toast. */ + onFocusout(): void { + this.resume(); + } + + /** Dismiss when the close control is activated. */ + onCloseClick(): void { + this.dismiss(); + } + + /** Dismiss the toast once the countdown reaches zero. */ + complete(): void { + super.complete(); + this.dismiss(); + } + + /** + * Animate the toast out and remove it from the DOM; the registry then + * destroys this component (and `Timer`'s `mounted()` cleanup clears any + * pending countdown). + */ + dismiss(): void { + if (this.#dismissed) { + return; + } + + this.#dismissed = true; + this.clear(); + this.$emit('dismiss', { el: this.$el }); + void viewTransition(() => this.$el.remove()); + } +} diff --git a/packages/v4/migration/Toaster/Toaster.spec.ts b/packages/v4/migration/Toaster/Toaster.spec.ts new file mode 100644 index 00000000..50d7fc24 --- /dev/null +++ b/packages/v4/migration/Toaster/Toaster.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Toast } from './Toast.js'; +import { Toaster } from './Toaster.js'; + +registerComponents(Toaster, Toast); + +afterEach(resetDom); + +/** + * `viewTransition()` chains onto a module-level tail the scheduler does not + * track — `settle()` gives no guarantee the DOM mutation inside it has run — + * and a real headless compositor can take longer than usual to finish one. + * Poll instead of trusting a fixed wait. + */ +async function waitFor(predicate: () => boolean, timeout = 1000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor: timed out'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +async function render(): Promise<{ root: HTMLElement; instance: Toaster }> { + const root = document.createElement('div'); + root.innerHTML = ` +
+
+
+ +
`; + document.body.append(root); + await settle(); + return { + root, + instance: getInstance(root.querySelector('[data-component="Toaster"]'), 'Toaster'), + }; +} + +describe('Toaster', () => { + it('inserts a Toast into the polite region by default, with the message and duration', async () => { + const { root, instance } = await render(); + + const toast = instance.show('Hello'); + const polite = root.querySelector('[data-ref="polite"]') as HTMLElement; + await waitFor(() => polite.contains(toast)); + await settle(); + + expect(toast.dataset.type).toBe('info'); + expect(toast.dataset.optionDelay).toBe('3'); + expect(toast.querySelector('[data-message]')?.textContent).toBe('Hello'); + expect(getInstance(toast, 'Toast')).toBeTruthy(); + }); + + it('routes an error toast to the assertive region', async () => { + const { root, instance } = await render(); + + const toast = instance.show('Oops', { type: 'error' }); + const assertive = root.querySelector('[data-ref="assertive"]') as HTMLElement; + await waitFor(() => assertive.contains(toast)); + + expect(toast.dataset.type).toBe('error'); + }); + + it('disables autostart instead of setting a delay for a sticky (duration 0) toast', async () => { + const { root, instance } = await render(); + + const toast = instance.show('Sticky', { duration: 0 }); + const polite = root.querySelector('[data-ref="polite"]') as HTMLElement; + await waitFor(() => polite.contains(toast)); + await settle(); + + expect(toast.dataset.optionDelay).toBeUndefined(); + // A boolean option's presence is its value regardless of the string + // written: the negated attribute name is what actually turns it off. + expect(toast.hasAttribute('data-option-no-autostart')).toBe(true); + + const toastInstance = getInstance(toast, 'Toast'); + expect(toastInstance.timerId).toBeNull(); + }); + + it('emits show with the toast, message and type', async () => { + const { root, instance } = await render(); + const events: Array<{ toast: HTMLElement; message: string; type: string }> = []; + root + .querySelector('[data-component="Toaster"]')! + .addEventListener('show', (event) => { + events.push((event as CustomEvent).detail); + }); + + const toast = instance.show('Hi', { type: 'success' }); + + expect(events).toEqual([{ toast, message: 'Hi', type: 'success' }]); + }); + + it('assigns each toast a unique view-transition-name', async () => { + const { instance } = await render(); + + const first = instance.show('One'); + const second = instance.show('Two'); + + expect(first.style.getPropertyValue('view-transition-name')).not.toBe( + second.style.getPropertyValue('view-transition-name'), + ); + }); +}); diff --git a/packages/v4/migration/Toaster/Toaster.ts b/packages/v4/migration/Toaster/Toaster.ts new file mode 100644 index 00000000..336637fb --- /dev/null +++ b/packages/v4/migration/Toaster/Toaster.ts @@ -0,0 +1,113 @@ +import { Base, viewTransition, type BaseConfig, type BaseProps } from '../../src/index.js'; +import { Toast } from './Toast.js'; + +export interface ToasterShowOptions { + /** + * The toast kind. `error` routes the toast to the assertive live region so + * it interrupts the screen reader; anything else goes to the polite one. + * The value is mirrored on the toast as `data-type` for styling. + */ + type?: string; + /** + * How long the toast stays before it auto-dismisses, in seconds. Pass `0` + * for a sticky toast that only closes on demand. Defaults to the + * `duration` option. + */ + duration?: number; +} + +export type ToasterProps = BaseProps & { + $refs: { + polite: HTMLElement; + assertive: HTMLElement; + template: HTMLTemplateElement; + }; + $options: { duration: number }; + $emits: { + show: { toast: HTMLElement; message: string; type: string }; + }; +}; + +/** + * Running counter for the unique `view-transition-name` assigned to each + * toast. Module-level so names stay unique even when several `Toaster` + * instances flush into the same transition batch. + */ +let count = 0; + +/** + * A headless notifications region. Two permanent `aria-live` regions — + * declared as the `polite` and `assertive` refs — live in the DOM from + * mount, so a toast inserted into one is announced by assistive tech + * without focus ever moving. + * + * The class is only a factory: it clones a toast from the `template` ref, + * fills in the message, type and a unique `view-transition-name`, then + * appends it to the matching region through the shared `viewTransition` + * scheduler. Everything else belongs to the `Toast` it inserts — the + * auto-dismiss countdown, pausing on hover/focus and the leave animation — + * which the registry mounts and destroys automatically. Bursts fired in the + * same tick coalesce into a single coordinated transition. + * + * @link https://ui.studiometa.dev/reference/items/Toaster/ + */ +export class Toaster extends Base { + static config: BaseConfig = { + name: 'Toaster', + // `Toast` lives inside the Toaster; declaring it as a child registers it, + // so the registry mounts every toast the factory inserts. + components: { Toast }, + refs: ['polite', 'assertive', 'template'], + options: { + // In seconds, matching the Timer/TimerProgress convention. + duration: { type: Number, default: 5 }, + }, + }; + + /** + * Show a toast holding the given message, and return its (not-yet-mounted) + * element. The inserted toast is a `Toast` component the registry mounts + * on append; it owns its own dismissal. + */ + show( + message: string, + { type = 'info', duration = this.$options.duration }: ToasterShowOptions = {}, + ): HTMLElement { + const region = + type === 'error' ? (this.$refs.assertive ?? this.$refs.polite) : this.$refs.polite; + const toast = this.$refs.template.content.firstElementChild!.cloneNode(true) as HTMLElement; + + // Guarantee the clone is a `Toast`, whatever the template declares, so + // the registry mounts it. Composes with any other component already on + // the root. + const components = new Set((toast.dataset.component ?? '').split(' ').filter(Boolean)); + components.add('Toast'); + toast.dataset.component = [...components].join(' '); + + toast.dataset.type = type; + count += 1; + toast.style.setProperty('view-transition-name', `toaster-${count}`); + + const messageTarget = toast.querySelector('[data-message]'); + if (messageTarget) { + messageTarget.textContent = message; + } + + // The toast is a `Toast` (a `Timer`): its `delay` is the lifetime, in + // seconds. A duration of 0 (or less) means sticky — disable autostart + // so the countdown never runs and only the close control dismisses it. + // A boolean option's presence is its value regardless of the string + // written, so turning one off — `autostart` defaults `true` — takes the + // negated attribute name, not `="false"`. + if (duration > 0) { + toast.dataset.optionDelay = String(duration); + } else { + toast.setAttribute('data-option-no-autostart', ''); + } + + this.$emit('show', { toast, message, type }); + void viewTransition(() => region.append(toast)); + + return toast; + } +} diff --git a/packages/v4/migration/Toaster/index.ts b/packages/v4/migration/Toaster/index.ts new file mode 100644 index 00000000..b649d317 --- /dev/null +++ b/packages/v4/migration/Toaster/index.ts @@ -0,0 +1,2 @@ +export { Toast, type ToastProps } from './Toast.js'; +export { Toaster, type ToasterProps, type ToasterShowOptions } from './Toaster.js'; diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 6e15ca79..9160ce65 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -21,6 +21,7 @@ export * from './Sentinel/index.js'; export * from './Slider/index.js'; export * from './Sticky/index.js'; export * from './Timer/index.js'; +export * from './Toaster/index.js'; export * from './Track/index.js'; export * from './Transition/index.js'; From aca338154b20f468dcfd22107864dc348c677a4d Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 19:02:47 +0200 Subject: [PATCH 09/24] feat(v4): port the Figure family onto in-view mount and Transitionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractFigure implements Transitionable directly on Base rather than extending the ported Transition class, the same reason MenuList does: Transition is a plain, non-generic Base subclass, and this hierarchy (AbstractFigure -> AbstractFigureDynamic -> FigureShopify/FigureTwicpics) needs generic prop threading through four levels. Figure drops v3's onLoad() { $terminate() }: v4 has no termination (the LazyInclude port hit the same gap), and none is needed here either — AbstractFigure.mounted() only loads when src !== this.src, which is already false once loaded, so a later remount is a no-op on its own. AbstractFigureDynamic gains resized() through withResize(AbstractFigure), since v4 requires a mixin for a service hook v3 auto-wired from the method's mere presence. --- .../migration/Figure/AbstractFigure.spec.ts | 76 +++++++++++ .../v4/migration/Figure/AbstractFigure.ts | 119 ++++++++++++++++++ .../migration/Figure/AbstractFigureDynamic.ts | 60 +++++++++ packages/v4/migration/Figure/Figure.ts | 25 ++++ .../v4/migration/Figure/FigureShopify.spec.ts | 66 ++++++++++ packages/v4/migration/Figure/FigureShopify.ts | 47 +++++++ .../migration/Figure/FigureTwicpics.spec.ts | 75 +++++++++++ .../v4/migration/Figure/FigureTwicpics.ts | 90 +++++++++++++ packages/v4/migration/Figure/index.ts | 8 ++ packages/v4/migration/Figure/utils.ts | 4 + packages/v4/migration/index.ts | 1 + 11 files changed, 571 insertions(+) create mode 100644 packages/v4/migration/Figure/AbstractFigure.spec.ts create mode 100644 packages/v4/migration/Figure/AbstractFigure.ts create mode 100644 packages/v4/migration/Figure/AbstractFigureDynamic.ts create mode 100644 packages/v4/migration/Figure/Figure.ts create mode 100644 packages/v4/migration/Figure/FigureShopify.spec.ts create mode 100644 packages/v4/migration/Figure/FigureShopify.ts create mode 100644 packages/v4/migration/Figure/FigureTwicpics.spec.ts create mode 100644 packages/v4/migration/Figure/FigureTwicpics.ts create mode 100644 packages/v4/migration/Figure/index.ts create mode 100644 packages/v4/migration/Figure/utils.ts diff --git a/packages/v4/migration/Figure/AbstractFigure.spec.ts b/packages/v4/migration/Figure/AbstractFigure.spec.ts new file mode 100644 index 00000000..5ae9b438 --- /dev/null +++ b/packages/v4/migration/Figure/AbstractFigure.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { Figure } from './Figure.js'; + +registerComponents(Figure); + +afterEach(resetDom); + +const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; +const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; + +// A real 1x1 transparent PNG, so `loadImage()` succeeds without network access. +const PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +async function observed(): Promise { + for (let i = 0; i < 6; i += 1) { + await settle(); + } +} + +// A tiny 1x1 white pixel, distinct from PIXEL, standing in for a placeholder. +const PLACEHOLDER = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + +function render(style: string, attributes = 'data-option-lazy="true"'): { + el: HTMLElement; + img: HTMLImageElement; +} { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
`; + document.body.append(root); + const el = root.firstElementChild as HTMLElement; + return { el, img: el.querySelector('[data-ref="img"]') as HTMLImageElement }; +} + +describe('Figure (AbstractFigure)', () => { + it('loads the data-src once scrolled into view, emitting load', async () => { + const { el, img } = render(OFFSCREEN); + const events: unknown[] = []; + el.addEventListener('load', () => events.push(1)); + + await observed(); + expect(events).toEqual([]); + expect(img.src).toBe(PLACEHOLDER); + + el.setAttribute('style', ONSCREEN); + await observed(); + + expect(events).toEqual([1]); + expect(img.src).toBe(PIXEL); + }); + + it('does not load when the `lazy` option is not set', async () => { + const { el } = render(ONSCREEN, ''); + const events: unknown[] = []; + el.addEventListener('load', () => events.push(1)); + + await observed(); + + expect(events).toEqual([]); + }); + + it('runs the enter transition once loaded', async () => { + const { el, img } = render(ONSCREEN, 'data-option-lazy="true" data-option-enter-to="visible" data-option-enter-keep="true"'); + + await observed(); + + expect(img.classList.contains('visible')).toBe(true); + expect(getInstance
(el, 'Figure').state).toBe('entering'); + }); +}); diff --git a/packages/v4/migration/Figure/AbstractFigure.ts b/packages/v4/migration/Figure/AbstractFigure.ts new file mode 100644 index 00000000..fe104a9f --- /dev/null +++ b/packages/v4/migration/Figure/AbstractFigure.ts @@ -0,0 +1,119 @@ +import { Base, type BaseConfig, type BaseProps } from '../../src/index.js'; +import { loadImage } from '../../src/utils/load.js'; +import { + enterTransition, + leaveTransition, + TRANSITION_OPTIONS, + type TransitionOptions, +} from '../../src/utils/transition.js'; +import type { Transitionable } from '../Transition/index.js'; + +/** Gap: core ships no `$warn`. */ +function warn(...args: unknown[]): void { + console.warn('[Figure]', ...args); +} + +export type AbstractFigureProps = BaseProps & { + $refs: { img: HTMLImageElement }; + $options: TransitionOptions & { lazy: boolean }; + $emits: { + 'transition-enter': void; + 'transition-enter-start': void; + 'transition-enter-end': void; + 'transition-leave': void; + 'transition-leave-start': void; + 'transition-leave-end': void; + load: void; + }; +}; + +/** + * Shared base for the image figure components. It implements + * `Transitionable` around a single `img` ref and, through the `in-view` + * mount strategy, defers loading of the `data-src` source until the element + * enters the viewport when the `lazy` option is set, running the enter + * transition and emitting `load` once the image is ready. + * + * v3 mixed `withMountWhenInView` onto `Transition`; v4's ported `Transition` + * is a plain, non-generic `Base` subclass rather than a mixin (the same + * reason `MenuList` implements `Transitionable` directly instead of + * extending it), so this does too — `enter()`/`leave()` below are otherwise + * unchanged from `Transition`'s own. + */ +export class AbstractFigure + extends Base + implements Transitionable +{ + static config: BaseConfig = { + name: 'AbstractFigure', + refs: ['img'], + mountStrategy: 'in-view', + options: { + ...TRANSITION_OPTIONS, + lazy: Boolean, + }, + }; + + state: 'entering' | 'leaving' | null = null; + + get target(): HTMLElement { + return this.$refs.img; + } + + get src(): string { + return this.$refs.img.src; + } + + set src(value: string) { + this.$refs.img.src = value; + } + + get original(): string { + return this.$refs.img.dataset.src ?? ''; + } + + async enter(): Promise { + this.state = 'entering'; + this.$emit('transition-enter'); + this.$emit('transition-enter-start'); + await enterTransition(this.target, this.$options); + this.$emit('transition-enter-end'); + } + + async leave(): Promise { + this.state = 'leaving'; + this.$emit('transition-leave'); + this.$emit('transition-leave-start'); + await leaveTransition(this.target, this.$options); + this.$emit('transition-leave-end'); + } + + toggle(): Promise { + return this.state === 'entering' ? this.leave() : this.enter(); + } + + /** Load on mount. */ + async mounted(): Promise { + const { img } = this.$refs; + + if (!img || !(img instanceof HTMLImageElement)) { + warn('The `img` ref is missing or not an `` element.'); + return; + } + + const src = this.original; + + if (this.$options.lazy && src && src !== this.src) { + try { + await loadImage(src); + } catch { + warn(`Failed to load image "${src}".`); + return; + } + + this.src = src; + void this.enter(); + this.$emit('load'); + } + } +} diff --git a/packages/v4/migration/Figure/AbstractFigureDynamic.ts b/packages/v4/migration/Figure/AbstractFigureDynamic.ts new file mode 100644 index 00000000..1bd1c391 --- /dev/null +++ b/packages/v4/migration/Figure/AbstractFigureDynamic.ts @@ -0,0 +1,60 @@ +import { withResize, type BaseConfig, type BaseProps } from '../../src/index.js'; +import { loadImage } from '../../src/utils/load.js'; +import { AbstractFigure, type AbstractFigureProps } from './AbstractFigure.js'; + +/** Gap: core ships no `$warn`. */ +function warn(...args: unknown[]): void { + console.warn('[Figure]', ...args); +} + +export type AbstractFigureDynamicProps = AbstractFigureProps & { + $options: AbstractFigureProps['$options'] & { disable: boolean; step: number }; +}; + +/** + * Shared base for figures whose source is computed at runtime from the + * element's rendered size. It extends `AbstractFigure`, defaults the `lazy` + * option to `true`, and passes the original `data-src` through the + * overridable `formatSrc` method, unless the `disable` option is set. Its + * own `formatSrc` returns the source unchanged, so subclasses provide the + * actual transformation, and on resize it recomputes and reloads the + * source. + */ +export class AbstractFigureDynamic extends withResize( + AbstractFigure, +) { + static config: BaseConfig = { + ...AbstractFigure.config, + name: 'AbstractFigureDynamic', + options: { + ...AbstractFigure.config.options, + disable: Boolean, + step: { type: Number, default: 50 }, + lazy: { type: Boolean, default: true }, + }, + }; + + /** The formatted source, or the original based on the `disable` option. */ + get original(): string { + return this.$options.disable ? super.original : this.formatSrc(super.original); + } + + /** Format the source with dynamic parameters. */ + formatSrc(src: string): string { + return src; + } + + /** Reassign the source from the original on resize. */ + async resized(): Promise { + const { original } = this; + + try { + await loadImage(original); + } catch { + warn(`Failed to load image "${original}".`); + return; + } + + this.src = original; + } +} diff --git a/packages/v4/migration/Figure/Figure.ts b/packages/v4/migration/Figure/Figure.ts new file mode 100644 index 00000000..9f05e70b --- /dev/null +++ b/packages/v4/migration/Figure/Figure.ts @@ -0,0 +1,25 @@ +import type { BaseConfig, BaseProps } from '../../src/index.js'; +import { AbstractFigure, type AbstractFigureProps } from './AbstractFigure.js'; + +export type FigureProps = AbstractFigureProps; + +/** + * Concrete lazy-loaded image figure built on `AbstractFigure`. It loads the + * `data-src` source when the element scrolls into view, running the enter + * transition and emitting `load` once it is ready. + * + * v3's `onLoad()` called `$terminate()`, since it has no further work to do + * after the reveal — v4 has no termination (the `LazyInclude` port hit the + * same gap). It needs none here either: `AbstractFigure.mounted()` only + * loads when `src !== this.src`, which is already false once loaded, so a + * later remount (the `in-view` strategy can trigger one) is a no-op on its + * own. + * + * @link https://ui.studiometa.dev/reference/items/Figure/ + */ +export class Figure extends AbstractFigure { + static config: BaseConfig = { + ...AbstractFigure.config, + name: 'Figure', + }; +} diff --git a/packages/v4/migration/Figure/FigureShopify.spec.ts b/packages/v4/migration/Figure/FigureShopify.spec.ts new file mode 100644 index 00000000..5ed41958 --- /dev/null +++ b/packages/v4/migration/Figure/FigureShopify.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { FigureShopify } from './FigureShopify.js'; + +registerComponents(FigureShopify); + +afterEach(resetDom); + +// `lazy` is left unset (defaults false on a plain Figure, but +// AbstractFigureDynamic defaults it true) so it is disabled explicitly: +// `formatSrc` is tested as a pure function, and mounting must not attempt a +// real network fetch against a fabricated CDN URL. +async function render(attributes = ''): Promise { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
`; + document.body.append(root); + await settle(); + return getInstance(root.firstElementChild, 'FigureShopify'); +} + +describe('FigureShopify', () => { + it('sizes the source to the rendered element, rounded to the step', async () => { + const instance = await render('data-option-step="50"'); + + const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg')); + + expect(url.searchParams.get('width')).toBe(String(100 * window.devicePixelRatio)); + expect(url.searchParams.get('height')).toBe(String(200 * window.devicePixelRatio)); + }); + + it('rounds a size up to the next step', async () => { + const instance = await render('data-option-step="150"'); + + const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg')); + + // 100 -> 150, 200 -> 300, per `normalizeSize`. + expect(url.searchParams.get('width')).toBe(String(150 * window.devicePixelRatio)); + expect(url.searchParams.get('height')).toBe(String(300 * window.devicePixelRatio)); + }); + + it('sets the crop parameter when the option is given', async () => { + const instance = await render('data-option-crop="center"'); + + const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg')); + + expect(url.searchParams.get('crop')).toBe('center'); + }); + + it('omits the crop parameter by default', async () => { + const instance = await render(); + + const url = new URL(instance.formatSrc('https://cdn.shopify.com/shop/product.jpg')); + + expect(url.searchParams.has('crop')).toBe(false); + }); + + it('bypasses formatSrc when disabled', async () => { + const instance = await render('data-option-disable'); + + expect(instance.original).toBe('https://cdn.shopify.com/shop/product.jpg'); + }); +}); diff --git a/packages/v4/migration/Figure/FigureShopify.ts b/packages/v4/migration/Figure/FigureShopify.ts new file mode 100644 index 00000000..60b60197 --- /dev/null +++ b/packages/v4/migration/Figure/FigureShopify.ts @@ -0,0 +1,47 @@ +import type { BaseConfig, BaseProps } from '../../src/index.js'; +import { AbstractFigureDynamic, type AbstractFigureDynamicProps } from './AbstractFigureDynamic.js'; +import { normalizeSize } from './utils.js'; + +export type FigureShopifyProps = AbstractFigureDynamicProps & { + $options: AbstractFigureDynamicProps['$options'] & { + crop?: 'top' | 'left' | 'right' | 'bottom' | 'center'; + }; +}; + +/** + * Dynamic image figure that rewrites its source for the Shopify CDN, + * sized to the rendered element. + * + * @link https://shopify.dev/docs/api/liquid/filters/image_url + * @link https://ui.studiometa.dev/reference/items/FigureShopify/ + */ +export class FigureShopify extends AbstractFigureDynamic< + FigureShopifyProps & T +> { + static config: BaseConfig = { + ...AbstractFigureDynamic.config, + name: 'FigureShopify', + options: { + ...AbstractFigureDynamic.config.options, + crop: String, + }, + }; + + /** Format the source for Shopify CDN API. */ + formatSrc(src: string): string { + const { crop, step } = this.$options; + + const url = new URL(src, 'https://localhost'); + const width = normalizeSize(this.$refs.img.offsetWidth, step) * window.devicePixelRatio; + const height = normalizeSize(this.$refs.img.offsetHeight, step) * window.devicePixelRatio; + + url.searchParams.set('width', String(width)); + url.searchParams.set('height', String(height)); + + if (crop) { + url.searchParams.set('crop', crop); + } + + return url.toString(); + } +} diff --git a/packages/v4/migration/Figure/FigureTwicpics.spec.ts b/packages/v4/migration/Figure/FigureTwicpics.spec.ts new file mode 100644 index 00000000..73cc9a91 --- /dev/null +++ b/packages/v4/migration/Figure/FigureTwicpics.spec.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { FigureTwicpics } from './FigureTwicpics.js'; + +registerComponents(FigureTwicpics); + +afterEach(resetDom); + +// `lazy` disabled for the same reason as the FigureShopify spec: `formatSrc` +// is a pure function under test, and mounting must not fetch a fabricated URL. +async function render(attributes = '', src = 'https://example.com/original/photo.jpg'): Promise { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
`; + document.body.append(root); + await settle(); + return getInstance(root.firstElementChild, 'FigureTwicpics'); +} + +describe('FigureTwicpics', () => { + it('builds a twic query from the measured size and the default cover mode', async () => { + const instance = await render('data-option-step="50"'); + + const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg')); + + expect(url.searchParams.get('twic')).toBe(`v1/cover=${100 * window.devicePixelRatio}x${200 * window.devicePixelRatio}`); + }); + + it('includes the transform ahead of the mode when given', async () => { + const instance = await render('data-option-transform="my-transform" data-option-step="50"'); + + const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg')); + + expect(url.searchParams.get('twic')).toBe( + `v1/my-transform/cover=${100 * window.devicePixelRatio}x${200 * window.devicePixelRatio}`, + ); + }); + + it('defaults the domain to the source host', async () => { + const instance = await render(); + + expect(instance.domain).toBe('example.com'); + }); + + it('uses the domain option over the source host when given', async () => { + const instance = await render('data-option-domain="cdn.twic.pics"'); + + const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg')); + + expect(url.host).toBe('cdn.twic.pics'); + }); + + it('prefixes the pathname with the path option, without a doubled slash', async () => { + const instance = await render('data-option-path="/my/base/"'); + + expect(instance.path).toBe('my/base'); + const url = new URL(instance.formatSrc('https://example.com/original/photo.jpg')); + expect(url.pathname).toBe('/my/base/original/photo.jpg'); + }); + + it('reports device pixel ratio 1 when dpr is disabled', async () => { + const instance = await render('data-option-no-dpr'); + + expect(instance.devicePixelRatio).toBe(1); + }); + + it('reports the real device pixel ratio by default', async () => { + const instance = await render(); + + expect(instance.devicePixelRatio).toBe(window.devicePixelRatio); + }); +}); diff --git a/packages/v4/migration/Figure/FigureTwicpics.ts b/packages/v4/migration/Figure/FigureTwicpics.ts new file mode 100644 index 00000000..abebd4cd --- /dev/null +++ b/packages/v4/migration/Figure/FigureTwicpics.ts @@ -0,0 +1,90 @@ +import type { BaseConfig, BaseProps } from '../../src/index.js'; +import { withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from '../../src/utils/strings.js'; +import { AbstractFigureDynamic, type AbstractFigureDynamicProps } from './AbstractFigureDynamic.js'; +import { normalizeSize } from './utils.js'; + +export type FigureTwicpicsProps = AbstractFigureDynamicProps & { + $options: AbstractFigureDynamicProps['$options'] & { + transform: string; + domain: string; + path: string; + mode: string; + dpr: boolean; + }; +}; + +/** Whether the user agent is a bot. */ +const isBot = /bot|crawl|slurp|spider/i.test(navigator.userAgent); + +/** + * Dynamic image figure that rewrites its source into a TwicPics URL sized to + * the rendered element. Its `formatSrc` injects a `twic` query built from + * the `domain`, `path`, `transform` and `mode` options and the measured + * dimensions, multiplied by the device pixel ratio unless `dpr` is disabled + * or a bot is detected. + * + * @link https://ui.studiometa.dev/reference/items/FigureTwicpics/ + */ +export class FigureTwicpics extends AbstractFigureDynamic< + FigureTwicpicsProps & T +> { + static config: BaseConfig = { + ...AbstractFigureDynamic.config, + name: 'FigureTwicpics', + options: { + ...AbstractFigureDynamic.config.options, + transform: String, + domain: String, + path: String, + mode: { type: String, default: 'cover' }, + dpr: { type: Boolean, default: true }, + }, + }; + + /** The TwicPics path. */ + get path(): string { + return withoutTrailingSlash(withoutLeadingSlash(this.$options.path)); + } + + /** The TwicPics domain. */ + get domain(): string { + return this.$options.domain || new URL(this.$refs.img.dataset.src ?? '').host; + } + + /** + * The current device pixel ratio. `1` for a bot, and `1` when `dpr` is + * disabled (`data-option-no-dpr`, since it defaults `true`). + */ + get devicePixelRatio(): number { + if (!this.$options.dpr || isBot) { + return 1; + } + + return window.devicePixelRatio; + } + + /** Format the source for TwicPics. */ + formatSrc(src: string): string { + const { transform, mode, step } = this.$options; + + const url = new URL(src, 'https://localhost'); + url.host = this.domain; + url.port = ''; + + if (this.path && !url.pathname.startsWith(withLeadingSlash(this.path))) { + url.pathname = `/${this.path}${url.pathname}`; + } + + const width = normalizeSize(this.$refs.img.offsetWidth, step) * this.devicePixelRatio; + const height = normalizeSize(this.$refs.img.offsetHeight, step) * this.devicePixelRatio; + + url.searchParams.set( + 'twic', + ['v1', transform, `${mode}=${width}x${height}`].filter(Boolean).join('/'), + ); + + url.search = decodeURIComponent(url.search); + + return url.toString(); + } +} diff --git a/packages/v4/migration/Figure/index.ts b/packages/v4/migration/Figure/index.ts new file mode 100644 index 00000000..45614f6d --- /dev/null +++ b/packages/v4/migration/Figure/index.ts @@ -0,0 +1,8 @@ +export { AbstractFigure, type AbstractFigureProps } from './AbstractFigure.js'; +export { + AbstractFigureDynamic, + type AbstractFigureDynamicProps, +} from './AbstractFigureDynamic.js'; +export { Figure, type FigureProps } from './Figure.js'; +export { FigureShopify, type FigureShopifyProps } from './FigureShopify.js'; +export { FigureTwicpics, type FigureTwicpicsProps } from './FigureTwicpics.js'; diff --git a/packages/v4/migration/Figure/utils.ts b/packages/v4/migration/Figure/utils.ts new file mode 100644 index 00000000..ee280d43 --- /dev/null +++ b/packages/v4/migration/Figure/utils.ts @@ -0,0 +1,4 @@ +/** Normalize a size to the given step. */ +export function normalizeSize(size: number, step: number): number { + return Math.ceil(size / step) * step; +} diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 9160ce65..ea2e49ac 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -10,6 +10,7 @@ export * from './Data/index.js'; export * from './Draggable/index.js'; export * from './Dialog/index.js'; export * from './Fetch/index.js'; +export * from './Figure/index.js'; export * from './Hoverable/index.js'; export * from './InView/index.js'; export * from './LazyInclude/index.js'; From 392fe673e54432c644fdcc03788ea0dea7896544 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 19:12:56 +0200 Subject: [PATCH 10/24] feat(v4): port FigureVideo and FigureVideoTwicpics FigureVideo implements Transitionable directly on Base, same as AbstractFigure and MenuList. Unlike Figure, it has no naturally idempotent load check (load() unconditionally reassigns every source), so v3's onLoad() { $terminate() } becomes a load-bearing hasLoaded flag rather than documentation of an already-idempotent path. FigureVideoTwicpics's onLoad() override was an empty no-op cancelling that termination so it could still reload on resize; with no termination to cancel, there is nothing to override. --- .../migration/FigureVideo/FigureVideo.spec.ts | 106 ++++++++++++ .../v4/migration/FigureVideo/FigureVideo.ts | 155 ++++++++++++++++++ .../FigureVideo/FigureVideoTwicpics.spec.ts | 66 ++++++++ .../FigureVideo/FigureVideoTwicpics.ts | 144 ++++++++++++++++ packages/v4/migration/FigureVideo/index.ts | 2 + packages/v4/migration/index.ts | 1 + 6 files changed, 474 insertions(+) create mode 100644 packages/v4/migration/FigureVideo/FigureVideo.spec.ts create mode 100644 packages/v4/migration/FigureVideo/FigureVideo.ts create mode 100644 packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts create mode 100644 packages/v4/migration/FigureVideo/FigureVideoTwicpics.ts create mode 100644 packages/v4/migration/FigureVideo/index.ts diff --git a/packages/v4/migration/FigureVideo/FigureVideo.spec.ts b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts new file mode 100644 index 00000000..47eeb9c5 --- /dev/null +++ b/packages/v4/migration/FigureVideo/FigureVideo.spec.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerComponents } from '../../src/index.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { FigureVideo } from './FigureVideo.js'; + +registerComponents(FigureVideo); + +afterEach(resetDom); + +const OFFSCREEN = 'position:absolute;top:300vh;left:0;width:50px;height:50px'; +const ONSCREEN = 'position:absolute;top:0;left:0;width:50px;height:50px'; + +// A real 1x1 PNG, so `loadImage()` succeeds without network access. +const PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +async function observed(): Promise { + for (let i = 0; i < 6; i += 1) { + await settle(); + } +} + +function render(style: string, attributes = 'data-option-lazy="true"'): { el: HTMLElement; video: HTMLVideoElement } { + const root = document.createElement('div'); + root.innerHTML = ` +
+ +
`; + document.body.append(root); + const el = root.firstElementChild as HTMLElement; + return { el, video: el.querySelector('[data-ref="video"]') as HTMLVideoElement }; +} + +/** + * `loadSources()` waits on the real `loadeddata` event, which a data-URI + * `` may never fire in a headless browser. Dispatching it directly + * is the same technique used elsewhere in this migration to drive a + * component's logic without depending on real media decoding. + */ +function fireLoadedData(video: HTMLVideoElement): void { + video.dispatchEvent(new Event('loadeddata')); +} + +describe('FigureVideo', () => { + it('loads the poster and sources once scrolled into view, emitting load', async () => { + const { el, video } = render(OFFSCREEN); + const events: unknown[] = []; + el.addEventListener('load', () => events.push(1)); + + await observed(); + expect(events).toEqual([]); + expect(video.querySelector('source')?.src).toBe(''); + + el.setAttribute('style', ONSCREEN); + await settle(); + fireLoadedData(video); + await observed(); + + expect(events).toEqual([1]); + expect(video.querySelector('source')?.src).toBe(PIXEL); + expect(video.poster).toBe(PIXEL); + }); + + it('does not load when the `lazy` option is not set', async () => { + const { el, video } = render(ONSCREEN, ''); + const events: unknown[] = []; + el.addEventListener('load', () => events.push(1)); + + await observed(); + fireLoadedData(video); + await observed(); + + expect(events).toEqual([]); + }); + + it('does not reload once already loaded', async () => { + const { el, video } = render(ONSCREEN); + await settle(); + fireLoadedData(video); + await observed(); + + const instance = getInstance(el, 'FigureVideo'); + const spy = vi.spyOn(instance, 'load'); + + // A later mount cycle on the same instance — the in-view strategy can + // trigger one — must not repeat the load. + await instance.mounted(); + + expect(spy).not.toHaveBeenCalled(); + expect(instance.hasLoaded).toBe(true); + }); + + it('warns and does not throw when the video ref is missing', async () => { + const root = document.createElement('div'); + root.innerHTML = `
`; + document.body.append(root); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(observed()).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/v4/migration/FigureVideo/FigureVideo.ts b/packages/v4/migration/FigureVideo/FigureVideo.ts new file mode 100644 index 00000000..364efb81 --- /dev/null +++ b/packages/v4/migration/FigureVideo/FigureVideo.ts @@ -0,0 +1,155 @@ +import { Base, type BaseConfig, type BaseProps } from '../../src/index.js'; +import { loadImage } from '../../src/utils/load.js'; +import { + enterTransition, + leaveTransition, + TRANSITION_OPTIONS, + type TransitionOptions, +} from '../../src/utils/transition.js'; +import type { Transitionable } from '../Transition/index.js'; + +/** Gap: core ships no `$warn`. */ +function warn(...args: unknown[]): void { + console.warn('[FigureVideo]', ...args); +} + +export type FigureVideoProps = BaseProps & { + $refs: { video: HTMLVideoElement }; + $options: TransitionOptions & { lazy: boolean }; + $emits: { + 'transition-enter': void; + 'transition-enter-start': void; + 'transition-enter-end': void; + 'transition-leave': void; + 'transition-leave-start': void; + 'transition-leave-end': void; + load: void; + }; +}; + +/** + * Lazy-loaded video counterpart to `Figure`. Implementing `Transitionable` + * directly (the same reason `AbstractFigure` does, rather than extending + * the ported `Transition`) and mounting through the `in-view` strategy, it + * defers loading of the `video` ref's `data-poster` and `data-src` sources + * until the element enters the viewport when `lazy` is set, runs the enter + * transition, and emits `load`. + * + * @link https://ui.studiometa.dev/reference/items/FigureVideo/ + */ +export class FigureVideo + extends Base + implements Transitionable +{ + static config: BaseConfig = { + name: 'FigureVideo', + refs: ['video'], + mountStrategy: 'in-view', + options: { + ...TRANSITION_OPTIONS, + lazy: Boolean, + }, + }; + + state: 'entering' | 'leaving' | null = null; + + /** + * Whether the sources have already been loaded, so a later mount (the + * `in-view` strategy can trigger one) does not repeat it. v3 called + * `$terminate()` from `onLoad()` for this; v4 has no termination (the + * `Figure` and `LazyInclude` ports hit the same gap), and unlike `Figure` + * this component has no naturally idempotent check to fall back on — + * `load()` always reassigns every source — so the flag is load-bearing + * here, not just documentation. + */ + hasLoaded = false; + + get target(): HTMLVideoElement { + return this.$refs.video; + } + + get sources(): HTMLSourceElement[] { + return [...this.$refs.video.querySelectorAll('source')]; + } + + async enter(): Promise { + this.state = 'entering'; + this.$emit('transition-enter'); + this.$emit('transition-enter-start'); + await enterTransition(this.target, this.$options); + this.$emit('transition-enter-end'); + } + + async leave(): Promise { + this.state = 'leaving'; + this.$emit('transition-leave'); + this.$emit('transition-leave-start'); + await leaveTransition(this.target, this.$options); + this.$emit('transition-leave-end'); + } + + toggle(): Promise { + return this.state === 'entering' ? this.leave() : this.enter(); + } + + /** Load the poster onto the video element. */ + async loadPoster(): Promise { + const { video } = this.$refs; + + if (!video.dataset.poster) { + return; + } + + try { + await loadImage(video.dataset.poster); + video.poster = video.dataset.poster; + } catch { + warn(`Failed to load poster "${video.dataset.poster}".`); + } + } + + /** Load every ``'s `data-src` and wait for the video to have data. */ + loadSources(): Promise { + const { video } = this.$refs; + + for (const source of this.sources) { + if (source.dataset.src) { + source.src = source.dataset.src; + } + } + + return new Promise((resolve) => { + video.addEventListener( + 'loadeddata', + () => { + resolve(); + }, + { once: true }, + ); + video.load(); + }); + } + + load(): Promise<[void, void]> { + return Promise.all([this.loadPoster(), this.loadSources()]); + } + + /** Load on mount, once per element while lazy is set. */ + async mounted(): Promise { + const { video } = this.$refs; + + if (!video || !(video instanceof HTMLVideoElement)) { + warn('The `video` ref is missing or not a `