diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index b44974ba..10280108 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -2,6 +2,49 @@ 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. +- In forced colours, only the current nav row and rail item keep a border. Every row was boxed, and + the current rail item had no mark at all. + ## 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..fa28c29b 100644 --- a/packages/ui/src/components/nav-list/nav-drill.tsx +++ b/packages/ui/src/components/nav-list/nav-drill.tsx @@ -9,31 +9,36 @@ 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; + const nudge = parseFloat(read('--eq-nav-motion-nudge')); + return { + nudge: Number.isFinite(nudge) ? 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 +217,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 +368,10 @@ const NavDrillLevel = React.forwardRef( className={cn(styles['nav-list-item'], styles['nav-list-item--back'])} onClick={() => requestBack(parent)} > -