{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-block",
  "type": "registry:block",
  "title": "Settings",
  "description": "Searchable multi-page settings shell with responsive navigation and accessible control groups.",
  "dependencies": [
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "http://127.0.0.1:3000/r/button.json",
    "http://127.0.0.1:3000/r/core.json",
    "http://127.0.0.1:3000/r/empty.json",
    "http://127.0.0.1:3000/r/field.json",
    "http://127.0.0.1:3000/r/input-group.json",
    "http://127.0.0.1:3000/r/scroll-area.json",
    "http://127.0.0.1:3000/r/sidebar.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/control-ui/settings-data.ts",
      "target": "@components/control-ui/blocks/settings-data.ts",
      "type": "registry:block",
      "content": "import type { ReactNode } from \"react\";\n\nexport type SettingsControlProps = {\n  id: string;\n  \"aria-labelledby\": string;\n  \"aria-describedby\"?: string;\n};\n\nexport type SettingDefinition = {\n  id: string;\n  title: string;\n  description?: string;\n  keywords?: readonly string[];\n  renderControl: (props: SettingsControlProps) => ReactNode;\n};\n\nexport type SettingsSectionDefinition = {\n  id: string;\n  title: string;\n  settings: readonly SettingDefinition[];\n};\n\nexport type SettingsPageDefinition = {\n  id: string;\n  title: string;\n  icon?: ReactNode;\n  sections: readonly SettingsSectionDefinition[];\n};\n\nexport type SettingsSearchResult = {\n  page: SettingsPageDefinition;\n  section: SettingsSectionDefinition;\n  setting: SettingDefinition;\n  score: number;\n  order: number;\n};\n\nexport function normalizeSettingsSearchText(value: string) {\n  return value\n    .normalize(\"NFKD\")\n    .replace(/\\p{Diacritic}/gu, \"\")\n    .toLocaleLowerCase()\n    .trim()\n    .replace(/\\s+/g, \" \");\n}\n\ntype NormalizedSettingSearchFields = {\n  title: string;\n  description: string;\n  keywords: string;\n  pageTitle: string;\n  sectionTitle: string;\n};\n\nfunction titleSearchScore(title: string, query: string) {\n  if (title === query) return 1_000;\n  if (title.startsWith(query)) return 800;\n  if (title.includes(query)) return 600;\n  return 0;\n}\n\nfunction settingSearchScore(fields: NormalizedSettingSearchFields, query: string, tokens: ReadonlySet<string>) {\n  const { title, description, keywords, pageTitle, sectionTitle } = fields;\n  const searchableText = [title, description, keywords, pageTitle, sectionTitle].join(\" \");\n  for (const token of tokens) if (!searchableText.includes(token)) return undefined;\n\n  let score = titleSearchScore(title, query);\n  if (keywords.includes(query)) score += 300;\n  if (sectionTitle.includes(query)) score += 180;\n  if (pageTitle.includes(query)) score += 160;\n  if (description.includes(query)) score += 100;\n  // Partial-token scoring needs substring checks, so Set membership would change search behavior.\n  // react-doctor-disable-next-line react-doctor/js-set-map-lookups\n  for (const token of tokens) score += (title.indexOf(token) >= 0 ? 20 : 0) + (keywords.indexOf(token) >= 0 ? 10 : 0);\n  return score;\n}\n\nexport function searchSettings(pages: readonly SettingsPageDefinition[], query: string): SettingsSearchResult[] {\n  const normalizedQuery = normalizeSettingsSearchText(query);\n  if (!normalizedQuery) return [];\n\n  const tokens = new Set(normalizedQuery.split(\" \"));\n  let order = 0;\n  const results: SettingsSearchResult[] = [];\n\n  for (const page of pages) {\n    const pageTitle = normalizeSettingsSearchText(page.title);\n    for (const section of page.sections) {\n      const sectionTitle = normalizeSettingsSearchText(section.title);\n      for (const setting of section.settings) {\n        const title = normalizeSettingsSearchText(setting.title);\n        const description = normalizeSettingsSearchText(setting.description ?? \"\");\n        const keywords = normalizeSettingsSearchText(setting.keywords?.join(\" \") ?? \"\");\n        const currentOrder = order;\n        order += 1;\n        const score = settingSearchScore({ title, description, keywords, pageTitle, sectionTitle }, normalizedQuery, tokens);\n        if (score === undefined) continue;\n        results.push({ page, section, setting, score, order: currentOrder });\n      }\n    }\n  }\n\n  return results.sort((a, b) => b.score - a.score || a.order - b.order);\n}\n"
    },
    {
      "path": "src/registry/blocks/control-ui/settings.tsx",
      "target": "@components/control-ui/blocks/settings.tsx",
      "type": "registry:block",
      "content": "\"use client\";\n\nimport { ArrowLeftIcon, SearchIcon, XIcon } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { Button } from \"@/components/control-ui/ui/button\";\nimport { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from \"@/components/control-ui/ui/empty\";\nimport {\n  Field,\n  FieldContent,\n  FieldDescription,\n  FieldGroup,\n  FieldLabel,\n  FieldLegend,\n  FieldSeparator,\n  FieldSet,\n} from \"@/components/control-ui/ui/field\";\nimport { InputGroup, InputGroupAddon, InputGroupInput } from \"@/components/control-ui/ui/input-group\";\nimport { ScrollArea } from \"@/components/control-ui/ui/scroll-area\";\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarProvider,\n  type SidebarStyle,\n  SidebarTrigger,\n  useSidebar,\n} from \"@/components/control-ui/ui/sidebar\";\nimport {\n  type SettingDefinition,\n  type SettingsControlProps,\n  type SettingsPageDefinition,\n  type SettingsSearchResult,\n  searchSettings,\n} from \"./settings-data\";\n\nexport type { SettingDefinition, SettingsControlProps, SettingsPageDefinition, SettingsSectionDefinition } from \"./settings-data\";\n\nexport type SettingsBlockProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  pages: readonly SettingsPageDefinition[];\n  activePageId?: string;\n  defaultActivePageId?: string;\n  onActivePageChange?: (pageId: string) => void;\n  backAction?: ReactNode;\n  searchPlaceholder?: string;\n  layout?: \"viewport\" | \"contained\";\n};\n\ntype PendingTarget = {\n  pageId: string;\n  rowId: string;\n  controlId: string;\n};\n\nexport function SettingsBlock({\n  pages,\n  activePageId,\n  defaultActivePageId,\n  onActivePageChange,\n  backAction,\n  searchPlaceholder = \"Search settings\",\n  layout = \"viewport\",\n  className,\n  style,\n  ...props\n}: SettingsBlockProps) {\n  const [internalPageId, setInternalPageId] = useState(defaultActivePageId ?? pages[0]?.id);\n  const [query, setQuery] = useState(\"\");\n  const [pendingTarget, setPendingTarget] = useState<PendingTarget | null>(null);\n  const instanceId = useId();\n  const requestedPageId = activePageId ?? internalPageId;\n  const currentPage = pages.find((page) => page.id === requestedPageId) ?? pages[0];\n  const results = searchSettings(pages, query);\n\n  function changePage(pageId: string) {\n    if (activePageId === undefined) setInternalPageId(pageId);\n    onActivePageChange?.(pageId);\n  }\n\n  const providerStyle: SidebarStyle = { \"--sidebar-width\": \"15.5rem\", ...style };\n\n  return (\n    <SidebarProvider\n      data-settings-layout={layout}\n      className={cn(\n        \"overflow-hidden bg-background text-foreground\",\n        layout === \"viewport\" ? \"h-svh min-h-svh\" : \"relative h-full min-h-0\",\n        className,\n      )}\n      style={providerStyle}\n      {...props}\n    >\n      <SettingsShell\n        pages={pages}\n        currentPage={currentPage}\n        query={query}\n        results={results}\n        backAction={backAction}\n        searchPlaceholder={searchPlaceholder}\n        contained={layout === \"contained\"}\n        instanceId={instanceId}\n        pendingTarget={pendingTarget}\n        onPendingTargetChange={setPendingTarget}\n        onPageChange={changePage}\n        onQueryChange={setQuery}\n      />\n    </SidebarProvider>\n  );\n}\n\nfunction SettingsShell({\n  pages,\n  currentPage,\n  query,\n  results,\n  backAction,\n  searchPlaceholder,\n  contained,\n  instanceId,\n  pendingTarget,\n  onPendingTargetChange,\n  onPageChange,\n  onQueryChange,\n}: {\n  pages: readonly SettingsPageDefinition[];\n  currentPage?: SettingsPageDefinition;\n  query: string;\n  results: readonly SettingsSearchResult[];\n  backAction?: ReactNode;\n  searchPlaceholder: string;\n  contained: boolean;\n  instanceId: string;\n  pendingTarget: PendingTarget | null;\n  onPendingTargetChange: (target: PendingTarget | null) => void;\n  onPageChange: (pageId: string) => void;\n  onQueryChange: (query: string) => void;\n}) {\n  const { setOpenMobile } = useSidebar();\n\n  useEffect(() => {\n    if (!pendingTarget || currentPage?.id !== pendingTarget.pageId) return;\n\n    const frame = requestAnimationFrame(() => {\n      const row = document.getElementById(pendingTarget.rowId);\n      const control = document.getElementById(pendingTarget.controlId);\n      row?.scrollIntoView({\n        block: \"center\",\n        behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\",\n      });\n      (control instanceof HTMLElement ? control : row)?.focus({ preventScroll: true });\n      onPendingTargetChange(null);\n    });\n\n    return () => cancelAnimationFrame(frame);\n  }, [currentPage?.id, onPendingTargetChange, pendingTarget]);\n\n  function navigate(pageId: string) {\n    onPageChange(pageId);\n    setOpenMobile(false);\n  }\n\n  function openResult(result: SettingsSearchResult) {\n    const ids = settingDomIds(instanceId, result.page.id, result.section.id, result.setting.id);\n    onPendingTargetChange({ pageId: result.page.id, rowId: ids.row, controlId: ids.control });\n    navigate(result.page.id);\n  }\n\n  return (\n    <>\n      <Sidebar collapsible=\"offcanvas\" className={contained ? \"absolute! inset-y-0! h-full\" : undefined}>\n        <SidebarHeader className=\"gap-3 border-b border-sidebar-border p-3\">\n          {backAction ? <div className=\"min-w-0\">{backAction}</div> : null}\n          <SettingsSearch value={query} placeholder={searchPlaceholder} onValueChange={onQueryChange} />\n        </SidebarHeader>\n        <SidebarContent>\n          {query.trim() ? (\n            <SettingsSearchNavigation results={results} onSelect={openResult} />\n          ) : (\n            <SettingsPageNavigation pages={pages} activePageId={currentPage?.id} onSelect={navigate} />\n          )}\n        </SidebarContent>\n      </Sidebar>\n\n      <SidebarInset className=\"h-full min-h-0 min-w-0\">\n        <header className=\"flex h-12 shrink-0 items-center gap-2 border-b px-3 md:hidden\">\n          <SidebarTrigger />\n          <span className=\"truncate text-label font-medium\">{currentPage?.title ?? \"Settings\"}</span>\n        </header>\n        <ScrollArea className=\"min-h-0 flex-1\" lockAxis=\"x\">\n          {currentPage ? (\n            <SettingsPage page={currentPage} instanceId={instanceId} />\n          ) : (\n            <SettingsEmpty title=\"No settings\" description=\"Add a settings page to populate this space.\" />\n          )}\n        </ScrollArea>\n      </SidebarInset>\n    </>\n  );\n}\n\nfunction SettingsSearch({\n  value,\n  placeholder,\n  onValueChange,\n}: {\n  value: string;\n  placeholder: string;\n  onValueChange: (value: string) => void;\n}) {\n  return (\n    <InputGroup size=\"sm\">\n      <InputGroupAddon className=\"pl-2.5\">\n        <SearchIcon className=\"size-3.5\" aria-hidden=\"true\" />\n      </InputGroupAddon>\n      <InputGroupInput\n        type=\"search\"\n        aria-label={placeholder}\n        placeholder={placeholder}\n        value={value}\n        onChange={(event) => onValueChange(event.currentTarget.value)}\n      />\n      {value ? (\n        <Button variant=\"ghost\" size=\"xs\" iconOnly aria-label=\"Clear settings search\" className=\"mr-1\" onClick={() => onValueChange(\"\")}>\n          <XIcon className=\"size-3.5\" />\n        </Button>\n      ) : null}\n    </InputGroup>\n  );\n}\n\nfunction SettingsPageNavigation({\n  pages,\n  activePageId,\n  onSelect,\n}: {\n  pages: readonly SettingsPageDefinition[];\n  activePageId?: string;\n  onSelect: (pageId: string) => void;\n}) {\n  return (\n    <SidebarGroup>\n      <SidebarGroupLabel>Settings</SidebarGroupLabel>\n      <SidebarMenu>\n        {pages.map((page) => (\n          <SidebarMenuItem key={page.id}>\n            <SidebarMenuButton size=\"sm\" isActive={page.id === activePageId} onClick={() => onSelect(page.id)}>\n              {page.icon}\n              <span>{page.title}</span>\n            </SidebarMenuButton>\n          </SidebarMenuItem>\n        ))}\n      </SidebarMenu>\n    </SidebarGroup>\n  );\n}\n\nfunction SettingsSearchNavigation({\n  results,\n  onSelect,\n}: {\n  results: readonly SettingsSearchResult[];\n  onSelect: (result: SettingsSearchResult) => void;\n}) {\n  if (results.length === 0) {\n    return <SettingsEmpty title=\"No matching settings\" description=\"Try another name or keyword.\" />;\n  }\n\n  const groups = new Map<string, { page: SettingsPageDefinition; results: SettingsSearchResult[] }>();\n  for (const result of results) {\n    const group = groups.get(result.page.id) ?? { page: result.page, results: [] };\n    group.results.push(result);\n    groups.set(result.page.id, group);\n  }\n\n  return (\n    <>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {results.length} {results.length === 1 ? \"setting\" : \"settings\"} found\n      </span>\n      {[...groups.values()].map((group) => (\n        <SidebarGroup key={group.page.id}>\n          <SidebarGroupLabel>\n            {group.page.icon}\n            <span>{group.page.title}</span>\n          </SidebarGroupLabel>\n          <SidebarMenu>\n            {group.results.map((result) => (\n              <SidebarMenuItem key={`${result.section.id}/${result.setting.id}`}>\n                <SidebarMenuButton size=\"sm\" className=\"h-auto min-h-[var(--control-h-sm)] py-1.5\" onClick={() => onSelect(result)}>\n                  <span>{result.setting.title}</span>\n                </SidebarMenuButton>\n              </SidebarMenuItem>\n            ))}\n          </SidebarMenu>\n        </SidebarGroup>\n      ))}\n    </>\n  );\n}\n\nfunction SettingsPage({ page, instanceId }: { page: SettingsPageDefinition; instanceId: string }) {\n  return (\n    <div className=\"mx-auto flex w-full max-w-4xl flex-col gap-10 px-5 py-8 md:px-10 md:py-12\">\n      <h1 className=\"text-heading font-semibold text-foreground text-balance\">{page.title}</h1>\n      {page.sections.map((section) => (\n        <FieldSet key={section.id} className=\"gap-3\">\n          <FieldLegend className=\"text-body font-semibold\">{section.title}</FieldLegend>\n          <FieldGroup className=\"gap-0 overflow-hidden rounded-[var(--radius-lg)] border bg-card\">\n            {section.settings.map((setting, index) => (\n              <SettingsRow\n                key={setting.id}\n                pageId={page.id}\n                sectionId={section.id}\n                setting={setting}\n                instanceId={instanceId}\n                separated={index > 0}\n              />\n            ))}\n          </FieldGroup>\n        </FieldSet>\n      ))}\n    </div>\n  );\n}\n\nfunction SettingsRow({\n  pageId,\n  sectionId,\n  setting,\n  instanceId,\n  separated,\n}: {\n  pageId: string;\n  sectionId: string;\n  setting: SettingDefinition;\n  instanceId: string;\n  separated: boolean;\n}) {\n  const ids = settingDomIds(instanceId, pageId, sectionId, setting.id);\n  const controlProps: SettingsControlProps = {\n    id: ids.control,\n    \"aria-labelledby\": ids.label,\n    \"aria-describedby\": setting.description ? ids.description : undefined,\n  };\n\n  return (\n    <>\n      {separated ? <FieldSeparator /> : null}\n      <Field\n        id={ids.row}\n        orientation=\"responsive\"\n        tabIndex={-1}\n        className=\"scroll-m-8 gap-3 p-4 outline-none transition-colors duration-[var(--duration-fast)] focus:bg-foreground/4 focus-within:bg-foreground/4\"\n      >\n        <FieldContent>\n          <FieldLabel id={ids.label} htmlFor={ids.control} className=\"leading-snug\">\n            {setting.title}\n          </FieldLabel>\n          {setting.description ? <FieldDescription id={ids.description}>{setting.description}</FieldDescription> : null}\n        </FieldContent>\n        <div className=\"flex shrink-0 items-center\">{setting.renderControl(controlProps)}</div>\n      </Field>\n    </>\n  );\n}\n\nfunction SettingsEmpty({ title, description }: { title: string; description: string }) {\n  return (\n    <Empty className=\"min-h-40 gap-3 p-4\">\n      <EmptyHeader>\n        <EmptyMedia className=\"mb-0 size-8\">\n          <SearchIcon className=\"size-4\" />\n        </EmptyMedia>\n        <EmptyTitle>{title}</EmptyTitle>\n        <EmptyDescription>{description}</EmptyDescription>\n      </EmptyHeader>\n    </Empty>\n  );\n}\n\nfunction settingDomIds(instanceId: string, pageId: string, sectionId: string, settingId: string) {\n  const base = [instanceId, pageId, sectionId, settingId].map((value) => value.replace(/[^a-zA-Z0-9_-]/g, \"-\")).join(\"-\");\n  return {\n    row: `${base}-row`,\n    control: `${base}-control`,\n    label: `${base}-label`,\n    description: `${base}-description`,\n  };\n}\n\nexport function SettingsBackAction({\n  children = \"Back to app\",\n  ...props\n}: Omit<ComponentProps<typeof Button>, \"children\"> & { children?: ReactNode }) {\n  return (\n    <Button variant=\"quiet\" size=\"sm\" className=\"-ml-1 justify-start gap-2\" {...props}>\n      <ArrowLeftIcon className=\"size-4\" />\n      {children}\n    </Button>\n  );\n}\n"
    }
  ],
  "meta": {}
}
