diff --git a/.changeset/animated-layout-swap.md b/.changeset/animated-layout-swap.md new file mode 100644 index 0000000000000..4d1d513b9dd1a --- /dev/null +++ b/.changeset/animated-layout-swap.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The animated-layout module gains its internal in-place swap: old content held as one inert, transient copy while new content enters in its place, on the shared motion. Nothing uses it yet and nothing public changes. In right-to-left containers, content after a Collapsible or TreeList size change (with an `animatedLayout` handle) now glides by its inline-start edge instead of jumping. + +@cixzhang diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 5a57a0608e75c..5b83c22e48908 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -111,6 +111,9 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { 'playLayoutChange', 'applyLayoutChangeTransition', 'createAnimatedLayout', + 'holdLayoutSwap', + 'playLayoutSwap', + 'playLayoutFrames', ]) { expect( source, @@ -120,6 +123,16 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { } }); + it("keeps the swap's copy mechanism private to the module", () => { + const users = sourceFiles(coreSrc) + .filter(path => + /from '[./]+(motion\/)?transientCopy'/.test(readFileSync(path, 'utf8')), + ) + .map(path => relative(coreSrc, path)) + .sort(); + expect(users).toEqual(['motion/animatedLayout.ts']); + }); + it('is used only by the hook and the components that adopt it', () => { const users = sourceFiles(coreSrc) .filter(path => path !== join(here, 'animatedLayout.ts')) diff --git a/packages/core/src/motion/animatedLayout.swap.test.ts b/packages/core/src/motion/animatedLayout.swap.test.ts new file mode 100644 index 0000000000000..325d5179bec79 --- /dev/null +++ b/packages/core/src/motion/animatedLayout.swap.test.ts @@ -0,0 +1,827 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatedLayout.swap.test.ts + * @input vitest (jsdom), the animated-layout module, a recording stand-in for + * Element.animate + * @output Checks the in-place swap (`spec:AST-105` FR8): one inert transient + * copy of old content that takes no part in the page, played on FR4's one + * motion with the incoming elements, opacity-only under Reduce Motion with + * a coarse pointer, nothing under an immediate change, and removed when the + * swap ends + * @position Testing; the real geometry is proven by the adopters' + * real-browser frames + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + createAnimatedLayout, + holdLayoutSwap, + playLayoutFrames, + playLayoutSwap, + reportLayoutChange, +} from './animatedLayout'; + +interface Played { + element: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + startTime: number | null; + finish?: () => void; +} + +let played: Played[] = []; +let media: Record = {}; +const originalAnimate = Element.prototype.animate; +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + played = []; + media = {}; + Object.defineProperty(document, 'timeline', { + configurable: true, + value: {currentTime: 500}, + }); + Element.prototype.animate = function ( + this: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: number | KeyframeAnimationOptions, + ) { + const entry: Played = { + element: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + startTime: null, + }; + played.push(entry); + return { + cancel: vi.fn(), + addEventListener(type: string, listener: () => void) { + if (type === 'finish') { + entry.finish = listener; + } + }, + set startTime(value: number) { + entry.startTime = value; + }, + } as unknown as Animation; + }; + window.matchMedia = (query: string) => + ({ + matches: media[query] === true, + media: query, + addEventListener() {}, + removeEventListener() {}, + }) as unknown as MediaQueryList; +}); + +afterEach(() => { + Element.prototype.animate = originalAnimate; + window.matchMedia = originalMatchMedia; + document.body.innerHTML = ''; +}); + +function setup() { + document.body.innerHTML = ` +
+
+
+

Projects

+ + + + +
Rows
+ Axis +
+
+
`; + const area = document.getElementById('area') as HTMLElement; + const handle = createAnimatedLayout(() => area); + const content = document.getElementById('content') as HTMLElement; + const host = document.getElementById('host') as HTMLElement; + return {handle, content, host}; +} + +const travel = (p: number): Keyframe => ({ + opacity: 1 - p, + translate: `${-200 * p}px 0px`, +}); + +describe('the in-place swap (spec:AST-105 FR8)', () => { + it('holds old content as one inert copy that takes no part in the page', () => { + const {handle, content} = setup(); + const swap = holdLayoutSwap(handle, content); + expect(swap).not.toBeNull(); + const copy = swap?.copy as HTMLElement; + expect(copy.getAttribute('aria-hidden')).toBe('true'); + expect(copy.hasAttribute('inert')).toBe(true); + expect(copy.querySelector('[id]')).toBeNull(); + expect(copy.querySelector('[data-testid]')).toBeNull(); + // A copied checked radio joins no group; a copied frame loads nothing. + expect(copy.querySelector('input')?.hasAttribute('name')).toBe(false); + expect(copy.querySelector('iframe')?.hasAttribute('src')).toBe(false); + expect(copy.isConnected).toBe(false); + }); + + it('plays the copy out and the incoming elements in on one motion, then removes the copy', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const title = document.getElementById('title') as HTMLElement; + const end = playLayoutSwap(swap!, { + host, + copy: travel, + incoming: [{element: title, frames: p => ({opacity: p})}], + }); + const copy = swap!.copy; + expect(copy.parentElement).toBe(host); + const out = played.find(entry => entry.element === copy); + expect(out?.keyframes[0]).toMatchObject({opacity: 1, translate: '0px 0px'}); + expect(out?.keyframes.at(-1)).toMatchObject({ + opacity: 0, + translate: '-200px 0px', + }); + const enter = played.find(entry => entry.element === title); + expect(enter?.keyframes[0].opacity).toBe(0); + expect(enter?.keyframes.at(-1)?.opacity).toBe(1); + // One clock: the same duration, started on the same timeline time. + expect(new Set(played.map(entry => entry.options.duration)).size).toBe(1); + expect(new Set(played.map(entry => entry.startTime))).toEqual( + new Set([500]), + ); + played[0].finish?.(); + expect(copy.isConnected).toBe(false); + expect(typeof end).toBe('function'); + }); + + it("fades the copy's text on its own curve when asked", () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - Math.min(1, p / 0.4), + incoming: [], + }); + const text = played.find( + entry => + entry.element !== swap!.copy && + entry.element.textContent?.trim() === 'Projects', + ); + expect(text?.keyframes.at(-1)?.opacity).toBe(0); + }); + + it("paints a copied canvas with its source's pixels", () => { + const drawImage = vi.fn(); + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + () => + ({ + drawImage, + getImageData: () => ({data: new Uint8ClampedArray([0, 0, 0, 255])}), + }) as unknown as CanvasRenderingContext2D, + ); + const {handle, content} = setup(); + holdLayoutSwap(handle, content); + expect(drawImage).toHaveBeenCalledWith( + content.querySelector('canvas'), + 0, + 0, + ); + getContext.mockRestore(); + }); + + it('does not copy content holding a canvas it cannot read back', () => { + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + () => + ({ + drawImage: vi.fn(), + // A WebGL canvas without preserveDrawingBuffer reads back empty. + getImageData: () => ({data: new Uint8ClampedArray(16 * 16 * 4)}), + }) as unknown as CanvasRenderingContext2D, + ); + const {handle, content} = setup(); + expect(holdLayoutSwap(handle, content)).toBeNull(); + getContext.mockRestore(); + }); + + it('keeps a canvas with only a hairline drawn: thin marks read back', () => { + const data = new Uint8ClampedArray(40 * 20 * 4); + data[data.length - 1] = 255; + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + () => + ({ + drawImage: vi.fn(), + getImageData: () => ({data}), + }) as unknown as CanvasRenderingContext2D, + ); + const {handle, content} = setup(); + expect(holdLayoutSwap(handle, content)).not.toBeNull(); + getContext.mockRestore(); + }); + + /** The copy renders at `scale` of its layout size, `extra` px taller. */ + function stubCopyLayout(scale: number, extra = 0) { + const {handle, content, host} = setup(); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return parseFloat(this.style.width) || 0; + }, + ); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return (parseFloat(this.style.height) || 0) + extra; + }, + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === content) { + return DOMRect.fromRect({x: 100, y: 50, width: 400, height: 200}); + } + if (this.hasAttribute('inert')) { + return DOMRect.fromRect({ + x: 10, + y: 10, + width: this.offsetWidth * scale, + height: this.offsetHeight * scale, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + return {handle, content, host}; + } + + it('draws the copy at the old size under a scaled host', () => { + const {handle, content, host} = stubCopyLayout(0.5); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.width).toBe('800px'); + expect(swap!.copy.style.height).toBe('400px'); + expect(swap!.copy.style.left).toBe('180px'); + expect(swap!.copy.style.top).toBe('80px'); + vi.restoreAllMocks(); + }); + + it("does not take a size the copy's own CSS imposes for a scale", () => { + // A `min-height: 100%` makes the copy 143 px taller than asked. + const {handle, content, host} = stubCopyLayout(1, 143); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.top).toBe('40px'); + expect(swap!.copy.style.width).toBe('400px'); + vi.restoreAllMocks(); + }); + + it('labels no live control: a copied label drops its for', () => { + const {handle, content} = setup(); + const swap = holdLayoutSwap(handle, content); + const label = Array.from(swap!.copy.querySelectorAll('label')).find( + element => element.textContent === 'Pro plan', + ); + expect(label).toBeDefined(); + expect(label?.hasAttribute('for')).toBe(false); + }); + + it('keeps a scrolled descendant scrolled as it was once laid out', () => { + const {handle, content, host} = setup(); + const scroller = document.getElementById('scroller') as HTMLElement; + Object.defineProperty(scroller, 'scrollTop', {value: 25}); + const scrollTo = vi.fn(); + const original = Element.prototype.scrollTo; + Element.prototype.scrollTo = scrollTo; + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + const copied = scrollTo.mock.contexts.find( + element => (element as Element).textContent === 'Rows', + ); + expect(copied).toBeDefined(); + // At once, whatever the copy's own scroll-behavior (a smooth strip). + expect(scrollTo).toHaveBeenCalledWith({ + top: 25, + left: 0, + behavior: 'instant', + }); + Element.prototype.scrollTo = original; + }); + + it('fades a copied SVG label through its text element, not a span', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - p, + incoming: [], + }); + expect(swap!.copy.querySelector('svg span')).toBeNull(); + const axis = played.find( + entry => + entry.element.namespaceURI === 'http://www.w3.org/2000/svg' && + entry.element.textContent === 'Axis', + ); + expect(axis?.element.tagName.toLowerCase()).toBe('text'); + }); + + it('lays the copy out on its old rect whatever its box sizing', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.boxSizing).toBe('border-box'); + }); + + it('copies only the visible part of a scroll container, scrolled as it was', () => { + const {handle, content} = setup(); + Object.defineProperty(content, 'scrollTop', {value: 40}); + Object.defineProperty(content, 'scrollLeft', {value: 30}); + Object.defineProperty(content, 'clientWidth', {value: 600}); + Object.defineProperty(content, 'clientHeight', {value: 300}); + const swap = holdLayoutSwap(handle, content, {isVisiblePart: true}); + expect(swap?.rect.width).toBe(600); + expect(swap?.rect.height).toBe(300); + const inner = swap?.copy.firstElementChild as HTMLElement; + expect(inner.style.top).toBe('-40px'); + expect(inner.style.left).toBe('-30px'); + // The holder is the part inside the border: the copy carries none. + expect(inner.style.borderWidth).toBe('0px'); + expect(inner.style.width).toBe('600px'); + expect(inner.hasAttribute('role')).toBe(false); + }); + + it('only cross-fades under Reduce Motion with a coarse pointer (design:reduced-motion DR2)', () => { + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const title = document.getElementById('title') as HTMLElement; + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: () => 0, + incoming: [{element: title, frames: p => ({opacity: p, scale: '2'})}], + }); + expect(played.length).toBe(2); + for (const entry of played) { + for (const frame of entry.keyframes) { + expect(Object.keys(frame)).toEqual(['opacity']); + } + } + }); + + it('holds nothing under Reduce Motion with a fine pointer, without a handle, or outside the area', () => { + const {handle, content} = setup(); + expect(holdLayoutSwap(null, content)).toBeNull(); + const outside = document.createElement('div'); + document.body.append(outside); + expect(holdLayoutSwap(handle, outside)).toBeNull(); + media['(prefers-reduced-motion: reduce)'] = true; + expect(holdLayoutSwap(handle, content)).toBeNull(); + expect(played).toEqual([]); + }); +}); + +describe('a swap that rides a glide (spec:AST-105 FR8 with FR3)', () => { + function rideSetup() { + document.body.innerHTML = ` +
+
+

Projects

+
`; + const area = document.getElementById('area') as HTMLElement; + const rail = document.getElementById('rail') as HTMLElement; + const column = document.getElementById('column') as HTMLElement; + const content = document.getElementById('content') as HTMLElement; + let phase: 'before' | 'after' = 'before'; + const railWidth = () => (phase === 'before' ? 260 : 48); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area) { + return DOMRect.fromRect({x: 0, y: 0, width: 1000, height: 600}); + } + if (this === rail) { + return DOMRect.fromRect({ + x: 0, + y: 0, + width: railWidth(), + height: 600, + }); + } + if (this === column || this === content) { + return DOMRect.fromRect({ + x: railWidth(), + y: 0, + width: 1000 - railWidth(), + height: 600, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + return { + handle: createAnimatedLayout(() => area), + area, + rail, + column, + content, + after: () => (phase = 'after'), + }; + } + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('moves the copy with the glide of the element it rides, frame by frame', async () => { + const {handle, area, rail, column, content, after} = rideSetup(); + const swap = holdLayoutSwap(handle, content); + const change = reportLayoutChange(handle, {current: rail}); + change.measureBefore(); + after(); + change.animate(); + playLayoutSwap(swap!, { + host: area, + copy: p => ({opacity: 1 - p}), + // The travel stands only for a missing glide: the copy rides the + // glide from where it was laid, whatever the travel says. + rideWith: {element: column, travel: {x: 150, y: 0}}, + incoming: [], + }); + await Promise.resolve(); + await Promise.resolve(); + const glide = played.find(entry => entry.element === column); + const ride = played.find(entry => entry.element === swap!.copy); + expect(glide?.keyframes[0].translate).toBe('212px 0px'); + expect(ride?.keyframes[0]).toMatchObject({ + opacity: 1, + translate: '0px 0px', + }); + expect(ride?.keyframes.at(-1)).toMatchObject({ + opacity: 0, + translate: '-212px 0px', + }); + // The same frames: at every sample the copy sits where the column does, + // less the travel. + expect(ride?.keyframes.length).toBe(glide?.keyframes.length); + }); + + it('tells every part how far the ridden element has moved, on its glide', async () => { + const {handle, area, rail, column, content, after} = rideSetup(); + const swap = holdLayoutSwap(handle, content); + const change = reportLayoutChange(handle, {current: rail}); + change.measureBefore(); + after(); + change.animate(); + const title = document.createElement('h3'); + area.appendChild(title); + playLayoutSwap(swap!, { + host: area, + // A copy that stays put and follows the ride by its clip instead. + copy: (_p, ride) => ({clipPath: `inset(0px ${-ride.x}px 0px 0px)`}), + rideWith: {element: column, travel: {x: 212, y: 0}, isCopyMoved: false}, + incoming: [ + { + element: title, + frames: (_p, ride) => ({translate: `${ride.x}px 0px`}), + }, + ], + }); + await Promise.resolve(); + await Promise.resolve(); + const glide = played.find(entry => entry.element === column); + const copy = played.find(entry => entry.element === swap!.copy); + const incoming = played.find(entry => entry.element === title); + expect(copy?.keyframes[0].translate).toBeUndefined(); + expect(copy?.keyframes[0].clipPath).toBe('inset(0px 0px 0px 0px)'); + expect(copy?.keyframes.at(-1)?.clipPath).toBe('inset(0px 212px 0px 0px)'); + expect(incoming?.keyframes.at(-1)?.translate).toBe('-212px 0px'); + expect(incoming?.keyframes.length).toBe(glide?.keyframes.length); + }); + + it('takes its new place at once when the element it rides does not glide', async () => { + const {handle, area, column, content, after} = rideSetup(); + const swap = holdLayoutSwap(handle, content); + after(); + playLayoutSwap(swap!, { + host: area, + copy: p => ({opacity: 1 - p}), + rideWith: {element: column, travel: {x: 212, y: 0}}, + incoming: [], + }); + await Promise.resolve(); + const ride = played.find(entry => entry.element === swap!.copy); + expect(ride?.keyframes[0].translate).toBe('-212px 0px'); + expect(ride?.keyframes.at(-1)?.translate).toBe('-212px 0px'); + }); +}); + +describe('the glide in a right-to-left document', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("moves a follower by its container's inline-start edge, even with its own direction", async () => { + document.body.innerHTML = ` +
+
+
+
`; + const area = document.getElementById('area') as HTMLElement; + const rail = document.getElementById('rail') as HTMLElement; + const column = document.getElementById('column') as HTMLElement; + let railWidth = 260; + // The rail sits on the right; the column keeps its left edge at 0. + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === area) { + return DOMRect.fromRect({x: 0, y: 0, width: 1000, height: 600}); + } + if (this === rail) { + return DOMRect.fromRect({ + x: 1000 - railWidth, + y: 0, + width: railWidth, + height: 600, + }); + } + if (this === column) { + return DOMRect.fromRect({ + x: 0, + y: 0, + width: 1000 - railWidth, + height: 600, + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const handle = createAnimatedLayout(() => area); + const change = reportLayoutChange(handle, {current: rail}); + change.measureBefore(); + railWidth = 48; + change.animate(); + await Promise.resolve(); + const glide = played.find(entry => entry.element === column); + // Its right edge moved from 740 to 952: it starts 212 px back. + expect(glide?.keyframes[0].translate).toBe('-212px 0px'); + expect(glide?.keyframes.at(-1)?.translate).toBe('0px 0px'); + }); +}); + +describe('more swap layout cases', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('lays a fractional-size copy on its old place: the layout size rounds', () => { + const {handle, content, host} = setup(); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return Math.round(parseFloat(this.style.width) || 0); + }, + ); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return Math.round(parseFloat(this.style.height) || 0); + }, + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this === content) { + return DOMRect.fromRect({x: 0, y: 687, width: 420, height: 20.4}); + } + if (this.hasAttribute('inert')) { + // Unscaled: drawn at the requested size. + return DOMRect.fromRect({ + x: 0, + y: 0, + width: parseFloat(this.style.width), + height: parseFloat(this.style.height), + }); + } + return DOMRect.fromRect({x: 0, y: 0, width: 0, height: 0}); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host, copy: travel, incoming: []}); + expect(swap!.copy.style.top).toBe('687px'); + expect(swap!.copy.style.height).toBe('20.4px'); + }); + + it('plays frames on the same motion with no copy, opacity-only under DR2', () => { + const {handle} = setup(); + const title = document.getElementById('title') as HTMLElement; + playLayoutFrames(handle, [ + { + element: title, + frames: p => ({boxShadow: `${100 * (1 - p)}px 0px 0px 0px red`}), + }, + ]); + const spring = played.find(entry => entry.element === title); + expect(spring?.keyframes[0].boxShadow).toBe('100px 0px 0px 0px red'); + expect(spring?.keyframes.at(-1)?.boxShadow).toBe('0px 0px 0px 0px red'); + expect(spring?.startTime).toBe(500); + // Released when the motion ends: the element shows its own styles again. + const cancel = vi.fn(); + played = []; + const original = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + ...args: Parameters + ) { + const animation = original.apply(this, args); + (animation as unknown as {cancel: () => void}).cancel = cancel; + return animation; + }; + playLayoutFrames(handle, [{element: title, frames: () => ({opacity: 1})}]); + played[0].finish?.(); + expect(cancel).toHaveBeenCalled(); + Element.prototype.animate = original; + played = []; + played = []; + media['(prefers-reduced-motion: reduce)'] = true; + media['(pointer: coarse)'] = true; + playLayoutFrames(handle, [{element: title, frames: () => ({scale: '2'})}]); + expect(played[0].keyframes).toEqual([{opacity: 0}, {opacity: 1}]); + played = []; + media['(pointer: coarse)'] = false; + playLayoutFrames(handle, [{element: title, frames: () => ({scale: '2'})}]); + expect(played).toEqual([]); + }); +}); + +describe('a copy laid outside its ancestors', () => { + it('keeps what it inherited from them where the host passes something else', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + // The element inherits 13 px type and a custom property from an ancestor + // between it and the host; the host passes 16 px and the same color. + const computed: Record> = { + content: {'font-size': '13px', color: 'rgb(0, 0, 0)', '--row-gap': '6px'}, + area: {'font-size': '16px', color: 'rgb(0, 0, 0)'}, + }; + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const style = real(element); + const values = computed[element.id]; + if (values == null) { + return style; + } + const custom = Object.keys(values).filter(name => + name.startsWith('--'), + ); + // Only what the copy reads: values by name, and the custom ones. + return Object.assign(Object.create(style) as CSSStyleDeclaration, { + getPropertyValue: (name: string) => values[name] ?? '', + ...Object.fromEntries(custom.map((name, index) => [index, name])), + length: custom.length, + }); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(swap!.copy.style.fontSize).toBe('13px'); + expect(swap!.copy.style.getPropertyValue('--row-gap')).toBe('6px'); + // What the host already passes is not written again. + expect(swap!.copy.style.color).toBe(''); + }); + + it('carries a unitless line-height as its factor, not as pixels', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + // The computed style says 18.85px; the typed style map says 1.45. + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const style = real(element); + const values: Record = + element === content + ? {'line-height': '18.85px'} + : element === area + ? {'line-height': 'normal'} + : {}; + return Object.assign(Object.create(style) as CSSStyleDeclaration, { + getPropertyValue: (name: string) => values[name] ?? '', + length: 0, + }); + }, + ); + (content as unknown as {computedStyleMap: () => unknown}).computedStyleMap = + () => ({ + get: (name: string) => + name === 'line-height' ? {unit: 'number', value: 1.45} : undefined, + }); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(swap!.copy.style.lineHeight).toBe('1.45'); + }); + + it('carries a line-height factor without a typed style map, also where the host passes the same number', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + const computed = new Map>([ + // 13 px type at 1.5 inside a 16 px area at 1.25: the factor differs. + [content, {'font-size': '13px', 'line-height': '19.5px'}], + [area, {'font-size': '16px', 'line-height': '20px'}], + ]); + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const values = computed.get(element); + if (values == null) { + return real(element); + } + return Object.assign( + Object.create(real(element)) as CSSStyleDeclaration, + {getPropertyValue: (name: string) => values[name] ?? '', length: 0}, + ); + }, + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + expect(swap!.copy.style.lineHeight).toBe('1.5'); + // The host passes the same factor, perhaps as a length: still written. + computed.set(area, {'font-size': '16px', 'line-height': '24px'}); + const again = holdLayoutSwap(handle, content); + playLayoutSwap(again!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(again!.copy.style.lineHeight).toBe('1.5'); + }); + + it('keeps a length line-height as its pixels when the typed style map reports a length', () => { + const {handle, content} = setup(); + const area = document.getElementById('area') as HTMLElement; + const computed = new Map>([ + // The host passes the same 20px as a factor (1.25 at 16px). + [content, {'font-size': '13px', 'line-height': '20px'}], + [area, {'font-size': '16px', 'line-height': '20px'}], + ]); + const real = window.getComputedStyle; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element): CSSStyleDeclaration => { + const values = computed.get(element); + if (values == null) { + return real(element); + } + return Object.assign( + Object.create(real(element)) as CSSStyleDeclaration, + {getPropertyValue: (name: string) => values[name] ?? '', length: 0}, + ); + }, + ); + (content as unknown as {computedStyleMap: () => unknown}).computedStyleMap = + () => ({ + get: (name: string) => + name === 'line-height' ? {unit: 'px', value: 20} : undefined, + }); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, {host: area, copy: travel, incoming: []}); + vi.restoreAllMocks(); + expect(swap!.copy.style.lineHeight).toBe('20px'); + }); +}); + +describe('ending a swap', () => { + it('leaves the text of a style sheet or a textarea in place when its text fades', () => { + const {handle, content, host} = setup(); + content.insertAdjacentHTML( + 'beforeend', + '', + ); + const swap = holdLayoutSwap(handle, content); + playLayoutSwap(swap!, { + host, + copy: travel, + copyText: p => 1 - p, + incoming: [], + }); + expect(swap!.copy.querySelector('style')?.children).toHaveLength(0); + expect(swap!.copy.querySelector('style')?.textContent).toContain( + 'color: red', + ); + expect(swap!.copy.querySelector('textarea')?.children).toHaveLength(0); + expect(swap!.copy.querySelector('textarea')?.value).toBe('Draft'); + }); + + it('calls onEnd once when the swap ends', () => { + const {handle, content, host} = setup(); + const swap = holdLayoutSwap(handle, content); + const onEnd = vi.fn(); + const end = playLayoutSwap(swap!, { + host, + copy: travel, + incoming: [], + onEnd, + }); + played[0].finish?.(); + end(); + expect(onEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index eb352ea370ec9..7290b067609e5 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -7,7 +7,9 @@ * cross-fade clock (uikit/timing.ts) * @output Exports `createAnimatedLayout` and the opaque `AnimatedLayout` * handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` / - * `releaseLayoutExit` and `applyLayoutChangeTransition`: the one module + * `releaseLayoutExit`, the in-place swap (`holdLayoutSwap` / + * `playLayoutSwap`), `playLayoutFrames` and `applyLayoutChangeTransition`: + * the one module * that produces the * size-change glide `spec:AST-105` defines * (`architecture:react-component-runtime` INV12) @@ -45,6 +47,16 @@ * the same frame (`playLayoutChange`), or its exit: kept where it is while * it gives up its space and conceals (`holdLayoutExit`). A disclosure's * indicator turns on the same motion (`applyLayoutChangeTransition`). + * - A swap holds old content as one transient copy, inert and hidden from + * assistive technology, while new content enters in its place, on the same + * motion, and removes it when the swap ends (`holdLayoutSwap`, + * `playLayoutSwap`; `spec:AST-105` FR8). The caller shapes the frames over + * the swap's progress and, riding a gliding element, how far it has moved, + * so every part keeps to that glide; under Reduce Motion with a coarse + * pointer only opacity changes. + * - Frames on elements the page renders can play on the same motion with no + * copy (`playLayoutFrames`), such as a surface reaching over a gap a glide + * opens, and are released when the motion ends. * - Every pointer (`spec:AST-105` FR5). Under Reduce Motion nothing travels * (`design:reduced-motion` DR1–DR3): a cross-fade under a coarse primary * pointer, an immediate change otherwise. @@ -53,6 +65,14 @@ */ import {motionRole} from './roles'; +import { + inertCopy, + isCopyable, + paintedBackground, + placeOver, + visiblePartCopy, + wrapText, +} from './transientCopy'; import {isSpring} from './roleValue'; import {solveSpring, toSpring, type SpringDescription} from './spring'; import {registerSpringsTable, SETTLE_SPRING} from './uikit/springs'; @@ -772,7 +792,16 @@ function flush(batch: Batch): void { Math.abs(raw + shift) < Math.abs(raw) ? raw + shift : raw; for (const entry of entries) { entry.after = entry.element.getBoundingClientRect(); - const rawX = entry.before.left - entry.after.left; + // The move is the inline-start edge's, by the container's direction (it + // decides where the element sits): in a right-to-left container an + // element that narrows keeps its left edge and moves its right one. + const container = entry.element.parentElement ?? entry.element; + const isRtl = + container.ownerDocument.defaultView?.getComputedStyle(container) + .direction === 'rtl'; + const rawX = isRtl + ? entry.before.right - entry.after.right + : entry.before.left - entry.after.left; const rawY = entry.before.top - entry.after.top; // The siblings before the change stay with the ancestors around them. const isAncestor = @@ -1280,6 +1309,271 @@ export function releaseLayoutExit(element: HTMLElement): void { } } +// --------------------------------------------------------------------------- +// The in-place swap +// --------------------------------------------------------------------------- + +/** + * Old content held as one transient copy while new content enters in its + * place (`spec:AST-105` FR8): inert, hidden from assistive technology, and + * removed when the swap ends (`architecture:component-style-authoring` INV7). + */ +export interface LayoutSwap { + readonly area: Element; + readonly copy: HTMLElement; + /** Where the old content showed, in viewport coordinates. */ + readonly rect: DOMRect; +} + +/** How far a ridden element has moved since the swap began. */ +export interface LayoutRide { + readonly x: number; + readonly y: number; +} + +/** + * How a swap plays, as frames over its progress (0 → 1) and, with + * `rideWith`, the ridden element's travel so far. + */ +export interface LayoutSwapMotion { + /** The box the copy is laid out in, over the old content's place. */ + readonly host: Element; + /** The copy's frames while it travels. */ + readonly copy: (p: number, ride: LayoutRide) => Keyframe; + /** Its text's opacity, on its own curve, when that differs from the copy's. */ + readonly copyText?: (p: number) => number; + /** The copy paints above its neighbours, in the background it shows. */ + readonly isOpaque?: boolean; + /** + * An element the glide moves, such as the column the old content sat in. + * Every part plays over its glide, frame by frame, and is told how far it + * has moved; the copy rides it unless `isCopyMoved` is false. Without a + * glide (it lands silently) the ride is the whole `travel` at once (its + * old place less its new). + */ + readonly rideWith?: { + readonly element: Element; + readonly travel: LayoutRide; + readonly isCopyMoved?: boolean; + }; + /** Called once when the swap ends, however it ends. */ + readonly onEnd?: () => void; + /** Elements the page already renders, entering with the swap. */ + readonly incoming: ReadonlyArray<{ + readonly element: Element; + readonly frames: (p: number, ride: LayoutRide) => Keyframe; + }>; +} + +const NO_RIDE: LayoutRide = {x: 0, y: 0}; + +/** + * Before a change commits: hold `element`'s old content as one transient copy + * (`spec:AST-105` FR8). With `isVisiblePart`, the copy is the element's + * visible part, its content scrolled as it was (a scroll container). Returns + * null without a handle, outside its area, under an immediate change, or for + * content too large to copy. + */ +export function holdLayoutSwap( + handle: AnimatedLayout | null | undefined, + element: HTMLElement, + {isVisiblePart = false}: {isVisiblePart?: boolean} = {}, +): LayoutSwap | null { + if (handle == null || typeof element.animate !== 'function') { + return null; + } + const area = areaOf(handle, element); + if ( + area == null || + layoutMotion(area).kind === 'instant' || + !isCopyable(element) + ) { + return null; + } + const box = element.getBoundingClientRect(); + if (!isVisiblePart) { + const copy = inertCopy(element); + return copy == null ? null : {area, copy, rect: box}; + } + const part = visiblePartCopy(element); + return part == null ? null : {area, ...part}; +} + +/** + * After the change commits, before paint: the copy leaves while the incoming + * elements enter, on FR4's one motion, every part started on the same + * timeline time. Under Reduce Motion with a coarse pointer, only opacity + * changes (`design:reduced-motion` DR2). Returns what ends the swap at once; + * it also ends when its motion does, and the copy is removed. + */ +export function playLayoutSwap( + swap: LayoutSwap, + how: LayoutSwapMotion, +): () => void { + const motion = layoutMotion(swap.area); + if (motion.kind === 'instant') { + return () => {}; + } + const {copy} = swap; + placeOver(copy, how.host, swap.rect); + if (how.isOpaque === true) { + copy.style.zIndex = '1'; + copy.style.backgroundColor = paintedBackground(copy); + } + const animations: Animation[] = []; + let isDone = false; + const end = () => { + if (isDone) { + return; + } + isDone = true; + animations.forEach(animation => animation.cancel()); + copy.remove(); + how.onEnd?.(); + }; + const {copyText, rideWith} = how; + const fadedText = + copyText != null && motion.kind === 'spring' ? wrapText(copy) : []; + // Each part as a function of time: its frame, or the copy's with its ride. + const parts: [Element, (p: number, ride: LayoutRide) => Keyframe][] = []; + if (motion.kind === 'spring') { + const moves = rideWith != null && rideWith.isCopyMoved !== false; + parts.push([ + copy, + (p, ride) => ({ + ...how.copy(p, ride), + ...(moves ? {translate: `${ride.x}px ${ride.y}px`} : null), + }), + ]); + for (const span of fadedText) { + parts.push([span, p => ({opacity: copyText?.(p) ?? 1})]); + } + for (const {element, frames} of how.incoming) { + parts.push([element, frames]); + } + } else { + // Reduce Motion with a coarse pointer: only opacity changes. + parts.push([copy, p => ({opacity: 1 - p})]); + for (const {element} of how.incoming) { + parts.push([element, p => ({opacity: p})]); + } + } + const playAll = ( + durationMs: number, + progressAt: (t: number) => number, + rideAt: (t: number) => LayoutRide, + options: KeyframeAnimationOptions, + sample: (shape: (t: number) => Keyframe) => Keyframe[], + ) => { + for (const [element, shape] of parts) { + const animation = safeAnimate( + element, + sample(t => shape(progressAt(t), rideAt(t))), + {duration: durationMs, fill: 'both', ...options}, + ); + if (animation != null) { + animations.push(animation); + } + } + if (animations.length === 0) { + end(); + } else { + // Every part runs on one clock, so the first to finish ends them all. + animations[0].addEventListener?.('finish', end); + } + }; + if (motion.kind === 'fade') { + playAll( + motion.durationMs, + t => (t <= 0 ? 0 : 1), + () => NO_RIDE, + {easing: motion.easing}, + shape => [shape(0), shape(motion.durationMs)], + ); + return end; + } + const {progress} = motion; + const progressAt = (t: number) => 1 - progress.remaining(t); + if (rideWith == null) { + playAll( + progress.durationMs, + progressAt, + () => NO_RIDE, + {easing: 'linear'}, + shape => sampleOver(progress.durationMs, shape), + ); + return end; + } + // After this commit's glides start (the batch flushes in a microtask queued + // before this one), before the frame paints: every part plays over the + // ridden element's glide, told how far it has moved from where it showed. + queueMicrotask(() => { + if (isDone) { + return; + } + const glide = glides.get(rideWith.element); + const durationMs = glide?.durationMs ?? progress.durationMs; + const rideAt = (t: number): LayoutRide => { + if (glide == null) { + return {x: -rideWith.travel.x, y: -rideWith.travel.y}; + } + const now = glide.offsetAt(t); + const start = glide.offsetAt(0); + return {x: now.x - start.x, y: now.y - start.y}; + }; + playAll(durationMs, progressAt, rideAt, {easing: 'linear'}, shape => + sampleOver(durationMs, shape), + ); + }); + return end; +} + +/** + * After a change commits, before paint: elements the page already renders + * play frames over the change's progress on FR4's one motion, with no copy, + * such as a surface reaching over a gap a glide opens. Under Reduce Motion + * with a coarse pointer, only opacity changes; under an immediate change, + * nothing plays. Returns what ends them at once. + */ +export function playLayoutFrames( + handle: AnimatedLayout | null | undefined, + parts: LayoutSwapMotion['incoming'], +): () => void { + const area = handle?.[AREA]() ?? null; + const motion = area == null ? null : layoutMotion(area); + if (motion == null || motion.kind === 'instant') { + return () => {}; + } + const animations: Animation[] = []; + for (const {element, frames: shape} of parts) { + const animation = + motion.kind === 'spring' + ? safeAnimate( + element, + sampleKeyframes(motion.progress, remaining => + shape(1 - remaining, NO_RIDE), + ), + { + duration: motion.progress.durationMs, + easing: 'linear', + fill: 'both', + }, + ) + : safeAnimate(element, [{opacity: 0}, {opacity: 1}], { + duration: motion.durationMs, + easing: motion.easing, + }); + if (animation != null) { + animations.push(animation); + } + } + // Held through the motion, then released: every part shares one clock, so + // the first to finish ends them all and each element shows its own styles. + const end = () => animations.forEach(animation => animation.cancel()); + animations[0]?.addEventListener?.('finish', end); + return end; +} + /** * A disclosure's indicator (its chevron) turns on the same motion as the * glide: its CSS transition takes the spring as a `linear()` easing, and an diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts new file mode 100644 index 0000000000000..32bb8e65830f8 --- /dev/null +++ b/packages/core/src/motion/transientCopy.ts @@ -0,0 +1,374 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file transientCopy.ts + * @input The DOM + * @output Exports `isCopyable`, `inertCopy`, `visiblePartCopy`, `placeOver`, + * `paintedBackground` and `wrapText`: how Core makes, lays out and fades one + * transient copy of old content + * @position Motion foundation, internal to core; the copy mechanism the + * in-place swap uses (`spec:AST-105` FR8, + * `architecture:component-style-authoring` INV7). Only + * motion/animatedLayout.ts imports it. + * + * A copy takes no part in the page: it is inert and hidden from assistive + * technology, carries no ids or test ids, joins no form or radio group (a + * copied checked radio would uncheck the live one), and its frames and media + * load nothing. Laying it out changes no ancestor's position, and a scaled + * host draws it at the old size. Laid in a host outside the element's own + * ancestors, it keeps what it inherited from them: its inherited properties + * and custom properties that differ from the host's are written on it. + * Limits: a rule keyed on a removed `id`, or matched through an ancestor + * between the host and the element (`.collapsed .label`), does not style the + * copy as it styled the element; and content holding a canvas whose pixels read back + * empty is not copied, so the new content enters without an old copy: a + * canvas that cannot be read back (a WebGL canvas without + * `preserveDrawingBuffer`, a tainted one) or a blank one. + * + * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts + */ + +/** Above this many elements, old content is not copied: the new one enters. */ +const MAX_COPIED_ELEMENTS = 1500; + +/** Whether `element` is small enough to copy. */ +export function isCopyable(element: Element): boolean { + return element.getElementsByTagName('*').length <= MAX_COPIED_ELEMENTS; +} + +/** + * Paints `canvas` with `source`'s pixels at its own size; false when they + * read back empty: unreadable (a WebGL canvas without + * `preserveDrawingBuffer`, a tainted one) or blank. + */ +function paintCanvas( + canvas: HTMLCanvasElement, + source: HTMLCanvasElement, +): boolean { + if (canvas.width === 0 || canvas.height === 0) { + return true; + } + const context = canvas.getContext('2d', {willReadFrequently: true}); + if (context == null) { + // No 2D canvas here (a test environment): nothing to paint or tell. + return true; + } + try { + context.drawImage(source, 0, 0); + const {data} = context.getImageData(0, 0, canvas.width, canvas.height); + for (let index = 3; index < data.length; index += 4) { + if (data[index] !== 0) { + return true; + } + } + return false; + } catch { + return false; + } +} + +/** Inherited properties a copy carries when laid outside its ancestors. */ +const INHERITED_PROPERTIES = [ + 'color', + 'cursor', + 'direction', + 'font-family', + 'font-feature-settings', + 'font-size', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-variation-settings', + 'font-weight', + 'hyphens', + 'letter-spacing', + 'line-height', + 'list-style-type', + 'overflow-wrap', + 'tab-size', + 'text-align', + 'text-indent', + 'text-shadow', + 'text-transform', + 'visibility', + 'white-space', + 'word-break', + 'word-spacing', + 'writing-mode', +]; + +/** Each copy's inherited values, read from its element when it was made. */ +const inheritedValues = new WeakMap>(); + +/** A computed line-height over its font size, or null for `normal`. */ +function lineHeightFactor(style: CSSStyleDeclaration): number | null { + const lineHeight = parseFloat(style.getPropertyValue('line-height')); + const fontSize = parseFloat(style.getPropertyValue('font-size')); + return Number.isFinite(lineHeight) && fontSize > 0 + ? Math.round((lineHeight / fontSize) * 1000) / 1000 + : null; +} + +/** The element's inherited properties and custom properties, as computed. */ +function readInherited(element: Element): Map { + const values = new Map(); + const style = getComputedStyle(element); + for (const name of INHERITED_PROPERTIES) { + values.set(name, style.getPropertyValue(name)); + } + // A unitless line-height inherits as its factor, but the computed style + // reports it in pixels. The typed style map tells a factor from a length + // (a length stays its pixels); without one, the pixels over the font size + // stand for the factor. + if (typeof element.computedStyleMap === 'function') { + const lineHeight = element.computedStyleMap().get('line-height'); + if ( + lineHeight != null && + 'unit' in lineHeight && + lineHeight.unit === 'number' && + 'value' in lineHeight + ) { + values.set('line-height', String(lineHeight.value)); + } + } else { + const factor = lineHeightFactor(style); + if (factor != null) { + values.set('line-height', String(factor)); + } + } + for (let index = 0; index < style.length; index++) { + const name = style[index]; + if (name.startsWith('--')) { + values.set(name, style.getPropertyValue(name)); + } + } + return values; +} + +/** Scroll offsets a copy restores once laid out (a clone carries none). */ +const scrollOffsets = new WeakMap< + HTMLElement, + ReadonlyArray +>(); + +/** + * A copy that takes no part: no ids or test ids, no form or radio group (a + * copied checked radio would uncheck the live one), no label for a live + * control, and copied frames and media load nothing. Scrolled descendants + * keep their offsets once the copy is laid out (`placeOver`). + */ +export function inertCopy(element: HTMLElement): HTMLElement | null { + const copy = element.cloneNode(true) as HTMLElement; + const sourceNodes = [element, ...element.querySelectorAll('*')]; + const copyNodes = [copy, ...copy.querySelectorAll('*')]; + const offsets: [Element, number, number][] = []; + sourceNodes.forEach((source, index) => { + if (source.scrollTop !== 0 || source.scrollLeft !== 0) { + offsets.push([copyNodes[index], source.scrollTop, source.scrollLeft]); + } + }); + scrollOffsets.set(copy, offsets); + inheritedValues.set(copy, readInherited(element)); + for (const node of copyNodes) { + for (const name of [ + 'id', + 'data-testid', + 'name', + 'form', + 'for', + 'autofocus', + ]) { + node.removeAttribute(name); + } + } + for (const node of copy.querySelectorAll( + 'iframe, video, audio, source, embed, object', + )) { + for (const name of ['src', 'srcdoc', 'data', 'autoplay']) { + node.removeAttribute(name); + } + } + // A clone's canvas is blank: paint each with its source's pixels. Content + // with a canvas that reads back empty is not copied. + const sources = element.querySelectorAll('canvas'); + const canvases = Array.from(copy.querySelectorAll('canvas')); + if (!canvases.every((canvas, index) => paintCanvas(canvas, sources[index]))) { + return null; + } + copy.setAttribute('aria-hidden', 'true'); + copy.setAttribute('inert', ''); + copy.style.pointerEvents = 'none'; + return copy; +} + +/** The background `element` shows: its own, or the nearest ancestor's. */ +export function paintedBackground(element: Element): string { + for (let node: Element | null = element; node; node = node.parentElement) { + const color = getComputedStyle(node).backgroundColor; + if (color !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(color)) { + return color; + } + } + return 'Canvas'; +} + +/** + * Lay `element` over `rect` (viewport coordinates), absolutely, as the last + * child of `host`, wherever its containing block is: no ancestor's position + * changes. + */ +export function placeOver( + element: HTMLElement, + host: Element, + rect: DOMRect, +): void { + Object.assign(element.style, { + position: 'absolute', + boxSizing: 'border-box', + margin: '0px', + left: '0px', + top: '0px', + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + host.appendChild(element); + // What it inherited where it was, where the host passes something else. + const hostStyle = getComputedStyle(host); + for (const [name, value] of inheritedValues.get(element) ?? []) { + // A line-height is always written: the host may pass the same number as + // the other kind (a factor against a length), which descendants inherit + // differently. + if ( + value !== '' && + (name === 'line-height' || hostStyle.getPropertyValue(name) !== value) + ) { + element.style.setProperty(name, value); + } + } + // The origin, and the host's scale: the rect is in viewport pixels, the + // copy's styles in the host's own. + // The rendered size over the layout size, as the glide's `scaleOf` reads + // it, so a size the copy's own CSS imposes is not taken for a scale. + // The layout size rounds to whole pixels: within a pixel of the requested + // size, the requested size is the layout size. + const origin = element.getBoundingClientRect(); + const layoutWidth = + Math.abs(element.offsetWidth - rect.width) < 1 + ? rect.width + : element.offsetWidth; + const layoutHeight = + Math.abs(element.offsetHeight - rect.height) < 1 + ? rect.height + : element.offsetHeight; + const scaleX = + origin.width > 0 && layoutWidth > 0 ? origin.width / layoutWidth : 1; + const scaleY = + origin.height > 0 && layoutHeight > 0 ? origin.height / layoutHeight : 1; + element.style.width = `${rect.width / scaleX}px`; + element.style.height = `${rect.height / scaleY}px`; + element.style.left = `${(rect.left - origin.left) / scaleX}px`; + element.style.top = `${(rect.top - origin.top) / scaleY}px`; + for (const [node, top, left] of scrollOffsets.get(element) ?? []) { + // At once, whatever the element's own `scroll-behavior`. + if (typeof node.scrollTo === 'function') { + node.scrollTo({top, left, behavior: 'instant'}); + } else { + node.scrollTop = top; + node.scrollLeft = left; + } + } +} + +/** + * `element`'s visible part as a copy: its content scrolled as it was, in a + * holder the size of the visible part (a scroll container), and where that + * part showed, in viewport coordinates. + */ +export function visiblePartCopy(element: HTMLElement): { + copy: HTMLElement; + rect: DOMRect; +} | null { + const content = inertCopy(element); + if (content == null) { + return null; + } + content.removeAttribute('role'); + content.removeAttribute('tabindex'); + Object.assign(content.style, { + position: 'absolute', + top: `${-element.scrollTop}px`, + left: `${-element.scrollLeft}px`, + boxSizing: 'border-box', + width: `${element.clientWidth}px`, + height: 'auto', + padding: getComputedStyle(element).padding, + // The holder is the visible part, inside the border. + borderWidth: '0px', + margin: '0px', + overflow: 'visible', + translate: 'none', + }); + const holder = element.ownerDocument.createElement('div'); + holder.setAttribute('aria-hidden', 'true'); + holder.setAttribute('inert', ''); + Object.assign(holder.style, {overflow: 'hidden', pointerEvents: 'none'}); + holder.appendChild(content); + // The content's own offset is its position; its scrolled descendants + // restore theirs when the holder is laid out. + scrollOffsets.set( + holder, + (scrollOffsets.get(content) ?? []).filter(([node]) => node !== content), + ); + const values = inheritedValues.get(content); + if (values != null) { + inheritedValues.set(holder, values); + } + const box = element.getBoundingClientRect(); + return { + copy: holder, + rect: DOMRect.fromRect({ + x: box.left + element.clientLeft, + y: box.top + element.clientTop, + width: element.clientWidth, + height: element.clientHeight, + }), + }; +} + +const SVG = 'http://www.w3.org/2000/svg'; + +/** + * The elements that let a copy's text fade: each run of HTML text wrapped in + * its own span, and each SVG `text` element as it is (a span does not render + * inside SVG). + */ +export function wrapText(copy: HTMLElement): Element[] { + const walker = copy.ownerDocument.createTreeWalker(copy, 4); + const texts: Text[] = []; + while (walker.nextNode()) { + if (walker.currentNode.textContent?.trim()) { + texts.push(walker.currentNode as Text); + } + } + const faded = new Set(); + for (const text of texts) { + const parent = text.parentElement; + // Text that is not rendered as text (a style sheet, a script, a + // textarea's default value, a title) stays where it is. + if (parent?.closest('style, script, textarea, title, noscript') != null) { + continue; + } + if (parent?.namespaceURI === SVG) { + const svgText = parent.closest('text'); + if (svgText != null) { + faded.add(svgText); + } + continue; + } + const span = copy.ownerDocument.createElement('span'); + text.replaceWith(span); + span.appendChild(text); + faded.add(span); + } + return [...faded]; +}