• DR-101Keyboard navigation in tree viewopen5 pts
  • DR-102Fix color contrast in dark modein-review2 pts
  • DR-103Support Arabic numerals in currency inputclosed3 pts
  • DR-104Caret stability when backspacing delimitersopen8 pts

Keyboard

←→
Move between views
HomeEnd
First or last view
F2
Rename the focused view
Escape
Cancel a rename

The CLI also installs the npm packages and registry items it builds on, and reads the style in your components.json to pick the Base UI or Radix build.

saved-views-usage.tsx
"use client"

import { useState } from "react"
import { readViewParam, writeViewParam } from "@/lib/saved-views"
import { SavedViews, type SavedView } from "@/components/ui/saved-views"

type Filters = { status: string }

const EMPTY: Filters = { status: "all" }

export function IssueViewSwitcher({
  initialViews,
  search,
  onSearchChange,
}: {
  initialViews: SavedView<Filters>[]
  search: string
  onSearchChange: (search: string) => void
}) {
  const [views, setViews] = useState(initialViews)
  const activeId = readViewParam(search, views)
  const activeView = views.find((view) => view.id === activeId)
  const [draft, setDraft] = useState<{ viewId?: string; filters: Filters }>()
  const filters =
    draft && draft.viewId === activeId
      ? draft.filters
      : (activeView?.data ?? EMPTY)

  return (
    <div className="flex flex-col gap-3">
      <SavedViews
        views={views}
        onViewsChange={setViews}
        activeId={activeId}
        onActiveChange={(id) => onSearchChange(writeViewParam(search, id))}
        currentData={filters}
        onReset={() => setDraft(undefined)}
        onSave={(view) =>
          fetch(`/api/views/${view.id}`, {
            method: "PUT",
            body: JSON.stringify(view),
          }).then((res) => {
            if (!res.ok) throw new Error("Save failed")
          })
        }
      />
      <select
        aria-label="Status"
        value={filters.status}
        onChange={(event) =>
          setDraft({ viewId: activeId, filters: { status: event.target.value } })
        }
      >
        <option value="all">All</option>
        <option value="open">Open</option>
        <option value="closed">Closed</option>
      </select>
    </div>
  )
}

SavedViews

Switch, create, rename, duplicate, delete and persist named view configurations with an unsaved changes state.

Prop
viewsSavedView<T>[]Controlled list of views.
defaultViewsSavedView<T>[]Default[]Initial list of views when uncontrolled.
onViewsChange(views: SavedView<T>[]) => voidCalled when views are created, renamed, duplicated, or deleted.
activeIdstringControlled active view ID.
defaultActiveIdstringInitial active view ID when uncontrolled.
onActiveChange(activeId: string) => voidCalled when the active view changes.
currentDataTCurrent configuration data. New views take it, Save writes it to the active view, and isDirty is worked out by comparing it with the active view's data. Keep it structured-cloneable, since Duplicate copies view data with structuredClone.
isEqual(saved: T, current: T) => booleanDefaultisViewDataEqualEquality rule for dirty tracking. The default is a deep compare that ignores key order and undefined fields. Views with no data are never dirty.
isDirtybooleanOverrides the computed unsaved state and skips the compare. Shows the unsaved indicator and the Save and Discard buttons. Leave it out to compare currentData with the active view.
onSave(view: SavedView<T>) => void | PromiseLike<unknown>Called when the Save button is clicked. Return a Promise or other thenable to mark the view pending and undo the change if it rejects or the callback throws.
onReset(view: SavedView<T>) => voidCalled when the Discard button is clicked.
onCreate(view: SavedView<T>) => void | PromiseLike<unknown>Called when a new view is created. Return a Promise or other thenable to mark the view pending and undo the change if it rejects or the callback throws.
onRename(view: SavedView<T>, newName: string) => void | PromiseLike<unknown>Called when a view is renamed. Return a Promise or other thenable to mark the view pending and undo the change if it rejects or the callback throws.
onDuplicate(newView: SavedView<T>, sourceView: SavedView<T>) => void | PromiseLike<unknown>Called when a view is duplicated. Return a Promise or other thenable to mark the view pending and undo the change if it rejects or the callback throws.
onDelete(deletedViewId: string) => void | PromiseLike<unknown>Called when a view is deleted. Return a Promise or other thenable to mark the view pending and undo the change if it rejects or the callback throws.
onSetDefault(defaultViewId: string) => void | PromiseLike<unknown>Called when a view is marked as default. Return a Promise or other thenable to mark the view pending and undo the change if it rejects or the callback throws.
onError(error: unknown, action: "create" | "rename" | "duplicate" | "delete" | "setDefault" | "save") => voidCalled with the rejection reason after a failed change, for logging or a toast. Without it the error goes to console.error. An inline alert is shown either way.
variant"tabs" | "select"Default"tabs"Display mode: horizontal tablist or compact dropdown selector.
showResetbooleanDefaulttrueWhether to show the Discard button when isDirty is true.
allowCreatebooleanDefaulttrueWhether to allow creating new views.
allowRenamebooleanDefaulttrueWhether to allow renaming views.
allowDuplicatebooleanDefaulttrueWhether to allow duplicating views.
allowDeletebooleanDefaulttrueWhether to allow deleting views.
allowSetDefaultbooleanDefaulttrueWhether to allow setting a view as default.
placeholderstringDefault"New view name..."Placeholder text for new view inputs.

