// ~/src/siteplane/components/filter-bar/filter-bar.tsx "use client"; import type * as React from "react"; import { cn } from "../../system/utils/cn"; import { useResolvedDensity } from "../../system/provider/siteplane-ui-provider"; import { MotionReveal } from "../motion"; export interface FilterBarProps extends React.ComponentProps<"section"> { density?: "compact" | "default"; } /** * The space between the bar's rows is `--filter-bar-gap`: 12 px, 8 px in the * compact density. FilterBarActiveFilters reads the same value, so the space * above the active filters opens and closes with their row instead of * appearing in one step. The rows stack in a flex column, where the row's * negative margin can take the gap back while the row is still closed. */ export function FilterBar({ className, density, ...props }: FilterBarProps): React.ReactElement { const resolvedDensity = useResolvedDensity(density); return (
); } export function FilterBarControls({ className, ...props }: React.ComponentProps<"div">): React.ReactElement { return (
); } export function FilterBarSearch({ className, ...props }: React.ComponentProps<"div">): React.ReactElement { return (
); } export function FilterBarFilters({ className, ...props }: React.ComponentProps<"div">): React.ReactElement { return (
); } export interface FilterBarActiveFiltersProps extends React.ComponentProps<"div"> { /** * Shows the row. While no filter applies, set it to `false`: the row * collapses with a fade and keeps its last tags while it goes, and the space * above it closes with it, so the collection below glides up. Default `true`. */ open?: boolean | undefined; } /** * The applied filters under the controls. The row reveals itself like a * FieldError: the reveal takes back the bar's gap and the row gives it again * as its own top padding, so the space opens and closes with the row. */ export function FilterBarActiveFilters({ className, open = true, ...props }: FilterBarActiveFiltersProps): React.ReactElement { return (
); } export function FilterBarActions({ className, ...props }: React.ComponentProps<"div">): React.ReactElement { return (
); } // ~/src/siteplane/components/filter-bar/filter-bar-tabs.tsx "use client"; import { SearchIcon, XIcon } from "lucide-react"; import * as React from "react"; import { useResolvedControlSize } from "../../system/provider/siteplane-ui-provider"; import { cn } from "../../system/utils/cn"; import { Button } from "../button"; import { KitButton } from "../button/button-internal"; import { Input, type InputProps } from "../input"; import { swapFadeClassName } from "../motion/motion-presence"; import { ScrollArea } from "../scroll-area"; import { Tabs, TabsList, TabsRail, type TabsProps } from "../tabs"; /** The search input's own props; the bar owns its value, type, size and surface. */ export type FilterBarTabsInputProps = Omit< InputProps, | "children" | "defaultValue" | "nativeInput" | "onChange" | "onValueChange" | "profile" | "profileMode" | "ref" | "renderInput" | "size" | "textSize" | "type" | "unstyled" | "value" | "variant" >; export interface FilterBarTabsProps extends Omit< TabsProps, "children" | "orientation" > { /** Names the tab list. */ "aria-label"?: string; /** Names the tab list from another element. */ "aria-labelledby"?: string; /** The views: `TabsTab` elements, each with a `value`. */ children?: React.ReactNode; /** The clear button's accessible name. */ clearLabel?: string; /** The search is open and the bar holds the field instead of the tabs. Uncontrolled by default. */ defaultSearching?: boolean; /** The search text. Uncontrolled by default; closing the search empties it. */ defaultQuery?: string; /** * The bar takes the rest of its row (`true`, the default), or, with `false`, * the width of its tabs, so its surface ends after the last tab while the * filters beside it keep their place. The search then opens in that width. * Tabs that do not fit still scroll. */ grow?: boolean; /** Props of the search input, such as `placeholder` or `aria-label`. */ inputProps?: FilterBarTabsInputProps; /** Called with the new text as it is typed, and with an empty text when the search closes. */ onQueryChange?: (query: string) => void; /** Called when the search opens or closes. */ onSearchingChange?: (searching: boolean) => void; /** The search text, for a controlled search. */ query?: string; /** The visible name of the leading search segment. */ searchLabel?: string; /** Whether the search is open, for a controlled search. */ searching?: boolean; /** `default` shares the Tabs surface; `pill` rounds the rail and the tabs fully. */ variant?: "default" | "pill"; } /** Controlled when the prop is set, uncontrolled from `defaultValue` otherwise. */ function useControllableState( controlled: Value | undefined, defaultValue: Value, onChange: ((value: Value) => void) | undefined, ): [Value, (value: Value) => void] { const [uncontrolled, setUncontrolled] = React.useState(defaultValue); const isControlled = controlled !== undefined; const setValue = React.useCallback( (next: Value) => { if (!isControlled) setUncontrolled(next); onChange?.(next); }, [isControlled, onChange], ); return [isControlled ? controlled : uncontrolled, setValue]; } /** * View tabs that open into a search field inside the same bar. The leading * Search segment takes the place of the selected tab; while it is open the tabs * give way to the field and the clear button, and the shared Tabs indicator * stays on Search. Escape or the clear button empties the search and returns to * the tabs, with the selected tab unchanged and the focus back on Search. * * The tab selection stays with Tabs (`value`, `onValueChange`); the search * state is separate (`searching`, `query`), so the application decides what a * search means for its list. Tabs that do not fit scroll beside the Search * segment, in a ScrollArea that fades the edge where more tabs wait. */ export function FilterBarTabs({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, clearLabel = "Clear search", defaultQuery = "", defaultSearching = false, grow = true, inputProps, onQueryChange, onSearchingChange, query: queryProp, searchLabel = "Search", searching: searchingProp, size, variant = "default", ...props }: FilterBarTabsProps): React.ReactElement { const [searching, setSearching] = useControllableState( searchingProp, defaultSearching, onSearchingChange, ); const [query, setQuery] = useControllableState( queryProp, defaultQuery, onQueryChange, ); const resolvedSize = useResolvedControlSize(size); const fieldId = React.useId(); const searchRef = React.useRef(null); const inputRef = React.useRef(null); // The field takes the focus when the search opens, not when it starts open. const wasSearching = React.useRef(searching); React.useEffect(() => { if (searching && !wasSearching.current) inputRef.current?.focus(); wasSearching.current = searching; }, [searching]); function closeSearch() { // The focus moves first: the field is inert as soon as the search is closed. searchRef.current?.focus(); if (query !== "") setQuery(""); setSearching(false); } const { className: inputClassName, ...restInputProps } = inputProps ?? {}; return ( { if ( !searching || event.key !== "Escape" || event.nativeEvent.isComposing ) return; // The first Escape leaves the search; a surrounding overlay keeps its own. event.preventDefault(); event.stopPropagation(); closeSearch(); }} variant={variant} > { if (searching) inputRef.current?.focus(); else setSearching(true); }} profile="tabs-tab" profileMode={variant} ref={searchRef} size={resolvedSize} variant="text" >
{/* Tabs that do not fit scroll, and the ScrollArea fades the edge where more tabs wait. Its viewport would join the tab order, so the strip is inert with the tabs while the field is open. */} {children}
{ // The space around the text belongs to the field: a press there focuses it. if (event.target !== event.currentTarget) return; event.preventDefault(); inputRef.current?.focus(); }} >
); } // ~/src/siteplane/components/filter-bar/index.ts export * from "./filter-bar"; export * from "./filter-bar-tabs";