Source: https://monarchui.dev/components/saved-views/

Components · Search and discovery

# SavedViews

View switcher for a list: named combinations of filters, sort and columns, with save, save as new, rename, delete, set default and an "Edited" marker.

@rookcom/monarch-react/data

Component

## Views with filters, save and discard

## Usage examples

The example at the top of the page is the only one for this component.

## Purpose

Lets people jump between named views of a list and keep the filters, sort and columns they set up. It shows when the current state differs from the selected view and offers to save or discard the change. The app stores the views; the component only renders them and reports choices.

### Use when

- A list or table has filters, sorting or column choices people return to.
- Built-in views and personal or shared views live side by side.

### Avoid when

- A handful of fixed views with no saving: use Tabs or TabsFilter.
- Saving a single search query: use SearchInput history in the app.

## Design principles

- No data: views come in as `views`, changes go out through `onSaveNew`, `onSaveChanges`, `onRename`, `onDelete`, `onSetDefault` and `onDiscardChanges`. What a view stores is up to the app.
- The app decides "modified" (it compares its state with the view), so any state can be part of a view.
- Read-only views (built-in or someone else’s) can be applied and copied, never changed.
- Destructive actions are confirmed; names are validated (required, unique, `maxNameLength`).

## API reference

```tsx
import { SavedViews, defaultSavedViewsMessages } from '@rookcom/monarch-react/data';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `SavedViews` | component | client | `.mn-saved-views` |
| `defaultSavedViewsMessages` | utility | client | – |

Imported from `@rookcom/monarch-react/data`. The ref is the trigger button.

`SavedView`: `id`, `name`, `count`, `shared`, `isDefault`, `readOnly`, `group`.

Callbacks may return a promise: the name dialog waits for it and shows `messages.saveFailed` if it rejects; delete uses ConfirmDialog, which shows the error.

`messages` translates every string.

Generated from the TypeScript declarations of `packages/react/src/components/search/saved-views`.

## Properties

### `SavedViews`

Switcher for named views of a list (filters, sort and columns), with save, save as new, rename, delete, set default and an "Edited" marker. The app persists views through the callbacks; the component holds no data.

| Prop | Type | Description |
| --- | --- | --- |
| `views` required | `readonly SavedView[]` |  |
| `className` | `string` |  |
| `defaultValue` | `string \| null` | Initially selected view id (uncontrolled). |
| `disabled` | `boolean` |  |
| `maxNameLength` | `number` | Longest allowed name. Default 80. |
| `messages` | `Partial<SavedViewsMessages>` |  |
| `modified` | `boolean` | The current filters, sort or columns differ from the selected view. Shows "Edited". |
| `onDelete` | `((id: string) => unknown)` |  |
| `onDiscardChanges` | `(() => void)` | "Discard changes": the app reapplies the selected view. |
| `onRename` | `((id: string, name: string) => unknown)` |  |
| `onSaveChanges` | `((id: string) => unknown)` | "Save changes": the app overwrites the selected view with the current state. |
| `onSaveNew` | `((name: string) => unknown)` | "Save as new view…": the app stores the current state under this name and selects it. |
| `onSetDefault` | `((id: string) => unknown)` |  |
| `onValueChange` | `((id: string) => void)` | A view was chosen. Apply its filters, sort and columns in the app. |
| `size` | `"xs" \| "sm" \| "md"` |  |
| `value` | `string \| null` | Selected view id (controlled). `null` for none. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `size` | `'xs'``'sm'``'md'` | Trigger size. Default `sm`. |
| `group (view)` | `'string'` | Views with the same `group` are listed under that heading, in order. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Modified | `data-modified` | An "Edited" marker in the trigger; the menu offers "Save changes to …" (editable views) and "Discard changes". |
| Selected view | `aria-checked` | Radio item of the current view. |
| Default view | – | A star, announced as "Default view". |
| Shared view | – | A "Shared" marker. |
| Saving | `aria-busy` | The name dialog is busy while the save promise runs and keeps the error on failure. |
| No view | – | The trigger shows `messages.noView` ("All items"). |

## Interaction behaviour

- Choosing a view calls `onValueChange`; the app applies its filters, sort and columns.
- "Save as new view…" and "Rename…" open a dialog with a name field; Enter submits.
- "Delete view…" asks for confirmation first.
- "Set as default" is offered for views that are not already the default.
- After a dialog closes, focus returns to the trigger.

## Keyboard support

| Keys | Action |
| --- | --- |
| Enter / Space / Arrow Down | Opens the menu from the trigger. |
| Arrow Up / Down, Home / End | Move through views and actions; type-ahead jumps by name. |
| Enter | Selects a view or runs an action; submits the name dialog. |
| Escape | Closes the menu or dialog and returns focus to the trigger. |

## Accessibility

Role

button with a menu (menuitemradio items)

Accessible name

Visually hidden "View:" plus the view name, count and "Edited", e.g. "View: My open tickets 7 Edited".

- Views are a radio group, so the current one is announced as checked.
- The default star has a text alternative; "Edited" is text, not colour alone.
- Actions are real menu items, not buttons nested in menu items.
- The name field is labelled and its errors are linked (FieldError).

## Responsive behaviour

- The trigger truncates long names; the menu is at most 22rem or the screen width minus 2rem.
- Dialogs follow Dialog’s small size on phones.

## Theme support

- Trigger, menu and dialog use Button, DropdownMenu and Dialog tokens; the "Edited" marker uses the warning subtle tokens in every theme.

## Do and don't

### Do

- Store filters, sort and visible columns in a view, and compute `modified` from all of them.
- Mark built-in and other people’s views `readOnly`.
- Return promises from callbacks so failures stay visible.

### Don't

- Do not store row data in a view, only the settings that produce it.
- Do not apply a view without updating the URL if the app keeps filters there.

## Related components

- [FilterBar](https://monarchui.dev/components/filter-bar/): Shows and edits the filters a view stores.
- [DataTable](https://monarchui.dev/components/data-table/): Sort and columns a view can store.
- [DropdownMenu](https://monarchui.dev/components/dropdown-menu/): The menu SavedViews is built on.
- [Tabs](https://monarchui.dev/components/tabs/): A few fixed views without saving.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [DropdownMenu](https://monarchui.dev/components/dropdown-menu/), [Dialog](https://monarchui.dev/components/dialog/), [ConfirmDialog](https://monarchui.dev/components/confirm-dialog/), [Field](https://monarchui.dev/components/form-field/), [Input](https://monarchui.dev/components/text-field/) |

## Testing coverage

- Trigger shows the view, count and "Edited".
- Switching views through grouped radio items (uncontrolled), default marker announced.
- Save changes, discard, rename, set default and delete appear only where allowed (read-only, modified, default).
- Save as new validates required and unique names, then returns focus to the trigger.
- A rejected save keeps the dialog open with an error; delete is confirmed.
- Keyboard open and Escape; server rendering.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | None yet |

Tests: `packages/react/src/components/search/saved-views`

## All example code

### Views with filters, save and discard

```tsx
import { useState } from 'react';
import { FilterBar, FilterChip } from '@rookcom/monarch-react';
import { SavedViews, type SavedView } from '@rookcom/monarch-react/data';

