diff --git a/.changeset/core-hit-area.md b/.changeset/core-hit-area.md new file mode 100644 index 0000000000000..b320921ddb8ed --- /dev/null +++ b/.changeset/core-hit-area.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a shared touch hit-area utility (utils/hitArea.ts) that grows a target toward 44 px under a coarse pointer and stops at half the gap to each neighbour, so a tap goes to the nearer target. Not exported. + +@cixzhang diff --git a/packages/core/src/utils/hitArea.test.ts b/packages/core/src/utils/hitArea.test.ts new file mode 100644 index 0000000000000..d07d3691ee41a --- /dev/null +++ b/packages/core/src/utils/hitArea.test.ts @@ -0,0 +1,628 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file hitArea.test.ts + * @input Vitest, jsdom with stubbed matchMedia, IntersectionObserver, + * ResizeObserver, requestAnimationFrame and element rects + * @output Checks the touch hit area: it grows toward 44 px, stops at half the + * gap to each neighbour, resolves a group's touch to the nearest target, and + * keeps a live control's insets current only where the pointer is coarse, + * only on screen, in one batched read-then-write pass per frame + * @position Test for utils/hitArea.ts + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + clampedHitInsets, + HIT_INSET_PROPERTIES, + type HitInsets, + type HitRect, + NO_HIT_INSETS, + resetHitAreaForTests, + resolveHitTarget, + watchHitArea, + writeHitInsets, +} from './hitArea'; + +function rect(left: number, top: number, width: number, height: number) { + return {left, top, right: left + width, bottom: top + height}; +} + +describe('clampedHitInsets', () => { + it('grows a lone 24 px target to 44 px, centered', () => { + expect(clampedHitInsets(rect(100, 100, 24, 24), [])).toEqual({ + top: 10, + right: 10, + bottom: 10, + left: 10, + }); + }); + + it('does not grow a target already 44 px or larger on an axis', () => { + expect(clampedHitInsets(rect(0, 0, 44, 44), [])).toEqual(NO_HIT_INSETS); + expect(clampedHitInsets(rect(0, 0, 120, 24), [])).toEqual({ + top: 10, + right: 0, + bottom: 10, + left: 0, + }); + }); + + it('stops at half the gap to a neighbour on that side, and only that side', () => { + // A link 8 px to the right. + expect( + clampedHitInsets(rect(100, 100, 24, 24), [rect(132, 100, 60, 24)]), + ).toEqual({top: 10, right: 4, bottom: 10, left: 10}); + }); + + it('stops at half the gap to a neighbour that does not grow itself (a long link)', () => { + // A 32 × 20 switch 8 px from a link wider and taller than 44 px. + expect( + clampedHitInsets(rect(100, 100, 32, 20), [rect(140, 88, 120, 44)]).right, + ).toBe(4); + }); + + it('does not grow toward a neighbour it touches', () => { + expect( + clampedHitInsets(rect(100, 100, 24, 24), [rect(124, 100, 24, 24)]).right, + ).toBe(0); + }); + + it('stops at half the gap even where it would not reach the neighbour itself, so a tap nearer the neighbour is never its', () => { + // 16 px apart: growing 10 px would take taps 2 px from the neighbour. + expect( + clampedHitInsets(rect(100, 100, 24, 24), [rect(140, 100, 24, 24)]).right, + ).toBe(8); + }); + + it('ignores a neighbour at least twice its growth away', () => { + expect( + clampedHitInsets(rect(100, 100, 24, 24), [rect(144, 100, 24, 24)]), + ).toEqual({top: 10, right: 10, bottom: 10, left: 10}); + }); + + it('stops a diagonal neighbour on the side with the larger gap, so neither area reaches the other', () => { + const a = rect(100, 100, 24, 24); + // Down and to the right: 4 px across, 6 px down. + const b = rect(128, 130, 24, 24); + const insetsA = clampedHitInsets(a, [b]); + const insetsB = clampedHitInsets(b, [a]); + expect(insetsA).toEqual({top: 10, right: 10, bottom: 3, left: 10}); + expect(insetsB).toEqual({top: 3, right: 10, bottom: 10, left: 10}); + expect(a.bottom + insetsA.bottom).toBeLessThanOrEqual(b.top - insetsB.top); + }); + + it('cuts the same side as a diagonal neighbour of another size, so the two areas never overlap', () => { + const small = rect(100, 100, 16, 16); + // 12 px across and 14 px down. + const wide = rect(128, 130, 32, 20); + const a = clampedHitInsets(small, [wide]); + const b = clampedHitInsets(wide, [small]); + expect(small.bottom + a.bottom).toBeLessThanOrEqual(wide.top - b.top); + expect(a.bottom).toBe(7); + expect(b.top).toBe(7); + }); + + it('cuts a wide and a tall diagonal pair whose full areas would meet', () => { + const square = rect(100, 100, 32, 32); + // 4 px across and 12 px down. + const wide = rect(136, 144, 40, 24); + const a = clampedHitInsets(square, [wide]); + const b = clampedHitInsets(wide, [square]); + expect(square.bottom + a.bottom).toBeLessThanOrEqual(wide.top - b.top); + }); + + it('ignores a neighbour that overlaps the target (an ancestor or an overlay)', () => { + expect( + clampedHitInsets(rect(100, 100, 24, 24), [rect(90, 90, 200, 60)]), + ).toEqual({top: 10, right: 10, bottom: 10, left: 10}); + }); +}); + +describe('resolveHitTarget', () => { + // Two 28 px buttons 4 px apart, as in a dense toolbar. + const toolbar: HitRect[] = [rect(0, 0, 28, 28), rect(32, 0, 28, 28)]; + + it('gives a touch in the gap to the nearer button', () => { + expect(resolveHitTarget(29, 14, toolbar)).toBe(0); + expect(resolveHitTarget(31, 14, toolbar)).toBe(1); + }); + + it('extends each button toward 44 px where nothing is near', () => { + expect(resolveHitTarget(-7, 14, toolbar)).toBe(0); + expect(resolveHitTarget(14, 35, toolbar)).toBe(0); + }); + + it('gives a corner touch to the nearer of two diagonal targets', () => { + const pair: HitRect[] = [rect(100, 100, 24, 24), rect(128, 130, 24, 24)]; + // 9.2 px from the first, 4 px from the second. + expect(resolveHitTarget(133, 126, pair)).toBe(1); + }); + + it('answers -1 beyond every hit area', () => { + expect(resolveHitTarget(14, 60, toolbar)).toBe(-1); + }); + + it('picks the nearer target, not the earlier one, where targets overlap', () => { + const overlapping: HitRect[] = [rect(0, 0, 40, 28), rect(30, 0, 40, 28)]; + expect(resolveHitTarget(36, 14, overlapping)).toBe(1); + }); +}); + +describe('writeHitInsets', () => { + it('sets the four custom properties in px', () => { + const element = document.createElement('div'); + writeHitInsets(element, {top: 1, right: 2, bottom: 3, left: 4}); + expect(element.style.getPropertyValue(HIT_INSET_PROPERTIES.top)).toBe( + '1px', + ); + expect(element.style.getPropertyValue(HIT_INSET_PROPERTIES.left)).toBe( + '4px', + ); + }); +}); + +describe('watchHitArea', () => { + let coarse = true; + let pointerListeners: Set<() => void>; + let frames: (() => void)[]; + let intersection: ((entries: IntersectionObserverEntry[]) => void) | null; + const rects = new Map(); + const log: string[] = []; + + function place(element: Element, at: HitRect): void { + rects.set(element, at); + element.getBoundingClientRect = () => { + log.push(`read ${element.id}`); + const r = rects.get(element) ?? rect(0, 0, 0, 0); + return { + ...r, + x: r.left, + y: r.top, + width: r.right - r.left, + height: r.bottom - r.top, + toJSON: () => r, + }; + }; + } + + function show(...elements: Element[]): void { + intersection?.( + elements.map( + target => ({target, isIntersecting: true}) as IntersectionObserverEntry, + ), + ); + } + + function flush(): void { + const due = frames; + frames = []; + due.forEach(run => run()); + } + + beforeEach(() => { + coarse = true; + pointerListeners = new Set(); + frames = []; + intersection = null; + rects.clear(); + log.length = 0; + vi.stubGlobal('matchMedia', (media: string) => ({ + media, + get matches() { + return media === '(pointer: coarse)' && coarse; + }, + addEventListener: (_type: string, listener: () => void) => + pointerListeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => + pointerListeners.delete(listener), + })); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: (entries: IntersectionObserverEntry[]) => void) { + intersection = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal('requestAnimationFrame', (run: () => void) => { + frames.push(run); + return frames.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + document.body.innerHTML = + '' + + 'Details' + + ''; + }); + + afterEach(() => { + resetHitAreaForTests(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + const byId = (id: string) => document.getElementById(id) as HTMLElement; + + it('measures an on-screen control in the next frame and stops at half the gap to a neighbouring link', () => { + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + place(byId('link'), rect(132, 100, 60, 24)); + place(byId('other'), rect(400, 400, 24, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + show(control); + expect(heard).toEqual([]); + flush(); + expect(heard).toEqual([{top: 10, right: 4, bottom: 10, left: 10}]); + stop(); + }); + + it('measures nothing off screen', () => { + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + flush(); + expect(heard).toEqual([]); + expect(log).toEqual([]); + stop(); + }); + + it('batches every pending control into one frame: all reads, then all writes', () => { + const first = byId('switch'); + const second = byId('other'); + place(first, rect(100, 100, 24, 24)); + place(second, rect(400, 400, 24, 24)); + place(byId('link'), rect(132, 100, 60, 24)); + const stops = [first, second].map(element => + watchHitArea(element, () => log.push(`write ${element.id}`)), + ); + show(first, second); + expect(frames).toHaveLength(1); + flush(); + const firstWrite = log.findIndex(entry => entry.startsWith('write')); + expect(firstWrite).toBeGreaterThan(0); + expect( + log.slice(firstWrite).every(entry => entry.startsWith('write')), + ).toBe(true); + expect(log.filter(entry => entry.startsWith('write'))).toEqual([ + 'write switch', + 'write other', + ]); + stops.forEach(stop => stop()); + }); + + it('counts a neighbour up to 44 px away for a small target, so two 8 px dots 24 px apart meet in the middle', () => { + const control = byId('switch'); + const other = byId('other'); + place(control, rect(100, 100, 8, 8)); + place(other, rect(132, 100, 8, 8)); + place(byId('link'), rect(400, 400, 60, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + show(control); + flush(); + expect(heard.at(-1)?.right).toBe(12); + stop(); + }); + + it('gives a second watch of the same element its insets', () => { + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + const first = watchHitArea(control, () => {}); + show(control); + flush(); + const heard: HitInsets[] = []; + const second = watchHitArea(control, insets => heard.push(insets)); + flush(); + expect(heard).toEqual([{top: 10, right: 10, bottom: 10, left: 10}]); + first(); + second(); + }); + + it('carries controls past the frame budget to the next frame, measuring at least one a frame', () => { + const controls = ['switch', 'other'].map(byId); + place(controls[0], rect(100, 100, 24, 24)); + place(controls[1], rect(400, 400, 24, 24)); + // Every clock read is 5 ms later: each frame's budget is spent after one + // control. + let clock = 0; + vi.spyOn(performance, 'now').mockImplementation(() => (clock += 5)); + const heard: string[] = []; + const stops = controls.map(element => + watchHitArea(element, () => heard.push(element.id)), + ); + show(...controls); + flush(); + expect(heard).toEqual(['switch']); + expect(frames).toHaveLength(1); + flush(); + expect(heard).toEqual(['switch', 'other']); + expect(frames).toHaveLength(0); + stops.forEach(stop => stop()); + }); + + it('measures nothing where the primary pointer is fine, and returns to 0 when it turns fine', () => { + coarse = false; + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + show(control); + flush(); + expect(heard).toEqual([]); + expect(log).toEqual([]); + // The pointer turns coarse (the observers start, and the visibility + // observer's first notice comes), then fine again. + coarse = true; + pointerListeners.forEach(listener => listener()); + show(control); + flush(); + expect(heard).toEqual([{top: 10, right: 10, bottom: 10, left: 10}]); + coarse = false; + pointerListeners.forEach(listener => listener()); + expect(heard.at(-1)).toEqual(NO_HIT_INSETS); + stop(); + }); + + it('re-measures once its parent’s text settles after a change that moves a neighbour without resizing the control', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const control = byId('switch'); + const link = byId('link'); + place(control, rect(100, 100, 24, 24)); + place(link, rect(400, 100, 60, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + show(control); + flush(); + expect(heard.at(-1)?.right).toBe(10); + // The label beside the switch shortens, and the link moves to 8 px away. + place(link, rect(132, 100, 60, 24)); + (control.parentElement as HTMLElement).lastChild!.textContent = ' Wi'; + await Promise.resolve(); + vi.advanceTimersByTime(100); + flush(); + expect(heard.at(-1)?.right).toBe(4); + stop(); + vi.useRealTimers(); + }); + + it('swallows the click of a touch that a fresh measurement puts nearer a neighbour, and only that one', () => { + const control = byId('switch'); + const link = byId('link'); + place(control, rect(100, 100, 24, 24)); + place(link, rect(400, 100, 60, 24)); + const stop = watchHitArea(control, () => {}); + show(control); + flush(); + // The link moved to 8 px away with nothing re-measuring yet. + place(link, rect(132, 100, 60, 24)); + const touch = (x: number) => { + const press = new Event('pointerdown', {bubbles: true}); + Object.assign(press, {pointerType: 'touch', clientX: x, clientY: 112}); + control.dispatchEvent(press); + }; + const click = () => { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + }); + control.dispatchEvent(event); + return event.defaultPrevented; + }; + // 6 px right of the switch: inside the old area, 2 px from the link. + touch(130); + expect(click()).toBe(true); + // One-shot: the next click is the control's. + expect(click()).toBe(false); + // A touch with no click leaves nothing behind for the next touch. + touch(130); + touch(110); + expect(click()).toBe(false); + // A keyboard activation (detail 0) after a touch with no click goes + // through, and so does a click after the browser cancelled the touch. + touch(130); + const key = new MouseEvent('click', {bubbles: true, cancelable: true}); + control.dispatchEvent(key); + expect(key.defaultPrevented).toBe(false); + touch(130); + control.dispatchEvent(new Event('pointercancel', {bubbles: true})); + expect(click()).toBe(false); + // A press elsewhere (the control's label) ends it before its click. + touch(130); + link.dispatchEvent(new Event('pointerdown', {bubbles: true})); + expect(click()).toBe(false); + stop(); + }); + + it('runs no observer or window listener for hit areas while the primary pointer is fine', () => { + coarse = false; + const constructed = vi.fn(); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor() { + constructed(); + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const onWindow = vi.spyOn(window, 'addEventListener'); + const onElement = vi.spyOn(byId('switch'), 'addEventListener'); + const stop = watchHitArea(byId('switch'), () => {}); + expect(constructed).not.toHaveBeenCalled(); + expect(onWindow).not.toHaveBeenCalled(); + expect(onElement).not.toHaveBeenCalled(); + stop(); + }); + + it('swallows a touch inside the fresh area that an offset neighbour is nearer (a corner)', () => { + const control = byId('switch'); + // A tall link beside it, 8 px to the right, reaching far below. + place(control, rect(100, 100, 24, 24)); + place(byId('link'), rect(132, 100, 60, 80)); + const stop = watchHitArea(control, () => {}); + show(control); + flush(); + // Inside the area (right inset 4, bottom 10), below the switch: 5 px from + // the link, 9.5 px from the switch. + const press = new Event('pointerdown', {bubbles: true}); + Object.assign(press, {pointerType: 'touch', clientX: 127, clientY: 133}); + control.dispatchEvent(press); + const click = new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + }); + control.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + stop(); + }); + + it('counts tree items, menu checkbox and radio items as neighbours', () => { + document.body.innerHTML = + '' + + '
'; + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + place(byId('item'), rect(132, 100, 60, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + show(control); + flush(); + expect(heard.at(-1)?.right).toBe(4); + stop(); + }); + + it('leaves a touch the browser hands a lone control a few px past its area to it: only a nearer neighbour swallows', () => { + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + place(byId('link'), rect(400, 400, 60, 24)); + const stop = watchHitArea(control, () => {}); + show(control); + flush(); + // 3 px right of the 44 px area (10 px of growth), nothing near. + const press = new Event('pointerdown', {bubbles: true}); + Object.assign(press, {pointerType: 'touch', clientX: 137, clientY: 112}); + control.dispatchEvent(press); + const click = new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + }); + control.dispatchEvent(click); + expect(click.defaultPrevented).toBe(false); + stop(); + }); + + it('measures a watched neighbour by its own box, not by its grown input', () => { + document.body.innerHTML = + '
' + + '
'; + const a = byId('a'); + const b = byId('b'); + place(a, rect(100, 100, 32, 20)); + place(b, rect(100, 128, 32, 20)); + // Each input is grown to its hit area: 44 px tall. + place(byId('ia'), rect(94, 88, 44, 44)); + place(byId('ib'), rect(94, 116, 44, 44)); + const heard: HitInsets[] = []; + const stopA = watchHitArea(a, insets => heard.push(insets)); + const stopB = watchHitArea(b, () => {}); + show(a, b); + flush(); + // Half the 8 px gap to b's box, not a quarter (b's input reaches a). + expect(heard.at(-1)?.bottom).toBe(4); + stopA(); + stopB(); + }); + + it('listens for presses once, on the document: a watched control, row or handle gets no listener of its own (spec:AST-103 FR5)', () => { + const elements = ['switch', 'other'].map(byId); + const onElements = elements.map(element => + vi.spyOn(element, 'addEventListener'), + ); + const onDocument = vi.spyOn(document, 'addEventListener'); + const stops = elements.map(element => watchHitArea(element, () => {})); + for (const spy of onElements) { + expect(spy).not.toHaveBeenCalled(); + } + expect( + onDocument.mock.calls.filter(([type]) => type === 'pointerdown'), + ).toHaveLength(1); + stops.forEach(stop => stop()); + }); + + it('removes its document listener when the last watch stops (spec:AST-094 FR24)', () => { + const offDocument = vi.spyOn(document, 'removeEventListener'); + const stops = ['switch', 'other'].map(id => + watchHitArea(byId(id), () => {}), + ); + stops[0](); + expect( + offDocument.mock.calls.filter(([type]) => type === 'pointerdown'), + ).toHaveLength(0); + stops[1](); + expect( + offDocument.mock.calls.filter(([type]) => type === 'pointerdown'), + ).toHaveLength(1); + }); + + it('leaves nothing behind after resetHitAreaForTests, even from a watch never stopped', () => { + watchHitArea(byId('switch'), () => {}); + const offDocument = vi.spyOn(document, 'removeEventListener'); + resetHitAreaForTests(); + expect( + offDocument.mock.calls.filter(([type]) => type === 'pointerdown'), + ).toHaveLength(1); + coarse = false; + const onDocument = vi.spyOn(document, 'addEventListener'); + const stop = watchHitArea(byId('other'), () => {}); + expect(onDocument).not.toHaveBeenCalled(); + stop(); + }); + + it('reads the pointer query at each watch, so a watch never left behind keeps a stale coarse answer', () => { + const left = watchHitArea(byId('switch'), () => {}); + // The environment's pointer turns fine through a new query object (a + // test's stub), and the earlier watch never stopped. + coarse = false; + const other = byId('other'); + const onElement = vi.spyOn(other, 'addEventListener'); + const stop = watchHitArea(other, () => {}); + expect(onElement).not.toHaveBeenCalled(); + stop(); + left(); + }); + + it('stops hearing a control after its function runs', () => { + const control = byId('switch'); + place(control, rect(100, 100, 24, 24)); + const heard: HitInsets[] = []; + const stop = watchHitArea(control, insets => heard.push(insets)); + show(control); + stop(); + flush(); + expect(heard).toEqual([]); + }); +}); + +describe('the package surface', () => { + it('keeps hitArea out of the public utils entry', () => { + const entry = fs.readFileSync( + path.join(path.dirname(new URL(import.meta.url).pathname), 'index.ts'), + 'utf8', + ); + expect(entry).not.toMatch(/hitArea/); + }); +}); diff --git a/packages/core/src/utils/hitArea.ts b/packages/core/src/utils/hitArea.ts new file mode 100644 index 0000000000000..7d27556148d23 --- /dev/null +++ b/packages/core/src/utils/hitArea.ts @@ -0,0 +1,770 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file hitArea.ts + * @input A target's rect and its neighbours' rects (or, for a live control, + * the element and the page around it) + * @output The touch hit area: how far a target may grow toward 44 px on each + * side without taking a tap that is nearer a neighbour. `clampedHitInsets` + * and `resolveHitTarget` answer from rects; `watchHitArea` keeps a control's + * insets live where the primary pointer is coarse + * @position Core-internal utility, not exported from `@astryxdesign/core/utils`. + * Groups that measure their actions at press start pass `resolveHitTarget`; + * a standalone control writes `watchHitArea`'s insets to an invisible + * coarse-only hit layer + * + * A finger needs a 44 × 44 px target, but a control may not grow on a phone, + * and its hit area must never take a neighbour's taps. Each side grows toward + * the minimum size and stops at half the gap to the nearest neighbour on that + * side (0 when they touch), so two neighbours' areas meet in the middle and a + * tap always goes to the nearer target. CSS cannot see where the neighbours + * are, so a live control measures: only where the primary pointer is coarse, + * only while on screen, and every control pending a measurement in one read + * pass per frame (all reads, then all writes), up to a 4 ms budget, the rest + * carried to the next frame. Until the first pass the + * insets are 0, so first paint and server rendering never overlap anything. + */ + +import {observeContent} from './sharedMutationObserver'; + +/** A rect in client pixels. */ +export interface HitRect { + readonly left: number; + readonly top: number; + readonly right: number; + readonly bottom: number; +} + +/** How far a hit area extends past its target on each side, px. */ +export interface HitInsets { + readonly top: number; + readonly right: number; + readonly bottom: number; + readonly left: number; +} + +/** The smallest touch target, px (Apple's 44 pt). */ +export const MIN_TOUCH_TARGET = 44; + +/** No growth: the hit area is the target. */ +export const NO_HIT_INSETS: HitInsets = {top: 0, right: 0, bottom: 0, left: 0}; + +/** + * The custom properties `writeHitInsets` sets, in px, for a coarse-only hit + * layer to read (`inset: calc(var(--astryx-hit-inset-top, 0px) * -1) ...`). + */ +export const HIT_INSET_PROPERTIES = { + top: '--astryx-hit-inset-top', + right: '--astryx-hit-inset-right', + bottom: '--astryx-hit-inset-bottom', + left: '--astryx-hit-inset-left', +} as const; + +/** + * The gap between two rects along each axis: positive where they are apart on + * that axis, negative where their projections overlap. + */ +function gaps(a: HitRect, b: HitRect): {x: number; y: number} { + return { + x: Math.max(b.left - a.right, a.left - b.right), + y: Math.max(b.top - a.bottom, a.top - b.bottom), + }; +} + +function grown(rect: HitRect, insets: HitInsets): HitRect { + return { + left: rect.left - insets.left, + top: rect.top - insets.top, + right: rect.right + insets.right, + bottom: rect.bottom + insets.bottom, + }; +} + +function intersects(a: HitRect, b: HitRect): boolean { + return ( + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom + ); +} + +/** How far `rect` grows toward `minSize` on each axis, centered, unclamped. */ +function growth(rect: HitRect, minSize: number): HitInsets { + const x = Math.max(0, (minSize - (rect.right - rect.left)) / 2); + const y = Math.max(0, (minSize - (rect.bottom - rect.top)) / 2); + return {top: y, right: x, bottom: y, left: x}; +} + +/** + * How far `target` may grow on each side: toward `minSize` on each axis, + * centered, and on each side no further than half the gap to a neighbour. + * A neighbour counts when either one, at full growth toward `minSize`, would + * reach into the other's half of the gap, or their full areas would meet; it + * stops the side facing it, or, + * for a neighbour apart on both axes (diagonal), the side with the larger gap. + * That decision rests on the two rects alone, so the neighbour decides the + * same about this target and the two areas never overlap. A neighbour + * overlapping the target (an ancestor, an overlay) is not beside it and stops + * nothing. + */ +export function clampedHitInsets( + target: HitRect, + neighbours: ReadonlyArray, + minSize: number = MIN_TOUCH_TARGET, +): HitInsets { + const full = growth(target, minSize); + if (full.top === 0 && full.left === 0) { + return NO_HIT_INSETS; + } + const insets = {...full}; + for (const rect of neighbours) { + const gap = gaps(target, rect); + if (gap.x < 0 && gap.y < 0) { + continue; + } + // Each one's half of the gap: a tap there is nearer that one. + const half = { + top: Math.max(0, gap.y) / 2, + right: Math.max(0, gap.x) / 2, + bottom: Math.max(0, gap.y) / 2, + left: Math.max(0, gap.x) / 2, + }; + // Either one, at full growth, would reach into the other's half, or + // their full areas would meet (a wide and a tall target, diagonally). + const reach = grown(target, full); + const neighbourReach = grown(rect, growth(rect, minSize)); + const crosses = + intersects(reach, grown(rect, half)) || + intersects(neighbourReach, grown(target, half)) || + intersects(reach, neighbourReach); + if (!crosses) { + continue; + } + if (gap.x >= gap.y) { + if (rect.left >= target.right) { + insets.right = Math.min(insets.right, half.right); + } else { + insets.left = Math.min(insets.left, half.left); + } + } else { + if (rect.top >= target.bottom) { + insets.bottom = Math.min(insets.bottom, half.bottom); + } else { + insets.top = Math.min(insets.top, half.top); + } + } + } + return insets; +} + +function contains(rect: HitRect, x: number, y: number): boolean { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function distanceTo(rect: HitRect, x: number, y: number): number { + const dx = Math.max(rect.left - x, 0, x - rect.right); + const dy = Math.max(rect.top - y, 0, y - rect.bottom); + return Math.hypot(dx, dy); +} + +/** + * Which of `rects` a touch at (x, y) is for: the nearest target (inside two + * overlapping targets, the one whose center is nearer), when the touch is + * within that target's growth toward `minSize`; -1 otherwise. Being nearest, + * it is on that target's side of every midpoint, corners included. For a + * group that measures its actions at press start. + */ +export function resolveHitTarget( + x: number, + y: number, + rects: ReadonlyArray, + minSize: number = MIN_TOUCH_TARGET, +): number { + let nearest = -1; + let nearestDistance = Infinity; + rects.forEach((rect, index) => { + const distance = + distanceTo(rect, x, y) * 1e6 + + Math.hypot( + x - (rect.left + rect.right) / 2, + y - (rect.top + rect.bottom) / 2, + ); + if (distance < nearestDistance) { + nearest = index; + nearestDistance = distance; + } + }); + if (nearest === -1) { + return -1; + } + const rect = rects[nearest]; + return contains(grown(rect, growth(rect, minSize)), x, y) ? nearest : -1; +} + +/** Sets `HIT_INSET_PROPERTIES` on `element`. */ +export function writeHitInsets(element: HTMLElement, insets: HitInsets): void { + for (const side of ['top', 'right', 'bottom', 'left'] as const) { + element.style.setProperty(HIT_INSET_PROPERTIES[side], `${insets[side]}px`); + } +} + +// --- Live controls ----------------------------------------------------------- + +/** What a touch can land on: the neighbours a hit area must not take from. */ +const INTERACTIVE = [ + 'a[href]', + 'area[href]', + 'button', + 'input:not([type="hidden"])', + 'select', + 'textarea', + 'summary', + 'label[for]', + '[contenteditable=""]', + '[contenteditable="true"]', + '[role="button"]', + '[role="link"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="treeitem"]', + '[role="combobox"]', + '[role="textbox"]', + '[role="searchbox"]', + '[role="spinbutton"]', + '[role="option"]', + '[role="slider"]', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const TOUCH_QUERY = '(pointer: coarse)'; + +/** + * How long one frame's pass may spend on controls, ms, after its one read of + * the page: a page appearing with hundreds of controls spreads over a few + * frames instead of one long task. + */ +const FRAME_BUDGET_MS = 4; + +/** + * How long a control's parent must stay still after a text or child change + * before its controls re-measure, ms: a label or link beside a control can + * move without resizing the control or its parent. + */ +const CONTENT_SETTLE_MS = 100; + +const PASSIVE_CAPTURE = {capture: true, passive: true} as const; + +function now(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +interface Watch { + readonly element: Element; + readonly minSize: number; + readonly onInsets: (insets: HitInsets) => void; + isVisible: boolean; + last: HitInsets; +} + +/** Each watched element: its watches, and what observes it while coarse. */ +interface Watched { + readonly watches: Set; + /** Undoes the element's observation; null while the pointer is fine. */ + release: (() => void) | null; + /** The armed one-shot click swallower, if a touch was nearer a neighbour. */ + disarm: (() => void) | null; +} + +const watched = new Map(); +const pending = new Set(); +const observedSizes = new WeakMap(); +const scopeTimers = new Map>(); +let frame: number | null = null; +let view: Window | null = null; +let pointer: MediaQueryList | null = null; +let visibility: IntersectionObserver | null = null; +let resize: ResizeObserver | null = null; +let isCoarse = false; + +function sameInsets(a: HitInsets, b: HitInsets): boolean { + return ( + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.left === b.left + ); +} + +function deliver(watch: Watch, insets: HitInsets): void { + if (sameInsets(watch.last, insets)) { + return; + } + watch.last = insets; + watch.onInsets(insets); +} + +/** Whether `candidate` is the control itself, or part of it. */ +function isOwn(watch: Watch, candidate: Element): boolean { + const {element} = watch; + if ( + candidate === element || + candidate.contains(element) || + element.contains(candidate) + ) { + return true; + } + // The control's own