{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"web-search","type":"registry:ui","title":"WebSearch","description":"A search the agent ran: the query in a search-bar header, an indeterminate scanning bar while it runs, and results that land one row at a time","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/web-search/WebSearch.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport type { SearchResultData } from \"../../internals/ai-types\";\nimport { hostOf, monogram } from \"../../internals/host\";\nimport { sanitizeHref } from \"../../internals/markdown\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./web-search.css\";\n\n/**\n * Props for WebSearch\n */\nexport interface WebSearchProps {\n\t/** What the agent looked up, shown in the search-bar header */\n\tquery: string;\n\t/** Hits found so far, oldest first; appending to it lands a new row */\n\tresults: SearchResultData[];\n\t/** Whether the lookup is still running: drives the scanning bar and the waiting state */\n\tsearching?: boolean;\n\t/** Called when a row is activated; supplying it turns every row into a button */\n\tonSelect?: (result: SearchResultData, index: number) => void;\n\t/** Replaces the built-in row body, keeping the row element and its behaviour */\n\titem?: (result: SearchResultData, index: number) => ReactNode;\n\t/** Rows shown before the expander takes over; `0` shows every result */\n\tmaxVisible?: number;\n\t/** Accessible name for the whole block */\n\tlabel?: string;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/**\n * A result url made safe for an `href`, or `null` when it must not be a link\n * at all. These arrive from a model with the rest of the answer, so they clear\n * the same scheme check as every link in this family; a bare host is promoted\n * to `https://` first, since an `href` of \"docs.example.dev/guide\" resolves\n * against this app's own origin. A genuine relative path is left alone.\n */\nfunction resolveHref(raw: string | undefined): string | null {\n\tif (typeof raw !== \"string\" || raw === \"\") return null;\n\tconst hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith(\"//\");\n\tconst host = raw.split(/[/?#]/, 1)[0] ?? \"\";\n\tconst looksHostLike = !hasScheme && !raw.startsWith(\"/\") && host.includes(\".\");\n\treturn sanitizeHref(looksHostLike ? `https://${raw}` : raw);\n}\n\nconst ROW_BASE =\n\t\"ft-websearch-row flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-left\";\nconst ROW_INTERACTIVE =\n\t\"hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-ring cursor-pointer transition-colors focus-visible:ring-1 focus-visible:outline-none\";\n\n/**\n * A search the agent ran, shown as it happens: the query in a search-bar\n * header, a scanning bar while the lookup is in flight, and result rows that\n * land one at a time as they are found.\n *\n * The root element arrives through the ref channel rather than a `ref` prop,\n * per PORTING.md — the Svelte source declares `ref = $bindable(null)`.\n */\nexport const WebSearch = forwardRef<HTMLDivElement, WebSearchProps>(function WebSearch(\n\t{\n\t\tquery,\n\t\tresults,\n\t\tsearching = false,\n\t\tonSelect,\n\t\titem,\n\t\tmaxVisible = 0,\n\t\tlabel = \"Web search\",\n\t\tclassName,\n\t\tsound,\n\t},\n\tref\n) {\n\tconst uid = useFancyId();\n\tconst listId = `${uid}-list`;\n\tconst playCue = useSoundCue(sound);\n\n\tconst [expanded, setExpanded] = useState(false);\n\n\t// `0` — and any value below it, which would otherwise hide everything — means\n\t// the list is its own cap.\n\tconst cap = maxVisible > 0 ? Math.floor(maxVisible) : results.length;\n\tconst hiddenCount = Math.max(0, results.length - cap);\n\n\t// Expansion only means anything while something is hidden: with nothing behind\n\t// the cap there is no expanded state to be in, and the label and `aria-expanded`\n\t// would otherwise describe a button that is not on screen.\n\tconst isExpanded = expanded && hiddenCount > 0;\n\tconst visible = isExpanded || hiddenCount === 0 ? results : results.slice(0, cap);\n\tconst countText = results.length === 1 ? \"1 result\" : `${results.length} results`;\n\n\t// An emptied list is a new search starting, not the old one still expanded: the\n\t// reader asked to see the rest of a result set that no longer exists, so the\n\t// request retires with it and the next set arrives capped like the first.\n\tuseEffect(() => {\n\t\tif (results.length === 0) setExpanded(false);\n\t}, [results.length]);\n\n\tfunction pick(result: SearchResultData, index: number) {\n\t\tplayCue(\"select\");\n\t\tonSelect?.(result, index);\n\t}\n\n\t/** Disclosure, not a menu: `open`/`close` follow the same toggle either way. */\n\tfunction toggleExpanded() {\n\t\tconst next = !isExpanded;\n\t\tplayCue(next ? \"open\" : \"close\");\n\t\tsetExpanded(next);\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName={cn(\"ft-websearch w-full text-sm\", className)}\n\t\t\trole=\"group\"\n\t\t\taria-label={label}\n\t\t\taria-busy={searching}\n\t\t>\n\t\t\t<div className=\"ft-websearch-header border-border bg-background/60 flex items-center gap-2 rounded-lg border px-3 py-2\">\n\t\t\t\t<svg\n\t\t\t\t\tclassName=\"text-muted-foreground size-3.5 flex-none\"\n\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\tfill=\"none\"\n\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t>\n\t\t\t\t\t<circle cx=\"11\" cy=\"11\" r=\"7\" />\n\t\t\t\t\t<path d=\"m20 20-3.6-3.6\" />\n\t\t\t\t</svg>\n\n\t\t\t\t<span className=\"ft-websearch-query text-foreground min-w-0 flex-1 truncate\" title={query}>\n\t\t\t\t\t{query}\n\t\t\t\t</span>\n\n\t\t\t\t{results.length > 0 && (\n\t\t\t\t\t<span className=\"ft-websearch-count text-muted-foreground flex-none text-xs tabular-nums\">\n\t\t\t\t\t\t{countText}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/*\n\t\t\t\tDecorative: `aria-busy` on the group already says the lookup is running, and\n\t\t\t\tthe waiting line below says it in words while there is nothing to read.\n\t\t\t*/}\n\t\t\t{searching && (\n\t\t\t\t<div className=\"ft-websearch-scan mt-1.5\" aria-hidden=\"true\">\n\t\t\t\t\t<span className=\"ft-websearch-beam\"></span>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{visible.length > 0 ? (\n\t\t\t\t<ul id={listId} className=\"ft-websearch-list mt-1.5\">\n\t\t\t\t\t{/*\n\t\t\t\t\t\tKeyed by id so an appended hit mounts as a fresh node and plays the\n\t\t\t\t\t\tentrance on its own, while the rows already on screen keep the DOM nodes\n\t\t\t\t\t\tthey had — a CSS animation does not replay on an element that has one.\n\t\t\t\t\t*/}\n\t\t\t\t\t{visible.map((result, index) => {\n\t\t\t\t\t\tconst domain = hostOf(result.url);\n\t\t\t\t\t\tconst safeHref = resolveHref(result.url);\n\t\t\t\t\t\tconst body = item ? (\n\t\t\t\t\t\t\titem(result, index)\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{/* A row with no parsable host falls back to its title for the circle. */}\n\t\t\t\t\t\t\t\t<span className=\"ft-websearch-monogram\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t{monogram(domain || result.title)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<span className=\"ft-websearch-text min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"ft-websearch-title text-foreground block truncate font-medium\">\n\t\t\t\t\t\t\t\t\t\t{result.title}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t{domain && (\n\t\t\t\t\t\t\t\t\t\t<span className=\"ft-websearch-domain text-muted-foreground block truncate text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t{domain}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{result.snippet && (\n\t\t\t\t\t\t\t\t\t\t<span className=\"ft-websearch-snippet text-muted-foreground mt-0.5 text-xs\">\n\t\t\t\t\t\t\t\t\t\t\t{result.snippet}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<li key={result.id} className=\"ft-websearch-item\">\n\t\t\t\t\t\t\t\t{onSelect ? (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclassName={cn(ROW_BASE, ROW_INTERACTIVE)}\n\t\t\t\t\t\t\t\t\t\tonClick={() => pick(result, index)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{body}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t) : safeHref ? (\n\t\t\t\t\t\t\t\t\t// Somebody else's page, arriving from a model: `nofollow ugc` keeps the\n\t\t\t\t\t\t\t\t\t// host from vouching for it, and `noopener noreferrer` keeps the opened\n\t\t\t\t\t\t\t\t\t// tab away from this one.\n\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\tclassName={cn(ROW_BASE, ROW_INTERACTIVE)}\n\t\t\t\t\t\t\t\t\t\thref={safeHref}\n\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\trel=\"noopener noreferrer nofollow ugc\"\n\t\t\t\t\t\t\t\t\t\tonClick={() => pick(result, index)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{body}\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<div className={ROW_BASE}>{body}</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t</ul>\n\t\t\t) : searching ? (\n\t\t\t\t<p className=\"ft-websearch-status text-muted-foreground mt-1.5 px-2 text-xs\">Searching…</p>\n\t\t\t) : (\n\t\t\t\t<p className=\"ft-websearch-empty text-muted-foreground mt-1.5 px-2 text-xs\">No results</p>\n\t\t\t)}\n\n\t\t\t{hiddenCount > 0 && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"ft-websearch-more text-muted-foreground hover:text-foreground focus-visible:ring-ring mt-1 cursor-pointer rounded-md px-2 py-1 text-xs transition-colors focus-visible:ring-1 focus-visible:outline-none\"\n\t\t\t\t\taria-expanded={isExpanded}\n\t\t\t\t\taria-controls={listId}\n\t\t\t\t\tonClick={toggleExpanded}\n\t\t\t\t>\n\t\t\t\t\t{isExpanded ? \"Show less\" : `Show ${hiddenCount} more`}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/web-search/WebSearch.tsx"},{"path":"react/src/components/web-search/index.ts","content":"\"use client\";\n\nexport { WebSearch } from \"./WebSearch\";\nexport type { WebSearchProps } from \"./WebSearch\";\n","type":"registry:ui","target":"@ui/fancy/components/web-search/index.ts"},{"path":"react/src/components/web-search/web-search.css","content":".ft-websearch {\n\t/* Neutral surfaces are mixes of currentColor, so one set of values reads\n\t   correctly in both themes. The beam cannot do that — an accent that is a\n\t   fade of the text colour is not an accent — so it is read at its point of\n\t   use off the `--ft-status-*` vocabulary this family shares, whose defaults\n\t   are per-theme `light-dark()` pairs. */\n\t--ft-websearch-track: color-mix(in oklab, currentColor 12%, transparent);\n\t--ft-websearch-monogram-bg: color-mix(in oklab, currentColor 10%, transparent);\n\t--ft-websearch-scan-duration: 1.4s;\n\t--ft-websearch-enter-duration: 260ms;\n\t--ft-websearch-snippet-lines: 2;\n}\n\n.ft-websearch-list {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.ft-websearch-scan {\n\tposition: relative;\n\theight: 2px;\n\toverflow: hidden;\n\tborder-radius: 9999px;\n\tbackground: var(--ft-websearch-track);\n}\n\n/*\n * Reduced motion gets a filled bar rather than a parked beam: a 40% sliver\n * frozen at the left edge reads as a progress bar stuck at 40%, which is a\n * claim this component cannot make.\n */\n.ft-websearch-beam {\n\tposition: absolute;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\tleft: 0;\n\tborder-radius: inherit;\n\tbackground: var(\n\t\t--ft-websearch-beam,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n\topacity: 0.45;\n}\n\n.ft-websearch-monogram {\n\tdisplay: inline-flex;\n\tflex: none;\n\talign-items: center;\n\tjustify-content: center;\n\t/* Sits on the middle of the title's line box rather than its top. */\n\tmargin-top: 0.0625rem;\n\twidth: 1.25rem;\n\theight: 1.25rem;\n\tborder-radius: 9999px;\n\tbackground: var(--ft-websearch-monogram-bg);\n\tfont-size: 0.625rem;\n\tfont-weight: 600;\n\tline-height: 1;\n}\n\n/* The clamp owns `display`, so the snippet carries no display utility. */\n.ft-websearch-snippet {\n\tdisplay: -webkit-box;\n\t-webkit-box-orient: vertical;\n\t-webkit-line-clamp: var(--ft-websearch-snippet-lines);\n\tline-clamp: var(--ft-websearch-snippet-lines);\n\toverflow: hidden;\n\tline-height: 1.45;\n}\n\n/*\n * Both animations live entirely inside `no-preference`, so reduced motion is\n * not a degraded variant to keep in sync: a new row simply appears where it\n * belongs, and the scanning bar is a steady tint instead of a sweep.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-websearch-item {\n\t\tanimation: ft-websearch-enter var(--ft-websearch-enter-duration) cubic-bezier(0.16, 1, 0.3, 1);\n\t}\n\n\t.ft-websearch-beam {\n\t\tright: auto;\n\t\twidth: 40%;\n\t\topacity: 1;\n\t\tanimation: ft-websearch-scan var(--ft-websearch-scan-duration) ease-in-out infinite;\n\t}\n}\n\n@keyframes ft-websearch-enter {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(4px);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: none;\n\t}\n}\n\n/* 250% of a 40%-wide beam is one full track, so it leaves on the right exactly\n   as it entered on the left. */\n@keyframes ft-websearch-scan {\n\tfrom {\n\t\ttransform: translateX(-100%);\n\t}\n\tto {\n\t\ttransform: translateX(250%);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/web-search/web-search.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/web-search?framework=react"}}