Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7243571
feat(v4): port Sentinel onto withInView
titouanmathis Aug 19, 2026
42f6db1
feat(v4): port Sticky onto withScroll/withResize and a Sentinel child
titouanmathis Aug 19, 2026
555e8d9
feat(v4): port Hoverable onto withPointer/withRaf
titouanmathis Aug 19, 2026
4bbcdd7
feat(v4): port AnchorScrollTo as ScrollTo
titouanmathis Aug 19, 2026
1dcabb5
feat(v4): port the AnchorNav family onto in-view mount and ScrollTo
titouanmathis Aug 19, 2026
79e6d6b
feat(v4): port the Menu family onto withKey, $closest and $watchChildren
titouanmathis Aug 19, 2026
20e29c4
feat(v4): port Timer and TimerProgress onto withRaf
titouanmathis Aug 19, 2026
e4c5b3a
feat(v4): port Toast and Toaster onto Timer and viewTransition
titouanmathis Aug 19, 2026
aca3381
feat(v4): port the Figure family onto in-view mount and Transitionable
titouanmathis Aug 19, 2026
392fe67
feat(v4): port FigureVideo and FigureVideoTwicpics
titouanmathis Aug 19, 2026
ba74bdb
feat(v4): fill the FetchShopifyPartial gap noted in the Fetch report
titouanmathis Aug 19, 2026
d52a9b1
docs(v4): record the 2026-08-19 batch and gap 45 in the migration report
titouanmathis Aug 19, 2026
99bbac0
fix(v4): apply the boolean-negation fix to specs committed before it
titouanmathis Aug 20, 2026
2116e02
feat(v4): add a withTransition mixin and make both transitions generic
titouanmathis Aug 20, 2026
f500649
refactor(v4): move the four transition consumers onto withTransition
titouanmathis Aug 20, 2026
aa47526
fix(v4): satisfy the project linter and formatter across the ported f…
titouanmathis Aug 20, 2026
e13bd44
docs(v4): close gap 45, and record the third instance of gap 43
titouanmathis Aug 20, 2026
22fd1d1
refactor(v4): let withTransition declare its own options, and correct…
titouanmathis Aug 20, 2026
bea4464
feat(v4): restore withTransition's target surface, and fix a spec fla…
titouanmathis Aug 20, 2026
40270d4
feat(v4): open the diagnostic channel to consumers, and close gap 10'…
titouanmathis Aug 24, 2026
d364868
fix(v4): three correctness issues the PR review found
titouanmathis Aug 24, 2026
6d291e7
docs(v4): record the review findings as gap 47
titouanmathis Aug 24, 2026
6273853
test(v4): update the packed-package export count to 86
titouanmathis Aug 24, 2026
816983b
fix(v4): merge and read headers across every HeadersInit form
titouanmathis Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ describe('no-deprecated-properties', () => {
}`,
options: [{ version: 'v3' }],
},
// v4 has $warn and $error again: they report on the cancelable
// diagnostic channel rather than writing to the console.
{
code: `class Slider extends Base {
static config = { name: 'Slider' };
mounted() {
this.$warn('slider.off', 'Off.');
this.$error('slider.failed', 'Failed.', new Error('why'));
}
}`,
options: v4,
},
// v4 keeps $services — only its two switches are gone.
{
code: `class Slider extends Base {
Expand Down Expand Up @@ -79,14 +91,6 @@ describe('no-deprecated-properties', () => {
options: v4,
errors: [{ messageId: 'removed' }],
},
{
code: `class Slider extends Base {
static config = { name: 'Slider' };
mounted() { this.$warn('a'); }
}`,
options: v4,
errors: [{ messageId: 'removed' }],
},
{
code: `class Slider extends Base {
static config = { name: 'Slider' };
Expand Down
5 changes: 4 additions & 1 deletion packages/eslint-plugin/src/rules/no-deprecated-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const V4_REMOVED = new Map([
['$root', '$closest()'],
['$children', '$watchChildren()'],
['$update', 'nothing — $refs and $options read the DOM on every access'],
['$warn', 'console.warn()'],
// `$warn` is not listed: v4 has one again, and it is a different thing from
// v3's logger — it reports on the cancelable diagnostic channel, filling in
// the component name and element, rather than writing to the console. See
// `$error` for the counterpart that carries a cause.
['$log', 'console.log()'],
['$terminate', '$destroy()'],
]);
Expand Down
23 changes: 19 additions & 4 deletions packages/v4/migration/Action/Action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,16 +461,31 @@ describe('Action — the component', () => {
expect(el.id).toBe('foo');
});

it('warns instead of throwing when the effect fails', async () => {
it('reports on the diagnostic channel instead of throwing when the effect fails', async () => {
const root = await render(`
<div id="action" data-component="Action" data-on:click="() => consol.log()"></div>
`);
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const details: Array<Record<string, unknown>> = [];
// Canceling suppresses the default sink, so the failure does not reach
// `reportError()` and fail the run.
const listener = (event: Event) => {
details.push((event as CustomEvent<Record<string, unknown>>).detail);
event.preventDefault();
};
document.addEventListener('js-toolkit:diagnostic', listener);

click(root.querySelector('#action') as Element);

expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
expect(details).toHaveLength(1);
expect(details[0]).toMatchObject({
severity: 'error',
code: 'action.effect-failed',
component: 'Action',
});
// The cause survives, which a bare `console.warn` never carried.
expect(details[0].error).toBeInstanceOf(Error);

document.removeEventListener('js-toolkit:diagnostic', listener);
});
});

Expand Down
8 changes: 3 additions & 5 deletions packages/v4/migration/Action/ActionEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ const DEFAULT_DEBOUNCE_DELAY = 100;
/** A resolved target: one entry, keyed by the component's name. */
export type ActionTarget = Record<string, Base>;

function warn(...args: unknown[]): void {
console.warn('[action]', ...args);
}

/** One runtime event binding from an attribute or the option triple. */
export class ActionEvent {
static targetSeparator = ' ';
Expand Down Expand Up @@ -170,7 +166,9 @@ export class ActionEvent {
(value as EffectFunction).apply(action.$el, args);
}
} catch (error) {
warn(error);
// Reported as the `Action` this binding belongs to, which is the
// component a listener would want to filter on.
this.action.$error('action.effect-failed', 'An action effect threw.', error);
}
}
}
Expand Down
100 changes: 100 additions & 0 deletions packages/v4/migration/AnchorNav/AnchorNav.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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<void> {
for (let i = 0; i < 6; i += 1) {
await settle();
}
}

/**
* `AnchorNav` fire-and-forgets the link's transition, and a kept end state
* lands a few frames after that — so the class is polled rather than assumed
* present once the observer has delivered. `MenuList`'s spec has the same
* helper for the same reason, after this shape flaked under full-suite load.
*/
async function waitForClass(el: HTMLElement, className: string, timeout = 1000): Promise<void> {
const deadline = Date.now() + timeout;
while (!el.classList.contains(className)) {
if (Date.now() > deadline) {
throw new Error(`waitForClass: "${className}" never landed on the element`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}

async function render(): Promise<{ root: HTMLElement; target: HTMLElement }> {
const root = document.createElement('div');
root.innerHTML = `
<div data-component="AnchorNav">
<a data-component="AnchorNavLink" href="#one" data-option-enter-to="active" data-option-enter-keep="true"></a>
<div id="one" data-component="AnchorNavTarget" style="${OFFSCREEN}"></div>
</div>`;
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<AnchorNavLink>(
root.querySelector('[data-component="AnchorNavLink"]'),
'AnchorNavLink',
);

target.setAttribute('style', ONSCREEN);
await observed();

expect(link.state).toBe('entering');
await waitForClass(link.$el, 'active');
});

it('leaves the matching link once its target scrolls back out of view', async () => {
const { root, target } = await render();
const link = getInstance<AnchorNavLink>(
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 = `
<div data-component="AnchorNav">
<a data-component="AnchorNavLink" href="#unrelated"></a>
<div id="one" data-component="AnchorNavTarget" style="${OFFSCREEN}"></div>
</div>`;
document.body.append(root);
await settle();
const link = getInstance<AnchorNavLink>(
root.querySelector('[data-component="AnchorNavLink"]'),
'AnchorNavLink',
);
const target = root.querySelector('#one') as HTMLElement;

target.setAttribute('style', ONSCREEN);
await observed();

expect(link.state).toBeNull();
});
});
41 changes: 41 additions & 0 deletions packages/v4/migration/AnchorNav/AnchorNav.ts
Original file line number Diff line number Diff line change
@@ -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<T extends BaseProps = BaseProps> extends Base<AnchorNavProps & T> {
links: ChildrenCollection<AnchorNavLink> = this.$watchChildren<AnchorNavLink>('AnchorNavLink');

targets: ChildrenCollection<AnchorNavTarget> = this.$watchChildren<AnchorNavTarget>(
'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]();
}
}
}
}
84 changes: 84 additions & 0 deletions packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AnchorNavLink> {
const root = document.createElement('div');
root.innerHTML = `<a data-component="AnchorNavLink" href="#section-one" ${OPTIONS_ATTRS}></a>`;
document.body.append(root);
await settle();
return getInstance<AnchorNavLink>(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);
});
});
29 changes: 29 additions & 0 deletions packages/v4/migration/AnchorNav/AnchorNavLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { component, type BaseProps } from '../../src/index.js';
import { ScrollTo } from '../ScrollTo/index.js';
import { withTransition, type TransitionProps } from '../Transition/index.js';

export type AnchorNavLinkProps = BaseProps & TransitionProps;

/**
* A `ScrollTo` link that also enters/leaves a CSS transition on itself,
* driven by `AnchorNav` as its matching `AnchorNavTarget` mounts and
* unmounts.
*
* `withTransition(ScrollTo)` is v3's own declaration restored — it mixed the
* same decorator onto `AnchorScrollTo` — and it is the case that shows why
* the mixin has to exist rather than the utilities being called directly:
* the transition belongs on a class that already extends something else.
*
* @link https://ui.studiometa.dev/reference/items/AnchorNav/
*/
@component({
name: 'AnchorNavLink',
})
export class AnchorNavLink<T extends BaseProps = BaseProps> extends withTransition(ScrollTo)<
AnchorNavLinkProps & T
> {
/** The target section id, read from the link's hash. */
get targetId(): string {
return this.$el.hash.replace(/^#/, '');
}
}
Loading
Loading