Skip to content

Commit ed8ae10

Browse files
committed
fix: address search review findings
1 parent 50df56e commit ed8ae10

8 files changed

Lines changed: 86 additions & 57 deletions

File tree

docs/search-restoration-plan.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

docs/search.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ Blacklisted vaults remain findable but are shown as the distinct **Blacklisted v
2121
Every result provides a name, entity type, one-month APY, latest TVL, canonical destination and logo URL. Aggregate entity metrics use eligible, non-blacklisted vaults and USD-normalised TVL. One-month APY is TVL-weighted and excludes invalid or extreme values through the same helper used by vault listings.
2222

2323
Results sort by relevance first, then latest TVL descending and name ascending. The full results table starts sorted by latest TVL, with blacklisted vaults still placed last. The typeahead diversifies the initial suggestions by entity type before filling any remaining positions.
24+
The results page returns at most 100 rows and explicitly reports when a broader query has been truncated.
2425

2526
## Typeahead
2627

2728
`src/lib/search/components/Search.svelte` sends a request to `/search/suggestions` after a 200 ms debounce. It cancels the preceding request and ignores stale responses. The endpoint caps requests at 20 results (the widget asks for 10) and queries at 100 characters.
2829

2930
Keyboard navigation follows the combobox pattern: Arrow keys choose a suggestion, Enter opens the selected result or submits the query, and Escape closes the quick search. On mobile, the widget opens as a full-height dialog and restores focus to its trigger when closed. The dialog is layered above page content and only the mobile dialog locks body scrolling.
31+
On desktop, interacting outside the search closes the quick-results panel.
3032

3133
Desktop vault suggestions and full results show the 90-day price mini-map when chart data is available. Aggregate entities do not show a mini-map.
3234

