// ~/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"
>
{searchLabel}
);
}
// ~/src/siteplane/components/filter-bar/index.ts
export * from "./filter-bar";
export * from "./filter-bar-tabs";