type Filters = { status: string | null; owner: string | null };

const INITIAL: (SavedView & { filters: Filters })[] = [
  {
    id: 'all',
    name: 'All tickets',
    count: 64,
    readOnly: true,
    group: 'Built-in',
    filters: { status: null, owner: null },
  },
  {
    id: 'open',
    name: 'Open',
    count: 18,
    readOnly: true,
    group: 'Built-in',
    filters: { status: 'Open', owner: null },
  },
  {
    id: 'mine',
    name: 'My open tickets',
    count: 7,
    isDefault: true,
    group: 'My views',
    filters: { status: 'Open', owner: 'Me' },
  },
  {
    id: 'team',
    name: 'Team backlog',
    count: 23,
    shared: true,
    group: 'My views',
    filters: { status: 'Pending', owner: null },
  },
];

const same = (a: Filters, b: Filters) => a.status === b.status && a.owner === b.owner;

function ViewsWithFilters() {
  const [views, setViews] = useState(INITIAL);
  const [viewId, setViewId] = useState<string | null>('mine');
  const [filters, setFilters] = useState<Filters>({ status: 'Open', owner: null });
  const current = views.find((v) => v.id === viewId);
  const modified = !!current && !same(current.filters, filters);
  return (
    <div style={{ display: 'grid', gap: 12 }}>
      <SavedViews
        views={views}
        value={viewId}
        modified={modified}
        onValueChange={(id) => {
          setViewId(id);
          const v = views.find((x) => x.id === id);
          if (v) setFilters(v.filters);
        }}
        onDiscardChanges={() => current && setFilters(current.filters)}
        onSaveChanges={(id) =>
          setViews((all) => all.map((v) => (v.id === id ? { ...v, filters } : v)))
        }
        onSaveNew={(name) => {
          const id = `view-${views.length + 1}`;
          setViews((all) => [...all, { id, name, group: 'My views', filters }]);
          setViewId(id);
        }}
        onRename={(id, name) =>
          setViews((all) => all.map((v) => (v.id === id ? { ...v, name } : v)))
        }
        onSetDefault={(id) => setViews((all) => all.map((v) => ({ ...v, isDefault: v.id === id })))}
        onDelete={(id) => {
          setViews((all) => all.filter((v) => v.id !== id));
          setViewId('all');
          setFilters({ status: null, owner: null });
        }}
      />
      <FilterBar>
        {filters.status ? (
          <FilterChip
            name="Status"
            value={filters.status}
            onRemove={() => setFilters((f) => ({ ...f, status: null }))}
          />
        ) : null}
        {filters.owner ? (
          <FilterChip
            name="Owner"
            value={filters.owner}
            onRemove={() => setFilters((f) => ({ ...f, owner: null }))}
          />
        ) : null}
      </FilterBar>
    </div>
  );
}

<ViewsWithFilters />
```
