## Expanding Search — Input Icon to field with focus handled. Docs: https://www.interior.dev/docs/expanding-search Reference: https://www.interior.dev/reference/expanding-search License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/expanding-search.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { ExpandingSearch } from "@/components/interior/expanding-search"; export function LibraryToolbar({ items }: { items: string[] }) { const [query, setQuery] = useState(""); const [searching, setSearching] = useState(false); const hits = items.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()), ); return (

Library

console.log("submit", value)} />
); } ``` ### Props - `label` (`string`) — default: `"Search"` Accessible name shared by the collapsed trigger and the input, so both read the same in the a11y tree. - `placeholder` (`string`) — default: `"Search"` Placeholder text; it is clipped by the shell while collapsed rather than being unmounted. - `resultCount` (`number`) When supplied, reserves a fixed tabular slot in the field and feeds the debounced live region. Omit it and the slot is never allocated. - `align` (`"left" | "right"`) — default: `"right"` Which edge of the reserved track the field is anchored to. Right-anchored fields open leftward over the toolbar actions. - `value` (`string`) Controlled query. Leave undefined to let the component own it. - `defaultValue` (`string`) — default: `""` Initial query when uncontrolled. A literal, so remounting resets the field. - `onChange` (`(value: string) => void`) Fires on every keystroke. Use it to mirror state, not to run the search. - `onSearch` (`(value: string) => void`) Fires once the typing settles, and immediately on Enter. This is the one to hang a query off. - `onSubmit` (`(value: string) => void`) Enter. The pending debounce is flushed first so onSearch never arrives after it. - `debounce` (`number`) — default: `220` Milliseconds of quiet before onSearch fires. - `open` (`boolean`) Controlled expansion. Focus handling still runs; only the state lives outside. - `defaultOpen` (`boolean`) — default: `false` Start expanded, for a page whose primary action is searching. - `onOpenChange` (`(open: boolean) => void`) Called once per real transition, never twice for the same expand. - `collapseOnBlur` (`boolean`) — default: `true` Collapse when focus leaves and the query is empty. A non-empty query is never collapsed away. - `disabled` (`boolean`) — default: `false` Blocks expansion and the input, and keeps the trigger out of the tab order's reach. - `className` (`string`) — default: `""` Appended last to the reserved track, so width and position are overridable from outside. ### Behavior notes - The track reserves the expanded width before anything opens, so the row beside the field never reflows; neighbouring actions fade where they stand instead of being shoved sideways. - Focus moves to the input synchronously inside the click handler rather than on animation-complete, so the iOS keyboard is not suppressed and a screen reader is never left pointing at a trigger that has already gone. - Blur collapses the field only when it is empty, and a blur caused by switching browser tabs is ignored, so a typed query is never destroyed by clicking somewhere else. - Escape clears a non-empty query and collapses an empty one, returning focus to the trigger instead of the document body, and the event is consumed so a dialog behind the field does not close along with it. - Keystrokes are debounced before onSearch fires and Enter flushes the pending call, so the search runs once per intent; the polite live region announces only the settled result count, not one message per character. - The input holds its expanded width at all times and is clipped by the shell, so the text inside never re-wraps mid-spring, and prefers-reduced-motion drops the transitions to zero without hiding either state. ### Source (`components/interior/expanding-search.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { motion, useReducedMotion } from "motion/react"; const DISCLOSE = { type: "spring", stiffness: 380, damping: 38, mass: 0.7 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const INSTANT = { duration: 0 } as const; const COLLAPSED = 40; const TEXT_LEFT = 34; const CLEAR_SLOT = 35; const COUNT_SLOT = 38; const ANNOUNCE_DELAY = 500; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; export type UseExpandingSearchOptions = { value?: string; defaultValue?: string; onChange?: (value: string) => void; onSearch?: (value: string) => void; onSubmit?: (value: string) => void; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; debounce?: number; collapseOnBlur?: boolean; disabled?: boolean; }; export type UseExpandingSearchReturn = { open: boolean; focused: boolean; query: string; expand: () => void; collapse: (returnFocus?: boolean) => void; toggle: () => void; clear: () => void; inputRef: React.RefObject; triggerRef: React.RefObject; rootProps: { onFocus: (event: React.FocusEvent) => void; onBlur: (event: React.FocusEvent) => void; }; triggerProps: { ref: React.RefObject; type: "button"; disabled: boolean; tabIndex: number; "aria-expanded": boolean; onClick: () => void; }; inputProps: { ref: React.RefObject; value: string; disabled: boolean; tabIndex: number; onChange: (event: React.ChangeEvent) => void; onKeyDown: (event: React.KeyboardEvent) => void; onFocus: () => void; }; }; export function useExpandingSearch({ value, defaultValue = "", onChange, onSearch, onSubmit, open, defaultOpen = false, onOpenChange, debounce = 220, collapseOnBlur = true, disabled = false, }: UseExpandingSearchOptions = {}): UseExpandingSearchReturn { const [ownValue, setOwnValue] = useState(defaultValue); const [ownOpen, setOwnOpen] = useState(defaultOpen); const [focused, setFocused] = useState(false); const query = value ?? ownValue; const isOpen = open ?? ownOpen; const inputRef = useRef(null); const triggerRef = useRef(null); const timer = useRef | null>(null); const openRef = useRef(isOpen); const latest = useRef({ query, onChange, onSearch, onSubmit, onOpenChange }); latest.current = { query, onChange, onSearch, onSubmit, onOpenChange }; useEffect(() => { openRef.current = isOpen; }, [isOpen]); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const setOpen = useCallback((next: boolean) => { if (openRef.current === next) return; openRef.current = next; setOwnOpen(next); latest.current.onOpenChange?.(next); }, []); const commit = useCallback( (next: string) => { setOwnValue(next); latest.current.onChange?.(next); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => { timer.current = null; latest.current.onSearch?.(next); }, debounce); }, [debounce], ); const flush = useCallback(() => { if (!timer.current) return; clearTimeout(timer.current); timer.current = null; latest.current.onSearch?.(latest.current.query); }, []); const expand = useCallback(() => { if (disabled) return; setOpen(true); inputRef.current?.focus(); }, [disabled, setOpen]); const collapse = useCallback( (returnFocus = false) => { setOpen(false); if (returnFocus) triggerRef.current?.focus(); }, [setOpen], ); const toggle = useCallback(() => { if (openRef.current) collapse(true); else expand(); }, [collapse, expand]); const clear = useCallback(() => { commit(""); inputRef.current?.focus(); }, [commit]); const onRootFocus = useCallback(() => setFocused(true), []); const onRootBlur = useCallback( (event: React.FocusEvent) => { const next = event.relatedTarget as Node | null; if (next && event.currentTarget.contains(next)) return; setFocused(false); if (!collapseOnBlur) return; if (!document.hasFocus()) return; if (latest.current.query.length > 0) return; setOpen(false); }, [collapseOnBlur, setOpen], ); const onInputKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (latest.current.query.length > 0) { commit(""); return; } collapse(true); return; } if (event.key === "Enter") { event.preventDefault(); flush(); latest.current.onSubmit?.(latest.current.query); } }, [collapse, commit, flush], ); const onInputFocus = useCallback(() => setOpen(true), [setOpen]); const onInputChange = useCallback( (event: React.ChangeEvent) => commit(event.currentTarget.value), [commit], ); return { open: isOpen, focused, query, expand, collapse, toggle, clear, inputRef, triggerRef, rootProps: { onFocus: onRootFocus, onBlur: onRootBlur }, triggerProps: { ref: triggerRef, type: "button", disabled, tabIndex: isOpen ? -1 : 0, "aria-expanded": isOpen, onClick: expand, }, inputProps: { ref: inputRef, value: query, disabled, tabIndex: isOpen ? 0 : -1, onChange: onInputChange, onKeyDown: onInputKeyDown, onFocus: onInputFocus, }, }; } export type ExpandingSearchProps = UseExpandingSearchOptions & { label?: string; placeholder?: string; resultCount?: number; align?: "left" | "right"; className?: string; }; export function ExpandingSearch({ label = "Search", placeholder = "Search", resultCount, align = "right", className = "", ...options }: ExpandingSearchProps) { const reduced = useReducedMotion(); const auto = useId(); const inputId = `${auto}-field`; const { open, focused, query, clear, inputRef, rootProps, triggerProps, inputProps, } = useExpandingSearch(options); const trackRef = useRef(null); const [track, setTrack] = useState(0); useIsomorphicLayoutEffect(() => { const el = trackRef.current; if (!el) return; const read = (w: number) => setTrack((prev) => (Math.abs(prev - w) < 0.5 ? prev : w)); read(el.getBoundingClientRect().width); const observer = new ResizeObserver((entries) => { const box = entries[0]; if (box) read(box.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); const [announced, setAnnounced] = useState(""); useEffect(() => { const id = setTimeout(() => { if (!open || query.length === 0 || resultCount === undefined) { setAnnounced(""); return; } setAnnounced( `${resultCount} ${resultCount === 1 ? "result" : "results"} for ${query}`, ); }, ANNOUNCE_DELAY); return () => clearTimeout(id); }, [open, query, resultCount]); const expanded = Math.max(COLLAPSED, track); const rightInset = CLEAR_SLOT + (resultCount === undefined ? 0 : COUNT_SLOT); const inner = Math.max(0, expanded - TEXT_LEFT - rightInset); const filled = query.length > 0; const shellMotion = reduced ? INSTANT : DISCLOSE; const fadeMotion = reduced ? INSTANT : CROSSFADE; const cellMotion = reduced ? INSTANT : CELL; return (
{ if (event.target !== event.currentTarget) return; event.preventDefault(); if (open) inputRef.current?.focus(); }} className={`absolute inset-y-0 ${ align === "right" ? "right-0" : "left-0" } overflow-hidden rounded-[10px] border-2 transition-[background-color,border-color,box-shadow] duration-150 ${ focused ? "border-[#4568FF] bg-white dark:border-[#93B0FF] dark:bg-[#252522]" : "border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]" }`} > {resultCount === undefined ? null : ( {filled ? resultCount : ""} )} {announced}
); } ```