"use client" /** * Airbnb-style Basic search pill for the Library hub: * Keyword | Type | Difficulty | Search * * One row at every width. When space runs out, segment content compresses * (full → mid → compact) instead of overflowing past the pill border. */ import * as React from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { SEARCH_BAR_ROW_HEIGHT, searchBarShellClassName, } from "@/components/search-bar-shell" import { SearchRecentsPopover } from "@/components/search-recents-popover" import { Tip } from "@exxatdesignux/ui/components/tip" import { formatLibraryBasicSearchLabel, normalizeLibraryBasicSearchSnapshot, type LibraryBasicSearchRecentsController, type LibraryBasicSearchSnapshot, } from "@/lib/library-basic-search-recents" import { cn } from "@/lib/utils" import type { LibraryItemType, LibraryLevel } from "@/lib/mock/library" const TYPE_OPTIONS: { value: LibraryItemType | ""; label: string }[] = [ { value: "", label: "Any type" }, { value: "multiple_choice", label: "Multiple choice" }, { value: "true_false", label: "True / false" }, { value: "short_answer", label: "Short answer" }, ] const DIFFICULTY_OPTIONS: { value: LibraryLevel | ""; label: string }[] = [ { value: "", label: "Any difficulty" }, { value: "easy", label: "Easy" }, { value: "medium", label: "Medium" }, { value: "hard", label: "Hard" }, ] type BarTier = "full" | "mid" | "compact" const BAR_TIER_FULL_MIN = 640 const BAR_TIER_MID_MIN = 460 function useBarTier(ref: React.RefObject): BarTier { const [tier, setTier] = React.useState("full") React.useLayoutEffect(() => { const node = ref.current if (!node || typeof ResizeObserver === "undefined") return const observer = new ResizeObserver((entries) => { const width = entries[0]?.contentRect.width ?? 0 setTier( width >= BAR_TIER_FULL_MIN ? "full" : width >= BAR_TIER_MID_MIN ? "mid" : "compact", ) }) observer.observe(node) return () => observer.disconnect() }, [ref]) return tier } function BarDivider() { return ( ) } const SEGMENT_CLASSNAME = cn( SEARCH_BAR_ROW_HEIGHT, "min-w-0 max-w-[14rem] shrink grow-0 justify-between gap-2 rounded-full bg-transparent px-3 text-start font-normal dark:bg-transparent aria-expanded:shadow-xs", ) function SegmentBody({ icon, label, value, active, tier, }: { icon: string label: string value: string active: boolean tier: BarTier }) { return ( <> {tier === "compact" ? null : ( {label} )} {tier === "full" ? ( {value} ) : active ? ( 1 ) : null} {tier === "compact" ? null : ( )} ) } function FacetSegment({ label, valueLabel, icon, active, tier, disabled, children, }: { label: string valueLabel: string icon: string active: boolean tier: BarTier disabled?: boolean children: React.ReactNode }) { const ariaLabel = active ? `${label}: ${valueLabel}` : label const trigger = ( ) return ( {tier === "compact" && !disabled ? ( {trigger} ) : ( trigger )} {children} ) } export interface LibraryBasicSearchBarProps { keyword: string type: LibraryItemType | "" difficulty: LibraryLevel | "" onKeywordChange: (value: string) => void onTypeChange: (value: LibraryItemType | "") => void onDifficultyChange: (value: LibraryLevel | "") => void onSubmit: (next: LibraryBasicSearchSnapshot) => void /** Structured Basic recents (keyword + facets). Opens from keyword focus or empty Search. */ recents?: Pick< LibraryBasicSearchRecentsController, "read" | "clear" | "eventName" | "record" > /** In-flight search. Locks the bar and shows a Searching control. */ searching?: boolean footer?: React.ReactNode } export function LibraryBasicSearchBar({ keyword, type, difficulty, onKeywordChange, onTypeChange, onDifficultyChange, onSubmit, recents, searching = false, footer, }: LibraryBasicSearchBarProps) { // `keyword` is what the last search ran on; `draft` is what the user has // typed since. They part company as soon as a key is pressed, so the draft // cannot simply read the prop. When the prop moves on its own, the draft // catches up here rather than in an effect: an effect would paint one frame // of the old keyword first, and that frame is the one the user sees blink. const [draft, setDraft] = React.useState(keyword) const [searchedKeyword, setSearchedKeyword] = React.useState(keyword) if (keyword !== searchedKeyword) { setSearchedKeyword(keyword) setDraft(keyword) } const barRef = React.useRef(null) const tier = useBarTier(barRef) const compact = tier === "compact" const [recentsOpen, setRecentsOpen] = React.useState(false) const [recentItems, setRecentItems] = React.useState( [], ) React.useEffect(() => { if (!recents) return const sync = () => setRecentItems(recents.read()) sync() window.addEventListener(recents.eventName, sync) return () => window.removeEventListener(recents.eventName, sync) }, [recents]) const typeLabel = TYPE_OPTIONS.find((o) => o.value === type)?.label ?? "Any type" const difficultyLabel = DIFFICULTY_OPTIONS.find((o) => o.value === difficulty)?.label ?? "Any difficulty" const currentSnapshot = React.useCallback((): LibraryBasicSearchSnapshot => { return { keyword: draft.trim(), type, difficulty, } }, [draft, type, difficulty]) const hasActiveQuery = React.useCallback(() => { return Boolean(normalizeLibraryBasicSearchSnapshot(currentSnapshot())) }, [currentSnapshot]) const runSearch = React.useCallback( (next: LibraryBasicSearchSnapshot) => { if (searching) return const snapshot = normalizeLibraryBasicSearchSnapshot(next) if (!snapshot) return setDraft(snapshot.keyword) onKeywordChange(snapshot.keyword) onTypeChange(snapshot.type) onDifficultyChange(snapshot.difficulty) recents?.record(snapshot) onSubmit(snapshot) setRecentsOpen(false) }, [ onDifficultyChange, onKeywordChange, onSubmit, onTypeChange, recents, searching, ], ) const openRecents = React.useCallback(() => { if (searching || !recents || recentItems.length === 0) return setRecentsOpen(true) }, [recents, recentItems.length, searching]) const searchIcon = searching ? ( ) : ( ) const searchButton = compact ? ( ) : ( ) const form = (
{ event.preventDefault() if (searching) return if (!hasActiveQuery()) { openRecents() return } runSearch(currentSnapshot()) }} >
setDraft(event.target.value)} onFocus={() => { if (!draft.trim()) openRecents() }} onClick={() => { if (!draft.trim()) openRecents() }} className="h-full min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-70 md:text-sm" /> {draft && !searching ? ( ) : null}
{TYPE_OPTIONS.map((option) => ( ))} {DIFFICULTY_OPTIONS.map((option) => ( ))} {searchButton} ) return (
{recents && recentItems.length > 0 ? ( { const snapshot = recentItems[index] if (snapshot) runSearch(snapshot) }} onClear={() => recents.clear()} anchor={form} /> ) : ( form )} {footer}
) }