Skip to content

feat(lab): SearchField, iOS 26's search field: stacked, bottom and tab placements - #9399

Closed
vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:c09/bank-l1-searchfield
Closed

vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:c09/bank-l1-searchfield

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

User need

Astryx has no phone search field like iOS 26's UISearchController: a glass close button on UIKit's 383 ms clock, a bottom field above the keyboard with no page pan, and a tab bar's search tab.

Evidence

iOS: activate, cancel iOS: blocked thread
iOS: cancel mid-activation iOS: right to left
iOS: bottom, above the keyboard iOS: tab, no page move
Android: activate, cancel Android: bottom, above Gboard, measured from the visual viewport

The Android bottom clip is from this code; the others, from the replaced drafts.

Problem and solution fit

  • Stacked (default): a tap narrows the field and slides the close button in on UIKit's curve; the close button or Escape cancels.
  • Bottom: while active, the field's centre stays 36 px above the keyboard, measured from the visual viewport, never by overlaying the keyboard. Its gel replays frames measured on iOS.
  • Tab: only the content, sized to the tab bar's box, plus handleRef.cancel().
  • Pointers: in bottom and tab, a finger or pen tap on the text is claimed wherever the keyboard covers the page (Safari, Chrome on Android), so the page does not pan. Stacked keeps the browser's tap. A mouse gets end states at once.

Current specification

  • Lab: Lab is not shipped; no record governs SearchField.
  • Core, no behavior change: internal/touch re-exports existing modules for Lab, as spec:AST-017 FR21 allows. The animator's factory is createMotionAnimator, since no internal factory takes a public name (architecture:react-component-runtime INV13). claimFocusMoves is BottomSheet's keyboard hook's focus-move claim, extracted so both share it.
  • CLI: astryx discover lists SearchField because official-packages.json is regenerated from the repository, as spec:AST-051 FR11 requires.

Public delta and caller burden

  • Lab: SearchField with placement ('stacked' | 'bottom' | 'tab') and handleRef (focus(), cancel()).
  • Core internal/touch: createMotionAnimator and its types, cubicBezier, timing, useGestureRecognizer, useTouchPresentation, isRtlElement, claimFocusMoves; two catalog names.
  • Shared Lab modules: internal/useMotionAnimator, keyboard/useKeyboardHeight, internal/searchMotion.

Why one PR of this size

Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)

About 7,220 added lines replace five stacked drafts; over half are proofs (2,900 lines of interaction specs, 1,400 of unit tests).

