{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "saved-views",
  "type": "registry:ui",
  "title": "SavedViews",
  "description": "Saved views for the filter model: useSavedViews (apply as one undo step, save, save as, rename, delete through your persistence callbacks, optimistic until your views catch up with rollback on rejected promises, a dirty flag that ignores ids and chips without values, a diff, and migration of views saved with older schemas) and SavedViewsMenu, a popover grouped by personal and team scope with inline rename, confirmed delete and a save-as form.",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/filter-core.json",
    "https://components.dethink.co.uk/r/filter-bar.json",
    "https://components.dethink.co.uk/r/popover.json",
    "https://components.dethink.co.uk/r/input.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/filter-builder/use-saved-views.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/use-saved-views.ts",
      "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  createFilterId,\n  diffFilter,\n  getFilterSignature,\n  isFilterConditionActive,\n  normalizeFilter,\n  reconcileFilterIds,\n} from \"./filter-core\";\nimport type {\n  Filter,\n  FilterDiff,\n  FilterFields,\n  FilterNode,\n} from \"./filter-types\";\nimport type { FilterState } from \"./use-filter-state\";\n\nexport type SavedViewScope = \"personal\" | \"team\";\n\n/** A named filter people can return to. Persistence is up to you. */\nexport interface SavedView<TMeta = Record<string, unknown>> {\n  id: string;\n  name: string;\n  filter: Filter;\n  /** Filter schema version the view was saved with. */\n  version: number;\n  scope?: SavedViewScope;\n  meta?: TMeta;\n}\n\nexport interface UseSavedViewsOptions<TData = unknown> {\n  state: FilterState;\n  fields: FilterFields<TData>;\n  views: readonly SavedView[];\n  /** Current filter schema version, stamped on saves. Defaults to 1. */\n  version?: number;\n  /** Upgrades a view saved with an older version before it is applied. */\n  migrate?: (filter: Filter, fromVersion: number) => Filter;\n  activeViewId?: string | null;\n  defaultActiveViewId?: string | null;\n  onActiveViewChange?: (id: string | null) => void;\n  /** Persist a new view. `views` should include it afterwards. */\n  onCreate?: (view: SavedView) => void | Promise<unknown>;\n  /** Persist a changed view (saved filter or new name). */\n  onUpdate?: (view: SavedView) => void | Promise<unknown>;\n  onDelete?: (view: SavedView) => void | Promise<unknown>;\n  /** Id for a new view. Defaults to a random id. */\n  createId?: () => string;\n}\n\nexport interface SavedViewsState {\n  /** Views with pending saves applied, until `views` catches up. */\n  views: readonly SavedView[];\n  activeViewId: string | null;\n  activeView?: SavedView;\n  /** Whether the filter differs from the active view's. */\n  isDirty: boolean;\n  /** What changed since the active view, matched by node id. */\n  diff: FilterDiff;\n  /** Replaces the filter with a view's, as one undo step. */\n  apply: (id: string) => void;\n  /** Saves the filter into the active view. */\n  save: () => void;\n  /** Saves the filter as a new view and makes it active. */\n  saveAs: (\n    name: string,\n    options?: { scope?: SavedViewScope; meta?: Record<string, unknown> },\n  ) => SavedView;\n  rename: (id: string, name: string) => void;\n  remove: (id: string) => void;\n  /** Leaves the filter as is and clears the active view. */\n  deselect: () => void;\n}\n\nconst emptyDiff: FilterDiff = { added: [], removed: [], changed: [] };\n\ninterface PendingOperation {\n  /** The view after this operation; null when it deletes the view. */\n  change: SavedView | null;\n  create: boolean;\n}\n\nfunction sameView(a: SavedView, b: SavedView) {\n  return (\n    a.name === b.name &&\n    a.version === b.version &&\n    a.scope === b.scope &&\n    getFilterSignature(a.filter) === getFilterSignature(b.filter)\n  );\n}\n\n/** The filter without conditions that have no usable value yet. */\nfunction pruneFilter<TData>(filter: Filter, fields: FilterFields<TData>) {\n  const prune = (node: FilterNode): FilterNode[] => {\n    if (node.type === \"condition\") {\n      return isFilterConditionActive(node, fields) ? [node] : [];\n    }\n\n    return [{ ...node, children: node.children.flatMap(prune) }];\n  };\n\n  return { ...filter, children: filter.children.flatMap(prune) };\n}\n\n/**\n * Saved views over a filter state: apply (one undo step), save, save as,\n * rename and delete through your callbacks, plus an accurate dirty flag.\n * Dirty compares filter content, so new ids, redundant groups and chips\n * without a value yet don't count as changes.\n */\nexport function useSavedViews<TData>({\n  activeViewId: activeProp,\n  createId = () => createFilterId(\"view\"),\n  defaultActiveViewId = null,\n  fields,\n  migrate,\n  onActiveViewChange,\n  onCreate,\n  onDelete,\n  onUpdate,\n  state,\n  version = 1,\n  views: viewsProp,\n}: UseSavedViewsOptions<TData>): SavedViewsState {\n  const [activeInternal, setActiveInternal] = useState(defaultActiveViewId);\n  const activeViewId = activeProp === undefined ? activeInternal : activeProp;\n  // Optimistic operations per view id, oldest first. The newest one still\n  // in flight is shown until `views` reflects it; a rejected one is removed\n  // (a rejected create removes everything after it too).\n  const [pending, setPending] = useState<\n    ReadonlyMap<string, readonly PendingOperation[]>\n  >(() => new Map());\n\n  // Views deleted while their create was still in flight: their tombstone\n  // must outlive the moment the create lands in `views`.\n  const creatingRef = useRef(new Map<string, Promise<unknown>>());\n  const [awaitingCreate, setAwaitingCreate] = useState<ReadonlySet<string>>(\n    () => new Set(),\n  );\n  const stopAwaiting = useCallback((id: string) => {\n    setAwaitingCreate((current) => {\n      if (!current.has(id)) {\n        return current;\n      }\n\n      const rest = new Set(current);\n\n      rest.delete(id);\n\n      return rest;\n    });\n  }, []);\n\n  // Once a deleted-while-creating view shows up in `views`, its tombstone\n  // settles like any other: when the view is gone again.\n  useEffect(() => {\n    for (const id of awaitingCreate) {\n      if (viewsProp.some((view) => view.id === id)) {\n        stopAwaiting(id);\n      }\n    }\n  }, [awaitingCreate, stopAwaiting, viewsProp]);\n\n  const isSettled = (id: string, change: SavedView | null) => {\n    const persisted = viewsProp.find((view) => view.id === id);\n\n    return change === null\n      ? persisted === undefined && !awaitingCreate.has(id)\n      : persisted !== undefined && sameView(persisted, change);\n  };\n  const settled = [...pending].flatMap(([id, operations]) => {\n    const latest = operations.at(-1);\n\n    return latest && isSettled(id, latest.change)\n      ? [[id, latest] as const]\n      : [];\n  });\n\n  // Drop operations `views` now reflects, so later changes to those views\n  // (from another tab, a refetch, an undo) show through.\n  if (settled.length > 0) {\n    setPending((current) => {\n      const next = new Map(current);\n\n      for (const [id, latest] of settled) {\n        if (next.get(id)?.at(-1) === latest) {\n          next.delete(id);\n        }\n      }\n\n      return next;\n    });\n  }\n\n  const views = useMemo(() => {\n    const open = new Map(\n      [...pending].flatMap(([id, operations]) => {\n        const latest = operations.at(-1);\n\n        return latest &&\n          !settled.some(\n            ([settledId, settledLatest]) =>\n              settledId === id && settledLatest === latest,\n          )\n          ? [[id, latest.change] as const]\n          : [];\n      }),\n    );\n    const merged = viewsProp.flatMap((view) => {\n      if (!open.has(view.id)) {\n        return [view];\n      }\n\n      const change = open.get(view.id);\n\n      return change ? [change] : [];\n    });\n\n    for (const [id, change] of open) {\n      if (change && !viewsProp.some((view) => view.id === id)) {\n        merged.push(change);\n      }\n    }\n\n    return merged;\n    // `settled` is derived from `pending` and `viewsProp`.\n  }, [pending, viewsProp]);\n\n  const setActive = useCallback(\n    (id: string | null) => {\n      setActiveInternal(id);\n      onActiveViewChange?.(id);\n    },\n    [onActiveViewChange],\n  );\n  const activeRef = useRef(activeViewId);\n\n  activeRef.current = activeViewId;\n\n  const record = (\n    view: SavedView,\n    change: SavedView | null,\n    persist: void | Promise<unknown>,\n    {\n      active,\n      create = false,\n    }: {\n      // On rollback, restore the active view `to` if it is still `from`.\n      active?: { from: string | null; to: string | null };\n      create?: boolean;\n    } = {},\n  ) => {\n    const operation: PendingOperation = { change, create };\n\n    setPending((current) =>\n      new Map(current).set(view.id, [\n        ...(current.get(view.id) ?? []),\n        operation,\n      ]),\n    );\n\n    // A rejected operation rolls back to the newest one still in flight, or\n    // to the persisted view. A rejected create takes later ones with it.\n    if (persist && typeof persist.then === \"function\") {\n      persist.then(undefined, () => {\n        setPending((current) => {\n          const operations = current.get(view.id);\n\n          if (!operations?.includes(operation)) {\n            return current;\n          }\n\n          const rest = create\n            ? []\n            : operations.filter((candidate) => candidate !== operation);\n          const next = new Map(current);\n\n          if (rest.length > 0) {\n            next.set(view.id, rest);\n          } else {\n            next.delete(view.id);\n          }\n\n          return next;\n        });\n\n        if (active && activeRef.current === active.from) {\n          setActive(active.to);\n        }\n      });\n    }\n  };\n\n  const viewFilter = useCallback(\n    (view: SavedView) =>\n      migrate && view.version < version\n        ? normalizeFilter(migrate(view.filter, view.version))\n        : view.filter,\n    [migrate, version],\n  );\n\n  const activeView = views.find((view) => view.id === activeViewId);\n  const effective = (filter: Filter) =>\n    getFilterSignature(normalizeFilter(pruneFilter(filter, fields)));\n  const isDirty =\n    activeView !== undefined &&\n    effective(viewFilter(activeView)) !== effective(state.filter);\n  // Compare under one root id: the root is the same filter either way.\n  const pruned = pruneFilter(state.filter, fields);\n  // diffFilter matches by id; give the view's nodes the ids of matching\n  // nodes in the current filter so only real changes show.\n  const diff = activeView\n    ? diffFilter(\n        reconcileFilterIds(viewFilter(activeView), pruned, {\n          // Stable across renders: the diff is compared, never stored.\n          createId: (node) => `${node.id}~view`,\n        }),\n        pruned,\n      )\n    : emptyDiff;\n\n  const snapshot = (): Filter => pruneFilter(state.filter, fields);\n\n  return {\n    views,\n    activeViewId,\n    activeView,\n    isDirty,\n    diff: isDirty ? diff : emptyDiff,\n    apply: (id) => {\n      const view = views.find((candidate) => candidate.id === id);\n\n      if (!view) {\n        return;\n      }\n\n      const next = viewFilter(view);\n\n      if (effective(next) !== effective(state.filter)) {\n        state.setFilter(reconcileFilterIds(next, state.filter));\n      }\n\n      setActive(id);\n    },\n    save: () => {\n      if (!activeView) {\n        return;\n      }\n\n      const next = { ...activeView, filter: snapshot(), version };\n\n      record(activeView, next, onUpdate?.(next));\n    },\n    saveAs: (name, { meta, scope } = {}) => {\n      const view: SavedView = {\n        id: createId(),\n        name,\n        filter: snapshot(),\n        version,\n        ...(scope ? { scope } : {}),\n        ...(meta ? { meta } : {}),\n      };\n\n      const persist = onCreate?.(view);\n\n      if (persist && typeof persist.then === \"function\") {\n        const creating = creatingRef.current;\n\n        creating.set(view.id, persist);\n        persist.then(\n          () => creating.delete(view.id),\n          () => creating.delete(view.id),\n        );\n      }\n\n      record(view, view, persist, {\n        active: { from: view.id, to: activeViewId },\n        create: true,\n      });\n      setActive(view.id);\n\n      return view;\n    },\n    rename: (id, name) => {\n      const view = views.find((candidate) => candidate.id === id);\n\n      if (!view || view.name === name) {\n        return;\n      }\n\n      const next = { ...view, name };\n\n      record(view, next, onUpdate?.(next));\n    },\n    remove: (id) => {\n      const view = views.find((candidate) => candidate.id === id);\n\n      if (!view) {\n        return;\n      }\n\n      const creating = creatingRef.current.get(id);\n      let persist: void | Promise<unknown>;\n\n      if (creating && !viewsProp.some((candidate) => candidate.id === id)) {\n        // Delete once the create lands; if it fails there is nothing to\n        // delete and the tombstone can go.\n        setAwaitingCreate((current) => new Set(current).add(id));\n        persist = creating.then(\n          // However the delete ends, stop waiting: a refetch may never show\n          // the view at all.\n          () =>\n            Promise.resolve(onDelete?.(view)).finally(() => {\n              stopAwaiting(id);\n            }),\n          () => {\n            // The failed create already removed this view's operations.\n            stopAwaiting(id);\n          },\n        );\n      } else {\n        persist = onDelete?.(view);\n      }\n\n      record(\n        view,\n        null,\n        persist,\n        id === activeViewId ? { active: { from: null, to: id } } : {},\n      );\n\n      if (id === activeViewId) {\n        setActive(null);\n      }\n    },\n    deselect: () => {\n      setActive(null);\n    },\n  };\n}\n"
    },
    {
      "path": "packages/components/src/components/filter-builder/saved-views-parts.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/saved-views-parts.tsx",
      "content": "import { cn } from \"../../utils/cn\";\nimport type { SavedViewScope } from \"./use-saved-views\";\n\nexport interface SavedViewsMenuLabels {\n  /** Trigger text when no view is active. */\n  views: string;\n  /** Accessible name of the popover. */\n  menu: string;\n  /** Appended to the trigger's name while the filter differs from the view. */\n  edited: string;\n  empty: string;\n  scope: Record<SavedViewScope, string>;\n  /** Heading for views without a scope. */\n  unscoped: string;\n  apply: (name: string) => string;\n  rename: (name: string) => string;\n  delete: (name: string) => string;\n  confirmDelete: (name: string) => string;\n  deleteConfirm: string;\n  cancel: string;\n  save: string;\n  saveChanges: string;\n  saveAs: string;\n  viewName: string;\n  scopeLabel: string;\n  /** Clears the active view and keeps the filter. */\n  clear: string;\n}\n\nexport const defaultSavedViewsMenuLabels: SavedViewsMenuLabels = {\n  views: \"Views\",\n  menu: \"Saved views\",\n  edited: \"edited\",\n  empty: \"No saved views yet.\",\n  scope: { personal: \"Personal\", team: \"Team\" },\n  unscoped: \"Views\",\n  apply: (name) => `Apply view ${name}`,\n  rename: (name) => `Rename view ${name}`,\n  delete: (name) => `Delete view ${name}`,\n  confirmDelete: (name) => `Delete \"${name}\"?`,\n  deleteConfirm: \"Delete\",\n  cancel: \"Cancel\",\n  save: \"Save\",\n  saveChanges: \"Save changes\",\n  saveAs: \"Save as new view\",\n  viewName: \"View name\",\n  scopeLabel: \"Visible to\",\n  clear: \"Leave view\",\n};\n\nexport const savedViewsPopoverClasses =\n  \"w-[min(22rem,calc(100vw_-_var(--dt-space-6)))] p-[var(--dt-space-2)]\";\n\nexport const savedViewsSectionClasses = \"grid gap-[var(--dt-space-0-5)]\";\n\nexport const savedViewsHeadingClasses =\n  \"px-[var(--dt-space-2)] pt-[var(--dt-space-2)] pb-[var(--dt-space-1)] text-xs font-medium text-muted-foreground\";\n\nexport const savedViewsRowClasses =\n  \"group/row grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-[var(--dt-space-0-5)] rounded-md data-[active]:bg-muted/60\";\n\nexport const savedViewsApplyClasses =\n  \"flex min-w-0 items-center gap-[var(--dt-space-2)] rounded-md px-[var(--dt-space-2)] py-[var(--dt-space-1-5)] text-start text-sm text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-fast)]\";\n\nexport const savedViewsIconButtonClasses =\n  \"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-fast)]\";\n\nexport const savedViewsFooterClasses =\n  \"mt-[var(--dt-space-2)] grid gap-[var(--dt-space-1)] border-t border-border pt-[var(--dt-space-2)]\";\n\nexport const savedViewsTextButtonClasses =\n  \"inline-flex h-8 items-center justify-center gap-[var(--dt-space-1-5)] rounded-md px-[var(--dt-space-2-5)] text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-fast)]\";\n\nexport const savedViewsPrimaryButtonClasses = cn(\n  savedViewsTextButtonClasses,\n  \"bg-primary text-primary-foreground hover:bg-primary/90\",\n);\n\nexport const savedViewsGhostButtonClasses = cn(\n  savedViewsTextButtonClasses,\n  \"justify-start text-foreground hover:bg-muted\",\n);\n\nexport const savedViewsDangerButtonClasses = cn(\n  savedViewsTextButtonClasses,\n  \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n);\n\nexport const savedViewsDirtyDotClasses =\n  \"size-1.5 shrink-0 rounded-full bg-warning\";\n\nexport function CheckIcon({ visible }: { visible: boolean }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      className={cn(\"size-3.5 shrink-0\", !visible && \"invisible\")}\n    >\n      <path\n        d=\"m3.5 8.5 3 3 6-7\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  );\n}\n\nexport function PencilIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\">\n      <path\n        d=\"m10.5 3 2.5 2.5L6 12.5H3.5V10z\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  );\n}\n\nexport function TrashIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\">\n      <path\n        d=\"M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.5 8.5h6l.5-8.5\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  );\n}\n\nexport function ViewsIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\">\n      <path\n        d=\"M4 2.5h8v11l-4-2.5-4 2.5z\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/components/filter-builder/saved-views-menu.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/saved-views-menu.tsx",
      "content": "import {\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type FormEvent,\n  type HTMLAttributes,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { Input } from \"../input\";\nimport { Popover, PopoverContent } from \"../popover\";\nimport {\n  filterBarActionClassNames,\n  type FilterBarSize,\n} from \"./filter-bar-parts\";\nimport {\n  CheckIcon,\n  PencilIcon,\n  TrashIcon,\n  ViewsIcon,\n  defaultSavedViewsMenuLabels,\n  savedViewsApplyClasses,\n  savedViewsDangerButtonClasses,\n  savedViewsDirtyDotClasses,\n  savedViewsFooterClasses,\n  savedViewsGhostButtonClasses,\n  savedViewsHeadingClasses,\n  savedViewsIconButtonClasses,\n  savedViewsPopoverClasses,\n  savedViewsPrimaryButtonClasses,\n  savedViewsRowClasses,\n  savedViewsSectionClasses,\n  type SavedViewsMenuLabels,\n} from \"./saved-views-parts\";\nimport type {\n  SavedView,\n  SavedViewScope,\n  SavedViewsState,\n} from \"./use-saved-views\";\n\nexport interface SavedViewsMenuProps extends Omit<\n  HTMLAttributes<HTMLButtonElement>,\n  \"children\"\n> {\n  /** State from `useSavedViews`. */\n  savedViews: SavedViewsState;\n  /** Scopes offered when saving a new view. Defaults to personal only. */\n  scopes?: SavedViewScope[];\n  labels?: Partial<SavedViewsMenuLabels>;\n  size?: FilterBarSize;\n}\n\ntype Mode =\n  | { kind: \"list\" }\n  | { kind: \"rename\"; id: string; name: string }\n  | { kind: \"confirm\"; id: string }\n  | { kind: \"saveAs\"; name: string; scope: SavedViewScope };\n\nconst SCOPE_ORDER: (SavedViewScope | \"none\")[] = [\"personal\", \"team\", \"none\"];\n\n/**\n * Saved views in a popover: apply a view (one undo step), save changes to\n * the active view, save as a new view, rename and delete. The trigger names\n * the active view and marks it edited when the filter has changed.\n */\nexport function SavedViewsMenu({\n  className,\n  labels: labelOverrides,\n  savedViews,\n  scopes = [\"personal\"],\n  size = \"md\",\n  ...props\n}: SavedViewsMenuProps) {\n  const labels = useMemo(\n    () => ({ ...defaultSavedViewsMenuLabels, ...labelOverrides }),\n    [labelOverrides],\n  );\n  const [open, setOpen] = useState(false);\n  const [mode, setMode] = useState<Mode>({ kind: \"list\" });\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const focusAfterRef = useRef<string | null>(null);\n  const baseId = useId();\n  const nameId = `${baseId}-name`;\n  const { activeView, isDirty, views } = savedViews;\n\n  const groups = useMemo(\n    () =>\n      SCOPE_ORDER.map((scope) => ({\n        scope,\n        views: views.filter((view) => (view.scope ?? \"none\") === scope),\n      })).filter((group) => group.views.length > 0),\n    [views],\n  );\n\n  // Move focus to the control a mode change leaves behind.\n  useEffect(() => {\n    const target = focusAfterRef.current;\n\n    if (!open || target === null) {\n      return;\n    }\n\n    focusAfterRef.current = null;\n    contentRef.current\n      ?.querySelector<HTMLElement>(`[data-focus-key=\"${target}\"]`)\n      ?.focus();\n  });\n\n  const changeMode = (next: Mode, focusKey: string) => {\n    focusAfterRef.current = focusKey;\n    setMode(next);\n  };\n\n  const close = () => {\n    setOpen(false);\n    setMode({ kind: \"list\" });\n    triggerRef.current?.focus();\n  };\n\n  const submitRename = (event: FormEvent, view: SavedView, name: string) => {\n    event.preventDefault();\n\n    const trimmed = name.trim();\n\n    if (trimmed) {\n      savedViews.rename(view.id, trimmed);\n    }\n\n    changeMode({ kind: \"list\" }, `apply:${view.id}`);\n  };\n\n  const submitSaveAs = (\n    event: FormEvent,\n    name: string,\n    scope: SavedViewScope,\n  ) => {\n    event.preventDefault();\n\n    const trimmed = name.trim();\n\n    if (!trimmed) {\n      return;\n    }\n\n    savedViews.saveAs(trimmed, {\n      scope: scopes.length > 1 ? scope : scopes[0],\n    });\n    close();\n  };\n\n  const renderRow = (view: SavedView) => {\n    const active = view.id === savedViews.activeViewId;\n\n    if (mode.kind === \"rename\" && mode.id === view.id) {\n      return (\n        <li key={view.id} data-slot=\"saved-view\">\n          <form\n            className=\"flex items-center gap-[var(--dt-space-1)] p-[var(--dt-space-1)]\"\n            onSubmit={(event) => {\n              submitRename(event, view, mode.name);\n            }}\n          >\n            <Input\n              aria-label={labels.rename(view.name)}\n              data-focus-key={`rename:${view.id}`}\n              controlSize=\"sm\"\n              value={mode.name}\n              onChange={(event) => {\n                setMode({ ...mode, name: event.currentTarget.value });\n              }}\n              onKeyDown={(event) => {\n                if (event.key === \"Escape\") {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  changeMode({ kind: \"list\" }, `apply:${view.id}`);\n                }\n              }}\n            />\n            <button type=\"submit\" className={savedViewsPrimaryButtonClasses}>\n              {labels.save}\n            </button>\n          </form>\n        </li>\n      );\n    }\n\n    if (mode.kind === \"confirm\" && mode.id === view.id) {\n      return (\n        <li key={view.id} data-slot=\"saved-view\">\n          <div\n            role=\"group\"\n            aria-label={labels.confirmDelete(view.name)}\n            className=\"flex flex-wrap items-center gap-[var(--dt-space-1)] p-[var(--dt-space-1)]\"\n          >\n            <span className=\"min-w-0 flex-1 truncate px-[var(--dt-space-1)] text-sm\">\n              {labels.confirmDelete(view.name)}\n            </span>\n            <button\n              type=\"button\"\n              data-focus-key={`confirm:${view.id}`}\n              className={savedViewsDangerButtonClasses}\n              onClick={() => {\n                savedViews.remove(view.id);\n                changeMode({ kind: \"list\" }, \"save-as\");\n              }}\n            >\n              {labels.deleteConfirm}\n            </button>\n            <button\n              type=\"button\"\n              className={savedViewsGhostButtonClasses}\n              onClick={() => {\n                changeMode({ kind: \"list\" }, `delete:${view.id}`);\n              }}\n            >\n              {labels.cancel}\n            </button>\n          </div>\n        </li>\n      );\n    }\n\n    return (\n      <li\n        key={view.id}\n        data-slot=\"saved-view\"\n        data-active={active ? \"\" : undefined}\n        className={savedViewsRowClasses}\n      >\n        <button\n          type=\"button\"\n          data-focus-key={`apply:${view.id}`}\n          aria-label={labels.apply(view.name)}\n          aria-current={active ? \"true\" : undefined}\n          className={savedViewsApplyClasses}\n          onClick={() => {\n            savedViews.apply(view.id);\n            close();\n          }}\n        >\n          <CheckIcon visible={active} />\n          <span className=\"min-w-0 truncate\">{view.name}</span>\n        </button>\n        <button\n          type=\"button\"\n          data-focus-key={`rename-button:${view.id}`}\n          aria-label={labels.rename(view.name)}\n          className={savedViewsIconButtonClasses}\n          onClick={() => {\n            changeMode(\n              { kind: \"rename\", id: view.id, name: view.name },\n              `rename:${view.id}`,\n            );\n          }}\n        >\n          <PencilIcon />\n        </button>\n        <button\n          type=\"button\"\n          data-focus-key={`delete:${view.id}`}\n          aria-label={labels.delete(view.name)}\n          className={savedViewsIconButtonClasses}\n          onClick={() => {\n            changeMode({ kind: \"confirm\", id: view.id }, `confirm:${view.id}`);\n          }}\n        >\n          <TrashIcon />\n        </button>\n      </li>\n    );\n  };\n\n  return (\n    <>\n      <button\n        {...props}\n        ref={triggerRef}\n        type=\"button\"\n        data-slot=\"saved-views-trigger\"\n        data-dirty={isDirty ? \"\" : undefined}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-label={`${activeView?.name ?? labels.views}${isDirty ? `, ${labels.edited}` : \"\"}`}\n        className={filterBarActionClassNames({\n          size,\n          className: cn(\"text-foreground\", className),\n        })}\n        onClick={() => {\n          setMode({ kind: \"list\" });\n          setOpen(!open);\n        }}\n      >\n        <ViewsIcon />\n        <span className=\"max-w-40 truncate\">\n          {activeView?.name ?? labels.views}\n        </span>\n        {isDirty ? (\n          <span aria-hidden=\"true\" className={savedViewsDirtyDotClasses} />\n        ) : null}\n      </button>\n      <Popover\n        anchorRef={triggerRef}\n        open={open}\n        onOpenChange={(next) => {\n          setOpen(next);\n\n          if (!next) {\n            setMode({ kind: \"list\" });\n          }\n        }}\n      >\n        <PopoverContent\n          aria-label={labels.menu}\n          placement=\"bottom start\"\n          className={savedViewsPopoverClasses}\n        >\n          <div ref={contentRef} data-slot=\"saved-views-menu\">\n            {groups.length === 0 ? (\n              <p className=\"text-muted-foreground px-[var(--dt-space-2)] py-[var(--dt-space-2)] text-sm\">\n                {labels.empty}\n              </p>\n            ) : (\n              groups.map((group) => {\n                const headingId = `${baseId}-${group.scope}`;\n\n                return (\n                  <section\n                    key={group.scope}\n                    aria-labelledby={headingId}\n                    className={savedViewsSectionClasses}\n                  >\n                    <h3 id={headingId} className={savedViewsHeadingClasses}>\n                      {group.scope === \"none\"\n                        ? labels.unscoped\n                        : labels.scope[group.scope]}\n                    </h3>\n                    <ul className={savedViewsSectionClasses}>\n                      {group.views.map(renderRow)}\n                    </ul>\n                  </section>\n                );\n              })\n            )}\n            <div className={savedViewsFooterClasses}>\n              {activeView ? (\n                <button\n                  type=\"button\"\n                  data-focus-key=\"save\"\n                  disabled={!isDirty}\n                  className={savedViewsGhostButtonClasses}\n                  onClick={() => {\n                    savedViews.save();\n                    close();\n                  }}\n                >\n                  {labels.saveChanges}\n                </button>\n              ) : null}\n              {mode.kind === \"saveAs\" ? (\n                <form\n                  className=\"grid gap-[var(--dt-space-2)] p-[var(--dt-space-1)]\"\n                  onSubmit={(event) => {\n                    submitSaveAs(event, mode.name, mode.scope);\n                  }}\n                >\n                  <label htmlFor={nameId} className=\"text-xs font-medium\">\n                    {labels.viewName}\n                  </label>\n                  <Input\n                    id={nameId}\n                    data-focus-key=\"save-as-name\"\n                    controlSize=\"sm\"\n                    value={mode.name}\n                    onChange={(event) => {\n                      setMode({ ...mode, name: event.currentTarget.value });\n                    }}\n                    onKeyDown={(event) => {\n                      if (event.key === \"Escape\") {\n                        event.preventDefault();\n                        event.stopPropagation();\n                        changeMode({ kind: \"list\" }, \"save-as\");\n                      }\n                    }}\n                  />\n                  {scopes.length > 1 ? (\n                    <fieldset className=\"flex flex-wrap items-center gap-[var(--dt-space-3)] text-sm\">\n                      <legend className=\"sr-only\">{labels.scopeLabel}</legend>\n                      <span aria-hidden=\"true\" className=\"text-xs font-medium\">\n                        {labels.scopeLabel}\n                      </span>\n                      {scopes.map((scope) => (\n                        <label\n                          key={scope}\n                          className=\"inline-flex items-center gap-[var(--dt-space-1-5)]\"\n                        >\n                          <input\n                            type=\"radio\"\n                            name={`${baseId}-scope`}\n                            value={scope}\n                            checked={mode.scope === scope}\n                            onChange={() => {\n                              setMode({ ...mode, scope });\n                            }}\n                          />\n                          {labels.scope[scope]}\n                        </label>\n                      ))}\n                    </fieldset>\n                  ) : null}\n                  <div className=\"flex justify-end gap-[var(--dt-space-1)]\">\n                    <button\n                      type=\"button\"\n                      className={savedViewsGhostButtonClasses}\n                      onClick={() => {\n                        changeMode({ kind: \"list\" }, \"save-as\");\n                      }}\n                    >\n                      {labels.cancel}\n                    </button>\n                    <button\n                      type=\"submit\"\n                      disabled={mode.name.trim() === \"\"}\n                      className={savedViewsPrimaryButtonClasses}\n                    >\n                      {labels.save}\n                    </button>\n                  </div>\n                </form>\n              ) : (\n                <button\n                  type=\"button\"\n                  data-focus-key=\"save-as\"\n                  className={savedViewsGhostButtonClasses}\n                  onClick={() => {\n                    changeMode(\n                      {\n                        kind: \"saveAs\",\n                        name: \"\",\n                        scope: scopes[0] ?? \"personal\",\n                      },\n                      \"save-as-name\",\n                    );\n                  }}\n                >\n                  {labels.saveAs}\n                </button>\n              )}\n              {activeView ? (\n                <button\n                  type=\"button\"\n                  className={savedViewsGhostButtonClasses}\n                  onClick={() => {\n                    savedViews.deselect();\n                    close();\n                  }}\n                >\n                  {labels.clear}\n                </button>\n              ) : null}\n            </div>\n          </div>\n        </PopoverContent>\n      </Popover>\n    </>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
