{"version":3,"file":"ios-gesture.js","sources":["../../src/ios-gesture.ts"],"sourcesContent":["// Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes\n// during a momentum-scroll cancel the momentum on iOS WebKit, so we defer\n// scroll-position adjustments triggered by mid-scroll resizes until the\n// scroll settles. SSR-safe (returns false when navigator is unavailable).\nlet _isIOSResult: boolean | undefined\nexport const isIOSWebKit = (): boolean => {\n if (_isIOSResult !== undefined) return _isIOSResult\n if (typeof navigator === 'undefined') return (_isIOSResult = false)\n if (/iP(hone|od|ad)/.test(navigator.userAgent)) return (_isIOSResult = true)\n // iPadOS 13+ reports as MacIntel; touch-points distinguishes it from desktop.\n const mtp = (navigator as Navigator & { maxTouchPoints?: number })\n .maxTouchPoints\n return (_isIOSResult =\n navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0)\n}\n\n// iOS 27 changed what a scrollTop write does to a fling: it no longer stops\n// the momentum, the fling carries on along its own path and the write shows\n// for one frame (measured on the 27.0 simulator in #1307, device check still\n// open). WebKit shipped `overflow-anchor` in the same release, so that is\n// the feature check; iOS 26 and earlier report false. Cached like the iOS\n// check; SSR-safe.\nlet _iosMomentumOverridesWritesResult: boolean | undefined\nexport const iosMomentumOverridesWrites = (): boolean => {\n if (_iosMomentumOverridesWritesResult !== undefined) {\n return _iosMomentumOverridesWritesResult\n }\n return (_iosMomentumOverridesWritesResult =\n isIOSWebKit() &&\n typeof CSS !== 'undefined' &&\n typeof CSS.supports === 'function' &&\n CSS.supports('overflow-anchor: auto'))\n}\n\n// Test hook: reset the iOS detection caches. Not exported.\nexport const _resetIOSDetectionForTests = () => {\n _isIOSResult = undefined\n _iosMomentumOverridesWritesResult = undefined\n}\n\n// Touch listeners on the scroll element run in the capture phase, so a row\n// that stops a touch event's propagation cannot hide the gesture from the\n// iOS deferral.\nconst touchListenerOptions = {\n capture: true,\n passive: true,\n}\n\n// The touch gesture in progress. iOS WebKit ignores a scrollTop write while\n// a finger drags the content (one resting on it does not stop the write)\n// and cancels momentum on one after it, so adjustments are deferred through\n// the whole gesture: `down` from touchstart to touchend/touchcancel, then\n// `tail` while momentum runs. iOS fires no touch events during momentum, so\n// every scroll event re-arms the tail and it closes ~150ms after the last\n// one. Null between gestures, and after touchend on non-iOS, which arms no\n// tail. The tail record owns its timer: every replacement of the state goes\n// through `end`, which clears it, so a live timer always has its record.\n//\n// `moved`: the content moved under or after the finger other than by our\n// own write's read-back. While `down`, a scroll event that changed the\n// position sets it: the finger is dragging. The tail inherits it from the\n// drag, and any other scroll event re-arming the tail sets it: a fling can\n// be running. A tap's tail has it unset.\n//\n// `awaitingFling`: the tail follows a drag whose fling has not reported\n// yet. The first frame of a fling can come 150–250ms after touchend when\n// the main thread is busy (27.0 simulator, #1307); if the tail closes\n// first, `lateFling` waits until `until` for that frame to reopen it. It\n// defers nothing: other scroll events with no touch before them must not\n// defer corrections (#1250).\ntype IosTouch =\n | { phase: 'down'; moved: boolean }\n | { phase: 'tail'; moved: boolean; awaitingFling: boolean; timerId: number }\n | { phase: 'lateFling'; until: number }\n | null\n\ntype TimerWindow = Pick\n\nexport class IosGesture {\n private _touch: IosTouch = null\n\n constructor(\n private readonly host: {\n // The scroll element's window, which runs the tail timer; null when\n // there is no element.\n getWindow: () => TimerWindow | null\n now: () => number\n // Asked when the tail's timer fires: true keeps it open another\n // 150ms (the fling still moves between scroll events).\n tailStillMoving: () => boolean\n // The tail closed on its own: a write is possible again.\n onTailClosed: () => void\n },\n ) {}\n\n get touch(): IosTouch {\n return this._touch\n }\n\n // A touch gesture owns the scroll: on iOS a write now would be ignored or\n // would cancel the momentum, so adjustments wait for the tail to close.\n // `isScrolling` is not part of it: any scroll event sets it, including\n // the read-back of our own write, and a programmatic scroll has no\n // momentum to protect (#1250).\n inMotion = () => this._touch !== null && this._touch.phase !== 'lateFling'\n\n // A finger is dragging the content, so iOS ignores our writes and no\n // read-back will come. A resting finger does not: the write lands.\n dragging = () => this._touch?.phase === 'down' && this._touch.moved\n\n // The open tail, if any: after a tap, or after a drag or fling. On iOS 27\n // a fling's momentum overrides a write within a frame (see\n // `iosMomentumOverridesWrites`).\n tail = (): 'tap' | 'fling' | null =>\n this._touch?.phase === 'tail' ? (this._touch.moved ? 'fling' : 'tap') : null\n\n // A scroll event reached the handler. `ownReadBack`: it is our own\n // write's read-back; `moved`: it changed the layout position; `external`:\n // the user or a fling moved it (not our write nor our smooth animation).\n // Returns whether it shows a finger dragging the content.\n onScroll = ({\n isScrolling,\n ownReadBack,\n moved,\n external,\n }: {\n isScrolling: boolean\n ownReadBack: boolean\n moved: boolean\n external: boolean\n }): boolean => {\n // Momentum after touchend fires a scroll event per frame but no touch\n // events, so each one re-arms the tail to span the fling. Only an open\n // tail is extended: a scroll event with no touch before it (our own\n // write's read-back) never opens one.\n if (isScrolling && this._touch?.phase === 'tail') {\n this._openTail(this._touch.moved || !ownReadBack, false)\n } else if (this._touch?.phase === 'lateFling') {\n // A drag's fling reporting after its tail closed reopens it.\n if (this.host.now() > this._touch.until) {\n this._touch = null\n } else if (external && isScrolling && moved) {\n this._openTail(true, false)\n }\n }\n // A finger that moved the content is dragging. Our own write landing\n // under a resting finger is not, and neither is a report at rest that\n // leaves the position where it was.\n if (!ownReadBack && isScrolling && moved && this._touch?.phase === 'down') {\n this._touch.moved = true\n return true\n }\n return false\n }\n\n touchStart = () => {\n // A finger back down inside the grace window cancels its tail.\n this.end()\n this._touch = { phase: 'down', moved: false }\n }\n\n touchEnd = () => {\n const moved = this._touch?.phase === 'down' && this._touch.moved\n this.end()\n this._openTail(moved, moved)\n }\n\n // Drop the gesture, and the tail's timer with it.\n end = () => {\n const win = this.host.getWindow()\n if (this._touch?.phase === 'tail' && win != null) {\n win.clearTimeout(this._touch.timerId)\n }\n this._touch = null\n }\n\n // Follow the touch events on `target`; the returned function stops and\n // drops the gesture. Listening is unconditional: the listeners are\n // passive and cheap, and on non-touch devices they never fire. The gating\n // by `isIOSWebKit()` lives where a write is decided.\n listen = (target: EventTarget) => {\n const onTouchStart = () => this.touchStart()\n const onTouchEnd = () => this.touchEnd()\n target.addEventListener('touchstart', onTouchStart, touchListenerOptions)\n target.addEventListener('touchend', onTouchEnd, touchListenerOptions)\n // Same handler as touchend: iOS can end a touch with touchcancel (system\n // gesture, interrupted scroll) and never fire touchend.\n target.addEventListener('touchcancel', onTouchEnd, touchListenerOptions)\n return () => {\n target.removeEventListener(\n 'touchstart',\n onTouchStart,\n touchListenerOptions,\n )\n target.removeEventListener('touchend', onTouchEnd, touchListenerOptions)\n target.removeEventListener(\n 'touchcancel',\n onTouchEnd,\n touchListenerOptions,\n )\n this.end()\n }\n }\n\n // (Re)open the tail after the finger lifts, closing ~150ms after the last\n // touchend or momentum frame. On non-iOS there is nothing to protect.\n private _openTail = (moved: boolean, awaitingFling: boolean) => {\n const win = this.host.getWindow()\n if (!isIOSWebKit() || win == null) return\n this.end()\n const tail = {\n phase: 'tail' as const,\n moved,\n awaitingFling,\n // Only fires while this tail is current: `end` clears it otherwise.\n timerId: win.setTimeout(() => {\n if (this.host.tailStillMoving()) {\n this._openTail(tail.moved, tail.awaitingFling)\n return\n }\n this._touch = tail.awaitingFling\n ? { phase: 'lateFling', until: this.host.now() + 1000 }\n : null\n this.host.onTailClosed()\n }, 150),\n }\n this._touch = tail\n }\n}\n"],"names":[],"mappings":"AAIA,IAAI;AACG,MAAM,cAAc,MAAe;AACxC,MAAI,iBAAiB,OAAW,QAAO;AACvC,MAAI,OAAO,cAAc,YAAa,QAAQ,eAAe;AAC7D,MAAI,iBAAiB,KAAK,UAAU,SAAS,UAAW,eAAe;AAEvE,QAAM,MAAO,UACV;AACH,SAAQ,eACN,UAAU,aAAa,cAAc,QAAQ,UAAa,MAAM;AACpE;AAQA,IAAI;AACG,MAAM,6BAA6B,MAAe;AACvD,MAAI,sCAAsC,QAAW;AACnD,WAAO;AAAA,EACT;AACA,SAAQ,oCACN,iBACA,OAAO,QAAQ,eACf,OAAO,IAAI,aAAa,cACxB,IAAI,SAAS,uBAAuB;AACxC;AAGO,MAAM,6BAA6B,MAAM;AAC9C,iBAAe;AACf,sCAAoC;AACtC;AAKA,MAAM,uBAAuB;AAAA,EAC3B,SAAS;AAAA,EACT,SAAS;AACX;AAgCO,MAAM,WAAW;AAAA,EAGtB,YACmB,MAWjB;AAXiB,SAAA,OAAA;AAHnB,SAAQ,SAAmB;AAyB3B,SAAA,WAAW,MAAM,KAAK,WAAW,QAAQ,KAAK,OAAO,UAAU;AAI/D,SAAA,WAAW,MAAA;AAxGb;AAwGmB,yBAAK,WAAL,mBAAa,WAAU,UAAU,KAAK,OAAO;AAAA;AAK9D,SAAA,OAAO;AA7GT;AA8GI,yBAAK,WAAL,mBAAa,WAAU,SAAU,KAAK,OAAO,QAAQ,UAAU,QAAS;AAAA;AAM1E,SAAA,WAAW,CAAC;AAAA,MACV;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA,MAMa;AA9HjB;AAmII,UAAI,iBAAe,UAAK,WAAL,mBAAa,WAAU,QAAQ;AAChD,aAAK,UAAU,KAAK,OAAO,SAAS,CAAC,aAAa,KAAK;AAAA,MACzD,aAAW,UAAK,WAAL,mBAAa,WAAU,aAAa;AAE7C,YAAI,KAAK,KAAK,IAAA,IAAQ,KAAK,OAAO,OAAO;AACvC,eAAK,SAAS;AAAA,QAChB,WAAW,YAAY,eAAe,OAAO;AAC3C,eAAK,UAAU,MAAM,KAAK;AAAA,QAC5B;AAAA,MACF;AAIA,UAAI,CAAC,eAAe,eAAe,WAAS,UAAK,WAAL,mBAAa,WAAU,QAAQ;AACzE,aAAK,OAAO,QAAQ;AACpB,eAAO;AAAA,MACT;AACA,aAAO;AAAA,IACT;AAEA,SAAA,aAAa,MAAM;AAEjB,WAAK,IAAA;AACL,WAAK,SAAS,EAAE,OAAO,QAAQ,OAAO,MAAA;AAAA,IACxC;AAEA,SAAA,WAAW,MAAM;AA7JnB;AA8JI,YAAM,UAAQ,UAAK,WAAL,mBAAa,WAAU,UAAU,KAAK,OAAO;AAC3D,WAAK,IAAA;AACL,WAAK,UAAU,OAAO,KAAK;AAAA,IAC7B;AAGA,SAAA,MAAM,MAAM;AApKd;AAqKI,YAAM,MAAM,KAAK,KAAK,UAAA;AACtB,YAAI,UAAK,WAAL,mBAAa,WAAU,UAAU,OAAO,MAAM;AAChD,YAAI,aAAa,KAAK,OAAO,OAAO;AAAA,MACtC;AACA,WAAK,SAAS;AAAA,IAChB;AAMA,SAAA,SAAS,CAAC,WAAwB;AAChC,YAAM,eAAe,MAAM,KAAK,WAAA;AAChC,YAAM,aAAa,MAAM,KAAK,SAAA;AAC9B,aAAO,iBAAiB,cAAc,cAAc,oBAAoB;AACxE,aAAO,iBAAiB,YAAY,YAAY,oBAAoB;AAGpE,aAAO,iBAAiB,eAAe,YAAY,oBAAoB;AACvE,aAAO,MAAM;AACX,eAAO;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,QAAA;AAEF,eAAO,oBAAoB,YAAY,YAAY,oBAAoB;AACvE,eAAO;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,QAAA;AAEF,aAAK,IAAA;AAAA,MACP;AAAA,IACF;AAIA,SAAQ,YAAY,CAAC,OAAgB,kBAA2B;AAC9D,YAAM,MAAM,KAAK,KAAK,UAAA;AACtB,UAAI,CAAC,YAAA,KAAiB,OAAO,KAAM;AACnC,WAAK,IAAA;AACL,YAAM,OAAO;AAAA,QACX,OAAO;AAAA,QACP;AAAA,QACA;AAAA;AAAA,QAEA,SAAS,IAAI,WAAW,MAAM;AAC5B,cAAI,KAAK,KAAK,mBAAmB;AAC/B,iBAAK,UAAU,KAAK,OAAO,KAAK,aAAa;AAC7C;AAAA,UACF;AACA,eAAK,SAAS,KAAK,gBACf,EAAE,OAAO,aAAa,OAAO,KAAK,KAAK,IAAA,IAAQ,IAAA,IAC/C;AACJ,eAAK,KAAK,aAAA;AAAA,QACZ,GAAG,GAAG;AAAA,MAAA;AAER,WAAK,SAAS;AAAA,IAChB;AAAA,EAtIG;AAAA,EAEH,IAAI,QAAkB;AACpB,WAAO,KAAK;AAAA,EACd;AAmIF;"}