Skip to content

feat(core): minQueryLength — hold the typeahead search until the query is long enough - #5385

Open
freddymeta wants to merge 3 commits into
mainfrom
feat/typeahead-min-query-length
Open

feat(core): minQueryLength — hold the typeahead search until the query is long enough#5385
freddymeta wants to merge 3 commits into
mainfrom
feat/typeahead-min-query-length

Conversation

@freddymeta

Copy link
Copy Markdown
Contributor

Closes #5384 (RFC filed alongside this branch — happy to hold the code until the shape is agreed; opening it as a draft so the discussion has something concrete to argue with).

The problem

BaseTypeahead searches every non-empty query, and performSearch opens the layer whenever searchQuery.length > 0 — including when the source came back empty. On a remote source that means a request per keystroke, and, worse, a menu reading No results found after one character, when the truth is that one character matched too much to be worth asking.

A SearchSource can already refuse a short query (q.length < 3 ? [] : fetch(q)), which covers the network. It cannot close the menu: the open decision lives inside the component and there is no controlled isOpen, no imperative handle, and onOpenChange only reports. The one lever left to a wrapper is CSS-hiding Astryx's own popover, which is worse than the empty state it hides.

The change

minQueryLength?: number on BaseTypeahead, forwarded by Typeahead and Tokenizer.

<Typeahead
  label="Assignee"
  searchSource={userSource}
  value={assignee}
  onChange={setAssignee}
  minQueryLength={3}
/>
query length minQueryLength={3} default (1)
0 untouched state; hasEntriesOnFocus decides, as today unchanged
1–2 no search, no live-region announcement, menu closed, in-flight search cancelled unchanged — searches
3+ searches and opens, as today unchanged

The default is 1, which is exactly today's behaviour — every non-empty query searches, and every existing call site renders identically. The prop is the opt-in.

Two follow-on details, both deliberate:

  • ArrowDown does not fall back to bootstrap entries while a below-threshold query sits in the input. The closed-menu ArrowDown branch bootstraps when hasEntriesOnFocus and there are no results; below the threshold that would open a menu of default suggestions that ignore the two characters already typed. Gated on the same predicate, so with the default it never fires differently.
  • The live-region announcement is gated with the search, not separately — the "no results found" a screen reader hears today after one keystroke is the same defect from the other side.

Naming

minLength is what jQuery UI (default 1) and PrimeReact (default 1) call it, and I started there. On a component that renders an <input> it reads as the native minlength validation attribute — a different length of a different string — which is exactly the collision API Conventions says not to create. minQueryLength names the string being measured, and matches PowerSearchField.typeaheadMinQueryLength, which is this same concept already in the repo one layer up. Happy to change it if the team reads that differently.

Deliberately not in this PR

No "type N more characters" hint. Below the threshold the field looks untouched. A hint would be a new user-facing string, a new i18n key across 29 locales, and a design decision about a state the design system has never had — that is the team's call, and it is the open question in #5384. Consumers who want one today can put it in description. Whichever way that lands, it composes with this prop rather than replacing it.

Not wired into Selector / MultiSelector / CommandPalette. They render BaseTypeahead over local, already-loaded item lists where the threshold buys nothing.

Verification

  • pnpm test11,510 tests / 560 files pass, including 5 new ones (4 in Typeahead.test.tsx, 1 in Tokenizer.test.tsx).
  • pnpm lint:strict — 0 errors (55 pre-existing warnings, none in the touched files).
  • pnpm -F @astryxdesign/core typecheck (tests included) — clean.
  • pnpm build — clean. pnpm sync:exports:check — up to date.
  • Changeset: [feat] → patch (0.x).

The new tests carry their own positive controls: the "does not search below the threshold" test then types the third character and asserts the same harness does see search('App') and aria-expanded="true", so a permanently-closed menu could not pass it. There is also a regression test that the first character still searches when the prop is unset.

No visual change — the styling is untouched; the only difference is whether an existing popover opens.

Docs

BaseTypeahead, Typeahead and Tokenizer .doc.mjs (en + zh + dense), a Minimum Query Length story, and an argTypes control.

… is long enough (#5384)

BaseTypeahead searched every non-empty query and opened the layer whenever
the query was non-empty, so a remote source saw a request per keystroke and
the user saw "No results found" for a one-character query that matched too
much rather than too little. A SearchSource can already refuse a short
query, but the open decision is internal — nothing outside the component
could keep the empty state closed.

minQueryLength (default 1 — today's behaviour) gates the search, the
result-count announcement and the menu. Forwarded by Typeahead and
Tokenizer; ArrowDown does not fall back to bootstrap entries while a
below-threshold query sits in the input.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 24, 2026
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Aug 24, 2026
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 25, 2026 4:52am

Request Review

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

DateInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 3257 -
Complexity N/A Very High (354) -
Tokenizer (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 680 -
Complexity N/A Very High (102) -
Typeahead (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1200 -
Complexity N/A Very High (168) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: 1 accessibility violation(s) found — 1 serious.

Tokenizer - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/21 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@freddymeta
freddymeta marked this pull request as ready for review August 24, 2026 14:53
cixzhang added a commit to cixzhang/astryx that referenced this pull request Aug 24, 2026
cixzhang added a commit to cixzhang/astryx that referenced this pull request Aug 24, 2026
default: '10',
},
{
name: 'minQueryLength',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still English in docsZh. BaseTypeahead's is translated.

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this, and for the RFC first — the shape is right.

One thing to fix. The below-threshold branch bumps the search generation but
never clears isLoading, so the in-flight search fails its own check in
finally. Backspace from three characters to two on a remote source and the
field spins forever — "Loading" in the a11y tree until the third character goes
back in. Without the prop the same gesture clears in ~1.2s.

setResults([]);
setHasSearched(false);
setIsLoading(false);
interrupted mid-search search allowed to finish
stuck clean

"Ap" in both, three seconds after the same backspace.

The empty-field path above strands the same way on main; the line fixes both.

hasCreate builds the Create entry inside the search, so the threshold gates
creating too — QA + Enter adds a tag today and nothing with
minQueryLength={3}. Should Create sit outside the gate?

Also needs a merge with main (#5400
moved the keydown switch).

Can you add that line and re-push?

[Reviewed by Robohands]

… doc entry

Addresses the review on #5385.

**The stranded loading state.** Falling below `minQueryLength` bumps the search
generation to abandon the in-flight search — and that is exactly what makes the
abandoned search's own `finally` decline to clear `isLoading`, since it guards
on `searchGenRef.current === gen`. The field then reported "Loading" to
assistive technology until another search settled. Backspacing from three
characters to two on a remote source is the everyday way to reach it.

The same branch also covers the empty-field path, which strands identically and
does so on main today.

`handleSelect` abandons a search the same way and had the same hole: the menu
can still be showing the previous query's results while the next search is in
flight, so choosing one bumps the generation with a search still out. One line
there too.

**The zh doc entry.** `Tokenizer.doc.mjs`'s `docsZh` carried the English
description for `minQueryLength`; BaseTypeahead's was already translated. Now
matches it. (The short-blurb map lower in the same `docsZh` stays English —
that is how every prop in it reads, `menuWidth` included.)

**The merge.** #5400 added `menuWidth` beside this branch's `minQueryLength` in
the same four files, so every conflict was two props landing at one spot: both
kept, main's first. Two of them cut through an object literal in the doc props
array and had to be split back into separate entries rather than concatenated,
or the merged object would carry two `name:` keys and silently document only
one prop.

Verified in Chromium against a built Storybook, a 1.2s remote source and
`minQueryLength={3}`, reading the `role="status"` element's accessible name
three seconds after the backspace:

    before   whileSearching: "Loading"   afterBackspace: "Loading"
    after    whileSearching: "Loading"   afterBackspace: null

The regression test covers the below-threshold path. The select path is not
unit-tested: this suite's popover mock never puts options in the accessibility
tree, so there is no way to select one from jsdom.
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. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC] Typeahead/Tokenizer: no way to hold the search until the query is long enough

2 participants