+ );
+}
diff --git a/packages/demo/src/content/docs/components/nav.mdx b/packages/demo/src/content/docs/components/nav.mdx
index d81ab6ee..3b442209 100644
--- a/packages/demo/src/content/docs/components/nav.mdx
+++ b/packages/demo/src/content/docs/components/nav.mdx
@@ -3,7 +3,7 @@ title: "Nav"
description: "Sidebar navigation with an icon rail, a switcher and links that drill in and out"
---
-import { NavDemo } from "@demo/components/demo/nav";
+import { NavDemo, NavSwitcherErrorDemo } from "@demo/components/demo/nav";
import { NavListDemo } from "@demo/components/demo/nav-list";
## Overview
@@ -206,6 +206,26 @@ A rail of top-level app sections in `NavRail` beside a `NavPanel`, with a switch
The switcher's trigger stops a typed letter from picking an item while its list is closed: a switch navigates, and focus lands on the trigger afterwards. To do the same on your own `Select`, see [A select that navigates](/components/select#a-select-that-navigates).
+### When the List Fails to Load
+
+Pass `error` with `onRetry` when the list of items couldn't be read. The switcher stays a dropdown, and its list says so with a Retry row above any options it already has. Picking Retry calls `onRetry` and keeps the list open; pass `retrying` while that read runs, and the row reads "Retrying...". `error` stays true while the read runs, so `retrying` is what tells the switcher it has started.
+
+
+
+```tsx
+ projectsQuery.refetch()}
+/>
+```
+
### Navigation in a Sheet
On a phone, the sidebar comes in from the left in a [Sheet](/components/sheet). Give the sheet a title for screen readers. A sheet is at least a third of the screen wide, so set `minWidth: 0` to fit it to the list.
@@ -272,20 +292,23 @@ On a phone, the sidebar comes in from the left in a [Sheet](/components/sheet).
### NavSwitcher
-| Name | Description | Type | Default | Required |
-| ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------- | -------- |
-| `label` | The current item's name, shown as the panel's heading | `string` | | ✅ |
-| `noun` | Singular, for the trigger's name: `{label}, switch {noun}` | `string` | | ✅ |
-| `collection` | Plural, for the search and empty copy | `string` | | ✅ |
-| `options` | The items to switch between | `{ value, label, keywords? }[]` | | ✅ |
-| `value` | The current item's value | `string` | | ✅ |
-| `onValueChange` | Called with the picked item's value | `(value: string) => void` | | ✅ |
-| `onHighlight` | Hover or focus on an option, to preload its destination | `(value: string) => void` | | ❌ |
-| `onHighlightEnd` | Hover or focus leaves an option, to cancel its preload | `() => void` | | ❌ |
-| `action` | A command at the bottom of the list, kept while searching, such as "Create Project"; `onSelect` runs after the list closes | `{ label: string; onSelect: () => void }` | | ❌ |
-| `loading` | Holds the switcher's height while the current item loads | `boolean` | `false` | ❌ |
-
-With `loading`, the switcher takes no other props.
+| Name | Description | Type | Default | Required |
+| ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------- | ------------ |
+| `label` | The current item's name, shown as the panel's heading | `string` | | ✅ |
+| `noun` | Singular, for the trigger's name: `{label}, switch {noun}` | `string` | | ✅ |
+| `collection` | Plural, for the search and empty copy | `string` | | ✅ |
+| `options` | The items to switch between | `{ value, label, keywords? }[]` | | ✅ |
+| `value` | The current item's value | `string` | | ✅ |
+| `onValueChange` | Called with the picked item's value | `(value: string) => void` | | ✅ |
+| `onHighlight` | Hover or focus on an option, to preload its destination | `(value: string) => void` | | ❌ |
+| `onHighlightEnd` | Hover or focus leaves an option, to cancel its preload | `() => void` | | ❌ |
+| `action` | A command at the bottom of the list, kept while searching, such as "Create Project"; `onSelect` runs after the list closes | `{ label: string; onSelect: () => void }` | | ❌ |
+| `error` | The list failed to load: the list says so and offers Retry; needs `onRetry` | `boolean` | `false` | ❌ |
+| `retrying` | A retry is in flight: the Retry row reads "Retrying..." | `boolean` | `false` | ❌ |
+| `onRetry` | Called when Retry is picked; the list stays open | `() => void` | | With `error` |
+| `loading` | Holds the switcher's height while the current item loads | `boolean` | `false` | ❌ |
+
+With `loading`, the switcher takes no other props. With no `options`, no `action` and no `error`, it is a plain heading. One option is still a switcher, so the list shows it is the only one.
### NavListGroup
diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index 2ef683ca..b44974ba 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -2,6 +2,23 @@
Notable changes to Equality are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## 5.0.2 - 2026-10-28
+
+### Added
+
+- `NavSwitcher` takes `error`, `retrying` and `onRetry`, and `error` needs `onRetry`. With `error`,
+ the list shows "Couldn't load {collection}." with a Retry row above any options it already has.
+ Picking Retry calls `onRetry` and keeps the list open; with `retrying` the row reads
+ "Retrying...". Screen readers hear the failure, the retry and its result, but not a refetch of a
+ list that never failed.
+
+### Changed
+
+- A `NavSwitcher` with one option is now a switcher where it was a plain heading, so a user with a
+ single project sees the switcher they will get with more. Apps showing one item will see a
+ dropdown appear. It is a plain heading only when it has no options, no `action` and no `error`.
+- `Spinner` renders spans, not divs, so it can sit inside text such as a select option.
+
## 5.0.1 - 2026-10-28
### Fixed
diff --git a/packages/ui/package.json b/packages/ui/package.json
index dd7be3b1..1e911d49 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -7,7 +7,7 @@
"url": "git+https://github.com/eqtylab/equality.git",
"directory": "packages/ui"
},
- "version": "5.0.1",
+ "version": "5.0.2",
"license": "Apache-2.0",
"keywords": [
"component library",
diff --git a/packages/ui/src/components/nav/nav-switcher.tsx b/packages/ui/src/components/nav/nav-switcher.tsx
index 77f554b6..fcbf8c51 100644
--- a/packages/ui/src/components/nav/nav-switcher.tsx
+++ b/packages/ui/src/components/nav/nav-switcher.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { ChevronsUpDown } from 'lucide-react';
+import { ChevronsUpDown, TriangleAlert } from 'lucide-react';
import { buttonVariants } from '@/components/button/button';
import styles from '@/components/nav/nav.module.css';
@@ -14,6 +14,7 @@ import {
} from '@/components/select/select';
import { blockClosedTypeahead } from '@/components/select/select-typeahead';
import { Skeleton } from '@/components/skeleton/skeleton';
+import { Spinner } from '@/components/spinner/spinner';
import { cn } from '@/lib/utils';
interface NavSwitcherOption {
@@ -32,7 +33,7 @@ interface NavSwitcherAction {
onSelect: () => void;
}
-interface NavSwitcherLoadedProps {
+interface NavSwitcherBaseProps {
/** Shows a placeholder at the switcher's height while the current item loads */
loading?: false;
/** The current item's name, shown as the panel's heading */
@@ -55,6 +56,19 @@ interface NavSwitcherLoadedProps {
action?: NavSwitcherAction;
}
+type NavSwitcherRetryProps =
+ | { error?: false; retrying?: never; onRetry?: never }
+ | {
+ /** The list failed to load: the list says so and offers Retry */
+ error: boolean;
+ /** A retry is in flight: the Retry row reads "Retrying..." */
+ retrying?: boolean;
+ /** Called when Retry is picked; the list stays open */
+ onRetry: () => void;
+ };
+
+type NavSwitcherLoadedProps = NavSwitcherBaseProps & NavSwitcherRetryProps;
+
interface NavSwitcherLoadingProps {
loading: true;
}
@@ -65,14 +79,53 @@ type NavSwitcherProps = NavSwitcherLoadedProps | NavSwitcherLoadingProps;
// control does (WCAG 2.5.3)
const NavSwitcherSelect = React.forwardRef(
(
- { label, noun, collection, options, value, onValueChange, onHighlight, onHighlightEnd, action },
+ {
+ label,
+ noun,
+ collection,
+ options,
+ value,
+ onValueChange,
+ onHighlight,
+ onHighlightEnd,
+ action,
+ error,
+ retrying,
+ onRetry,
+ },
ref
) => {
const labelId = React.useId();
const actionValue = React.useId();
+ const retryValue = React.useId();
+ const errorId = React.useId();
const actionPending = React.useRef(false);
+ const [open, setOpen] = React.useState(false);
+ const keepOpen = React.useRef(false);
+ const [announcement, setAnnouncement] = React.useState('');
+ const [listState, setListState] = React.useState({ error, retrying });
+
+ if (listState.error !== error || listState.retrying !== retrying) {
+ setListState({ error, retrying });
+ // A background refetch toggles retrying with nothing to report
+ if (error || listState.error) {
+ setAnnouncement(
+ error
+ ? retrying
+ ? 'Retrying...'
+ : `Couldn't load ${collection}.`
+ : options.length > 0
+ ? `Loaded ${collection}.`
+ : `No ${collection} found.`
+ );
+ }
+ }
- if (options.length <= 1 && !action) {
+ const empty = options.length === 0 && !action && !error;
+
+ // Open, the list stays until it closes: a retry that found nothing would otherwise unmount it
+ // under focus
+ if (empty && !open) {
return (