Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion packages/demo/src/components/demo/nav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, type MouseEvent } from "react";
import { useEffect, useState, type MouseEvent } from "react";
import {
Button,
NavDrill,
NavDrillLevel,
NavList,
Expand Down Expand Up @@ -169,3 +170,66 @@ export function NavDemo() {
</div>
);
}

export function NavSwitcherErrorDemo() {
const [attempts, setAttempts] = useState(0);
const [retrying, setRetrying] = useState(false);
// The first retry fails too, so both outcomes show
const loaded = attempts >= 2;

useEffect(() => {
if (!retrying) return;
const timer = setTimeout(() => {
setRetrying(false);
setAttempts((count) => count + 1);
}, 1500);
return () => clearTimeout(timer);
}, [retrying]);

return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "start",
gap: 12,
}}
>
<div
style={{
width: 280,
borderRadius: 8,
border: "1px solid var(--color-border-raised)",
}}
>
<NavPanel
aria-label="Project: Ben Project"
header={
<NavSwitcher
label="Ben Project"
noun="project"
collection="projects"
options={loaded ? PROJECTS : []}
value="ben"
onValueChange={() => {}}
error={!loaded}
retrying={retrying}
onRetry={() => setRetrying(true)}
/>
}
/>
</div>
<Button
variant="secondary"
size="sm"
disabled={attempts === 0}
onClick={() => {
setRetrying(false);
setAttempts(0);
}}
>
Reset
</Button>
</div>
);
}
53 changes: 38 additions & 15 deletions packages/demo/src/content/docs/components/nav.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<NavSwitcherErrorDemo client:only="react" />

```tsx
<NavSwitcher
label={project.name}
noun="project"
collection="projects"
options={projects ?? []}
value={project.id}
onValueChange={switchProject}
error={projectsQuery.isError}
retrying={projectsQuery.isFetching}
onRetry={() => 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.
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 112 additions & 6 deletions packages/ui/src/components/nav/nav-switcher.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -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 */
Expand All @@ -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;
}
Expand All @@ -65,14 +79,53 @@ type NavSwitcherProps = NavSwitcherLoadedProps | NavSwitcherLoadingProps;
// control does (WCAG 2.5.3)
const NavSwitcherSelect = React.forwardRef<HTMLButtonElement, NavSwitcherLoadedProps>(
(
{ 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 (
<h2 className={styles['nav-switcher-heading']}>
<span
Expand All @@ -89,9 +142,27 @@ const NavSwitcherSelect = React.forwardRef<HTMLButtonElement, NavSwitcherLoadedP
<h2 aria-labelledby={labelId} className={styles['nav-switcher-heading']}>
<Select
value={value}
open={open}
onOpenChange={(next) => {
if (!next && keepOpen.current) keepOpen.current = false;
else {
setOpen(next);
setAnnouncement('');
}
}}
onValueChange={(next) => {
if (next === actionValue) actionPending.current = true;
else onValueChange(next);
else if (next === retryValue) {
// Before the trigger mounts, or inside a form, Radix's hidden native select picks
// Retry when value matches no option; acting on that while closed refetches in a loop
if (!open) return;
keepOpen.current = true;
// Only an item pick is followed by a close to absorb
queueMicrotask(() => {
keepOpen.current = false;
});
if (!retrying) onRetry?.();
} else onValueChange(next);
}}
>
<SelectTrigger
Expand Down Expand Up @@ -121,11 +192,46 @@ const NavSwitcherSelect = React.forwardRef<HTMLButtonElement, NavSwitcherLoadedP
action?.onSelect();
}}
>
<div className={styles['nav-switcher-status']} role="status" aria-live="polite">
{announcement}
</div>
<SelectSearch
placeholder={`Search ${collection}...`}
aria-label={`Search ${collection}`}
aria-describedby={error ? errorId : undefined}
/>
<SelectEmpty>No {collection} found</SelectEmpty>
{error && (
<SelectItem
value={retryValue}
textValue={`Couldn't load ${collection}. ${retrying ? 'Retrying' : 'Retry'}`}
persistent
aria-disabled={retrying || undefined}
className={styles['nav-switcher-retry']}
>
<span className={styles['nav-switcher-error']}>
<span id={errorId} className={styles['nav-switcher-error-message']}>
<TriangleAlert
className={styles['nav-switcher-error-icon']}
aria-hidden="true"
/>
Couldn&apos;t load {collection}.
</span>
{retrying ? (
<span className={styles['nav-switcher-error-message']}>
<Spinner size="xs" variant="neutral" />
Retrying...
</span>
) : (
<span className={buttonVariants({ variant: 'link', size: 'sm' })}>Retry</span>
)}
</span>
</SelectItem>
)}
{empty ? (
<div className={styles['nav-switcher-empty']}>No {collection} found</div>
) : (
(!error || options.length > 0) && <SelectEmpty>No {collection} found</SelectEmpty>
)}
{options.map((option) => (
<SelectItem
key={option.value}
Expand Down
Loading
Loading