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
2 changes: 1 addition & 1 deletion src/ui/src/components/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function SettingsDialog() {
className="flex-1 min-w-0 h-full overflow-hidden"
>
<ScrollArea className="h-full">
<div className="pb-4 pr-1">
<div className="pb-4 pr-3">
{activeTab?.id === "general" && (
<GeneralSettingsPanel isActive={open && activeTabId === "general"} />
)}
Expand Down
21 changes: 21 additions & 0 deletions src/ui/src/components/settings/SettingsNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,24 @@ describe("SettingsNav (#803) keyboard navigation", () => {
expect(buttons[0]).toHaveAttribute("tabindex", "-1");
});
});

describe("SettingsNav (#785) scrollable when window height is reduced", () => {
it("renders the tablist inside a scroll-area viewport, not a plain overflow div", () => {
// #785: the nav previously scrolled via a bare `overflow-y-auto` div, whose
// scrollbar is the OS/browser's native one — which can render with zero
// visible width (observed on Linux/GTK themes) or stay hidden until
// scrolled (macOS default), leaving no visible affordance that more tabs
// exist below the fold. The tablist must live inside the shared
// `ScrollArea` (the same styled-scrollbar component the settings panel
// uses) so it gets a visible, cross-OS-consistent scrollbar whenever its
// content overflows.
render(<SettingsNav tabs={tabs} activeTabId="general" onSelect={vi.fn()} />);

const tablist = screen.getByRole("tablist", { name: "Settings sections" });
const viewport = tablist.closest('[data-slot="scroll-area-viewport"]');
expect(viewport).not.toBeNull();

const scrollAreaRoot = viewport?.closest('[data-slot="scroll-area"]');
expect(scrollAreaRoot).not.toBeNull();
});
});
85 changes: 49 additions & 36 deletions src/ui/src/components/settings/SettingsNav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { ScrollArea } from "../ui/scroll-area";
import { SettingsNavHealthIndicator } from "./SettingsNavHealthIndicator";
import type { SettingsHealthMap } from "./settings-health";
import { nextRovingIndex } from "./settings-nav-keys";
Expand All @@ -23,7 +24,7 @@ interface SettingsNavProps {
* Keyboard-accessible Settings options list.
*
* Fixes #803:
* - `min-h-0` lets the nav shrink inside its flex row so `overflow-y-auto`
* - `min-h-0` lets the nav shrink inside its flex row so the scroll container
* actually engages and every tab (e.g. "Account") stays reachable instead of
* overflowing and being clipped.
* - Roving-tabindex + Up/Down/Home/End arrow navigation (the list previously
Expand All @@ -35,6 +36,16 @@ interface SettingsNavProps {
* was vetoed by the unsaved-changes guard (where `activeIndex` doesn't change
* and the resync effect below wouldn't fire). A later Arrow press then steps
* from where the user visibly is, not a stale keyboard position.
*
* Fixes #785:
* - The list previously scrolled via plain `overflow-y-auto`, which relies on
* the OS/browser's native scrollbar. That scrollbar can render with zero
* visible width (seen on Linux/GTK themes, and macOS's default
* hidden-until-scroll setting), leaving no visual affordance that the list
* has more content below the fold even though wheel/trackpad scrolling
* mechanically works. Wrapping the tablist in the same `ScrollArea` used by
* the settings panel gives it the app's styled, always-visible scrollbar
* thumb consistently across OSes, matching AC3/AC4/AC5.
*/
export function SettingsNav({ tabs, activeTabId, panelId, onSelect, tabHealth }: SettingsNavProps) {
const activeIndex = Math.max(
Expand All @@ -61,40 +72,42 @@ export function SettingsNav({ tabs, activeTabId, panelId, onSelect, tabHealth }:
};

return (
<div
aria-label="Settings sections"
role="tablist"
aria-orientation="vertical"
onKeyDown={handleKeyDown}
className="w-48 flex flex-col gap-1 flex-shrink-0 overflow-y-auto pr-1 min-h-0"
>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const health = tabHealth?.[tab.id];
return (
<button
key={tab.id}
ref={(el: HTMLButtonElement | null) => {
buttonsRef.current[index] = el;
}}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={panelId}
tabIndex={index === focusIndex ? 0 : -1}
onFocus={() => setFocusIndex(index)}
onClick={() => onSelect(tab.id)}
className={`group relative flex items-center justify-between gap-2 text-left px-3 py-2.5 rounded-md transition-colors border border-transparent ${
isActive
? "bg-white/10 border-white/20 text-white"
: "text-white/80 hover:bg-white/5 hover:text-white"
}`}
>
<div className="text-sm font-medium">{tab.label}</div>
{health ? <SettingsNavHealthIndicator health={health} /> : null}
</button>
);
})}
</div>
<ScrollArea type="auto" className="w-48 h-full flex-shrink-0 min-h-0">
<div
aria-label="Settings sections"
role="tablist"
aria-orientation="vertical"
onKeyDown={handleKeyDown}
className="flex flex-col gap-1 pr-3"
>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const health = tabHealth?.[tab.id];
return (
<button
key={tab.id}
ref={(el: HTMLButtonElement | null) => {
buttonsRef.current[index] = el;
}}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={panelId}
tabIndex={index === focusIndex ? 0 : -1}
onFocus={() => setFocusIndex(index)}
onClick={() => onSelect(tab.id)}
className={`group relative flex items-center justify-between gap-2 text-left px-3 py-2.5 rounded-md transition-colors border border-transparent ${
isActive
? "bg-white/10 border-white/20 text-white"
: "text-white/80 hover:bg-white/5 hover:text-white"
}`}
>
<div className="text-sm font-medium">{tab.label}</div>
{health ? <SettingsNavHealthIndicator health={health} /> : null}
</button>
);
})}
</div>
</ScrollArea>
);
}
Loading