-
Notifications
You must be signed in to change notification settings - Fork 139
feat(calm-hub-ui): nest namespaces in the Explore rail #3119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bfed4bb
7ad2522
66f6530
5bbd9bc
c819a68
f2bd1a9
111deff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { fireEvent, render, screen } from '@testing-library/react'; | ||
| import { MemoryRouter, Route, Routes } from 'react-router-dom'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { CollapsedRail } from './CollapsedRail.js'; | ||
| import { colors } from '../../../theme/colors.js'; | ||
| import { redesignTokens } from '../../../theme/redesign-tokens.js'; | ||
| import type { NamespaceCounts } from '../../../model/counts.js'; | ||
|
|
||
| function nc(namespace: string, total: number): NamespaceCounts { | ||
| return { namespace, architectures: 0, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total }; | ||
| } | ||
|
|
||
| const namespaceCounts: NamespaceCounts[] = [nc('finos.calm', 5), nc('barclays.payments', 3), nc('acme', 2)]; | ||
|
|
||
| const renderRail = (path = '/') => { | ||
| const onExpand = vi.fn(); | ||
| const rail = <CollapsedRail namespaceCounts={namespaceCounts} onExpand={onExpand} />; | ||
| const utils = render( | ||
| <MemoryRouter initialEntries={[path]}> | ||
| <Routes> | ||
| {['/', '/namespace/:ns', '/:namespace/:type/:id/:version'].map((p) => ( | ||
| <Route key={p} path={p} element={rail} /> | ||
| ))} | ||
| </Routes> | ||
| </MemoryRouter> | ||
| ); | ||
| return { ...utils, onExpand }; | ||
| }; | ||
|
|
||
| describe('CollapsedRail', () => { | ||
| it('shows one initial per root namespace, in tree order', () => { | ||
| renderRail(); | ||
| const initials = screen.getAllByRole('button', { name: /^(acme|barclays|finos)$/ }); | ||
| expect(initials.map((b) => b.textContent)).toEqual(['A', 'B', 'F']); | ||
| }); | ||
|
|
||
| it('calls onExpand from the expand-sidebar button', () => { | ||
| const { onExpand } = renderRail(); | ||
| fireEvent.click(screen.getByLabelText('Expand sidebar')); | ||
| expect(onExpand).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('accents the root initial whose subtree contains the active namespace', () => { | ||
| renderRail('/namespace/finos.calm'); | ||
| const finosInitial = screen.getByRole('button', { name: 'finos' }); | ||
| expect(finosInitial).toHaveStyle({ backgroundColor: colors.redesign.tintBg, boxShadow: redesignTokens.shadow.railAccent }); | ||
|
|
||
| const acmeInitial = screen.getByRole('button', { name: 'acme' }); | ||
| expect(acmeInitial).not.toHaveStyle({ backgroundColor: colors.redesign.tintBg }); | ||
| }); | ||
|
|
||
| it('opens the fly-out for a root on hover', () => { | ||
| renderRail(); | ||
| expect(screen.queryByText('calm')).not.toBeInTheDocument(); | ||
|
|
||
| fireEvent.mouseEnter(screen.getByRole('button', { name: 'finos' }).parentElement!); | ||
| expect(screen.getByText('calm')).toBeInTheDocument(); | ||
|
|
||
| fireEvent.mouseLeave(screen.getByRole('button', { name: 'finos' }).parentElement!); | ||
| expect(screen.queryByText('calm')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('opens the fly-out for a root on focus', () => { | ||
| renderRail(); | ||
| const finosInitial = screen.getByRole('button', { name: 'finos' }); | ||
| expect(screen.queryByText('calm')).not.toBeInTheDocument(); | ||
|
|
||
| fireEvent.focus(finosInitial); | ||
| expect(screen.getByText('calm')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('closes the fly-out on Escape', () => { | ||
| renderRail(); | ||
| const finosInitial = screen.getByRole('button', { name: 'finos' }); | ||
| fireEvent.focus(finosInitial); | ||
| expect(screen.getByText('calm')).toBeInTheDocument(); | ||
|
|
||
| fireEvent.keyDown(finosInitial, { key: 'Escape' }); | ||
| expect(screen.queryByText('calm')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders a group-only root row inside the fly-out without a link', () => { | ||
| renderRail(); | ||
| fireEvent.focus(screen.getByRole('button', { name: 'barclays' })); | ||
|
|
||
| // 'barclays' itself has no direct count (only barclays.payments does) β | ||
| // its own row inside the fly-out is text, not a link. | ||
| expect(screen.getByText('barclays', { selector: 'span' })).toBeInTheDocument(); | ||
| expect(screen.queryByRole('link', { name: /^barclays$/ })).not.toBeInTheDocument(); | ||
| expect(screen.getByRole('link', { name: /payments/ })).toHaveAttribute('href', '/namespace/barclays.payments'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import { useMemo, useState } from 'react'; | ||
| import { Link, useParams } from 'react-router-dom'; | ||
| import { IoChevronForwardOutline } from 'react-icons/io5'; | ||
| import { NamespaceCounts } from '../../../model/counts.js'; | ||
| import { colors } from '../../../theme/colors.js'; | ||
| import { redesignTokens } from '../../../theme/redesign-tokens.js'; | ||
| import { CountBadge } from './CountBadge.js'; | ||
| import { buildNamespaceTree, flattenNamespaceTree, type NamespaceTreeNode } from './namespace-tree.js'; | ||
|
|
||
| interface CollapsedRailProps { | ||
| namespaceCounts: NamespaceCounts[]; | ||
| onExpand: () => void; | ||
| } | ||
|
|
||
| function isWithin(container: HTMLElement, target: EventTarget | null): boolean { | ||
| return target instanceof Node && container.contains(target); | ||
| } | ||
|
|
||
| function FlyoutRow({ node, depth, active }: { node: NamespaceTreeNode; depth: number; active: boolean }) { | ||
| const isNamespace = node.total !== null; | ||
| const style = active ? { color: colors.redesign.activeText } : { color: colors.redesign.bodyAlt }; | ||
|
|
||
| const content = ( | ||
| <> | ||
| <span className="min-w-0 flex-1 truncate">{node.segment}</span> | ||
| {node.total !== null && <CountBadge count={node.total} active={active} />} | ||
| </> | ||
| ); | ||
|
|
||
| return ( | ||
| <div | ||
| className="flex items-center gap-1 px-2 py-1 rounded-[7px] text-[13px]" | ||
| style={{ | ||
| paddingLeft: 8 + depth * 14, | ||
| backgroundColor: active ? colors.redesign.tintBg : undefined, | ||
| boxShadow: active ? redesignTokens.shadow.railAccent : undefined, | ||
| }} | ||
| > | ||
| {isNamespace ? ( | ||
| <Link to={`/namespace/${encodeURIComponent(node.path)}`} className="flex items-center gap-1 min-w-0 flex-1 no-underline" style={style}> | ||
| {content} | ||
| </Link> | ||
| ) : ( | ||
| <span className="flex items-center gap-1 min-w-0 flex-1 italic" style={{ color: colors.redesign.muted }}> | ||
| {content} | ||
| </span> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| interface RootInitialProps { | ||
| root: NamespaceTreeNode; | ||
| isActive: boolean; | ||
| isOpen: boolean; | ||
| activeNamespace?: string; | ||
| onOpen: () => void; | ||
| onClose: () => void; | ||
| } | ||
|
|
||
| function RootInitial({ root, isActive, isOpen, activeNamespace, onOpen, onClose }: RootInitialProps) { | ||
| const rows = useMemo(() => flattenNamespaceTree([root], { collapsed: new Set(), filtering: false, visible: new Set() }), [root]); | ||
|
|
||
| return ( | ||
| <div | ||
| className="relative" | ||
| onMouseEnter={onOpen} | ||
| onMouseLeave={onClose} | ||
| onFocus={onOpen} | ||
| onBlur={(e) => { | ||
| if (!isWithin(e.currentTarget, e.relatedTarget)) onClose(); | ||
| }} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Escape') onClose(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape closes the fly-out unconditionally, which unmounts the |
||
| }} | ||
| > | ||
| <button | ||
| type="button" | ||
| aria-label={root.path} | ||
| aria-haspopup="true" | ||
| aria-expanded={isOpen} | ||
| className="flex items-center justify-center font-semibold text-[13px] rounded-[7px] border-0 cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-interaction)]" | ||
| style={{ | ||
| width: 32, | ||
| height: 24, | ||
| backgroundColor: isActive ? colors.redesign.tintBg : 'transparent', | ||
| boxShadow: isActive ? redesignTokens.shadow.railAccent : undefined, | ||
| color: isActive ? colors.redesign.activeText : colors.redesign.bodyAlt, | ||
| transition: redesignTokens.transition, | ||
| }} | ||
| > | ||
| {root.segment.charAt(0).toUpperCase()} | ||
| </button> | ||
|
|
||
| {isOpen && ( | ||
| <div | ||
| className="absolute top-0 left-full ml-1 flex flex-col gap-0.5 p-1.5 rounded-[12px] z-50" | ||
| style={{ | ||
| width: 220, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This panel has a fixed |
||
| backgroundColor: colors.redesign.surface, | ||
| border: `1px solid ${colors.redesign.border}`, | ||
| boxShadow: redesignTokens.shadow.floating, | ||
| }} | ||
| > | ||
| {rows.map((row) => ( | ||
| <FlyoutRow key={row.node.path} node={row.node} depth={row.depth} active={row.node.path === activeNamespace} /> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The rail collapsed to a 24px strip of per-root initials. Each initial opens a | ||
| * fully-flattened fly-out of its subtree on hover or focus β there is no collapse | ||
| * state in the fly-out, it is transient. | ||
| */ | ||
| export function CollapsedRail({ namespaceCounts, onExpand }: CollapsedRailProps) { | ||
| // Matches ExploreRail: on the detail route the param is `namespace`, so the accent | ||
| // survives a detail session just as the expanded rail's highlight does. | ||
| const { ns, namespace } = useParams<{ ns?: string; namespace?: string }>(); | ||
| const activeNamespace = ns ?? namespace; | ||
| const [openPath, setOpenPath] = useState<string | null>(null); | ||
| const tree = useMemo(() => buildNamespaceTree(namespaceCounts), [namespaceCounts]); | ||
|
|
||
| return ( | ||
| <div | ||
| className="h-full w-full flex flex-col items-center" | ||
| style={{ backgroundColor: colors.redesign.surfaceAlt, borderRight: `1px solid ${colors.redesign.border}` }} | ||
| > | ||
| <div className="flex items-center justify-center pt-3 pb-2"> | ||
| <button aria-label="Expand sidebar" className="btn btn-ghost btn-xs btn-circle" onClick={onExpand}> | ||
| <IoChevronForwardOutline /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col items-center gap-1.5"> | ||
| {tree.map((root) => ( | ||
| <RootInitial | ||
| key={root.path} | ||
| root={root} | ||
| isActive={activeNamespace === root.path || activeNamespace?.startsWith(`${root.path}.`) === true} | ||
| isOpen={openPath === root.path} | ||
| activeNamespace={activeNamespace} | ||
| onOpen={() => setOpenPath(root.path)} | ||
| onClose={() => setOpenPath((prev) => (prev === root.path ? null : prev))} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onMouseLeavecloses the fly-out unconditionally, unlikeonBlurjust below it, which checksisWithinbefore closing. A hybrid mouse+keyboard user who opens the fly-out via Tab (onFocus) and then nudges the mouse across/off the container will have it closed even though focus is still logically inside. Might be worth mirroring theisWithincheck here, or droppingonMouseLeavein favor ofonBlur.