Room list: unjoined project-room groups collapse behind one Browse entry #67
5 changed files with 114 additions and 13 deletions
|
|
@ -38,7 +38,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie
|
|||
import { makeNavCategoryId } from '../../state/closedNavCategories';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { EstateRoom, EstateTier } from './buildEstate';
|
||||
import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate';
|
||||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
import { useEstate, useEstateRootId } from './useEstate';
|
||||
import * as css from './EstateNavigator.css';
|
||||
|
|
@ -167,25 +167,53 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
|
||||
return (
|
||||
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
|
||||
{group.rooms.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is
|
||||
* remembered per device like any other section.
|
||||
*/
|
||||
function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) {
|
||||
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
|
||||
const key = makeNavCategoryId('estate', 'browse', tierId);
|
||||
const open = toggled.has(key);
|
||||
const count = groups.reduce((n, g) => n + g.rooms.length, 0);
|
||||
|
||||
return (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={!open}
|
||||
onClick={() => setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })}
|
||||
>
|
||||
{`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{open && groups.map((g) => <GroupSection key={g.mind} tierId={tierId} group={g} />)}
|
||||
</NavCategory>
|
||||
);
|
||||
}
|
||||
|
||||
function Tier({ tier }: { tier: EstateTier }) {
|
||||
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
|
||||
const { front, browse } = useMemo(() => splitGroups(tier), [tier]);
|
||||
return (
|
||||
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
|
||||
{tier.loose.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
{tier.groups.map((g) => (
|
||||
<Collapsible
|
||||
key={g.mind}
|
||||
categoryId={`${tier.id}|${g.mind}`}
|
||||
label={g.label}
|
||||
rooms={g.rooms}
|
||||
>
|
||||
{g.rooms.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
</Collapsible>
|
||||
{front.map((g) => (
|
||||
<GroupSection key={g.mind} tierId={tier.id} group={g} />
|
||||
))}
|
||||
{browse.length > 0 && <Browse tierId={tier.id} groups={browse} />}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate';
|
||||
import {
|
||||
buildEstate,
|
||||
EstateHierarchyRoom,
|
||||
EstateInput,
|
||||
splitGroups,
|
||||
stewardOfChild,
|
||||
} from './buildEstate';
|
||||
|
||||
const ROOT = '!root:gamecult.org';
|
||||
const PROJECTS = '!projects:gamecult.org';
|
||||
|
|
@ -324,3 +330,34 @@ describe('direct invites', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitGroups', () => {
|
||||
const projects = (membership: EstateInput['membership']) =>
|
||||
buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!;
|
||||
const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind);
|
||||
|
||||
it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => {
|
||||
const tier = projects(new Map());
|
||||
const { front, browse } = splitGroups(tier);
|
||||
expect(labels(front)).toEqual([]);
|
||||
expect(labels(browse).sort()).toEqual(['eureka', 'void']);
|
||||
expect(tier.loose.map((r) => r.name)).toEqual(['Misc']);
|
||||
});
|
||||
|
||||
it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => {
|
||||
const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']])));
|
||||
expect(labels(front)).toEqual(['eureka']);
|
||||
expect(labels(browse)).toEqual(['void']);
|
||||
});
|
||||
|
||||
it('keeps a group up front for an invite, which is something to act on', () => {
|
||||
const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']])));
|
||||
expect(labels(front)).toEqual(['void']);
|
||||
});
|
||||
|
||||
it('loses no group: front and browse together are the tier groups', () => {
|
||||
const tier = projects(new Map([['!cultlib:gamecult.org', 'join']]));
|
||||
const { front, browse } = splitGroups(tier);
|
||||
expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,6 +71,20 @@ export type EstateInput = {
|
|||
mindLabels?: ReadonlyMap<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is
|
||||
* something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first
|
||||
* screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away.
|
||||
*/
|
||||
export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => {
|
||||
const front: EstateGroup[] = [];
|
||||
const browse: EstateGroup[] = [];
|
||||
tier.groups.forEach((g) =>
|
||||
(g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g)
|
||||
);
|
||||
return { front, browse };
|
||||
};
|
||||
|
||||
export const DIRECT_TIER_ID = 'direct';
|
||||
export const ELSEWHERE_TIER_ID = 'elsewhere';
|
||||
|
||||
|
|
|
|||
21
src/app/features/room-nav/RoomNavCategoryButton.test.tsx
Normal file
21
src/app/features/room-nav/RoomNavCategoryButton.test.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RoomNavCategoryButton } from './RoomNavCategoryButton';
|
||||
|
||||
const html = (closed?: boolean) =>
|
||||
renderToStaticMarkup(<RoomNavCategoryButton closed={closed}>Browse</RoomNavCategoryButton>);
|
||||
|
||||
describe('RoomNavCategoryButton', () => {
|
||||
it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => {
|
||||
expect(html(true)).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
it('says it is expanded when open', () => {
|
||||
expect(html(false)).toContain('aria-expanded="true"');
|
||||
});
|
||||
|
||||
it('claims neither when the section does not toggle', () => {
|
||||
expect(html(undefined)).not.toContain('aria-expanded');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
|
|||
src={closed ? Icons.ChevronRight : Icons.ChevronBottom}
|
||||
/>
|
||||
}
|
||||
aria-expanded={closed === undefined ? undefined : !closed}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue