feat(core): minQueryLength — hold the typeahead search until the query is long enough - #5385
feat(core): minQueryLength — hold the typeahead search until the query is long enough#5385freddymeta wants to merge 3 commits into
Conversation
… 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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsDateInput (@astryxdesign/core) · View in Storybook
Tokenizer (@astryxdesign/core) · View in Storybook
Typeahead (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: 1 accessibility violation(s) found — 1 serious. Tokenizer - 1 issue(s)
Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
| default: '10', | ||
| }, | ||
| { | ||
| name: 'minQueryLength', |
There was a problem hiding this comment.
Still English in docsZh. BaseTypeahead's is translated.
cixzhang
left a comment
There was a problem hiding this comment.
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 |
|---|---|
![]() |
![]() |
"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.


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
BaseTypeaheadsearches every non-empty query, andperformSearchopens the layer wheneversearchQuery.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
SearchSourcecan 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 controlledisOpen, no imperative handle, andonOpenChangeonly 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?: numberonBaseTypeahead, forwarded byTypeaheadandTokenizer.minQueryLength={3}1)hasEntriesOnFocusdecides, as todayThe 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:
hasEntriesOnFocusand 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.Naming
minLengthis what jQuery UI (default1) and PrimeReact (default1) call it, and I started there. On a component that renders an<input>it reads as the nativeminlengthvalidation attribute — a different length of a different string — which is exactly the collision API Conventions says not to create.minQueryLengthnames the string being measured, and matchesPowerSearchField.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 renderBaseTypeaheadover local, already-loaded item lists where the threshold buys nothing.Verification
pnpm test— 11,510 tests / 560 files pass, including 5 new ones (4 inTypeahead.test.tsx, 1 inTokenizer.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.[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')andaria-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,TypeaheadandTokenizer.doc.mjs(en + zh + dense), aMinimum Query Lengthstory, and anargTypescontrol.