Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 19 additions & 3 deletions src/renderer/components/SplitTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean {
}

interface TabProps {
splitId: string;
active: boolean;
onClick: () => void;
count?: number;
children: React.ReactNode;
}

function Tab({ active, onClick, count, children }: TabProps) {
function Tab({ splitId, active, onClick, count, children }: TabProps) {
return (
<button
type="button"
role="tab"
aria-selected={active}
data-split-tab-id={splitId}
onClick={onClick}
className={`
px-3 py-2 text-sm font-medium whitespace-nowrap
border-b-2 transition-colors focus:outline-none
border-b-2 rounded-sm transition-colors focus:outline-none
${
active
? "border-blue-500 dark:border-blue-400 text-blue-600 dark:text-blue-400"
Expand Down Expand Up @@ -134,16 +139,22 @@ function SplitTabsImpl() {

// Always show the tab bar — Priority, Other, Archive Ready always visible; All on the far right
return (
<div className="flex h-10 border-b border-gray-200 dark:border-gray-700 px-2 overflow-x-auto">
<div
role="tablist"
aria-label="Inbox split tabs"
className="flex h-10 border-b border-gray-200 dark:border-gray-700 px-2 overflow-x-auto"
>
{/* Primary tabs: Priority, Other */}
<Tab
splitId="__priority__"
active={currentSplitId === "__priority__"}
onClick={() => setCurrentSplitId("__priority__")}
count={counts.get("__priority__")}
>
Priority
</Tab>
<Tab
splitId="__other__"
active={currentSplitId === "__other__"}
onClick={() => setCurrentSplitId("__other__")}
count={counts.get("__other__")}
Expand All @@ -153,6 +164,7 @@ function SplitTabsImpl() {

{/* Middle tabs: Archive Ready, custom splits, conditional tabs */}
<Tab
splitId="__archive-ready__"
active={currentSplitId === "__archive-ready__"}
onClick={() => setCurrentSplitId("__archive-ready__")}
count={archiveReadyCount}
Expand All @@ -174,6 +186,7 @@ function SplitTabsImpl() {
{sortedSplits.map(({ split, displayName }) => (
<Tab
key={split.id}
splitId={split.id}
active={currentSplitId === split.id}
onClick={() => setCurrentSplitId(split.id)}
count={counts.get(split.id)}
Expand All @@ -186,6 +199,7 @@ function SplitTabsImpl() {
{/* Conditional virtual tabs */}
{draftsCount > 0 && (
<Tab
splitId="__drafts__"
active={currentSplitId === "__drafts__"}
onClick={() => setCurrentSplitId("__drafts__")}
count={draftsCount}
Expand All @@ -205,6 +219,7 @@ function SplitTabsImpl() {
)}
{snoozedCount > 0 && (
<Tab
splitId="__snoozed__"
active={currentSplitId === "__snoozed__"}
onClick={() => setCurrentSplitId("__snoozed__")}
count={snoozedCount}
Expand All @@ -225,6 +240,7 @@ function SplitTabsImpl() {

{/* All tab on the far right */}
<Tab
splitId="__all__"
active={currentSplitId === null}
onClick={() => setCurrentSplitId(null)}
count={counts.get(null)}
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,13 +755,26 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
};

// --- Helper: navigate to next/prev split tab ---
const focusSplitTab = (splitId: string) => {
window.requestAnimationFrame(() => {
const tabs = document.querySelectorAll<HTMLButtonElement>("[data-split-tab-id]");
for (const tab of tabs) {
if (tab.dataset.splitTabId === splitId) {
tab.focus();
return;
}
}
});
};

const cycleSplit = (direction: "next" | "prev") => {
const ids = getOrderedSplitIds();
const currentIdx = ids.indexOf(currentSplitId ?? ALL_SENTINEL);
const step = direction === "next" ? 1 : -1;
const nextIdx = (currentIdx + step + ids.length) % ids.length;
const nextId = ids[nextIdx];
state.setCurrentSplitId(nextId === ALL_SENTINEL ? null : nextId);
focusSplitTab(nextId);
Comment thread
mickn marked this conversation as resolved.
Outdated
};

// Normal mode shortcuts (single-key, no modifiers)
Expand Down Expand Up @@ -828,6 +841,13 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
}
break;

case "Tab":
if (!showSettings) {
e.preventDefault();
cycleSplit(e.shiftKey ? "prev" : "next");
}
break;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
mickn marked this conversation as resolved.

case "o":
// "o" is Gmail's open conversation key (Gmail only, same as Enter)
if (!isGmail) break;
Expand Down
33 changes: 33 additions & 0 deletions tests/e2e/inbox-tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,40 @@ test.describe("Inbox Tabs - Default and Ordering", () => {
expect(labels[labels.length - 1]).toBe("All");
});

test("Tab key switches active split tabs in visible order", async () => {
const priorityTab = page.getByRole("tab", { name: /^Priority/ }).first();
const otherTab = page.getByRole("tab", { name: /^Other/ }).first();
const archiveReadyTab = page.getByRole("tab", { name: /Archive Ready/ }).first();

await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");

await page.keyboard.press("Tab");
await expect(otherTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toBeFocused();
await expect(otherTab).toHaveCSS("box-shadow", "none");

await page.keyboard.press("Tab");
await expect(archiveReadyTab).toHaveAttribute("aria-selected", "true");
await expect(archiveReadyTab).toBeFocused();
await expect(archiveReadyTab).toHaveCSS("box-shadow", "none");
Comment thread
mickn marked this conversation as resolved.
Outdated

await page.keyboard.press("Shift+Tab");
await expect(otherTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toBeFocused();

await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
});

test("Priority tab shows only priority emails (needsReply + done)", async () => {
const priorityTab = tabBar(page)
.locator("button")
.filter({ hasText: /^Priority/ })
.first();
await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");

// Priority should be active by default — verify we see threads. The per-row
// pill was suppressed in the Priority tab by issue #143 (every row in this
// tab is implicitly priority, so the pill would be noise), so this test
Expand Down