{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sort-toolbar",
  "title": "Sort Toolbar",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The rule above a listing: a result count announced to screen readers once results settle, a native sort select, a density switch and a slot for the mobile filter trigger.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/sort-toolbar/sort-toolbar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown, Grid2x2, Grid3x3 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface SortOption {\n  value: string\n  label: string\n}\n\nexport const DEFAULT_SORT_OPTIONS: SortOption[] = [\n  { value: \"featured\", label: \"Featured\" },\n  { value: \"newest\", label: \"Newest\" },\n  { value: \"price-asc\", label: \"Price, low to high\" },\n  { value: \"price-desc\", label: \"Price, high to low\" },\n  { value: \"rating\", label: \"Top rated\" },\n]\n\nexport interface SortToolbarProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Results for the current filters. */\n  total: number\n  /** While true the count dims and nothing is announced. */\n  loading?: boolean\n  sort?: string\n  onSortChange?: (value: string) => void\n  sortOptions?: SortOption[]\n  density?: \"comfortable\" | \"compact\"\n  /** Omit to hide the density switch. */\n  onDensityChange?: (density: \"comfortable\" | \"compact\") => void\n  /** Shown below `lg` only — usually a `<FilterSheet />`. */\n  filters?: React.ReactNode\n  locale?: string\n  noun?: { one: string; other: string }\n}\n\nexport function SortToolbar({\n  total,\n  loading,\n  sort,\n  onSortChange,\n  sortOptions = DEFAULT_SORT_OPTIONS,\n  density = \"comfortable\",\n  onDensityChange,\n  filters,\n  locale,\n  noun = { one: \"product\", other: \"products\" },\n  className,\n  ...props\n}: SortToolbarProps) {\n  const id = React.useId()\n  const label = `${new Intl.NumberFormat(locale).format(total)} ${\n    new Intl.PluralRules(locale).select(total) === \"one\" ? noun.one : noun.other\n  }`\n\n  // The visible count changes silently for a screen-reader user, who can't\n  // see the grid reflow. Announce it — but only once results settle, only\n  // after the first render, and at most once per burst of filter clicks.\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const settled = React.useRef(label)\n  const wasLoading = React.useRef(loading)\n\n  React.useEffect(() => {\n    const finishedLoading = wasLoading.current && !loading\n    wasLoading.current = loading\n    if (loading) return\n    // Nothing new to say: same count, and no fetch just completed.\n    if (!finishedLoading && settled.current === label) return\n    settled.current = label\n    const timer = window.setTimeout(() => {\n      // Alternate a zero-width space so an identical count is re-announced.\n      setAnnouncement((previous) =>\n        previous === label ? `${label}\\u200b` : label\n      )\n    }, 450)\n    return () => window.clearTimeout(timer)\n  }, [label, loading])\n\n  return (\n    // Bounded by hairlines top and bottom: it reads as the masthead rule of\n    // the listing, not as a floating control bar.\n    <div\n      className={cn(\n        \"flex flex-wrap items-center gap-x-4 gap-y-3 border-y py-3 text-sm\",\n        className\n      )}\n      {...props}\n    >\n      <p\n        className={cn(\n          \"tabular-nums transition-opacity\",\n          loading && \"opacity-40\"\n        )}\n        aria-hidden={loading || undefined}\n      >\n        {label}\n      </p>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n\n      <div className=\"ml-auto flex items-center gap-2\">\n        {filters ? <div className=\"lg:hidden\">{filters}</div> : null}\n\n        {/* A native select, deliberately: on a phone it opens the OS picker,\n            which is better than any popover we could draw, and it needs no\n            positioning, portal or scroll lock. */}\n        <label htmlFor={`${id}-sort`} className=\"sr-only sm:not-sr-only sm:text-muted-foreground\">\n          Sort\n        </label>\n        <div className=\"relative\">\n          <select\n            id={`${id}-sort`}\n            value={sort ?? sortOptions[0]?.value}\n            onChange={(event) => onSortChange?.(event.target.value)}\n            className=\"h-9 appearance-none rounded-sm border bg-background py-0 pr-8 pl-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]\"\n          >\n            {sortOptions.map((option) => (\n              <option key={option.value} value={option.value}>\n                {option.label}\n              </option>\n            ))}\n          </select>\n          <ChevronDown\n            aria-hidden\n            className=\"pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground\"\n          />\n        </div>\n\n        {onDensityChange && (\n          // Compact adds a column from `sm` up. Below that there is no room\n          // for one, so the switch hides rather than offering a dead control.\n          <div role=\"group\" aria-label=\"Grid density\" className=\"hidden items-center border sm:flex\">\n            {(\n              [\n                [\"comfortable\", Grid2x2, \"Comfortable grid\"],\n                [\"compact\", Grid3x3, \"Compact grid\"],\n              ] as const\n            ).map(([key, Icon, name]) => (\n              <button\n                key={key}\n                type=\"button\"\n                aria-label={name}\n                aria-pressed={density === key}\n                onClick={() => onDensityChange(key)}\n                className={cn(\n                  \"grid size-9 place-items-center transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                  density === key\n                    ? \"bg-foreground text-background\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <Icon className=\"size-4\" aria-hidden />\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/sort-toolbar.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "catalog",
    "listing"
  ],
  "type": "registry:component"
}