src/lib/components/Header.svelte

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ Responsive site header with menu, search and compact-navigation controls.
77
import Logo from '$lib/components/Logo.svelte';
88
import Menu from '$lib/components/Menu.svelte';
99
import NavPanel from '$lib/components/NavPanel.svelte';
10-
import TextInput from '$lib/components/TextInput.svelte';
1110
import IconMenu from '~icons/local/menu';
1211
1312
interface Props {
@@ -34,11 +33,7 @@ Responsive site header with menu, search and compact-navigation controls.
3433
</nav>
3534

3635
<div class="search">
37-
{#if search}
38-
{@render search(false, noop)}
39-
{:else}
40-
<TextInput type="search" --text-input-width="100%" />
41-
{/if}
36+
{@render search?.(false, noop)}
4237
</div>
4338

4439
<button class="show-nav-panel mobile-only" aria-label="Show navigation panel" onclick={() => (panelOpen = true)}>

src/lib/search/components/Search.svelte

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ underlying vault JSON index private.
1010
import { goto } from '$app/navigation';
1111
import { disableScroll } from '$lib/actions/scroll';
1212
import { removeOnError } from '$lib/actions/image';
13-
import { formatDollar, formatPercent } from '$lib/helpers/formatters';
13+
import { formatDollar, formatPercent, notFilledMarker } from '$lib/helpers/formatters';
1414
import {
1515
formatVaultAddressPrefix,
1616
searchEntityColours,
@@ -42,6 +42,7 @@ underlying vault JSON index private.
4242
let requestSequence = 0;
4343
let mobileSearchInput = $state<HTMLInputElement>();
4444
let searchTrigger = $state<HTMLButtonElement>();
45+
let searchRoot: HTMLDivElement;
4546
4647
let hasQuery = $derived(query.trim().length > 0);
4748
let activeOptionId = $derived(selectedIndex >= 0 ? `${listboxId}-${selectedIndex}` : undefined);
@@ -88,12 +89,23 @@ underlying vault JSON index private.
8889
};
8990
});
9091
92+
$effect(() => {
93+
if (!open || mobileDialogOpen) return;
94+
95+
function closeOnOutsidePointerDown(event: PointerEvent) {
96+
if (event.target instanceof Node && !searchRoot.contains(event.target)) closeSearch();
97+
}
98+
99+
document.addEventListener('pointerdown', closeOnOutsidePointerDown);
100+
return () => document.removeEventListener('pointerdown', closeOnOutsidePointerDown);
101+
});
102+
91103
function formatApy(value: number | null) {
92-
return value === null ? 'N/A' : formatPercent(value, 1, 1);
104+
return value === null ? notFilledMarker : formatPercent(value, 1, 1);
93105
}
94106
95107
function formatTvl(value: number | null) {
96-
return value === null ? 'N/A' : formatDollar(value, 1, 1);
108+
return value === null ? notFilledMarker : formatDollar(value, 1, 1);
97109
}
98110
99111
function openSearch() {
@@ -149,7 +161,12 @@ underlying vault JSON index private.
149161

150162
<svelte:body use:disableScroll={mobileDialogOpen} />
151163

152-
<div class="search" class:menu-search={menu} data-testid={menu ? 'mobile-menu-search' : 'nav-search'}>
164+
<div
165+
bind:this={searchRoot}
166+
class="search"
167+
class:menu-search={menu}
168+
data-testid={menu ? 'mobile-menu-search' : 'nav-search'}
169+
>
153170
<form class="desktop-search" action="/search" role="search" onsubmit={handleSubmit}>
154171
<input
155172
bind:value={query}

src/lib/search/vault-search.server.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { getLogoUrl } from '$lib/helpers/assets';
22
import { getChain } from '$lib/helpers/chain';
3-
import { slugify } from '$lib/helpers/slugify';
43
import { fetchStablecoinMetadataIndex } from '$lib/stablecoin-metadata/client';
54
import {
65
buildStablecoinMetadataLookup,
@@ -16,7 +15,11 @@ import {
1615
getProtocolDisplayName,
1716
getVaultCurrentTvlUsd,
1817
isBlacklisted,
18+
isUnknownVaultProtocol,
1919
meetsMinTvl,
20+
UNKNOWN_VAULT_PROTOCOL_DISPLAY_NAME,
21+
UNKNOWN_VAULT_PROTOCOL_SLUG,
22+
withVaultCurrentTvlUsd,
2023
withVaultDenominationTokenRate
2124
} from '$lib/top-vaults/helpers';
2225
import type { TopVaults, VaultInfo } from '$lib/top-vaults/schemas';
@@ -81,7 +84,7 @@ function getEligibleVaults(vaults: VaultInfo[]): VaultInfo[] {
8184

8285
function getGroupMetrics(vaults: VaultInfo[]) {
8386
const eligibleVaults = getEligibleVaults(vaults);
84-
const weightedVaults = eligibleVaults.map((vault) => ({ ...vault, current_nav: getVaultCurrentTvlUsd(vault) }));
87+
const weightedVaults = eligibleVaults.map(withVaultCurrentTvlUsd);
8588

8689
return {
8790
averageApy1m: calculateTvlWeightedApy(weightedVaults),
@@ -147,55 +150,58 @@ function buildIndex(topVaults: TopVaults, stablecoins: StablecoinMetadata[]): In
147150
for (const vault of vaults) {
148151
if (vault.curator_slug)
149152
curatorVaults.set(vault.curator_slug, [...(curatorVaults.get(vault.curator_slug) ?? []), vault]);
150-
protocolVaults.set(vault.protocol_slug, [...(protocolVaults.get(vault.protocol_slug) ?? []), vault]);
151-
152-
const stablecoinSlug =
153-
resolveStablecoinSlug(
154-
{ slug: vault.denomination_slug, symbol: vault.denomination, name: vault.normalised_denomination },
155-
metadataLookup
156-
) ?? vault.denomination_slug;
157-
stablecoinVaults.set(stablecoinSlug, [...(stablecoinVaults.get(stablecoinSlug) ?? []), vault]);
153+
const protocolSlug = isUnknownVaultProtocol(vault) ? UNKNOWN_VAULT_PROTOCOL_SLUG : vault.protocol_slug;
154+
protocolVaults.set(protocolSlug, [...(protocolVaults.get(protocolSlug) ?? []), vault]);
155+
156+
if (vault.stablecoinish) {
157+
const stablecoinSlug =
158+
resolveStablecoinSlug(
159+
{ slug: vault.denomination_slug, symbol: vault.denomination, name: vault.normalised_denomination },
160+
metadataLookup
161+
) ?? vault.denomination_slug;
162+
stablecoinVaults.set(stablecoinSlug, [...(stablecoinVaults.get(stablecoinSlug) ?? []), vault]);
163+
}
158164

159165
const chain = getChain(vault.chain_id);
160-
const chainSlug = chain?.slug ?? slugify(vault.chain);
161-
chainVaults.set(chainSlug, [...(chainVaults.get(chainSlug) ?? []), vault]);
166+
if (chain) chainVaults.set(chain.slug, [...(chainVaults.get(chain.slug) ?? []), vault]);
162167
}
163168

164169
for (const [slug, groupVaults] of curatorVaults) {
165170
const curator = topVaults.curators[slug];
166-
const fallbackVault = groupVaults[0];
167-
const logos = curator?.logos;
171+
if (!curator) continue;
172+
const logos = curator.logos;
168173
addRecord(
169174
records,
170175
{
171176
id: `curator:${slug}`,
172-
name: curator?.name ?? fallbackVault.curator_name ?? slug,
177+
name: curator.name,
173178
entityType: 'curator',
174179
vaultId: null,
175180
address: null,
176181
...getGroupMetrics(groupVaults),
177182
href: `/vaults/curators/${slug}`,
178183
logoUrl: logos?.generic ?? logos?.light ?? logos?.dark ?? null
179184
},
180-
[slug, fallbackVault.curator_name]
185+
[slug, curator.name]
181186
);
182187
}
183188

184189
for (const [slug, groupVaults] of protocolVaults) {
185190
const fallbackVault = groupVaults[0];
191+
const isUnknown = slug === UNKNOWN_VAULT_PROTOCOL_SLUG;
186192
addRecord(
187193
records,
188194
{
189195
id: `protocol:${slug}`,
190-
name: getProtocolDisplayName(fallbackVault.protocol, slug),
196+
name: isUnknown ? UNKNOWN_VAULT_PROTOCOL_DISPLAY_NAME : getProtocolDisplayName(fallbackVault.protocol, slug),
191197
entityType: 'protocol',
192198
vaultId: null,
193199
address: null,
194200
...getGroupMetrics(groupVaults),
195201
href: `/vaults/protocols/${slug}`,
196202
logoUrl: getVaultProtocolLogoUrl(slug) ?? null
197203
},
198-
[slug, fallbackVault.protocol]
204+
[slug, fallbackVault.protocol, isUnknown ? UNKNOWN_VAULT_PROTOCOL_DISPLAY_NAME : null]
199205
);
200206
}
201207

@@ -284,8 +290,8 @@ export async function searchVaultEntities(
284290
options: SearchOptions = {}
285291
): Promise<SearchResponse> {
286292
const trimmedQuery = query.trim();
287-
const index = await getIndex(fetch);
288293
if (!trimmedQuery) return { query: '', results: [], total: 0 };
294+
const index = await getIndex(fetch);
289295

290296
const normalisedQuery = normalise(trimmedQuery);
291297
if (!normalisedQuery) return { query: trimmedQuery, results: [], total: 0 };

src/routes/search/+page.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ Search vaults and vault-related data about curators, protocols and stablecoins.
112112
{:else if !data.total}
113113
<div class="message">No results found for “{data.query}”. Try another name or symbol.</div>
114114
{:else}
115-
<p class="result-count">{data.total} result{data.total === 1 ? '' : 's'} for “{data.query}”</p>
115+
<p class="result-count">
116+
{#if data.total > data.results.length}
117+
Showing {data.results.length} of {data.total} results for “{data.query}”
118+
{:else}
119+
{data.total} result{data.total === 1 ? '' : 's'} for “{data.query}”
120+
{/if}
121+
</p>
116122

117123
<VaultGroupTable
118124
class="search-results-table"

tests/integration/search.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ test.describe('site search typeahead', () => {
5959
await expect(page.getByRole('dialog', { name: 'Search' })).toBeHidden();
6060
});
6161

62+
test('closes desktop quick results when interacting outside search', async ({ page }) => {
63+
await page.goto('/vaults');
64+
await page.waitForLoadState('networkidle');
65+
66+
const search = page.getByTestId('nav-search').getByRole('combobox');
67+
await search.fill('Euler');
68+
const dialog = page.getByRole('dialog', { name: 'Search' });
69+
await expect(dialog).toBeVisible();
70+
71+
await page.getByTestId('top-vaults-meta').click();
72+
await expect(dialog).toBeHidden();
73+
});
74+
6275
test('opens a vault result from a nested page without a route-relative 404', async ({ page }) => {
6376
await page.goto('/vaults/curators/steakhouse-financial');
6477
await page.waitForLoadState('networkidle');

tests/integration/vaults/index.test.ts

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,19 @@ async function closeAdvancedSettings(page: import('@playwright/test').Page) {
1919
await expect(advancedFilters).toHaveJSProperty('open', false);
2020
}
2121

22-
async function waitForVaultRows(page: import('@playwright/test').Page) {
22+
/** Load additional listing rows until a named vault is rendered. */
23+
async function getVaultRow(page: import('@playwright/test').Page, name: string) {
2324
await expect(page.locator('tbody tr.targetable').first()).toBeVisible();
24-
}
25-
26-
async function searchVault(page: import('@playwright/test').Page, query: string) {
27-
await waitForVaultRows(page);
28-
const vaultSearch = page.getByTestId('vault-search');
29-
await vaultSearch.click();
30-
await vaultSearch.pressSequentially(query);
25+
const row = page.locator('tbody tr.targetable').filter({ hasText: name });
26+
for (let attempt = 0; attempt < 3 && (await row.count()) === 0; attempt++) {
27+
const sentinel = page.getByTestId('load-more-sentinel');
28+
if (!(await sentinel.isVisible())) break;
29+
const previousRowCount = await page.locator('tbody tr.targetable').count();
30+
await sentinel.scrollIntoViewIfNeeded();
31+
await expect.poll(() => page.locator('tbody tr.targetable').count()).toBeGreaterThan(previousRowCount);
32+
}
33+
await expect(row).toHaveCount(1);
34+
return row;
3135
}
3236

3337
async function toggleReturnOption(page: import('@playwright/test').Page, label: string) {
@@ -80,10 +84,7 @@ test.describe('vault index page', () => {
8084
});
8185

8286
test('shows lifetime data tooltip on the lifetime return cell', async ({ page }) => {
83-
await searchVault(page, 'Trading Strategy ICHIv3 LS 2');
84-
85-
const row = page.locator('tbody tr.targetable').filter({ hasText: 'Trading Strategy ICHIv3 LS 2' });
86-
await expect(row).toHaveCount(1);
87+
const row = await getVaultRow(page, 'Trading Strategy ICHIv3 LS 2');
8788

8889
await expectLifetimeDataTooltip(
8990
row.locator('td.return-column-lifetime-abs .tooltip'),
@@ -97,7 +98,6 @@ test.describe('vault index page', () => {
9798
const primaryFilters = page.locator('.primary-filters');
9899
await expect(primaryFilters.getByText('Technical risk', { exact: true })).toBeVisible();
99100
await expect(primaryFilters.getByText('Hide undepositable', { exact: true })).toBeVisible();
100-
await expect(primaryFilters.getByTestId('vault-search')).toBeVisible();
101101

102102
const advanced = page.getByTestId('advanced-filters');
103103
await expect(advanced).not.toHaveAttribute('open', '');
@@ -141,7 +141,6 @@ test.describe('vault index page', () => {
141141

142142
await expect(mobileFiltersTrigger).toHaveAttribute('aria-expanded', 'true');
143143
await expect(primaryFilters.getByText('Technical risk', { exact: true })).toBeVisible();
144-
await expect(primaryFilters.getByTestId('vault-search')).not.toBeVisible();
145144
await expect(page.locator('.advanced-filters-content').getByText('Min TVL')).toBeVisible();
146145
await expect(page.getByTestId('advanced-filters-summary')).not.toBeVisible();
147146
});
@@ -298,11 +297,8 @@ test.describe('vault index page', () => {
298297
});
299298

300299
test('shows limited data tooltips for partial 3M and 1Y returns', async ({ page }) => {
301-
await page.goto('/vaults?returns=1m-ann,3m-ann,1y-ann');
302-
await searchVault(page, 'Limited coverage vault');
303-
304-
const row = page.locator('tbody tr.targetable').filter({ hasText: 'Limited coverage vault' });
305-
await expect(row).toHaveCount(1);
300+
await page.goto('/vaults?returns=1m-ann,3m-ann,1y-ann&unknown=0');
301+
const row = await getVaultRow(page, 'Limited coverage vault');
306302

307303
await expectLimitedDataTooltip(
308304
page,
@@ -321,11 +317,8 @@ test.describe('vault index page', () => {
321317
});
322318

323319
test('shows limited data tooltip for partial 6M returns', async ({ page }) => {
324-
await page.goto('/vaults?returns=1m-ann,3m-ann,6m-ann');
325-
await searchVault(page, 'Limited coverage vault');
326-
327-
const row = page.locator('tbody tr.targetable').filter({ hasText: 'Limited coverage vault' });
328-
await expect(row).toHaveCount(1);
320+
await page.goto('/vaults?returns=1m-ann,3m-ann,6m-ann&unknown=0');
321+
const row = await getVaultRow(page, 'Limited coverage vault');
329322

330323
await expectLimitedDataTooltip(
331324
page,

0 commit comments

Comments
 (0)