From 8369b3ff7088b020bcef94922131c46ae387bbb2 Mon Sep 17 00:00:00 2001 From: Panthevm Date: Wed, 20 May 2026 15:03:43 +0300 Subject: [PATCH 1/2] ResourceBrowser: search UX polish, Aidbox/FHIR badges, line clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop sort dropdown; sort by fuzzy relevance while searching, by name otherwise - highlight fuzzy match ranges in name/description (shared util with Analytics) - unify chips/URL format with Analytics (?q + ?tags[]); whole chip removes the tag - weight name×10 over description×1 in the Fuse search - derive #Aidbox / #FHIR badges from SD publisher; brand color on the Aidbox filter chip, green on the FHIR one - treat 'deprecated' as a status (warning color in chips) - list-row badges share one info-primary color - line-clamp-2 on description - focus the first match on input, Enter opens it; same UX added to Analytics list pages --- src/components/ResourceBrowser/browser.tsx | 290 ++++++++++----------- src/routes/analytics.index.tsx | 94 ++++--- src/routes/resource.index.tsx | 19 +- src/utils/highlight.tsx | 34 +++ 4 files changed, 255 insertions(+), 182 deletions(-) create mode 100644 src/utils/highlight.tsx diff --git a/src/components/ResourceBrowser/browser.tsx b/src/components/ResourceBrowser/browser.tsx index b6a5f0a0..4c5de45d 100644 --- a/src/components/ResourceBrowser/browser.tsx +++ b/src/components/ResourceBrowser/browser.tsx @@ -2,18 +2,17 @@ import { useLocalStorage } from "@aidbox-ui/hooks/useLocalStorage"; import * as HSComp from "@health-samurai/react-components"; import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; -import { - ArrowDownAZ, - ArrowDownZA, - ArrowUpDown, - Pin, - Search, - X, -} from "lucide-react"; +import Fuse from "fuse.js"; +import { Pin, Search, X } from "lucide-react"; import React, { useMemo, useRef } from "react"; import { type AidboxClientR5, useAidboxClient } from "../../AidboxClient"; import { createFuzzySearch } from "../../utils/fuzzy-search"; -import { buildQuery, parseQuery, tagSlug } from "../../utils/tag-search"; +import { + filterHighlightRanges, + highlight, + type MatchRange, +} from "../../utils/highlight"; +import { parseQuery, tagSlug } from "../../utils/tag-search"; import { useWebMCPResourceBrowser } from "../../webmcp/resource-browser"; import type { ResourceBrowserActions } from "../../webmcp/resource-browser-context"; import { EmptyState } from "../empty-state"; @@ -28,6 +27,7 @@ const STATUS_VALUES = new Set([ "trial-use", "draft", "informative", + "deprecated", ]); type SDExtension = { @@ -44,8 +44,20 @@ type SDItem = { categoryTop?: string; categorySub?: string; standardsStatus?: string; + origin?: string; + nameMatches?: readonly MatchRange[]; + descriptionMatches?: readonly MatchRange[]; }; +const AIDBOX_PUBLISHER = "Health Samurai"; +const FHIR_PUBLISHER_PREFIX = "Health Level Seven"; + +function publisherToOrigin(publisher: string | undefined): string | undefined { + if (publisher === AIDBOX_PUBLISHER) return "Aidbox"; + if (publisher?.startsWith(FHIR_PUBLISHER_PREFIX)) return "FHIR"; + return undefined; +} + function decodeAmp(s: string): string { return s.replace(/&/g, "&"); } @@ -71,6 +83,7 @@ function parseStandardsStatus( function itemTagSlugs(item: SDItem): string[] { const slugs: string[] = [tagSlug(item.resourceType)]; + if (item.origin) slugs.push(tagSlug(item.origin)); if (item.categoryTop) slugs.push(tagSlug(item.categoryTop)); if (item.categorySub) slugs.push(tagSlug(item.categorySub)); if (item.standardsStatus) slugs.push(tagSlug(item.standardsStatus)); @@ -85,7 +98,10 @@ function filterByTags(items: SDItem[], tagTokens: string[]): SDItem[] { }); } -function chipStyleFor(slug: string): string { +function chipStyleFor(text: string): string { + const slug = tagSlug(text); + if (slug === "aidbox") return "bg-bg-brand-secondary text-text-brand-primary"; + if (slug === "fhir") return "bg-green-50 text-text-success-primary"; if (STATUS_VALUES.has(slug)) return "bg-yellow-50 text-text-warning-primary"; return "bg-blue-50 text-text-info-primary"; } @@ -96,6 +112,7 @@ type StructureDefinitionResource = { name?: string; url?: string; description?: string; + publisher?: string; extension?: SDExtension[]; }; @@ -110,7 +127,7 @@ function useStructureDefinitions(client: AidboxClientR5) { queryFn: async () => { const response = await client.rawRequest({ method: "GET", - url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url,description,extension", + url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url,description,extension,publisher", }); const bundle: StructureDefinitionBundle = await response.response.json(); return (bundle.entry ?? []).flatMap((entry) => { @@ -127,6 +144,7 @@ function useStructureDefinitions(client: AidboxClientR5) { categoryTop: cat.top, categorySub: cat.sub, standardsStatus: parseStandardsStatus(r.extension), + origin: publisherToOrigin(r.publisher), } satisfies SDItem, ]; }); @@ -134,10 +152,6 @@ function useStructureDefinitions(client: AidboxClientR5) { }); } -type SortColumn = "name" | "url"; -type SortDirection = "asc" | "desc"; -type SortState = { column: SortColumn; direction: SortDirection }; - function Badge({ text, onClick }: { text: string; onClick: () => void }) { return ( @@ -181,16 +194,22 @@ function ItemCard({ className="block" >
-
- {item.name} +
+ {highlight(item.name, item.nameMatches)}
{item.description && ( -
- {item.description} +
+ {highlight(item.description, item.descriptionMatches)}
)} - {(item.categoryTop || item.standardsStatus) && ( + {(item.origin || item.categoryTop || item.standardsStatus) && (
+ {item.origin && ( + onTagClick(item.origin ?? "")} + /> + )} {item.categoryTop && ( {chips.map((chip) => ( - onRemoveChip(chip)} + className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] leading-4 whitespace-nowrap cursor-pointer ${chipStyleFor(chip)}`} > #{chip} - - + + ))} void; -}) { - const label = - sort.column === "name" - ? sort.direction === "asc" - ? "Name A→Z" - : "Name Z→A" - : sort.direction === "asc" - ? "URL A→Z" - : "URL Z→A"; - const Icon = sort.direction === "asc" ? ArrowDownAZ : ArrowDownZA; - return ( - - - - - {label} - - - - onChange({ column: "name", direction: "asc" })} - > - - Name A→Z - - onChange({ column: "name", direction: "desc" })} - > - - Name Z→A - - onChange({ column: "url", direction: "asc" })} - > - - URL A→Z - - onChange({ column: "url", direction: "desc" })} - > - - URL Z→A - - - - ); -} - -function sortItems(items: SDItem[], sort: SortState): SDItem[] { - const sorted = [...items]; - sorted.sort((a, b) => { - const av = sort.column === "name" ? a.name : a.url; - const bv = sort.column === "name" ? b.name : b.url; - const cmp = av.localeCompare(bv); - return sort.direction === "asc" ? cmp : -cmp; - }); - return sorted; +function sortByName(items: SDItem[]): SDItem[] { + return [...items].sort((a, b) => a.name.localeCompare(b.name)); } function applyFavorites( @@ -380,17 +330,26 @@ function applyFavorites( export function Browser() { const client = useAidboxClient(); - const { q } = useSearch({ from: "/resource/" }); + const search = useSearch({ from: "/resource/" }); const navigate = useNavigate(); - const searchQ = q ?? ""; - const { chips, text: textPart } = parseQuery(searchQ); - const tagTokens = chips.map((c) => c.toLowerCase()); + const text = search.q ?? ""; + const chips = search.tags ?? []; + const tagTokens = chips.map(tagSlug); + const hasQuery = chips.length > 0 || Boolean(text); - const setSearchQ = (value: string) => { + const setText = (next: string) => { + navigate({ + from: "/resource/", + search: (prev) => ({ ...prev, q: next || undefined }), + replace: true, + }); + }; + const setTags = (next: string[]) => { navigate({ from: "/resource/", - search: (prev) => ({ ...prev, q: value || undefined }), + search: (prev) => ({ ...prev, tags: next.length > 0 ? next : undefined }), + replace: true, }); }; @@ -400,48 +359,75 @@ export function Browser() { }); const favorites = useMemo(() => new Set(favoritesArray), [favoritesArray]); - const [sort, setSort] = React.useState({ - column: "name", - direction: "asc", - }); - const { data, isLoading } = useStructureDefinitions(client); const allItems = data ?? []; const tagFiltered = filterByTags(allItems, tagTokens); - const fuzzy = useMemo( + const fuse = useMemo( () => - createFuzzySearch(tagFiltered, { + new Fuse(tagFiltered, { keys: [ - { name: "name", weight: 2 }, + { name: "name", weight: 10 }, { name: "description", weight: 1 }, ], + includeMatches: true, + ignoreLocation: true, + useExtendedSearch: true, minMatchCharLength: 1, threshold: 0.3, }), [tagFiltered], ); - const textFiltered = textPart ? fuzzy(textPart) : tagFiltered; - const sorted = sortItems(textFiltered, sort); - const items = applyFavorites(sorted, favorites, Boolean(searchQ)); + const textFiltered: SDItem[] = text + ? fuse.search(text).map((r) => { + const nameMatch = r.matches?.find((m) => m.key === "name"); + const descMatch = r.matches?.find((m) => m.key === "description"); + return { + ...r.item, + nameMatches: filterHighlightRanges( + text, + nameMatch?.indices as readonly MatchRange[] | undefined, + ), + descriptionMatches: filterHighlightRanges( + text, + descMatch?.indices as readonly MatchRange[] | undefined, + ), + }; + }) + : sortByName(tagFiltered); + const items = applyFavorites(textFiltered, favorites, hasQuery); const handleTagClick = (tagText: string) => { const slug = tagSlug(tagText); - if (chips.includes(slug)) return; - setSearchQ(buildQuery([...chips, slug], textPart)); + if (chips.some((c) => tagSlug(c) === slug)) return; + setTags([...chips, tagText]); }; - const removeChip = (slug: string) => { - setSearchQ( - buildQuery( - chips.filter((c) => c !== slug), - textPart, - ), - ); + const removeChip = (tag: string) => { + setTags(chips.filter((c) => c !== tag)); }; const updateTextPart = (next: string) => { - setSearchQ(buildQuery(chips, next)); + const parsed = parseQuery(next); + if (parsed.chips.length > 0) { + const seen = new Set(chips.map(tagSlug)); + const extra: string[] = []; + for (const c of parsed.chips) { + const s = tagSlug(c); + if (!seen.has(s)) { + extra.push(c); + seen.add(s); + } + } + if (extra.length > 0) setTags([...chips, ...extra]); + setText(parsed.text); + } else { + setText(next); + } + }; + const onClear = () => { + setTags([]); + setText(""); }; const toggleFavorite = (resourceType: string) => { @@ -468,8 +454,9 @@ export function Browser() { }, [focusedIndex]); React.useEffect(() => { - setFocusedIndex(-1); - }, []); + if (text && items.length > 0) setFocusedIndex(0); + else setFocusedIndex(-1); + }, [text, items.length]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { @@ -478,11 +465,8 @@ export function Browser() { } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { e.preventDefault(); setFocusedIndex((p) => Math.max(p - 1, -1)); - } else if ( - e.key === "Enter" && - focusedIndex >= 0 && - focusedIndex < items.length - ) { + } else if (e.key === "Enter") { + if (focusedIndex < 0) return; const it = items[focusedIndex]; if (!it) return; e.preventDefault(); @@ -497,12 +481,20 @@ export function Browser() { useWebMCPResourceBrowser(actionsRef); actionsRef.current = { listResourceTypes: (filter) => { - if (filter !== undefined) setSearchQ(filter); - const q2 = filter ?? searchQ; - const parsed = parseQuery(q2); - const tags = parsed.chips.map((c) => c.toLowerCase()); - const filtered = filterByTags(allItems, tags); - const t = parsed.text + let resolvedTags: string[]; + let resolvedText: string; + if (filter !== undefined) { + const parsed = parseQuery(filter); + setTags(parsed.chips); + setText(parsed.text); + resolvedTags = parsed.chips.map(tagSlug); + resolvedText = parsed.text; + } else { + resolvedTags = tagTokens; + resolvedText = text; + } + const filtered = filterByTags(allItems, resolvedTags); + const t = resolvedText ? createFuzzySearch(filtered, { keys: [ { name: "name", weight: 2 }, @@ -510,10 +502,13 @@ export function Browser() { ], minMatchCharLength: 1, threshold: 0.3, - })(parsed.text) - : filtered; - const sortedRes = sortItems(t, sort); - const finalList = applyFavorites(sortedRes, favorites, Boolean(q2)); + })(resolvedText) + : sortByName(filtered); + const finalList = applyFavorites( + t, + favorites, + Boolean(resolvedText) || resolvedTags.length > 0, + ); return finalList.map((row) => ({ resourceType: row.resourceType, url: row.url, @@ -533,17 +528,16 @@ export function Browser() { return (
-
+
setSearchQ("")} + onClear={onClear} onInputKeyDown={handleKeyDown} /> -
{!isLoading && items.length === 0 ? ( diff --git a/src/routes/analytics.index.tsx b/src/routes/analytics.index.tsx index f6f65bb2..8c20f859 100644 --- a/src/routes/analytics.index.tsx +++ b/src/routes/analytics.index.tsx @@ -17,29 +17,13 @@ import { import * as React from "react"; import { useAidboxClient } from "../AidboxClient"; import { EmptyState } from "../components/empty-state"; +import { + filterHighlightRanges, + highlight, + type MatchRange, +} from "../utils/highlight"; import { parseQuery, tagSlug } from "../utils/tag-search"; -type MatchRange = readonly [number, number]; - -function highlight(text: string, ranges: readonly MatchRange[] | undefined) { - if (!ranges || ranges.length === 0) return text; - const sorted = ranges.slice().sort((a, b) => a[0] - b[0]); - const parts: React.ReactNode[] = []; - let cursor = 0; - sorted.forEach(([start, end]) => { - if (start < cursor) return; - if (start > cursor) parts.push(text.slice(cursor, start)); - parts.push( - - {text.slice(start, end + 1)} - , - ); - cursor = end + 1; - }); - if (cursor < text.length) parts.push(text.slice(cursor)); - return <>{parts}; -} - const SQL_QUERY_TYPE_TOKEN = "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes|sql-query"; @@ -338,6 +322,7 @@ function SearchBar({ onTextChange, onRemoveChip, onClear, + onInputKeyDown, }: { chips: string[]; textPart: string; @@ -347,6 +332,7 @@ function SearchBar({ onTextChange: (next: string) => void; onRemoveChip: (tag: string) => void; onClear: () => void; + onInputKeyDown?: (e: React.KeyboardEvent) => void; }) { return (
@@ -372,7 +358,9 @@ function SearchBar({ if (e.key === "Backspace" && textPart === "" && last) { e.preventDefault(); onRemoveChip(last); + return; } + onInputKeyDown?.(e); }} placeholder={chips.length === 0 ? placeholder : ""} className="flex-1 min-w-[80px] bg-transparent outline-none typo-body text-text-primary placeholder:text-text-tertiary" @@ -389,7 +377,6 @@ function SearchBar({ ); } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: list page combines search/filter/grouping logic export function AnalyticsListPage({ kind, tags, @@ -502,23 +489,20 @@ export function AnalyticsListPage({ [tagFiltered], ); const needle = textQuery; - const minHighlight = Math.min(Math.max(needle.length, 1), 3); const items: RecentItem[] = needle ? fuse.search(needle).map((r) => { const labelMatch = r.matches?.find((m) => m.key === "label"); const descriptionMatch = r.matches?.find( (m) => m.key === "description", ); - const filterRanges = ( - indices?: readonly MatchRange[], - ): readonly MatchRange[] | undefined => - indices?.filter(([s, e]) => e - s + 1 >= minHighlight); return { ...r.item, - labelMatches: filterRanges( + labelMatches: filterHighlightRanges( + needle, labelMatch?.indices as readonly MatchRange[] | undefined, ), - descriptionMatches: filterRanges( + descriptionMatches: filterHighlightRanges( + needle, descriptionMatch?.indices as readonly MatchRange[] | undefined, ), }; @@ -570,6 +554,51 @@ export function AnalyticsListPage({ setText(""); }; + const [focusedIndex, setFocusedIndex] = React.useState(-1); + const focusedRowRef = React.useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: focusedIndex triggers scroll + React.useEffect(() => { + focusedRowRef.current?.scrollIntoView({ block: "nearest" }); + }, [focusedIndex]); + + React.useEffect(() => { + if (text && items.length > 0) setFocusedIndex(0); + else setFocusedIndex(-1); + }, [text, items.length]); + + const openItem = (it: RecentItem) => { + if (it.kind === "view") { + navigate({ + to: "/analytics/views/edit/$id", + params: { id: it.id }, + search: VIEW_EDIT_SEARCH, + }); + } else { + navigate({ + to: "/analytics/queries/edit/$id", + params: { id: it.id }, + search: QUERY_EDIT_SEARCH, + }); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { + e.preventDefault(); + setFocusedIndex((p) => Math.min(p + 1, items.length - 1)); + } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + e.preventDefault(); + setFocusedIndex((p) => Math.max(p - 1, -1)); + } else if (e.key === "Enter") { + if (focusedIndex < 0) return; + const it = items[focusedIndex]; + if (!it) return; + e.preventDefault(); + openItem(it); + } + }; + return (
@@ -583,6 +612,7 @@ export function AnalyticsListPage({ onTextChange={updateTextPart} onRemoveChip={removeChip} onClear={onClear} + onInputKeyDown={handleKeyDown} /> {kind === "view" ? ( @@ -673,13 +703,15 @@ export function AnalyticsListPage({
) : (
    - {items.map((it) => { + {items.map((it, index) => { const itemKey = `${it.kind}-${it.id}`; const isMenuOpen = openMenuKey === itemKey; + const focused = index === focusedIndex; return (
  • {it.kind === "view" ? ( ({ - q: typeof search.q === "string" && search.q ? search.q : undefined, - }), + validateSearch: (search: { + q?: unknown; + tags?: unknown; + }): { q?: string; tags?: string[] } => { + const out: { q?: string; tags?: string[] } = {}; + if (typeof search.q === "string" && search.q.length > 0) out.q = search.q; + if (Array.isArray(search.tags)) { + const tags = search.tags.filter( + (t): t is string => typeof t === "string" && t.length > 0, + ); + if (tags.length > 0) out.tags = tags; + } else if (typeof search.tags === "string" && search.tags.length > 0) { + out.tags = [search.tags]; + } + return out; + }, }); function RouteComponent() { diff --git a/src/utils/highlight.tsx b/src/utils/highlight.tsx new file mode 100644 index 00000000..a3bc952a --- /dev/null +++ b/src/utils/highlight.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; + +export type MatchRange = readonly [number, number]; + +export function highlight( + text: string, + ranges: readonly MatchRange[] | undefined, +): React.ReactNode { + if (!ranges || ranges.length === 0) return text; + const sorted = ranges.slice().sort((a, b) => a[0] - b[0]); + const parts: React.ReactNode[] = []; + let cursor = 0; + sorted.forEach(([start, end]) => { + if (start < cursor) return; + if (start > cursor) parts.push(text.slice(cursor, start)); + parts.push( + + {text.slice(start, end + 1)} + , + ); + cursor = end + 1; + }); + if (cursor < text.length) parts.push(text.slice(cursor)); + return <>{parts}; +} + +export function filterHighlightRanges( + needle: string, + ranges: readonly MatchRange[] | undefined, +): readonly MatchRange[] | undefined { + if (!ranges) return ranges; + const min = Math.min(Math.max(needle.length, 1), 3); + return ranges.filter(([s, e]) => e - s + 1 >= min); +} From b8790a84adbd30dea3deb41e1c3885a69f82f6dd Mon Sep 17 00:00:00 2001 From: Panthevm Date: Wed, 20 May 2026 15:04:04 +0300 Subject: [PATCH 2/2] Util: import React as type in highlight --- src/utils/highlight.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/highlight.tsx b/src/utils/highlight.tsx index a3bc952a..fa150bae 100644 --- a/src/utils/highlight.tsx +++ b/src/utils/highlight.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type * as React from "react"; export type MatchRange = readonly [number, number];