diff --git a/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts b/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts index acccfd058..45f2005fa 100644 --- a/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts +++ b/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts @@ -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 { @@ -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' }; diff --git a/packages/eslint-plugin/src/rules/no-deprecated-properties.ts b/packages/eslint-plugin/src/rules/no-deprecated-properties.ts index 8976ba530..4a7893866 100644 --- a/packages/eslint-plugin/src/rules/no-deprecated-properties.ts +++ b/packages/eslint-plugin/src/rules/no-deprecated-properties.ts @@ -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()'], ]); diff --git a/packages/v4/migration/Action/Action.spec.ts b/packages/v4/migration/Action/Action.spec.ts index 48996c4fc..40ff6ef53 100644 --- a/packages/v4/migration/Action/Action.spec.ts +++ b/packages/v4/migration/Action/Action.spec.ts @@ -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(`
`); - const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const details: Array> = []; + // 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>).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); }); }); diff --git a/packages/v4/migration/Action/ActionEvent.ts b/packages/v4/migration/Action/ActionEvent.ts index 1fcfcab39..8b5f09c3f 100644 --- a/packages/v4/migration/Action/ActionEvent.ts +++ b/packages/v4/migration/Action/ActionEvent.ts @@ -14,10 +14,6 @@ const DEFAULT_DEBOUNCE_DELAY = 100; /** A resolved target: one entry, keyed by the component's name. */ export type ActionTarget = Record; -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 = ' '; @@ -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); } } } diff --git a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts new file mode 100644 index 000000000..ed84c8cbb --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts @@ -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 { + 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 { + 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 = ` +
+ +
+
`; + 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'); + 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( + 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 000000000..03c11c274 --- /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 000000000..81d7770d5 --- /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 000000000..1ea979737 --- /dev/null +++ b/packages/v4/migration/AnchorNav/AnchorNavLink.ts @@ -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 extends withTransition(ScrollTo)< + AnchorNavLinkProps & T +> { + /** The target section id, read from the link's hash. */ + get targetId(): string { + return this.$el.hash.replace(/^#/, ''); + } +} diff --git a/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavTarget.spec.ts new file mode 100644 index 000000000..e7dc5a0c6 --- /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 000000000..89fbe8897 --- /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 000000000..490ed0d64 --- /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/Carousel/Indexable.ts b/packages/v4/migration/Carousel/Indexable.ts index 53e9dcda4..02cfe21e5 100644 --- a/packages/v4/migration/Carousel/Indexable.ts +++ b/packages/v4/migration/Carousel/Indexable.ts @@ -2,11 +2,6 @@ import { Base, type BaseConfig, type BaseProps } from '../../src/index.js'; import { clamp, fold, wrap } from '../../src/utils/maths.js'; import { randomInt } from '../../src/utils/random.js'; -/** Gap 10: core ships no `$warn`. */ -function warn(...args: unknown[]): void { - console.warn('[Indexable]', ...args); -} - export const INDEXABLE_BOUNDARIES = Object.freeze({ CLAMP: 'clamp', LOOP: 'loop', @@ -173,13 +168,13 @@ export class Indexable extends Base { it('warns for a binding type that names nothing', async () => { const details: string[] = []; - document.addEventListener(EVENTS.diagnostic, (event) => { + // Removed at the end: this listener cancels the default sink, and leaking + // it silenced every later diagnostic in the file. + const listener = (event: Event) => { const { detail } = event as CustomEvent<{ code: string; message: string }>; details.push(detail.code); event.preventDefault(); - }); + }; + document.addEventListener(EVENTS.diagnostic, listener); const root = await render(`
{ // The typo used to be an attribute that silently did nothing at all. expect(details).toContain('attribute.unknown-qualifier'); expect(at(root, '#d', 'DataBind').hasVirtualBindings).toBe(false); + + document.removeEventListener(EVENTS.diagnostic, listener); }); it('fails quietly when a virtual expression throws', async () => { diff --git a/packages/v4/migration/Data/DataBind.ts b/packages/v4/migration/Data/DataBind.ts index 1ff11d739..7a00b968b 100644 --- a/packages/v4/migration/Data/DataBind.ts +++ b/packages/v4/migration/Data/DataBind.ts @@ -39,10 +39,6 @@ export type DataBindProps = BaseProps & { $options: DataBindOptions; }; -function warn(...args: unknown[]): void { - console.warn('[data]', ...args); -} - /** * The namespace a virtual binding is declared by. Its qualifier head is finite * — the six binding types — while the name a `prop`, `attr`, `class` or `style` @@ -370,7 +366,8 @@ export class DataBind const { target } = this; if (!(target instanceof HTMLTemplateElement)) { - warn( + this.$warn( + 'data-bind.invalid-if-target', 'The data-bind:if binding can only be used on a