Skip to content
Open
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
43 changes: 43 additions & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.2",
"version": "5.1.0",
"license": "Apache-2.0",
"keywords": [
"component library",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,33 +15,17 @@ const MotionCollapsibleContent = ({
children,
className,
}: MotionCollapsibleContentProps) => {
const [contentHeight, setContentHeight] = useState<number>(0);
const [isSettled, setIsSettled] = useState(false);

const contentRef = useRef<HTMLDivElement>(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 (
<AnimatePresence initial={false} mode="wait">
{isOpen && (
<motion.div
key="motion-content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: contentHeight, opacity: 1 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
onAnimationStart={() => setIsSettled(false)}
Expand All @@ -52,7 +36,7 @@ const MotionCollapsibleContent = ({
className
)}
>
<div ref={contentRef}>{children}</div>
<div>{children}</div>
</motion.div>
)}
</AnimatePresence>
Expand Down
50 changes: 30 additions & 20 deletions packages/ui/src/components/nav-list/nav-drill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavDrillRequest, { current: Keyframe[]; previous: Keyframe[] }> = {
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"]';

Expand Down Expand Up @@ -212,9 +217,11 @@ const NavDrill = React.forwardRef<HTMLDivElement, NavDrillProps>(
// 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(
Expand Down Expand Up @@ -361,7 +368,10 @@ const NavDrillLevel = React.forwardRef<HTMLDivElement, NavDrillLevelProps>(
className={cn(styles['nav-list-item'], styles['nav-list-item--back'])}
onClick={() => requestBack(parent)}
>
<span className={styles['nav-list-item-prefix']} aria-hidden="true">
<span
className={cn(styles['nav-list-item-prefix'], styles['nav-list-item-muted'])}
aria-hidden="true"
>
<ChevronLeft />
</span>
<span className={styles['nav-list-item-label']} title={label}>
Expand Down
Loading
Loading