{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"filter-bar","title":"Filter Bar","description":"Responsive search, filter summary and action arrangement, with view tabs that open into a search field.","dependencies":["lucide-react@1.18.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/input","@siteplane/motion","@siteplane/scroll-area","@siteplane/tabs"],"files":[{"path":"packages/ui/src/components/filter-bar/filter-bar.tsx","content":"\"use client\";\n\nimport type * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { useResolvedDensity } from \"../../system/provider/siteplane-ui-provider\";\nimport { MotionReveal } from \"../motion\";\n\nexport interface FilterBarProps extends React.ComponentProps<\"section\"> {\n  density?: \"compact\" | \"default\";\n}\n\n/**\n * The space between the bar's rows is `--filter-bar-gap`: 12 px, 8 px in the\n * compact density. FilterBarActiveFilters reads the same value, so the space\n * above the active filters opens and closes with their row instead of\n * appearing in one step. The rows stack in a flex column, where the row's\n * negative margin can take the gap back while the row is still closed.\n */\nexport function FilterBar({\n  className,\n  density,\n  ...props\n}: FilterBarProps): React.ReactElement {\n  const resolvedDensity = useResolvedDensity(density);\n\n  return (\n    <section\n      className={cn(\n        \"flex min-w-0 flex-col gap-(--filter-bar-gap) [--filter-bar-gap:calc(var(--spacing)*3)] data-[density=compact]:[--filter-bar-gap:calc(var(--spacing)*2)]\",\n        className,\n      )}\n      data-density={resolvedDensity}\n      data-pattern=\"filter-bar\"\n      {...props}\n    />\n  );\n}\n\nexport function FilterBarControls({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 flex-wrap items-center justify-between gap-2\",\n        className,\n      )}\n      data-pattern=\"filter-bar-controls\"\n      {...props}\n    />\n  );\n}\n\nexport function FilterBarSearch({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(\"min-w-0\", className)}\n      data-pattern=\"filter-bar-search\"\n      {...props}\n    />\n  );\n}\n\nexport function FilterBarFilters({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(\"flex min-w-0 flex-wrap items-center gap-2\", className)}\n      data-pattern=\"filter-bar-filters\"\n      {...props}\n    />\n  );\n}\n\nexport interface FilterBarActiveFiltersProps extends React.ComponentProps<\"div\"> {\n  /**\n   * Shows the row. While no filter applies, set it to `false`: the row\n   * collapses with a fade and keeps its last tags while it goes, and the space\n   * above it closes with it, so the collection below glides up. Default `true`.\n   */\n  open?: boolean | undefined;\n}\n\n/**\n * The applied filters under the controls. The row reveals itself like a\n * FieldError: the reveal takes back the bar's gap and the row gives it again\n * as its own top padding, so the space opens and closes with the row.\n */\nexport function FilterBarActiveFilters({\n  className,\n  open = true,\n  ...props\n}: FilterBarActiveFiltersProps): React.ReactElement {\n  return (\n    <MotionReveal className=\"-mt-(--filter-bar-gap)\" open={open}>\n      <div\n        className={cn(\n          \"flex min-w-0 flex-wrap items-center gap-1.5 pt-(--filter-bar-gap)\",\n          className,\n        )}\n        data-pattern=\"filter-bar-active-filters\"\n        {...props}\n      />\n    </MotionReveal>\n  );\n}\n\nexport function FilterBarActions({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(\"flex shrink-0 items-center gap-2\", className)}\n      data-pattern=\"filter-bar-actions\"\n      {...props}\n    />\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/filter-bar/filter-bar.tsx"},{"path":"packages/ui/src/components/filter-bar/filter-bar-tabs.tsx","content":"\"use client\";\n\nimport { SearchIcon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { useResolvedControlSize } from \"../../system/provider/siteplane-ui-provider\";\nimport { cn } from \"../../system/utils/cn\";\nimport { Button } from \"../button\";\nimport { KitButton } from \"../button/button-internal\";\nimport { Input, type InputProps } from \"../input\";\nimport { swapFadeClassName } from \"../motion/motion-presence\";\nimport { ScrollArea } from \"../scroll-area\";\nimport { Tabs, TabsList, TabsRail, type TabsProps } from \"../tabs\";\n\n/** The search input's own props; the bar owns its value, type, size and surface. */\nexport type FilterBarTabsInputProps = Omit<\n  InputProps,\n  | \"children\"\n  | \"defaultValue\"\n  | \"nativeInput\"\n  | \"onChange\"\n  | \"onValueChange\"\n  | \"profile\"\n  | \"profileMode\"\n  | \"ref\"\n  | \"renderInput\"\n  | \"size\"\n  | \"textSize\"\n  | \"type\"\n  | \"unstyled\"\n  | \"value\"\n  | \"variant\"\n>;\n\nexport interface FilterBarTabsProps extends Omit<\n  TabsProps,\n  \"children\" | \"orientation\"\n> {\n  /** Names the tab list. */\n  \"aria-label\"?: string;\n  /** Names the tab list from another element. */\n  \"aria-labelledby\"?: string;\n  /** The views: `TabsTab` elements, each with a `value`. */\n  children?: React.ReactNode;\n  /** The clear button's accessible name. */\n  clearLabel?: string;\n  /** The search is open and the bar holds the field instead of the tabs. Uncontrolled by default. */\n  defaultSearching?: boolean;\n  /** The search text. Uncontrolled by default; closing the search empties it. */\n  defaultQuery?: string;\n  /**\n   * The bar takes the rest of its row (`true`, the default), or, with `false`,\n   * the width of its tabs, so its surface ends after the last tab while the\n   * filters beside it keep their place. The search then opens in that width.\n   * Tabs that do not fit still scroll.\n   */\n  grow?: boolean;\n  /** Props of the search input, such as `placeholder` or `aria-label`. */\n  inputProps?: FilterBarTabsInputProps;\n  /** Called with the new text as it is typed, and with an empty text when the search closes. */\n  onQueryChange?: (query: string) => void;\n  /** Called when the search opens or closes. */\n  onSearchingChange?: (searching: boolean) => void;\n  /** The search text, for a controlled search. */\n  query?: string;\n  /** The visible name of the leading search segment. */\n  searchLabel?: string;\n  /** Whether the search is open, for a controlled search. */\n  searching?: boolean;\n  /** `default` shares the Tabs surface; `pill` rounds the rail and the tabs fully. */\n  variant?: \"default\" | \"pill\";\n}\n\n/** Controlled when the prop is set, uncontrolled from `defaultValue` otherwise. */\nfunction useControllableState<Value>(\n  controlled: Value | undefined,\n  defaultValue: Value,\n  onChange: ((value: Value) => void) | undefined,\n): [Value, (value: Value) => void] {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n  const isControlled = controlled !== undefined;\n  const setValue = React.useCallback(\n    (next: Value) => {\n      if (!isControlled) setUncontrolled(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange],\n  );\n  return [isControlled ? controlled : uncontrolled, setValue];\n}\n\n/**\n * View tabs that open into a search field inside the same bar. The leading\n * Search segment takes the place of the selected tab; while it is open the tabs\n * give way to the field and the clear button, and the shared Tabs indicator\n * stays on Search. Escape or the clear button empties the search and returns to\n * the tabs, with the selected tab unchanged and the focus back on Search.\n *\n * The tab selection stays with Tabs (`value`, `onValueChange`); the search\n * state is separate (`searching`, `query`), so the application decides what a\n * search means for its list. Tabs that do not fit scroll beside the Search\n * segment, in a ScrollArea that fades the edge where more tabs wait.\n */\nexport function FilterBarTabs({\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  children,\n  className,\n  clearLabel = \"Clear search\",\n  defaultQuery = \"\",\n  defaultSearching = false,\n  grow = true,\n  inputProps,\n  onQueryChange,\n  onSearchingChange,\n  query: queryProp,\n  searchLabel = \"Search\",\n  searching: searchingProp,\n  size,\n  variant = \"default\",\n  ...props\n}: FilterBarTabsProps): React.ReactElement {\n  const [searching, setSearching] = useControllableState(\n    searchingProp,\n    defaultSearching,\n    onSearchingChange,\n  );\n  const [query, setQuery] = useControllableState(\n    queryProp,\n    defaultQuery,\n    onQueryChange,\n  );\n  const resolvedSize = useResolvedControlSize(size);\n  const fieldId = React.useId();\n  const searchRef = React.useRef<HTMLButtonElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  // The field takes the focus when the search opens, not when it starts open.\n  const wasSearching = React.useRef(searching);\n  React.useEffect(() => {\n    if (searching && !wasSearching.current) inputRef.current?.focus();\n    wasSearching.current = searching;\n  }, [searching]);\n\n  function closeSearch() {\n    // The focus moves first: the field is inert as soon as the search is closed.\n    searchRef.current?.focus();\n    if (query !== \"\") setQuery(\"\");\n    setSearching(false);\n  }\n\n  const { className: inputClassName, ...restInputProps } = inputProps ?? {};\n\n  return (\n    <Tabs\n      className={cn(\"min-w-0\", grow ? \"flex-1\" : \"flex-initial\", className)}\n      data-grow={grow ? undefined : \"false\"}\n      data-pattern=\"filter-bar-tabs\"\n      data-searching={searching || undefined}\n      size={resolvedSize}\n      {...props}\n    >\n      <TabsRail\n        activeIndicatorRef={searching ? searchRef : undefined}\n        onKeyDown={(event) => {\n          if (\n            !searching ||\n            event.key !== \"Escape\" ||\n            event.nativeEvent.isComposing\n          )\n            return;\n          // The first Escape leaves the search; a surrounding overlay keeps its own.\n          event.preventDefault();\n          event.stopPropagation();\n          closeSearch();\n        }}\n        variant={variant}\n      >\n        <KitButton\n          aria-controls={fieldId}\n          aria-expanded={searching}\n          data-active={searching || undefined}\n          onClick={() => {\n            if (searching) inputRef.current?.focus();\n            else setSearching(true);\n          }}\n          profile=\"tabs-tab\"\n          profileMode={variant}\n          ref={searchRef}\n          size={resolvedSize}\n          variant=\"text\"\n        >\n          <SearchIcon aria-hidden=\"true\" />\n          {searchLabel}\n        </KitButton>\n        <div className=\"relative min-w-0 flex-1\">\n          {/* Tabs that do not fit scroll, and the ScrollArea fades the edge\n              where more tabs wait. Its viewport would join the tab order, so\n              the strip is inert with the tabs while the field is open. */}\n          <ScrollArea\n            aria-hidden={searching || undefined}\n            inert={searching || undefined}\n            scrollbar=\"hidden\"\n            scrollFade\n          >\n            <TabsList\n              aria-label={ariaLabel}\n              aria-labelledby={ariaLabelledBy}\n              className={cn(\n                swapFadeClassName,\n                \"w-max max-w-none overflow-visible\",\n              )}\n              data-ending-style={searching ? \"\" : undefined}\n              embedded\n              variant={variant}\n            >\n              {children}\n            </TabsList>\n          </ScrollArea>\n          <div\n            aria-hidden={!searching || undefined}\n            className={cn(\n              \"sp-filter-bar-tabs-field\",\n              swapFadeClassName,\n              \"absolute inset-0 flex items-center gap-1 pe-1\",\n            )}\n            data-ending-style={searching ? undefined : \"\"}\n            data-slot=\"filter-bar-tabs-field\"\n            id={fieldId}\n            inert={!searching || undefined}\n            onMouseDown={(event) => {\n              // The space around the text belongs to the field: a press there focuses it.\n              if (event.target !== event.currentTarget) return;\n              event.preventDefault();\n              inputRef.current?.focus();\n            }}\n          >\n            <Input\n              aria-label={searchLabel}\n              autoComplete=\"off\"\n              enterKeyHint=\"search\"\n              {...restInputProps}\n              className={cn(\"min-w-0 flex-1\", inputClassName)}\n              onValueChange={setQuery}\n              ref={inputRef}\n              size={resolvedSize}\n              textSize={resolvedSize === \"xl\" ? \"base\" : \"sm\"}\n              type=\"search\"\n              unstyled\n              value={query}\n            />\n            <Button\n              aria-label={clearLabel}\n              onClick={closeSearch}\n              profile=\"field-action\"\n              size={resolvedSize === \"xl\" ? \"icon-sm\" : \"icon-xs\"}\n              variant=\"ghost\"\n            >\n              <XIcon aria-hidden=\"true\" className=\"size-3\" />\n            </Button>\n          </div>\n        </div>\n      </TabsRail>\n    </Tabs>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/filter-bar/filter-bar-tabs.tsx"},{"path":"packages/ui/src/components/filter-bar/index.ts","content":"export * from \"./filter-bar\";\nexport * from \"./filter-bar-tabs\";\n","type":"registry:ui","target":"~/src/siteplane/components/filter-bar/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/filter-bar.md","categories":["Data display"],"meta":{"avoidWhen":["Avoid for a collection that needs no search or filters; render the collection alone.","Avoid FilterBarTabs when the search must stay visible beside the tabs; place FilterBarSearch next to a Tabs list instead."],"description":"Responsive search, filter summary and action arrangement, with view tabs that open into a search field.","knownLimitations":["FilterBar arranges controls only; the application owns the query, the filters and the filtering of the collection.","FilterBarActiveFilters reads the bar's spacing from --filter-bar-gap (12 px, 8 px in the compact density). Change it on the FilterBar with [--filter-bar-gap:...], not with a gap utility, or the space above the active filters opens in one step.","Tabs of FilterBarTabs that do not fit scroll inside the bar and fade at the edge where more wait. While they overflow, the scrolling strip takes a tab stop of its own, as a Scroll Area does.","FilterBarTabs takes the rest of its row by default; grow={false} sizes it to its tabs, so its surface ends after the last tab."],"providerNotes":[],"releaseStatus":"stable","searchTerms":["filter-bar","filter","bar","search","tabs"],"useWhen":["Use to lay out search, filters, a summary of the active filters and actions above a collection.","Use FilterBarTabs for a list with a few views, such as My projects and Starred, where the search shares the tab bar."]}}