diff --git a/packages/demo/src/components/demo/nav.tsx b/packages/demo/src/components/demo/nav.tsx index a5489109..0486a4b8 100644 --- a/packages/demo/src/components/demo/nav.tsx +++ b/packages/demo/src/components/demo/nav.tsx @@ -1,5 +1,6 @@ -import { useState, type MouseEvent } from "react"; +import { useEffect, useState, type MouseEvent } from "react"; import { + Button, NavDrill, NavDrillLevel, NavList, @@ -169,3 +170,66 @@ export function NavDemo() { ); } + +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 ( +
+
+ {}} + error={!loaded} + retrying={retrying} + onRetry={() => setRetrying(true)} + /> + } + /> +
+ +
+ ); +} 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 (