Navigator: one gutter column, constant nesting step, quieter room icons #92

Merged
void merged 1 commit from void/nav-gutter into dev 2026-11-11 16:40:37 +00:00
7 changed files with 123 additions and 51 deletions

View file

@ -0,0 +1,12 @@
import React, { ReactNode } from 'react';
import * as css from './styles.css';
/** The fixed-width first column of a navigator row or section header; its content is centred in it. */
export function NavGutter({ children }: { children: ReactNode }) {
return <span className={css.NavGutter}>{children}</span>;
}
/** One nesting step: everything inside is indented by the same constant. */
export function NavLevel({ children }: { children: ReactNode }) {
return <div className={css.NavLevel}>{children}</div>;
}

View file

@ -4,3 +4,4 @@ export * from './NavEmptyLayout';
export * from './NavItem';
export * from './NavItemContent';
export * from './NavItemOptions';
export * from './NavGutter';

View file

@ -13,6 +13,29 @@ const SCRIM_HOVER = '14%';
const SCRIM_SELECTED = '22%';
const SCRIM_PRESSED = '48%';
// The navigator's one column grid. Every row, section header and join row begins with a gutter of
// the same width, with its chevron, room icon or avatar centred in it, so every label starts at
// the same x for its depth; each nesting level (NavLevel) indents by one constant step.
const GUTTER = toRem(32);
const LEVEL_STEP = toRem(16);
export const NavGutter = style({
width: GUTTER,
minWidth: GUTTER,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const NavLevel = style({
paddingLeft: LEVEL_STEP,
});
// Chevrons and room icons share one resting weight, so a foldout is not outweighed by its rooms.
export const NAV_ICON_REST_OPACITY = 0.6;
export const NAV_ICON_UNREAD_OPACITY = 0.9;
export const NavCategory = style([
DefaultReset,
{
@ -22,6 +45,11 @@ export const NavCategory = style([
export const NavCategoryHeader = style({
gap: config.space.S100,
selectors: {
// The header's own side padding would shift its gutter off the rows': the section button
// carries the same left inset as NavItemContent instead.
'&&': { paddingLeft: 0 },
},
'@media': {
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
// a phone's list is plain text).

View file

@ -1,16 +1,18 @@
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds';
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavGutter,
NavItem,
NavItemContent,
NavLevel,
} from '../../components/nav';
import { NavAction, NavLabel } from '../../components/nav/styles.css';
import { NAV_ICON_REST_OPACITY, NavAction, NavLabel } from '../../components/nav/styles.css';
import { UseStateProvider } from '../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../components/join-address-prompt';
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
@ -77,12 +79,14 @@ function JoinRow({ room }: { room: EstateRoom }) {
return (
<NavItem variant="Background" radii="400">
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px 0 0', minHeight: 38 }}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Hash} size="100" style={{ opacity: config.opacity.P300 }} />
</Avatar>
<NavGutter>
<Avatar size="200" radii="400">
<Icon src={Icons.Hash} size="100" style={{ opacity: NAV_ICON_REST_OPACITY }} />
</Avatar>
</NavGutter>
<Text className={NavLabel} as="span" size="Inherit" truncate>
{room.name}
</Text>
@ -166,7 +170,7 @@ function Collapsible({ categoryId, label, rooms, trailing, children }: Collapsib
</RoomNavCategoryButton>
{trailing}
</NavCategoryHeader>
{!closed && children}
{!closed && <NavLevel>{children}</NavLevel>}
</NavCategory>
);
}
@ -206,7 +210,13 @@ function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) {
{`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`}
</RoomNavCategoryButton>
</NavCategoryHeader>
{open && groups.map((g) => <GroupSection key={g.mind} tierId={tierId} group={g} />)}
{open && (
<NavLevel>
{groups.map((g) => (
<GroupSection key={g.mind} tierId={tierId} group={g} />
))}
</NavLevel>
)}
</NavCategory>
);
}
@ -235,9 +245,11 @@ function EntryPoints() {
<NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<NavGutter>
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" style={{ opacity: NAV_ICON_REST_OPACITY }} />
</Avatar>
</NavGutter>
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
@ -252,9 +264,15 @@ function EntryPoints() {
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<NavGutter>
<Avatar size="200" radii="400">
<Icon
src={Icons.Link}
size="100"
style={{ opacity: NAV_ICON_REST_OPACITY }}
/>
</Avatar>
</NavGutter>
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { as, Chip, Icon, Icons, Text } from 'folds';
import classNames from 'classnames';
import { NavGutter } from '../../components/nav';
import * as css from './styles.css';
export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
@ -9,17 +10,17 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
className={classNames(css.CategoryButton, className)}
variant="Background"
radii="Pill"
before={
aria-expanded={closed === undefined ? undefined : !closed}
{...props}
ref={ref}
>
<NavGutter>
<Icon
className={css.CategoryButtonIcon}
size="50"
src={closed ? Icons.ChevronRight : Icons.ChevronBottom}
/>
}
aria-expanded={closed === undefined ? undefined : !closed}
{...props}
ref={ref}
>
</NavGutter>
<Text size="O400" priority="300" truncate>
{children}
</Text>

View file

@ -20,8 +20,12 @@ import {
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { NavLabel } from '../../components/nav/styles.css';
import { NavGutter, NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import {
NAV_ICON_REST_OPACITY,
NAV_ICON_UNREAD_OPACITY,
NavLabel,
} from '../../components/nav/styles.css';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
@ -330,34 +334,36 @@ export function RoomNavItem({
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300,
}}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
<NavGutter>
<Avatar size="200" radii="400">
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity: unread ? NAV_ICON_UNREAD_OPACITY : NAV_ICON_REST_OPACITY,
}}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
</NavGutter>
<Box as="span" grow="Yes">
<Text
className={NavLabel}

View file

@ -2,9 +2,15 @@ import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { surfaceInk } from '../scene/sceneShell.css';
import { skyLabelInk } from '../scene/skyLabelInk';
import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css';
export const CategoryButton = style({
flexGrow: 1,
gap: config.space.S200,
selectors: {
// Same left inset as a row's NavItemContent, so the chevron's gutter sits over the rows' gutters.
'&&': { paddingLeft: config.space.S200 },
},
'@media': {
// A section heading on a phone is plain text: no pill behind it.
'screen and (max-width: 750px)': {
@ -25,5 +31,5 @@ export const CategoryButton = style({
},
});
export const CategoryButtonIcon = style({
opacity: config.opacity.P400,
opacity: NAV_ICON_REST_OPACITY,
});