From 7e9d8526b1e6c9918f44ff7320904667eeccbd50 Mon Sep 17 00:00:00 2001 From: "matthew.marie-rhodes" Date: Fri, 30 Oct 2026 15:46:10 -0400 Subject: [PATCH 1/2] feat(ui): release 5.1.0 with folder groups that open and close in Nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Need: Docs sites and apps need sidebar folders readers can open and close, built from Equality instead of hand-rolled. Problem: Nav could only drill into a new level, so a docs tree had no Equality part, and its hints each set their own colour. Solution: NavListExpandable adds folders that keep their links in server HTML and open around the current page. One muted colour styles every hint, and new-tab rows draw their own ↗. Co-Authored-By: Claude Opus 5.5 --- packages/ui/CHANGELOG.md | 41 +++ packages/ui/package.json | 2 +- .../motion-collapsible/motion-collapsible.tsx | 28 +- .../ui/src/components/nav-list/nav-drill.tsx | 49 ++-- .../components/nav-list/nav-list.module.css | 185 +++++++++++- .../ui/src/components/nav-list/nav-list.tsx | 198 ++++++++++++- packages/ui/src/components/nav/nav.module.css | 32 ++- .../ui/src/theme/theme-decisions-scoped.css | 14 + packages/ui/src/theme/theme-decisions.css | 14 + packages/ui/tests/motion-collapsible.test.tsx | 16 ++ packages/ui/tests/nav-drill.test.tsx | 21 ++ .../ui/tests/nav-list-expandable.test.tsx | 270 ++++++++++++++++++ packages/ui/tests/nav-list.test.tsx | 34 +++ 13 files changed, 837 insertions(+), 67 deletions(-) create mode 100644 packages/ui/tests/motion-collapsible.test.tsx create mode 100644 packages/ui/tests/nav-list-expandable.test.tsx diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index b44974ba..63824915 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -2,6 +2,47 @@ Notable changes to Equality are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 5.1.0 - 2026-10-29 + +### Added + +- `NavListExpandable`, `NavListExpandableTrigger` and `NavListExpandableContent`: a nav group the + user opens and closes with its header row. A closed group keeps its links in the page, hidden, + so they are in server-rendered HTML. Its arrow points down when closed and up when open, and a + closed group holding the current page lights its header row. A top-level group's header leads + in primary text, and a link beside it takes the same colour; its rows indent their text. A + group inside a group reads as a row, and its rows are inset behind a tree line, highlight + included. The header takes `prefix` and `suffix` like a row, so a status badge sits after its + label and screen readers read it out. A folder opens itself, and every folder around + it, when the current page moves inside, such as after a link that changes the page without a + reload. A reader can still close it; it opens again at their next page inside it. +- `--eq-nav-motion-duration`, `--eq-nav-motion-ease` and `--eq-nav-motion-nudge`: the nav's motion, + named once. `NavDrill` reads them, and a folder group opens with the same motion: its rows nudge + 12px down as they fade in, and on the way out are gone within the first third. +- `--eq-switcher-name-fade`: the fade on `NavSwitcher`'s name, from primary to secondary text, as + before. It is now a variable, so a site can set its own. +- `--eq-list-item-muted-color`: the colour of a row's quiet marks, tertiary text. The drill ›, a + group's arrow, the back row's ‹, `NavSwitcher`'s ↕ and the new-tab ↗ all read it, so a site + sets them in one place. +- `NavListItem` marks a link with `target="_blank"`, set on it or on its `asChild` link: a muted ↗ + after the label, "(opens in a new tab)" for screen readers, and `rel="noopener noreferrer"` + unless you pass your own. Remove any ↗ or "(opens in a new tab)" you put in `suffix`. + +### Changed + +- Nav chevrons, the drill › and a group's arrow, are muted, as quiet as a switcher's. Every muted + mark takes its row's colour on hover and on the current row, the back row's ‹ and the + switcher's ↕ included. Studio's › gets quieter. +- A nav row's hover fill shows only with a mouse or trackpad, so a row tapped on a touch screen + no longer stays lit. + +### Fixed + +- `MotionCollapsibleContent` no longer grows from nothing when it first renders open, such as an + `Input` that loads with an error. +- A `NavSwitcher` in `NavPanel`'s header keeps the group gap, 12px, above the rows below it. An + unlabelled first group sat right under it. A ‹ row below it is unchanged. + ## 5.0.2 - 2026-10-28 ### Added diff --git a/packages/ui/package.json b/packages/ui/package.json index 1e911d49..b51961a6 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.2", + "version": "5.1.0", "license": "Apache-2.0", "keywords": [ "component library", diff --git a/packages/ui/src/components/motion-collapsible/motion-collapsible.tsx b/packages/ui/src/components/motion-collapsible/motion-collapsible.tsx index 9d26a532..523b0bb5 100644 --- a/packages/ui/src/components/motion-collapsible/motion-collapsible.tsx +++ b/packages/ui/src/components/motion-collapsible/motion-collapsible.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useRef, useState } from 'react'; +import { ReactNode, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import styles from '@/components/motion-collapsible/motion-collapsible.module.css'; @@ -15,33 +15,17 @@ const MotionCollapsibleContent = ({ children, className, }: MotionCollapsibleContentProps) => { - const [contentHeight, setContentHeight] = useState(0); - const [isSettled, setIsSettled] = useState(false); - - const contentRef = useRef(null); - - useEffect(() => { - if (contentRef.current) { - const resizeObserver = new ResizeObserver(() => { - if (contentRef.current) { - setContentHeight(contentRef.current.scrollHeight); - } - }); - - resizeObserver.observe(contentRef.current); - setContentHeight(contentRef.current.scrollHeight); - - return () => resizeObserver.disconnect(); - } - }, [children]); + const [isSettled, setIsSettled] = useState(isOpen); + // Animates to `auto`, not a measured height: a measurement starts at 0, so content that + // mounts open would grow on every page load return ( {isOpen && ( setIsSettled(false)} @@ -52,7 +36,7 @@ const MotionCollapsibleContent = ({ className )} > -
{children}
+
{children}
)}
diff --git a/packages/ui/src/components/nav-list/nav-drill.tsx b/packages/ui/src/components/nav-list/nav-drill.tsx index 5c2fd4ae..6f5d6a0c 100644 --- a/packages/ui/src/components/nav-list/nav-drill.tsx +++ b/packages/ui/src/components/nav-list/nav-drill.tsx @@ -9,31 +9,35 @@ import { cn } from '@/lib/utils'; type NavDrillRequest = 'drill' | 'back'; // The leaving level is gone in the first third, so the two levels' rows never read as doubled text -const MOTION: Record = { - drill: { +const motion = (direction: NavDrillRequest, nudge: number) => { + const sign = direction === 'drill' ? 1 : -1; + return { current: [ - { transform: 'translateX(12px)', opacity: 0 }, + { transform: `translateX(${sign * nudge}px)`, opacity: 0 }, { transform: 'translateX(0)', opacity: 1 }, ], previous: [ { transform: 'translateX(0)', opacity: 1 }, { opacity: 0, offset: 0.35 }, - { transform: 'translateX(-12px)', opacity: 0 }, + { transform: `translateX(${-sign * nudge}px)`, opacity: 0 }, ], - }, - back: { - current: [ - { transform: 'translateX(-12px)', opacity: 0 }, - { transform: 'translateX(0)', opacity: 1 }, - ], - previous: [ - { transform: 'translateX(0)', opacity: 1 }, - { opacity: 0, offset: 0.35 }, - { transform: 'translateX(12px)', opacity: 0 }, - ], - }, + }; }; -const TIMING: KeyframeAnimationOptions = { duration: 300, easing: 'cubic-bezier(.2, .8, .2, 1)' }; + +// Folder groups animate in CSS from the same variables; reading them here keeps the two in step +function navMotion(element: Element) { + const style = getComputedStyle(element); + const read = (name: string) => style.getPropertyValue(name).trim(); + const duration = read('--eq-nav-motion-duration'); + const ms = duration.endsWith('ms') ? parseFloat(duration) : parseFloat(duration) * 1000; + return { + nudge: parseFloat(read('--eq-nav-motion-nudge')) || 12, + timing: { + duration: Number.isFinite(ms) ? ms : 300, + easing: read('--eq-nav-motion-ease') || 'cubic-bezier(.2, .8, .2, 1)', + }, + }; +} const LEVEL = '[data-slot="nav-drill-level"]'; @@ -212,9 +216,11 @@ const NavDrill = React.forwardRef( // The old rows leave from where the user saw them: neither the scroll reset nor a tighter // header moves them. A margin, because the slide owns transform ghostLevel.style.marginTop = `${leaving.top - live.getBoundingClientRect().top}px`; + const { nudge, timing } = navMotion(live); + const frames = motion(leaving.direction, nudge); const animations = [ - live.animate(MOTION[leaving.direction].current, TIMING), - ghost.animate(MOTION[leaving.direction].previous, TIMING), + live.animate(frames.current, timing), + ghost.animate(frames.previous, timing), ]; running.current = animations; Promise.all(animations.map((animation) => animation.finished)).then( @@ -361,7 +367,10 @@ const NavDrillLevel = React.forwardRef( className={cn(styles['nav-list-item'], styles['nav-list-item--back'])} onClick={() => requestBack(parent)} > -