Design and alternatives

  • Width animation relays out every frame and scaling stretches the capsule's ends, so rounded-end pseudo-elements and a scaling middle move on the compositor.
  • Bottom: a fitted spring is 3.8 pt off the measured motion, so frames replay.
  • Tab: iOS has one circle that becomes the cancel button, so the tab bar owns it.
  • Core's one click guard takes a claimed tap's late mouse events (fix(core): the click guard takes a claimed touch's late mousedown and mouseup #9677); resizes and focus moves use Core's observeResize and claimFocusMoves.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

What I ran and what it showed (Lab/SearchField stories):

Phone evidence on this head: a maintainer's check on a real phone, from this head's preview, of the bottom and tab placements with the keyboard up (pending).

On the drafts this replaces (the same component code apart from its imports, the keyboard measurement and Chrome's tap claim):

  1. iOS 26.2 simulator, real taps: the field narrows over about 380 ms without a jump, even through a 600 ms busy main thread, and cancel reverses. The bottom field keeps its centre 36 px above the keyboard, and Safari does not pan the page. The tab placement focuses without moving the page.
  2. Android emulator, Chrome: the same stacked activation and cancel.

On this component code:

  1. Android emulator, Chromium 158 with Gboard (spec:AST-099 DEC-3): first a bare fixed bottom input. With the browser's own tap, once the keyboard was up, the document scrolled 169 px and the visual viewport moved 312 px: the pan FR4 forbids. With a claimed tap (focus with preventScroll from the tap), Gboard came up and nothing moved. Then the bottom story: the tap focused the field, Gboard came up (visual viewport 754 → 442 px), and the field lifted 296 px, so its centre sat 36 px above the keyboard. The page did not scroll (scroll 0, visual-viewport offset 0), and overlaysContent stayed false. The close button brought it back to rest.
  2. Chromium and WebKit interaction specs (stacked, bottom, tab): each part follows UIKit's curve within half a pixel, and a mouse sees the end states at once. Reduced motion only fades, and Escape keeps focus in the search landmark. A change of mind is armed in the page at a set point of the motion's own clock: 120 ms into the activation, or the cancel's first frame. The page then holds the turn's own animations, so every run checks the whole turn. With Chrome on Android's keyboard report present, the field claims the tap and measures the visual viewport, and overlaysContent stays false. The story's stand-in tab bar also keeps above the keyboard with that API on the page. Written first, both tests failed before their fixes. A claimed tap's late mouse events never reach the page: built against Core's guard before fix(core): the click guard takes a claimed touch's late mousedown and mouseup #9677, that case failed in Chromium and WebKit. A resize while the field is active rescales the middle at once; with the resize subscription removed, that case fails.
  3. Unit tests: server rendering, translated names, focus after a cancel, the model's geometry, and the measured values' consistency. The keyboard listener reads the visual viewport with the VirtualKeyboard API present and never sets overlaysContent. A move that stops early, with no newer move, leaves the next one to start from rest. Each of these was written first and failed before its fix. claimFocusMoves has its own cases; with its focus removed, four BottomSheet keyboard tests fail.

Records and clauses checked: spec:AST-017 FR21 and architecture:react-component-runtime INV3 (each Effect names what it syncs), INV11 and INV13 (Lab keeps its own keyboard listener and reaches Core only through internal entries, none named like a public factory; one stray-click swallower, Core's); spec:AST-051 FR11 (the generated official list); architecture:knowledge-contracts INV29 (source comments and file headers); architecture:internationalization INV15 (per-module English is for Core modules; Lab keeps the public translator); architecture:component-style-authoring INV7 (decoration as pseudo-elements); design:reduced-motion DR2; and, followed though Lab is not governed, spec:AST-099 FR2, FR4, FR6, FR8, DEC-2 and DEC-4.

Local gates at ee42864, on main 4a8a76e, with node 24.22.1 from .nvmrc: install, pnpm review:pre, strict ESLint over the whole repo (0 errors), typecheck, the full build, Changeset coverage, export sync, the CLI readme and API types, i18n, token docs, Lab readiness, knowledge records, the ShadCN checks, pnpm api:report (no change), and docsite data and tests (63 files) all pass. pnpm test passes every project: 1,617 files, 34,999 tests. Interaction specs: all 46 SearchField cases pass in Chromium. BottomSheet's six a11y contracts pass in Chromium (44 tests), since its keyboard hook now calls claimFocusMoves. WebKit's CI profile (4 CPUs, 1 worker): the bottom placement ×10, 210 passed and 20 skipped; the stacked and tab placements ×1, 20 passed and 3 skipped. The skips are the Chromium-only trace and Android tests, WebKit's busy-thread proof, and the pen tests WebKit cannot drive. The gates ran on the tree at c4317b1b91. This head adds fixes since, each with cases that fail without it: the focus-move claim reads the live input after a placement switch remounts it, and a runtime placement switch places every part at once, resting the parts the previous placement moved, under a mouse and under a finger. At this head the Lab and core-internal unit tests (77), strict ESLint on the changed files, typecheck, the 46 SearchField interaction cases in Chromium and the three SearchField specs in WebKit's CI profile (41 passed, 5 skipped) pass again.

Supersedes #8180 #8181 #8216 #8224 #8689.

Other references: #8562, #8360 and #9677 (click guard), #8192 (TabBar's search tab).

@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 3, 2026 12:42pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 1, 2026
@astracat-bot

astracat-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 12:54 PM UTC

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Modified components

Component Size LoC Visual · A11y · RTL
lab/SearchField 52.9 kB (new) · 20 B CSS (new) 1,395 (new) — · ✓ · ✗
lab/MobilePowerSearch 168.9 kB (+61 B) · 1.2 kB CSS (0) — — · — · —
PowerSearch 184.2 kB (+57 B) · 20.6 kB CSS (0) — — · — · —
25 more components
Component Size LoC Visual · A11y · RTL
TimeInput 90.1 kB (+46 B) · 14.3 kB CSS (0) — — · — · —
lab/MobileTokenizer 100.9 kB (+46 B) · 880 B CSS (0) — — · — · —
DateTimeInput 115.1 kB (+35 B) · 15.8 kB CSS (0) — — · — · —
DateInput 103.3 kB (+34 B) · 15.3 kB CSS (0) — — · — · —
BottomSheet 35.1 kB (+29 B) · 7.5 kB CSS (0) — ✓ · ✓ · —
lab/CommentThread 226.9 kB (+17 B) · 2.4 kB CSS (0) — — · — · —
richtext/RichTextEditorToolbar 221.8 kB (+17 B) · 454 B CSS (0) — — · — · —
lab/Comments 232.3 kB (+16 B) · 2.5 kB CSS (0) — — · — · —
lab/CommentComposer 102.4 kB (+14 B) · 1.9 kB CSS (0) — — · — · —
Table 84.4 kB (+1 B) · 16.0 kB CSS (0) — — · — · —
BottomSheetSwitcher 15.0 kB (0) · 3.4 kB CSS (0) — ✓ · ✓ · —
ContextMenu 75.1 kB (0) · 15.3 kB CSS (0) — — · — · —
DropdownMenu 73.1 kB (0) · 15.4 kB CSS (0) — — · — · —
MoreMenu 73.4 kB (0) · 15.4 kB CSS (0) — — · — · —
MultiSelector 72.7 kB (0) · 15.2 kB CSS (0) — — · — · —
Pagination 85.9 kB (0) · 16.0 kB CSS (0) — — · — · —
Popover 50.1 kB (0) · 12.4 kB CSS (0) — — · — · —
Selector 77.0 kB (0) · 15.7 kB CSS (0) — — · — · —
TableCell 76.3 kB (0) · 15.8 kB CSS (0) — — · — · —
Tokenizer 70.2 kB (0) · 17.1 kB CSS (0) — — · — · —
lab/ChatEmojiPicker 65.1 kB (0) · 773 B CSS (0) — — · — · —
lab/ChatReactionBar 65.6 kB (0) · 1.1 kB CSS (0) — — · — · —
lab/TourStep 55.4 kB (0) · 590 B CSS (0) — — · — · —
Markdown 145.1 kB (−1 B) · 18.3 kB CSS (0) — — · — · —
TableHeaderCell 75.8 kB (−1 B) · 15.5 kB CSS (0) — — · — · —

Package size:

  • @astryxdesign/cli 3.31 MB packed (+4 B); no new dependencies
  • @astryxdesign/core 6.04 MB packed (+1.5 kB); no new dependencies

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 1, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 1, 2026 23:34

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs an owner decision before it can be approved. SearchField is a new Lab component with no governing spec, and because the PR also changes Core, the CLI, Storybook and scripts, the Lab-only exemption does not apply. An engineering owner can identify the governing record, or record the decision by commenting /approve-spec 38cc72f4acbfdced954510147422367c5b799a0c on this pull request (the author may, as an engineering owner). Independently of that decision, these need changes:

  • useKeyboardHeight takes Chrome on Android's keyboard height from navigator.virtualKeyboard and sets overlaysContent = true, which AST-099 DEC-4 and DEC-2 reject, so measure by FR2's visual viewport in every browser.
  • The new internal/motion entry adds a second route to the animator and re-exports the internal factory as createAnimator, the public factory's name, against react-component-runtime INV13, so re-export it from internal/touch under a non-public name.
  • At about 7,600 authored lines the PR should be split (Core entries and shared Lab modules, the stacked field, the bottom placement, the tab placement), and the two latest-ref layout effects in SearchField.tsx and the body's every-placement tap-claim statement need fixing.

[Automated review]

const hold = () => {
if (appOverlay == null) {
appOverlay = keyboard.overlaysContent;
keyboard.overlaysContent = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting overlaysContent = true overrides the app's own keyboard setting page-wide while the field has focus, and the height then comes from boundingRect. AST-099 DEC-4 and DEC-2 reject both ("Rejected: the browser's keyboard geometry report as the height"; "Rejected: ... overriding the app's viewport setting"). Measuring by FR2 (keyboardInsetOf) in every browser would conform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3eb0d70: useKeyboardHeight measures the keyboard from the visual viewport in every browser, and never touches navigator.virtualKeyboard or overlaysContent. New tests with Chrome's API present check both.

Comment thread packages/core/src/internal/motion.ts Outdated
* SYNC: When modified, update motion.test.ts and scripts/sync-exports.js.
*/

export {createAnimator} from '../motion/animator';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react-component-runtime INV13 routes the animator through internal/touch and says no internal factory shares a public factory's name; AST-107 FR6 names the public factory createAnimator(element). Re-exporting from internal/touch under a non-public name would keep both claims.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3eb0d70: the internal/motion entry is gone. internal/touch re-exports the factory as createMotionAnimator, and its test checks that the entry exports no public factory name.

Comment on lines +532 to +534
useLayoutEffect(() => {
focusOptionsRef.current = focusOptions;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This layout effect (and the cancelRef one at 588-590) synchronizes no external system. useImperativeHandle(handleRef, () => ({...}), [placement, cancel]) can compute the focus options and call cancel directly, and handleClear can read focusOptions with placement in its deps.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3eb0d70: both effects are gone. useImperativeHandle(handleRef, …, [placement, cancel]) computes the focus options and calls cancel directly, and handleClear reads them with placement in its deps.

@vjeux
vjeux force-pushed the c09/bank-l1-searchfield branch from 38cc72f to 3eb0d70 Compare November 2, 2026 19:52
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

The review's summary points, at 3eb0d70:

  • The description now scopes the tap claim to the bottom and tab placements. The stacked field keeps the browser's tap.
  • It stays one PR: Cindy asked for batches, and the description gives the size reason.
  • Lab needs no record. The Core change only re-exports existing modules through internal/touch (spec:AST-017 FR21), and the CLI's discover list is regenerated from the repository (spec:AST-051 FR11). The description's Current specification says so.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 3eb0d70

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes are needed before this can merge; SearchField itself needs no further spec decision, so what remains is code and scope.

  • SearchFieldTouch.ts adds its own document-level swallower for a claimed tap's late mouse events beside Core's click guard; extend the click guard, which the gesture host already arms on the claimed lift, and delete the local copy.
  • At about 7,200 authored lines with no behavior-module exception, please split it (Core entries and shared Lab modules, the stacked field, the bottom placement, the tab placement).
  • Derive claimsTap during render instead of setting it in a layout effect, and give the two motion effects the note naming the system they synchronize.
  • In the story, drop the navigator.virtualKeyboard check in the stand-in tab bar and the deep import of Core's touch-presentation module, and remove the unread bar and scrollAway tables from searchMotion.ts.

[Automated review]

// lets go (its click, the next press, or its window), the late events are
// over, and this swallow lets go too.
const disarmRef = useRef<(() => void) | null>(null);
const armSwallow = useCallback((doc: Document) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a second stray-click swallower (document capture listeners for mousedown, mouseup and click) beside Core's click guard, which the gesture host already arms on a claimed lift (utils/clickGuard.ts, GestureHost.ts). Extending the click guard to also take that lift's late mousedown/mouseup would keep one path, and this block, its unmount effect and the isClickGuardArmed re-export could go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept here: extending Core's shared click guard changes behavior for every gesture host (useMenuPress and GestureHost, under AST-057 and AST-094). That needs its own Core PR, with an AST-094 clause and tests, rather than a ride in a Lab batch. It is #9623; when it lands, this swallower and the isClickGuardArmed re-export go.

// Firefox, no software keyboard rises. There a mouse or a pen tablet keeps
// the browser's own focus and click.
const [claimsTap, setClaimsTap] = useState(false);
useLayoutEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claimsTap comes from capability reads, not a DOM measurement, so it can be derived during render: !isStacked && isTouch && typeof window !== 'undefined' && keyboardCoversPage(window). isTouch is false on the server and during hydration, so no markup differs, and touch mounts stop rendering twice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1748182: claimsTap is derived during render, from the same window useTouchPresentation reads. isTouch is false on the server and while hydrating, so no markup differs.

const viewport = win.visualViewport;
const inset =
viewport == null ||
'virtualKeyboard' in win.navigator ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keeps the rule the head removed from useKeyboardHeight: with navigator.virtualKeyboard present (Chromium, Chrome on Android) the stand-in bar reads 0 and stays under the keyboard. In a Chromium phone viewport with a 337 px stand-in keyboard, the bar's bottom stays 33px; with the API removed it is 349px.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1748182: the stand-in bar measures the visual viewport whatever the API. A new SearchFieldTab case keeps Chrome's API on the page and checks the 36 px lift; it failed on the old story.

import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {SearchField, type SearchFieldHandle} from '@astryxdesign/lab';
import {useTouchPresentation} from '@astryxdesign/core/utils/touchPresentation';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reaches Core's touch-presentation module by a source path Core does not export (it resolves only through Storybook's alias), which INV13 reserves for Core and Lab through internal/touch. matchMedia('(pointer: coarse)'), as the interaction specs use, would do for the stand-in host.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1748182: the story reads matchMedia('(pointer: coarse)') itself, with no Core import.

Comment on lines +74 to +116
bar: {
/**
* The fraction of the activation by which the navigation bar's back
* button and large title are gone. With p the activation's progress, the
* back button's opacity is 1 − p / 0.35, and it does not move. The large
* title's opacity is (1 − p / 0.35)², and the inline title stays hidden.
* The bar itself shrinks to its compact height on the activation's clock.
* @source measured: a pushed screen with a large title: the bar's height
* and the back button's and titles' alphas per frame over an activation
*/
fadeOutProgress: 0.35,
/**
* On cancel the large title fades back in over the activation's last
* stretch, from this fraction: opacity (p − 0.65) / 0.35. The back button
* is back at full opacity from the cancel's first frame.
* @source measured: the same screen over a cancel with the close button
*/
largeTitleFadeInProgress: 0.65,
},
scrollAway: {
/**
* How far the content must scroll before the field (with
* `hidesSearchBarWhenScrolling`) starts collapsing with it, pt. After that
* it follows 1:1.
* @source measured: contentOffset and the field's frame per frame over
* slow pans
*/
hysteresisPt: 10,
/**
* The collapse released past this many points snaps the field closed;
* short of it the field springs back open.
* @source GUESS — between the two measured releases: at 15 pt the field
* springs open, at 30 pt it snaps closed
* @measure M-SE1
*/
collapseThresholdPt: 22,
/**
* The snap closed (or open) after a release, in ms.
* @source measured: contentOffset per frame after a release at 30 pt
* (−138 → −116)
*/
snapMs: 230,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No shipped code reads bar or scrollAway (only a test sums two bar values), and collapseThresholdPt is a GUESS. These could land with the bar or scroll-away behavior that reads them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 1748182, with the test that summed them. They can land with the behavior that reads them.

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

The review's summary points, at 1748182:

  • Size: this PR is a batch, declared in the description (Batch: L1 · Lab: iOS chrome (TabBar, SearchField, NavigationStack, PageControl)), under the maintainers' batching direction, so it stays one PR.
  • Click guard: kept local, with the reason in its thread. The Core extension is Click guard: also swallow a claimed touch's late mousedown and mouseup #9623.
  • claimsTap is derived during render, and both motion effects name what they sync.
  • Story: no VirtualKeyboard check and no deep import.
  • searchMotion: the unread bar and scrollAway tables are gone.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1748182

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: SearchField itself is approved for this head and its size fits the owner-directed batch, but SearchFieldTouch.ts still carries its own swallower for a claimed tap's late mousedown, mouseup and click beside Core's click guard, and the touch runtime contract allows only one stray-click swallower. Please land the click-guard extension tracked in #9623 first, then delete armSwallow, its unmount effect, its call in the tap handler and the isClickGuardArmed re-export from internal/touch. The inline comments also suggest four small comment rewrites; those are optional.

[Automated review]

// lets go (its click, the next press, or its window), the late events are
// over, and this swallow lets go too.
const disarmRef = useRef<(() => void) | null>(null);
const armSwallow = useCallback((doc: Document) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This arms a second document-level swallower for a claimed tap's late mousedown, mouseup and click, beside Core's click guard, which the gesture host already arms at a claimed lift. The touch runtime contract (react-component-runtime INV13) allows only one stray-click swallower, and when the guard takes the click first these listeners stay on the document until the next press or key. Extending the guard (#9623) and deleting this copy keeps one path for every recognizer that claims a held tap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee42864: #9677 extended Core's click guard to take a claimed touch's late mousedown and mouseup, so armSwallow, its unmount Effect and the tap handler's call are gone. The interaction case now checks that the shared guard keeps those events from the page; built against the guard before #9677, it fails.

Comment thread packages/core/src/internal/touch.ts Outdated
} from '../motion/animator';
export {cubicBezier} from '../motion/bezier';
export {timing} from '../motion/uikit/timing';
export {isClickGuardArmed} from '../utils/clickGuard';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the local swallower in SearchFieldTouch.ts reads this; it can go with that swallower once the click guard takes the late mouse events itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in ee42864, along with its test and the changeset wording.

* iframe's visual viewport does not change for the keyboard. Chrome on
* Android counts too: its visual viewport shrinks for the keyboard as Mobile
* Safari's does. The page never takes the keyboard's own report or overlays
* the keyboard. That report comes later and overrides the app's own viewport

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: reading the report needs overlaysContent, and that is what overrides the app's setting. Suggest: "The browser's own keyboard report is not read. It needs overlaysContent, which overrides the app's viewport setting."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in ee42864.

/**
* The software keyboard's height as of its last report, while `enabled`,
* with no ride and no prediction: keyboardInsetOf on each visual-viewport
* resize, in every browser. `focusRoot` is the surface whose text controls

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the hook no longer filters by focus; it reads only focusRoot's window (line 57). Suggest: "focusRoot names the document whose window is measured."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in ee42864.

Comment thread packages/core/src/internal/touch.ts Outdated
/**
* @file touch.ts
* @input The gesture core: the arena, the recognizers, and their geometry
* @input The gesture core: the arena, the recognizers, their geometry and

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the @input now lists every re-export and the header runs to 22 lines. Suggest: "@input The gesture core and the motion, touch and direction modules Lab uses".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in ee42864. The rest of the header is main's own; its citations are the file's only links to their records, so it stays at main's length.

const fieldRef = useRef<HTMLDivElement>(null);
const isBottom = placement === 'bottom';
const isTab = placement === 'tab';
// The touch layer (SearchFieldTouch.ts): the parts' poses, UIKit's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this restates what useSearchFieldMotion returns; it can be deleted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in ee42864.

…ternal touch entry

Internal entries land with their first user, per meta#8562. Lab's SearchField
animates with Core's animator, solves UIKit's timing curves with Core's
cubic-bézier solver, listens for taps with Core's gesture hook, reads the
writing direction as Core does and claims focus moves as BottomSheet's
keyboard hook does, so `@astryxdesign/core/internal/touch` re-exports, by
name:

- the animator's internal factory as `createMotionAnimator`, a name no
  public factory has (architecture:react-component-runtime INV13;
  spec:AST-107 FR6's public factory is `createAnimator`), with its types
  `MotionAnimator`, `MotionAnimatorOptions`, `AnimateOptions`,
  `MotionCurve` and `MotionHandle`;
- `cubicBezier` and UIKit's `timing` table;
- `useGestureRecognizer`, `useTouchPresentation` and `isRtlElement`;
- `claimFocusMoves`, new in `utils/`: the capture-phase `blur` claim that
  BottomSheet's keyboard hook made inline, which that hook now calls. Its
  BottomSheet tests prove the move unchanged: with the claim's focus
  removed, four of them fail.

Named re-exports of code already on main, plus that one extraction: no
behavior changes, no docs, no root re-export, and the `@astryx/no-core-internal-import` lint
rule already limits the entry to Core and Lab. The entry's test checks it
re-exports the same values Core imports by path, and that it carries no
public factory's name.

Two Lab modules that other Lab components will share come with them:
`internal/useMotionAnimator`, a stable animator bound to an element through
a callback ref, and `keyboard/useKeyboardHeight`, Lab's listener for the
software keyboard's height: spec:AST-099 FR2's measurement from the visual
viewport, in every browser (DEC-4).
@vjeux
vjeux force-pushed the c09/bank-l1-searchfield branch from 1748182 to ee42864 Compare November 3, 2026 07:16
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

At ee42864, rebased on main after #9677:

  • Core's click guard now takes a claimed tap's late mousedown and mouseup (fix(core): the click guard takes a claimed touch's late mousedown and mouseup #9677), so SearchField's own swallower and the isClickGuardArmed re-export are gone.
  • The field's resize observation uses Core's shared observeResize.
  • Its focus-move claim now shares one helper with BottomSheet's keyboard hook: claimFocusMoves, in Core's utils/, re-exported through internal/touch.
  • The four optional comment rewrites are in, along with the comment points CodeBunny raised on the stacked PRs.

It is a draft again; the new head needs a fresh /approve-spec.

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec ee42864

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: SearchField is approved for this head and its size fits the owner-directed batch, but one concrete fix remains.

  • SearchFieldTouch.ts subscribes its focus-move claim with the input node read at setup, so after placement switches between 'tab' and 'bottom' (which remounts the input) focus moved into the field from another control is no longer claimed and the browser can pan the page; read inputRef.current inside the pick and add a placement-switch test.

Two optional comment tidy-ups are noted inline.

[Automated review]

Comment on lines +523 to +525
return claimFocusMoves(doc, event =>
event.relatedTarget === input ? input : null,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pick compares against the input read when the effect set up, and the effect re-runs only when claimsTap changes. Switching placement between 'tab' and 'bottom' keeps claimsTap true but remounts the input (it moves into the content div only for bottom), so the claim then holds the detached node and a focus move into the new input is left to the browser. Reading the node at event time keeps it current:

return claimFocusMoves(doc, event =>
  event.relatedTarget === inputRef.current ? inputRef.current : null,
);

A SearchField.test.tsx case that rerenders tab → bottom and checks the claim would hold it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b86fe2: the pick reads inputRef.current at event time. A new SearchField.test.tsx case renders tab, rerenders bottom, and checks that a focus move into the new input is claimed with preventScroll; it fails at ee42864.

},
});

// Where the clear button sits inside the field's trailing end, px.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this restates the constant. Naming it CLEAR_INSET_PX would say the unit, and the comment could go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in 7b86fe2: CLEAR_INSET_PX, and the comment is gone.

Comment on lines +159 to +160
// The state the parts last settled toward (null before the first
// layout), whether a move is still playing, and the latest move's number.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this narrates the three ref names. Keeping only the non-obvious part reads: "settledRef is null until the first layout places the parts."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in 7b86fe2.

@vjeux
vjeux force-pushed the c09/bank-l1-searchfield branch from ee42864 to 7b86fe2 Compare November 3, 2026 08:34
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 7b86fe2

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: SearchField is approved for this head and its size fits the owner-directed batch, but one concrete fix remains.

  • SearchFieldTouch.ts resets only the current placement's motion values (place for stacked, placeBottom for bottom, nothing for tab), so a runtime placement switch leaves the previous placement's values on the shared parts: after bottom → stacked the active close button is drawn at a fifth of its size, after a lifted stacked → bottom the field sits 53 px above its bar, and after bottom → tab the clear button sits 44 px inside the box; reset the other placements' channels on each switch and add a test per switch.

The earlier focus-move finding is fixed, with a test that covers the remount.

[Automated review]

Comment on lines +149 to +157
const place = useCallback(
(pose: SearchFieldPose) => {
rootAnimator.animator.set({y: pose.y});
middleAnimator.animator.set({scaleX: pose.middleScale});
endAnimator.animator.set({x: pose.endX});
closeAnimator.animator.set({x: pose.closeX, opacity: pose.closeOpacity});
},
[rootAnimator, middleAnimator, endAnimator, closeAnimator],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

place writes only the stacked channels, and placeBottom (below) only the bottom ones; tab writes none. The close button and the trailing part are the same nodes in every placement, so a runtime placement switch keeps the old values: after bottom → stacked the close button keeps scale: 0.2 (8.8 × 8.8 px when active), a lifted active stacked → bottom keeps the root's translate: 0 -53px, and an active bottom → tab keeps the trailing part's translate: -44px. Reset the other placements' channels here and in placeBottom (close scale 1, start/content/middle x 0, scaleY 1, root y 0, trailing x 0 for tab), and add a switch case per pair to SearchField.test.tsx.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0c4167a: place also rests the channels only the bottom placement moves (close scale, start, content and middle x, the scaleYs), placeBottom rests the stacked lift (root y), and a switch into tab rests every part. Three cases in SearchField.motion.test.tsx cover bottom → stacked, lifted stacked → bottom and bottom → tab; each failed at 7b86fe2 with the stale value (0.2, −53, −44).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: SearchField needs no further spec decision and its size fits the owner-directed batch, but the placement-switch fix is incomplete.

  • Under a finger, a placement switch that lands in the same render as an isActive change still takes the animated path in SearchFieldTouch.ts, which moves only the new placement's parts, so bottom → active stacked draws an 8.8 px close button, active bottom → inactive stacked shifts the leading cap and middle 16 px, and a lifted active stacked → inactive bottom leaves the field 53 px above its bar; place every part at once whenever placement changed, whatever the pointer, and repeat the three switch cases in SearchField.motion.test.tsx under a coarse pointer.

Under a mouse, all three switches now land correctly.

[Automated review]

place(poseFor(target, geometry()));
return;
}
settledRef.current = target;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under a finger, a placement switch that also flips isActive reaches this animated path, which plays only root y, middle scaleX, end x and close x/opacity from presented(). The channels only bottom drives stay as they were: after bottom → active stacked the close button keeps scale: 0.2 (8.8 × 8.8 px in Chromium), and after active bottom → inactive stacked the leading cap and middle keep x: -16. The bottom effect's animated path has the same gap for root y (a lifted field stays 53 px up). When placement changed since the last layout, place every part at once whatever the pointer, and add the three switch cases under a coarse pointer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3f2a38c: the stacked and bottom effects place every part at once whenever placement changed since the parts were last placed, whatever the pointer. The three switch cases now also run under a coarse pointer with the active state changing in the same render; each failed at 0c4167a with the stale value (0.2, −16, −53).

…it's clock

Lab's SearchField is the phone search field of iOS 26's UISearchController,
in three placements:

- stacked: under a navigation bar's title, a tap activates it into the bar
  over UIKit's 383 ms ease-in-ease-out: the field narrows, a glass close
  button slides in beside it, and the large title rides up and fades; the
  close button cancels on the same clock;
- bottom: iOS 26's toolbar field, resting above the keyboard by the
  keyboard's height as spec:AST-099 FR2 measures it from the visual
  viewport, in every browser (DEC-4), never overlaying the keyboard on the
  page (DEC-2); its gel plays the activation and cancel frames measured on
  iOS, and a change of mind mid-motion turns from where the field is,
  without a snap;
- tab: the content a tab bar's search tab holds.

In the bottom and tab placements, under a finger or a pen on a touch
screen, wherever the keyboard covers the page (Mobile Safari, Chrome on
Android), the field claims a tap on its text, so it focuses without the
browser's reveal, which would pan the page (FR4), and the caret lands
where iOS puts it; Core's click guard keeps the claimed tap's late mouse
events from the page. The stacked field keeps the browser's own tap.
With a fine primary pointer (a mouse, a desktop pen tablet) the browser
keeps its own focus and click, and the parts take their places at once.
With reduced motion the travel becomes a cross-fade under a finger
(design:reduced-motion DR2). The values measured on the iOS 26.2 simulator
live in Lab (internal/searchMotion.ts), where the bars that host the field
can read them too. Its close and clear buttons' names are in the shared
message catalog, as Lab's other components' are. `astryx discover` lists
it.

Its unit tests cover the model, the motion and the field; its interaction
specs prove the stacked, bottom and tab placements in Chromium and WebKit,
including the change-of-mind turns on the motion's own clock and, with
Chrome on Android's keyboard report present, the claimed tap and the
visual-viewport measurement.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending approval: SearchField's direction is settled, every change outside Lab rests on a current record, and the size fits the owner-directed batch.

  • The placement-switch fix holds: under a finger, a switch that also changes the active state now places every part at once, and the three new coarse-pointer cases in SearchField.motion.test.tsx fail against the previous touch layer and pass now.

No blocking findings remain.

[Automated review]

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 3f2a38c

@vjeux
vjeux marked this pull request as ready for review November 3, 2026 13:27
@cixzhang
cixzhang marked this pull request as draft November 3, 2026 13:37
@cixzhang
cixzhang marked this pull request as ready for review November 3, 2026 13:37
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/revoke-spec 3f2a38c

@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Retired: SearchField becomes Core TextInput (type="search"), and its measured keyboard ride lives on in the Core keyboard rule (#9750) and module (#8559, #9776, #9777). Thank you — this PR measured the ride the Core work is built on.

@vjeux vjeux closed this Nov 3, 2026

This branch was successfully deployed

1 active deployment
Preview — 3f2a38c3 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants