Skip to content
Open
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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-02-21 - Accessible Tabs Pattern
**Learning:** React Tabs need `role="tablist"`, `role="tab"`, `aria-selected`, `aria-controls`, and `id` for proper accessibility and keyboard navigation. Using React `useId` for components is helpful for auto-generating accessible IDs.
**Action:** When implementing tabs or collapsibles, ensure these ARIA attributes are correctly linked.
6 changes: 5 additions & 1 deletion src/app/cite/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1961,7 +1961,11 @@ function CitePageContent() {
onTabChange={setActiveTab}
/>

<div className="border border-wiki-border-light border-t-0 bg-wiki-white p-6 md:p-8">
<div
className="border border-wiki-border-light border-t-0 bg-wiki-white p-6 md:p-8"
role="tabpanel"
id={`panel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}>
Comment on lines +1967 to +1968
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions “generated IDs via useId() for tabs and panels”, but the tab/panel IDs here are derived directly from activeTab and the static tab IDs. Either update the implementation to use an instance-unique ID prefix (via useId) or adjust the PR description to match the actual approach.

Copilot uses AI. Check for mistakes.
Comment on lines +1964 to +1968
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page reuses a single role="tabpanel" container and changes its id/aria-labelledby based on activeTab. That means only the active tab’s aria-controls target exists, and the other tabs’ aria-controls point to nonexistent IDs. For correct ARIA tab semantics, render a tabpanel per tab with stable IDs and hide inactive panels (e.g., hidden), or otherwise ensure all panel-* IDs exist consistently.

Copilot uses AI. Check for mistakes.
{activeTab === "quick-add" && (
<div>
<h2 className="text-lg font-bold mb-4">Quick Add</h2>
Expand Down
8 changes: 6 additions & 2 deletions src/components/wiki/wiki-collapsible.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useState, useId } from "react";

interface WikiCollapsibleProps {
title: string;
Expand All @@ -14,6 +14,7 @@ export function WikiCollapsible({
defaultOpen = true,
}: WikiCollapsibleProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();

return (
<div className="border border-wiki-border-light bg-wiki-offwhite">
Expand All @@ -22,11 +23,14 @@ export function WikiCollapsible({
<button
onClick={() => setIsOpen(!isOpen)}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setIsOpen(!isOpen) can read a stale value under React concurrent updates. Using a functional update (setIsOpen(v => !v)) avoids that class of bug and is the recommended pattern for toggles.

Suggested change
onClick={() => setIsOpen(!isOpen)}
onClick={() => setIsOpen((v) => !v)}

Copilot uses AI. Check for mistakes.
className="text-wiki-link text-sm hover:underline"
aria-expanded={isOpen}
aria-controls={contentId}
aria-label={`${isOpen ? "Hide" : "Show"} ${title}`}
>
[{isOpen ? "hide" : "show"}]
</button>
</div>
{isOpen && <div className="px-4 py-3">{children}</div>}
{isOpen && <div id={contentId} className="px-4 py-3">{children}</div>}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-controls={contentId} points at an element that is conditionally rendered only when isOpen is true. When collapsed, the controlled element isn’t in the DOM, which can confuse assistive tech. Prefer rendering the content container always with a stable id and toggling it via hidden/CSS (and optionally aria-hidden) while keeping aria-expanded in sync.

Suggested change
{isOpen && <div id={contentId} className="px-4 py-3">{children}</div>}
<div
id={contentId}
className="px-4 py-3"
hidden={!isOpen}
aria-hidden={!isOpen}
>
{children}
</div>

Copilot uses AI. Check for mistakes.
</div>
);
}
6 changes: 5 additions & 1 deletion src/components/wiki/wiki-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ interface WikiTabsProps {

export function WikiTabs({ tabs, onTabChange }: WikiTabsProps) {
return (
<div className="flex border-b border-wiki-border-light">
<div className="flex border-b border-wiki-border-light" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={tab.active}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-selected={tab.active} can become undefined because active is optional on Tab, which results in the attribute being omitted for some tabs. For ARIA tabs, aria-selected should be an explicit boolean for every tab (typically true for the active tab and false otherwise).

Suggested change
aria-selected={tab.active}
aria-selected={tab.active === true}

Copilot uses AI. Check for mistakes.
aria-controls={`panel-${tab.id}`}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-controls points to panel-${tab.id}, but in src/app/cite/page.tsx only a single tabpanel is rendered with an id based on the active tab. This means non-selected tabs reference panel IDs that do not exist in the DOM, breaking the tab↔panel relationship for assistive tech. Consider rendering one tabpanel per tab (with matching id/aria-labelledby) and toggling visibility via hidden, or otherwise ensuring every aria-controls target exists consistently.

Suggested change
aria-controls={`panel-${tab.id}`}

Copilot uses AI. Check for mistakes.
id={`tab-${tab.id}`}
Comment on lines +23 to +24
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tab and panel IDs are derived only from tab.id (e.g. tab-quick-add / panel-quick-add). If more than one WikiTabs instance appears on the same page, these IDs will collide and the ARIA relationships become ambiguous. Consider using useId() to prefix the generated IDs (or accept an idBase prop) so IDs are unique per component instance.

Copilot uses AI. Check for mistakes.
onClick={() => onTabChange?.(tab.id)}
Comment on lines +17 to 25
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With role="tablist"/role="tab", the expected keyboard interaction is arrow-key navigation with a roving tabindex (only the selected tab has tabIndex=0, others -1) plus Home/End handling per WAI-ARIA authoring practices. Right now the tabs only handle click, so keyboard users will tab through every tab button and won’t get standard tab behavior.

Copilot uses AI. Check for mistakes.
className={`
px-4 py-2 text-sm border border-b-0 -mb-px
Expand Down
Loading