UI: Add searchable token picker on Swap (mirror PairSearchSelect) #481

Closed
opened 2026-07-12 11:13:47 +00:00 by PlasticDigits · 15 comments
PlasticDigits commented 2026-07-12 11:13:47 +00:00 (Migrated from gitlab.com)

Summary

User feedback (mainnet Swap): the YOU PAY / YOU RECEIVE token dropdowns are scroll-only lists (EMBER, JADE, ONYX, RUBY, …) with no search field. With more factory tokens this becomes hard to use. Trade and Limits already have a searchable pair combobox (PairSearchSelect); Swap should get an equivalent token search.

Current codebase

  • frontend-dapp/src/components/ui/TokenSelect.tsx — button trigger + portaled role="listbox". Options are tokens.filter(t => t !== excludeToken) with no query filter. Keyboard typeahead on the closed button exists (#244) via usePortalListboxKeyboard, but there is no visible search input.
  • Wired from frontend-dapp/src/pages/SwapPage.tsx for both legs (aria-label “Select token you pay” / receive). Token universe = getAllTokens(pairs) from factory LCD pairs (frontend-dapp/src/services/terraclassic/router.ts), not an indexer token list. Order is Set insertion order (not alphabetical). Wallet balance does not filter the list.
  • Also used on MintPage for a small faucet list (scroll is fine there).
  • Shared portal/CSS: PortalListbox.tsx, .token-select-* in index.css. E2E: frontend-dapp/e2e/helpers/token-select.ts (triggers via getByRole('button')).

Pair search (reference UX)

  • frontend-dapp/src/components/trade/PairSearchSelect.tsx — role="combobox" text input, 300ms debounce, indexer GET /api/v1/pairs?q=… with factory gate, offline fallback via pairSearchQuery.ts (#314, #301, #328, #350).
  • Used on Trade + Limits. Pool/Charts have separate pair search inputs.
  • Docs: docs/frontend.md § “Pair search combobox”.

Indexer gap (optional later)

  • GET /api/v1/tokens (indexer/src/api/tokens.rs) is paginated metadata only — no q / relevance sort. Pair search already matches single-token symbols via pair q, but that returns pairs, not unique tokens for the Swap picker.

Why needed

  • Mainnet token count already overflows a compact max-h-60 list; scroll-only discovery is slow on mobile in-wallet browsers.
  • Product inconsistency: Trade/Limits/Pool/Charts can type to find markets; Swap cannot type to find a token.
  • Button typeahead (#244) is undiscoverable and insufficient once the list grows.

Constraints / guardrails

  • Token universe stays factory-routable — only tokens already in getAllTokens(pairs) (plus existing native-wrap enrichment). Do not introduce an arbitrary external token list that could select non-routable assets.
  • Prefer client-side filter first (works offline / when indexer is down), mirroring PairSearchSelect degraded mode. Do not block Swap on indexer availability.
  • Reuse pair-search UX invariants: debounce ~300ms, min chars (2, or full terra1… address), result cap for long lists, preserve excludeToken so pay/receive cannot pick the same asset.
  • Accessibility: new control should be a combobox (aria-autocomplete="list", aria-activedescendant, Escape/arrows/Enter) like PairSearchSelect, not only a button+typeahead.
  • Keep TokenSelect for Mint (small list) unless search is trivial to share; avoid forcing Mint into a combobox if unnecessary.
  • Do not derive Swap options from getPairs(q) (duplicates / pair-shaped results).
  • Security: follow lessons from #459 — escape/ILIKE wildcards if any server-side token search is added later; client filter must not execute or render untrusted HTML from token metadata (symbols/names are text only). Logo URLs stay behind existing allowlist (resolveTrustedTokenLogoUrl).
  • Mobile: combobox input must remain usable in narrow Swap amount rows (w-full on small viewports); portaled listbox must still flip when space is tight.
  • No change to quote/routing/execution paths beyond the selected token id string.

Relevant files

Area Path
Current picker frontend-dapp/src/components/ui/TokenSelect.tsx
Swap wiring frontend-dapp/src/pages/SwapPage.tsx
Token list frontend-dapp/src/services/terraclassic/router.ts (getAllTokens)
Pair search template frontend-dapp/src/components/trade/PairSearchSelect.tsx
Pair search utils frontend-dapp/src/utils/pairSearchQuery.ts
Display / registry frontend-dapp/src/utils/tokenDisplay.ts, tokenRegistry.ts, hooks/useTokenDisplayInfo.ts
UI export frontend-dapp/src/components/ui/index.ts
E2E helpers frontend-dapp/e2e/helpers/token-select.ts
Docs docs/frontend.md
Optional indexer (follow-up) indexer/src/api/tokens.rs, frontend-dapp/src/services/indexer/client.ts
  1. Add TokenSearchSelect (new component under components/ui/ or components/trade/) modeled on PairSearchSelect: visible search input + portaled listbox with TokenLogo + symbol rows.
  2. Add tokenSearchQuery.ts reusing haystack ideas from legSearchTokens / buildPairLocalSearchHaystack (id, denom, cached symbol/name, tokenRegistry). Empty query shows allowed tokens (optionally sort by symbol; optionally cap + “type to filter” if list is large).
  3. Replace both TokenSelect usages on SwapPage only; keep Mint on TokenSelect.
  4. Unit tests mirroring PairSearchSelect.*.test.tsx; update E2E helpers to target combobox / labeled search and cover type-to-filter + select.
  5. Document in docs/frontend.md beside pair search.
  6. Out of scope / follow-up: indexer GET /api/v1/tokens?q= relevance API — only if factory token count outgrows comfortable client filtering.

Acceptance criteria

  • Swap YOU PAY and YOU RECEIVE expose a searchable token control (visible search field), UX-aligned with pair search.
  • Filtering matches symbol, name, denom, and CW20/native address (case-insensitive); empty query still allows browsing the allowed list.
  • excludeToken still removes the other leg; selecting a token updates Swap state exactly as today (quotes, wrap/unwrap, impact confirm reset behavior preserved).
  • Works with indexer down / offline (client-only filter).
  • Keyboard: open list, arrow navigate, Enter select, Escape close; screen-reader roles match combobox pattern.
  • Mint page behavior unchanged (or explicitly documented if shared).
  • Unit + E2E coverage for search + select paths; docs/frontend.md updated.

Test plan (all paths)

Unit

  • Empty query lists allowed tokens (minus excludeToken).
  • Query "EM" / "ember" filters to EMBER (and similar); no match → empty state / helper text.
  • Address / denom substring match (native uluna, CW20 terra1…).
  • Debounce: rapid typing does not thrash (if debounce used).
  • Disabled / empty tokens shows loading or disabled state.
  • Enter commits active/first hit; Escape closes without changing value when appropriate (mirror #350 behavior where applicable).

Manual / LocalTerra

  • Deployed Swap: open pay picker, type symbol of a mid-list token, select, confirm amount row + quote update.
  • Receive picker same; ensure excluded pay token absent.
  • Clear search / empty query still scroll-select works.
  • Mobile width / in-app browser: input focus + portaled list not clipped incorrectly.
  • Wallet disconnected and connected: list still searchable (balances independent).

E2E

  • Update token-select helpers; Swap smoke path: search → select from + to → quote visible.
  • Regression: existing Swap happy path still passes after trigger role change (button → combobox).

Test plan (attack / hack / abuse)

  • XSS via metadata: token symbol/name containing <script>, HTML, or markdown must render as plain text only (no dangerouslySetInnerHTML).
  • Logo SSRF / unsafe URL: malicious logoURI in cache/registry must still go through allowlist; block javascript: / untrusted hosts.
  • Query DoS (client): very long paste into search must not freeze UI (truncate or ignore oversized queries; keep filter O(n) over factory set only).
  • Selection injection: filtered option onChange must only emit ids present in the tokens prop (no free-typed arbitrary address that bypasses factory graph).
  • Same-asset bypass: cannot select excluded other-leg token via search tricks / paste.
  • If indexer q added later: escape SQL/ILIKE wildcards (%, _) per #459; enforce min query length; rate-limit / cap results; never return tokens outside factory-allowed set to Swap.

Verification criteria

  • On mainnet-like token counts, a mid-list token is findable by typing ≤3 characters without scrolling the full list.
  • QA checklist: Swap pay/receive searchable; Mint unchanged; no new console errors; a11y smoke (keyboard-only select).
  • CI: frontend unit tests + relevant Playwright Swap paths green.
  • Product parity note in docs: pair search (Trade/Limits) vs token search (Swap).

References

  • User report: Swap token dropdown lacks search “similar to the pair search”.
  • Related: #244 (TokenSelect typeahead), #314 / #301 / #328 / #350 (PairSearchSelect), #459 (pair search wildcard hardening).
## Summary User feedback (mainnet Swap): the **YOU PAY** / **YOU RECEIVE** token dropdowns are scroll-only lists (EMBER, JADE, ONYX, RUBY, …) with **no search field**. With more factory tokens this becomes hard to use. Trade and Limits already have a searchable pair combobox (`PairSearchSelect`); Swap should get an equivalent **token** search. ## Current codebase ### Swap token picker (no search) - `frontend-dapp/src/components/ui/TokenSelect.tsx` — button trigger + portaled `role="listbox"`. Options are `tokens.filter(t => t !== excludeToken)` with **no query filter**. Keyboard **typeahead on the closed button** exists (#244) via `usePortalListboxKeyboard`, but there is **no visible search input**. - Wired from `frontend-dapp/src/pages/SwapPage.tsx` for both legs (`aria-label` “Select token you pay” / receive). Token universe = `getAllTokens(pairs)` from factory LCD pairs (`frontend-dapp/src/services/terraclassic/router.ts`), not an indexer token list. Order is `Set` insertion order (not alphabetical). Wallet balance does **not** filter the list. - Also used on `MintPage` for a small faucet list (scroll is fine there). - Shared portal/CSS: `PortalListbox.tsx`, `.token-select-*` in `index.css`. E2E: `frontend-dapp/e2e/helpers/token-select.ts` (triggers via `getByRole('button')`). ### Pair search (reference UX) - `frontend-dapp/src/components/trade/PairSearchSelect.tsx` — `role="combobox"` text input, 300ms debounce, indexer `GET /api/v1/pairs?q=…` with factory gate, offline fallback via `pairSearchQuery.ts` (#314, #301, #328, #350). - Used on Trade + Limits. Pool/Charts have separate pair search inputs. - Docs: `docs/frontend.md` § “Pair search combobox”. ### Indexer gap (optional later) - `GET /api/v1/tokens` (`indexer/src/api/tokens.rs`) is paginated metadata only — **no `q` / relevance sort**. Pair search already matches single-token symbols via pair `q`, but that returns **pairs**, not unique tokens for the Swap picker. ## Why needed - Mainnet token count already overflows a compact `max-h-60` list; scroll-only discovery is slow on mobile in-wallet browsers. - Product inconsistency: Trade/Limits/Pool/Charts can type to find markets; Swap cannot type to find a token. - Button typeahead (#244) is undiscoverable and insufficient once the list grows. ## Constraints / guardrails - **Token universe stays factory-routable** — only tokens already in `getAllTokens(pairs)` (plus existing native-wrap enrichment). Do **not** introduce an arbitrary external token list that could select non-routable assets. - Prefer **client-side filter first** (works offline / when indexer is down), mirroring `PairSearchSelect` degraded mode. Do not block Swap on indexer availability. - Reuse pair-search UX invariants: debounce ~300ms, min chars (2, or full `terra1…` address), result cap for long lists, preserve `excludeToken` so pay/receive cannot pick the same asset. - Accessibility: new control should be a **combobox** (`aria-autocomplete="list"`, `aria-activedescendant`, Escape/arrows/Enter) like `PairSearchSelect`, not only a button+typeahead. - Keep **`TokenSelect` for Mint** (small list) unless search is trivial to share; avoid forcing Mint into a combobox if unnecessary. - Do **not** derive Swap options from `getPairs(q)` (duplicates / pair-shaped results). - Security: follow lessons from #459 — escape/`ILIKE` wildcards if any server-side token search is added later; client filter must not execute or render untrusted HTML from token metadata (symbols/names are text only). Logo URLs stay behind existing allowlist (`resolveTrustedTokenLogoUrl`). - Mobile: combobox input must remain usable in narrow Swap amount rows (`w-full` on small viewports); portaled listbox must still flip when space is tight. - No change to quote/routing/execution paths beyond the selected token id string. ## Relevant files | Area | Path | |------|------| | Current picker | `frontend-dapp/src/components/ui/TokenSelect.tsx` | | Swap wiring | `frontend-dapp/src/pages/SwapPage.tsx` | | Token list | `frontend-dapp/src/services/terraclassic/router.ts` (`getAllTokens`) | | Pair search template | `frontend-dapp/src/components/trade/PairSearchSelect.tsx` | | Pair search utils | `frontend-dapp/src/utils/pairSearchQuery.ts` | | Display / registry | `frontend-dapp/src/utils/tokenDisplay.ts`, `tokenRegistry.ts`, `hooks/useTokenDisplayInfo.ts` | | UI export | `frontend-dapp/src/components/ui/index.ts` | | E2E helpers | `frontend-dapp/e2e/helpers/token-select.ts` | | Docs | `docs/frontend.md` | | Optional indexer (follow-up) | `indexer/src/api/tokens.rs`, `frontend-dapp/src/services/indexer/client.ts` | ## Recommended direction 1. Add `TokenSearchSelect` (new component under `components/ui/` or `components/trade/`) modeled on `PairSearchSelect`: visible search input + portaled listbox with `TokenLogo` + symbol rows. 2. Add `tokenSearchQuery.ts` reusing haystack ideas from `legSearchTokens` / `buildPairLocalSearchHaystack` (id, denom, cached symbol/name, `tokenRegistry`). Empty query shows allowed tokens (optionally sort by symbol; optionally cap + “type to filter” if list is large). 3. Replace both `TokenSelect` usages on `SwapPage` only; keep Mint on `TokenSelect`. 4. Unit tests mirroring `PairSearchSelect.*.test.tsx`; update E2E helpers to target `combobox` / labeled search and cover type-to-filter + select. 5. Document in `docs/frontend.md` beside pair search. 6. **Out of scope / follow-up:** indexer `GET /api/v1/tokens?q=` relevance API — only if factory token count outgrows comfortable client filtering. ## Acceptance criteria - [ ] Swap **YOU PAY** and **YOU RECEIVE** expose a searchable token control (visible search field), UX-aligned with pair search. - [ ] Filtering matches symbol, name, denom, and CW20/native address (case-insensitive); empty query still allows browsing the allowed list. - [ ] `excludeToken` still removes the other leg; selecting a token updates Swap state exactly as today (quotes, wrap/unwrap, impact confirm reset behavior preserved). - [ ] Works with indexer down / offline (client-only filter). - [ ] Keyboard: open list, arrow navigate, Enter select, Escape close; screen-reader roles match combobox pattern. - [ ] Mint page behavior unchanged (or explicitly documented if shared). - [ ] Unit + E2E coverage for search + select paths; `docs/frontend.md` updated. ## Test plan (all paths) ### Unit - [ ] Empty query lists allowed tokens (minus `excludeToken`). - [ ] Query `"EM"` / `"ember"` filters to EMBER (and similar); no match → empty state / helper text. - [ ] Address / denom substring match (native `uluna`, CW20 `terra1…`). - [ ] Debounce: rapid typing does not thrash (if debounce used). - [ ] Disabled / empty `tokens` shows loading or disabled state. - [ ] Enter commits active/first hit; Escape closes without changing value when appropriate (mirror #350 behavior where applicable). ### Manual / LocalTerra - [ ] Deployed Swap: open pay picker, type symbol of a mid-list token, select, confirm amount row + quote update. - [ ] Receive picker same; ensure excluded pay token absent. - [ ] Clear search / empty query still scroll-select works. - [ ] Mobile width / in-app browser: input focus + portaled list not clipped incorrectly. - [ ] Wallet disconnected and connected: list still searchable (balances independent). ### E2E - [ ] Update `token-select` helpers; Swap smoke path: search → select from + to → quote visible. - [ ] Regression: existing Swap happy path still passes after trigger role change (button → combobox). ## Test plan (attack / hack / abuse) - [ ] **XSS via metadata:** token symbol/name containing `<script>`, HTML, or markdown must render as plain text only (no `dangerouslySetInnerHTML`). - [ ] **Logo SSRF / unsafe URL:** malicious `logoURI` in cache/registry must still go through allowlist; block `javascript:` / untrusted hosts. - [ ] **Query DoS (client):** very long paste into search must not freeze UI (truncate or ignore oversized queries; keep filter O(n) over factory set only). - [ ] **Selection injection:** filtered option `onChange` must only emit ids present in the `tokens` prop (no free-typed arbitrary address that bypasses factory graph). - [ ] **Same-asset bypass:** cannot select excluded other-leg token via search tricks / paste. - [ ] **If indexer `q` added later:** escape SQL/`ILIKE` wildcards (`%`, `_`) per #459; enforce min query length; rate-limit / cap results; never return tokens outside factory-allowed set to Swap. ## Verification criteria - [ ] On mainnet-like token counts, a mid-list token is findable by typing ≤3 characters without scrolling the full list. - [ ] QA checklist: Swap pay/receive searchable; Mint unchanged; no new console errors; a11y smoke (keyboard-only select). - [ ] CI: frontend unit tests + relevant Playwright Swap paths green. - [ ] Product parity note in docs: pair search (Trade/Limits) vs token search (Swap). ## References - User report: Swap token dropdown lacks search “similar to the pair search”. - Related: #244 (TokenSelect typeahead), #314 / #301 / #328 / #350 (PairSearchSelect), #459 (pair search wildcard hardening).
PlasticDigits commented 2026-07-12 11:21:10 +00:00 (Migrated from gitlab.com)

mentioned in commit c438038171

mentioned in commit c438038171222a6e8626ee8fa33141c37916ffc9
PlasticDigits commented 2026-07-12 11:21:21 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1018

mentioned in merge request !1018
PlasticDigits commented 2026-07-12 11:21:27 +00:00 (Migrated from gitlab.com)

Implemented on !1018 (feat/481-token-search-select): TokenSearchSelect + client-side tokenSearchQuery for Swap; Mint stays on TokenSelect. Docs: docs/frontend.md#token-search-combobox, skills/AGENTS_FRONTEND_TOKEN_SEARCH.md.

Implemented on !1018 (`feat/481-token-search-select`): TokenSearchSelect + client-side tokenSearchQuery for Swap; Mint stays on TokenSelect. Docs: docs/frontend.md#token-search-combobox, skills/AGENTS_FRONTEND_TOKEN_SEARCH.md.
PlasticDigits commented 2026-07-12 11:22:34 +00:00 (Migrated from gitlab.com)

mentioned in commit b72c960123

mentioned in commit b72c96012348044ff07735d8227ad27e1e00a5be
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-07-13 01:56:04 +00:00
PlasticDigits commented 2026-07-15 04:13:41 +00:00 (Migrated from gitlab.com)

mentioned in issue #498

mentioned in issue #498
PlasticDigits commented 2026-08-17 03:45:49 +00:00 (Migrated from gitlab.com)

mentioned in issue #542

mentioned in issue #542
PlasticDigits commented 2026-08-17 03:45:51 +00:00 (Migrated from gitlab.com)

marked as related to #542

marked as related to #542
PlasticDigits commented 2026-08-18 12:08:58 +00:00 (Migrated from gitlab.com)

mentioned in issue #562

mentioned in issue #562
PlasticDigits commented 2026-08-25 01:55:13 +00:00 (Migrated from gitlab.com)

mentioned in issue #630

mentioned in issue #630
PlasticDigits commented 2026-08-25 01:57:09 +00:00 (Migrated from gitlab.com)

mentioned in issue #632

mentioned in issue #632
PlasticDigits commented 2026-08-25 01:57:09 +00:00 (Migrated from gitlab.com)

marked as related to #632

marked as related to #632
PlasticDigits commented 2026-08-25 06:07:52 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1144

mentioned in merge request !1144
PlasticDigits commented 2026-08-31 04:56:53 +00:00 (Migrated from gitlab.com)

mentioned in issue #711

mentioned in issue #711
PlasticDigits commented 2026-08-31 04:56:54 +00:00 (Migrated from gitlab.com)

marked as related to #711

marked as related to #711
PlasticDigits commented 2026-08-31 05:36:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #713

mentioned in issue #713
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#481
No description provided.