Skip to content
Merged
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
44 changes: 42 additions & 2 deletions web/src/components/molecules/roadmap-topic-card/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { RoadmapTopicCard } from './index';

/**
* jsdom reports every element as 0x0, so the card's clamp detection never
* fires. Force scrollHeight > clientHeight so the card renders as a button
* that opens the popover.
*/
const forceTruncation = () => {
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200);
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(50);
};

const topic = {
title: 'Kubernetes fundamentals',
priority: 'HIGH' as const,
Expand All @@ -11,6 +21,8 @@ const topic = {
};

describe('RoadmapTopicCard', () => {
afterEach(() => vi.restoreAllMocks());

it('renders step, title, priority chip, rationale and gap marker', () => {
render(<RoadmapTopicCard step={1} topic={topic} />);
expect(screen.getByText('Step 1')).toBeInTheDocument();
Expand Down Expand Up @@ -45,4 +57,32 @@ describe('RoadmapTopicCard', () => {
'text-success',
);
});

// The popover is absolutely positioned over the card but is WIDER than it
// (20rem vs the ~12rem timeline column), so the same text wraps into fewer
// lines and the popover ends up SHORTER than the card it covers. The bottom
// of the collapsed card — "Read more" and its border — then showed through
// underneath. Hiding the card while the popover is open is what stops that;
// it stays in flow (`invisible`, not `hidden`) so the timeline row keeps its
// height.
it('hides the collapsed card while the popover is open', () => {
forceTruncation();
render(<RoadmapTopicCard step={3} topic={topic} />);

const card = screen.getByRole('button', { name: /Step 3/ });
expect(card).not.toHaveClass('invisible');

fireEvent.click(card);

expect(card).toHaveClass('invisible');
expect(card).toHaveAttribute('aria-hidden', 'true');
expect(card).toHaveAttribute('inert');
expect(
screen.getByRole('region', { name: /Kubernetes fundamentals/ }),
).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(card).not.toHaveClass('invisible');
expect(card).not.toHaveAttribute('inert');
});
});
26 changes: 24 additions & 2 deletions web/src/components/molecules/roadmap-topic-card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
const rationaleRef = useRef<HTMLParagraphElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const articleRef = useRef<HTMLElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const panelId = useId();
const [isTruncated, setIsTruncated] = useState(false);
const [isOpen, setIsOpen] = useState(false);
Expand All @@ -38,6 +40,19 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
setIsTruncated(clamped(rationaleRef.current) || clamped(titleRef.current));
}, [topic.title, topic.rationale]);

// The card goes `inert` while the popover is open, which drops focus to
// <body>. Hand it to the panel on open and take it back on close so keyboard
// users stay on this card.
const wasOpen = useRef(false);
useEffect(() => {
if (isOpen) {
panelRef.current?.focus();
} else if (wasOpen.current && document.activeElement === document.body) {
articleRef.current?.focus();
}
wasOpen.current = isOpen;
}, [isOpen]);

// Close the popover on outside-click or Escape.
useEffect(() => {
if (!isOpen) return;
Expand Down Expand Up @@ -68,11 +83,16 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
return (
<div ref={rootRef} className="relative w-full">
<article
ref={articleRef}
className={`bg-surface-raised border-border flex w-full flex-col gap-2 rounded-2xl border p-4 ${
isTruncated
? 'hover:border-accent cursor-pointer transition-colors'
: ''
}`}
} ${isOpen ? 'invisible' : ''}`}
aria-hidden={isOpen}
// While the popover is open the card is `invisible` (kept in flow so
// the timeline row does not reflow), so it must not be focusable.
inert={isOpen ? true : undefined}
onClick={isTruncated ? () => setIsOpen((v) => !v) : undefined}
role={isTruncated ? 'button' : undefined}
tabIndex={isTruncated ? 0 : undefined}
Expand Down Expand Up @@ -117,9 +137,11 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
{isOpen && (
<div
id={panelId}
ref={panelRef}
tabIndex={-1}
role="region"
aria-label={`${topic.title} details`}
className="bg-surface-raised border-border absolute top-0 left-0 z-30 flex w-[min(20rem,80vw)] flex-col gap-2 rounded-2xl border p-4 shadow-lg"
className="bg-surface-raised border-border absolute top-0 left-0 z-30 flex w-[min(20rem,80vw)] min-w-full flex-col gap-2 rounded-2xl border p-4 shadow-lg"
>
<div className="flex items-center justify-between gap-2">
<p className="text-label-s text-text-weaker">Step {step}</p>
Expand Down
Loading