diff --git a/web/src/components/molecules/roadmap-topic-card/index.spec.tsx b/web/src/components/molecules/roadmap-topic-card/index.spec.tsx
index d5fa5637..9d886551 100644
--- a/web/src/components/molecules/roadmap-topic-card/index.spec.tsx
+++ b/web/src/components/molecules/roadmap-topic-card/index.spec.tsx
@@ -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,
@@ -11,6 +21,8 @@ const topic = {
};
describe('RoadmapTopicCard', () => {
+ afterEach(() => vi.restoreAllMocks());
+
it('renders step, title, priority chip, rationale and gap marker', () => {
render();
expect(screen.getByText('Step 1')).toBeInTheDocument();
@@ -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();
+
+ 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');
+ });
});
diff --git a/web/src/components/molecules/roadmap-topic-card/index.tsx b/web/src/components/molecules/roadmap-topic-card/index.tsx
index a3fe86e0..350c0ff0 100644
--- a/web/src/components/molecules/roadmap-topic-card/index.tsx
+++ b/web/src/components/molecules/roadmap-topic-card/index.tsx
@@ -27,6 +27,8 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
const rationaleRef = useRef(null);
const titleRef = useRef(null);
const rootRef = useRef(null);
+ const articleRef = useRef(null);
+ const panelRef = useRef(null);
const panelId = useId();
const [isTruncated, setIsTruncated] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@@ -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
+ // . 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;
@@ -68,11 +83,16 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
return (
setIsOpen((v) => !v) : undefined}
role={isTruncated ? 'button' : undefined}
tabIndex={isTruncated ? 0 : undefined}
@@ -117,9 +137,11 @@ export const RoadmapTopicCard = ({ step, topic }: RoadmapTopicCardProps) => {
{isOpen && (