From e297a6b59fd5d60529e91d5487013875e16026cd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 18:36:26 -0700 Subject: [PATCH 1/9] chore(core): a shared touch hit area that grows toward 44 px and stops at half the gap to each neighbour A finger needs a 44 px target, but a control may not grow on a phone, and its hit area must never take a tap that is nearer a neighbour. utils/hitArea works this out once for every control: - clampedHitInsets(target, neighbours, minSize) grows each axis toward the minimum and stops each side at half the gap to a neighbour (0 when they touch); a diagonal neighbour stops the side with the larger gap. - resolveHitTarget(x, y, rects) gives a group's touch to the target whose clamped area holds it, nearest first, for groups that measure at press start. - watchHitArea(element, onInsets) keeps a standalone control's insets live: coarse pointer only, on-screen controls only (one shared IntersectionObserver), every pending control in one read pass per frame (all reads, then all writes), re-measured on its own, its parent's or the window's resize. The insets start at 0, so first paint and server rendering never overlap anything. writeHitInsets sets them as custom properties for an invisible coarse-only hit layer. Core-internal: not exported from the utils entry (a test checks). --- .changeset/core-hit-area.md | 7 + packages/core/src/utils/hitArea.test.ts | 307 +++++++++++++++ packages/core/src/utils/hitArea.ts | 479 ++++++++++++++++++++++++ 3 files changed, 793 insertions(+) create mode 100644 .changeset/core-hit-area.md create mode 100644 packages/core/src/utils/hitArea.test.ts create mode 100644 packages/core/src/utils/hitArea.ts diff --git a/.changeset/core-hit-area.md b/.changeset/core-hit-area.md new file mode 100644 index 0000000000000..1d34a8e31e401 --- /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) works out how far a target may grow toward 44 px under a coarse pointer without taking a tap that is nearer a neighbour. No component uses it yet; no behavior change. + +@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..61f4d2a229a0d --- /dev/null +++ b/packages/core/src/utils/hitArea.test.ts @@ -0,0 +1,307 @@ +// 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, + 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('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('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('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(() => { + 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('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, then fine again. + coarse = true; + pointerListeners.forEach(listener => listener()); + 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('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..ccf9885302fa3 --- /dev/null +++ b/packages/core/src/utils/hitArea.ts @@ -0,0 +1,479 @@ +// 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). Until the first pass the + * insets are 0, so first paint and server rendering never overlap anything. + */ + +/** 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 `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 it + * would reach. A neighbour apart on both axes (diagonal) stops the side with + * the larger gap, which keeps the most area and is the side the neighbour + * stops too, so 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 growX = Math.max(0, (minSize - (target.right - target.left)) / 2); + const growY = Math.max(0, (minSize - (target.bottom - target.top)) / 2); + if (growX === 0 && growY === 0) { + return NO_HIT_INSETS; + } + const insets = {top: growY, right: growX, bottom: growY, left: growX}; + // Nearest first, so a far neighbour is judged against the area its nearer + // ones left. + const beside = neighbours + .map(rect => ({rect, gap: gaps(target, rect)})) + .filter(({gap}) => gap.x >= 0 || gap.y >= 0) + .sort((a, b) => Math.max(a.gap.x, a.gap.y) - Math.max(b.gap.x, b.gap.y)); + for (const {rect, gap} of beside) { + // The neighbour's half of the gap: a tap there is nearer the neighbour. + const neighbourHalf = grown(rect, { + 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, + }); + if (!intersects(grown(target, insets), neighbourHalf)) { + continue; + } + if (gap.x >= gap.y) { + const half = gap.x / 2; + if (rect.left >= target.right) { + insets.right = Math.min(insets.right, half); + } else { + insets.left = Math.min(insets.left, half); + } + } else { + const half = gap.y / 2; + if (rect.top >= target.bottom) { + insets.bottom = Math.min(insets.bottom, half); + } else { + insets.top = Math.min(insets.top, half); + } + } + } + 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 one whose clamped hit area + * holds the point; where areas still meet (their targets overlap), the target + * nearest the point, then the one whose center is nearest; -1 where none + * holds it. 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 found = -1; + let foundDistance = Infinity; + rects.forEach((rect, index) => { + const others = rects.filter((_, other) => other !== index); + const area = grown(rect, clampedHitInsets(rect, others, minSize)); + if (!contains(area, x, y)) { + return; + } + // Inside two overlapping targets, the nearer center decides. + const distance = + distanceTo(rect, x, y) * 1e6 + + Math.hypot( + x - (rect.left + rect.right) / 2, + y - (rect.top + rect.bottom) / 2, + ); + if (distance < foundDistance) { + found = index; + foundDistance = distance; + } + }); + return found; +} + +/** 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="option"]', + '[role="slider"]', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const TOUCH_QUERY = '(pointer: coarse)'; + +interface Watch { + readonly element: Element; + readonly minSize: number; + readonly onInsets: (insets: HitInsets) => void; + isVisible: boolean; + last: HitInsets; +} + +const watches = new Map>(); +const pending = new Set(); +let frame: number | null = null; +let visibility: IntersectionObserver | null = null; +let resize: ResizeObserver | null = null; +let pointer: MediaQueryList | 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