Other props go to the <div>.

createSavedView, duplicateSavedView, renameSavedView, deleteSavedView, setDefaultSavedView

createSavedView(name: string, options?: Partial<SavedView<T>>): SavedView<T>; duplicateSavedView(source: SavedView<T>, views: SavedView<T>[]): SavedView<T>; renameSavedView(views: SavedView<T>[], id: string, name: string): SavedView<T>[]; deleteSavedView(views: SavedView<T>[], id: string, activeId?: string): { views: SavedView<T>[]; nextActiveId: string | null }; setDefaultSavedView(views: SavedView<T>[], id: string): SavedView<T>[]

Pure logic helpers imported from @/lib/saved-views.

isViewDataEqual, isViewDirty

isViewDataEqual(a: unknown, b: unknown): boolean; isViewDirty(view: SavedView<T> | undefined, currentData: T | undefined, isEqual?: (saved: T, current: T) => boolean): boolean

The dirty tracking rule. Plain objects compare deeply with key order ignored and undefined fields treated as missing. Arrays keep their order, dates compare by time, regular expressions compare by source and flags, Set members and Map values compare deeply, Map keys compare by identity, cyclic data is safe, and objects of different types are never equal. Other class instances compare by own enumerable fields only, so pass isEqual for Temporal, URL and similar types. A view with no data is never dirty.

readViewParam, writeViewParam

readViewParam(search: string | URLSearchParams, views: SavedView<T>[], key?: string): string | undefined; writeViewParam(search: string | URLSearchParams, activeId: string, key?: string): string

Map a ?view= search param to an active id and back, keeping other params. A missing or unknown id resolves to the current default view, or the first view when none is the default, so activeId stays controlled. The active view can then change without onActiveChange (back and forward, or a new default while the URL has no param), so derive the working data from activeId. key defaults to "view".

Controlled views

Manage views and activeId with React state to synchronize view selection with URL query parameters or backend storage.

Active View: all | Views: All Issues, Assigned to Me, High Priority

Dirty tracking

Pass currentData and the component compares it with the active view's data. Key order and undefined fields are not changes. Pass isEqual for a different rule.

The second button changes key order and adds an undefined field. Neither counts as an unsaved change.

Active view in the URL

readViewParam and writeViewParam map ?view= to an active id and back, keeping other params. Feed them from your router's search params.

?

A missing or unknown id shows the default view.

Server storage with rollback

Return a Promise from onCreate, onRename, onDuplicate, onDelete, onSetDefault or onSave. The view shows as pending, and a rejected request undoes the change and shows an error.

    Select dropdown variant

    variant='select' provides a compact dropdown trigger for toolbars with limited horizontal space.

    Unsaved

    Dense view switcher using a dropdown trigger, suitable for compact table headers.

    components/ui/saved-views.tsx
    CtrlK