From 1ef4662537c19e0b69d4b20177029f8aac702b23 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 15 Oct 2026 08:53:11 +0000 Subject: [PATCH] feat(settings): add reusable Tabs component and use it in calls settings modal Add a generic, accessible Tabs component (tablist/tab/tabpanel roles, keyboard navigation, controlled + uncontrolled usage) under src/components/tabs. Use it in the calls-page user settings modal to split the existing settings into "Devices" and "Profile" tabs, threading hideUsername/hideDevices through the UserSettings wrapper. Refs Eyevinn/intercom-frontend#516 Co-Authored-By: Claude Opus 4.8 --- src/components/calls-page/calls-page.tsx | 38 +++- src/components/tabs/tabs.test.tsx | 99 +++++++++ src/components/tabs/tabs.tsx | 196 ++++++++++++++++++ .../user-settings/user-settings.tsx | 6 + 4 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 src/components/tabs/tabs.test.tsx create mode 100644 src/components/tabs/tabs.tsx diff --git a/src/components/calls-page/calls-page.tsx b/src/components/calls-page/calls-page.tsx index 7f844542..a183a542 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -26,6 +26,7 @@ import { useIsGuest } from "../../hooks/use-is-guest"; import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; import { useWakeLock } from "../../hooks/use-wake-lock"; import { UserSettings } from "../user-settings/user-settings"; +import { Tabs, TTab } from "../tabs/tabs"; import { ConfirmationModal } from "../verify-decision/confirmation-modal"; import { HeaderActions } from "./header-actions"; import { ProductionLines } from "./production-lines"; @@ -431,6 +432,34 @@ export const CallsPage = () => { const { playExitSound } = useAudioCue(); + const settingsTabs: TTab[] = [ + { + id: "devices", + label: "Devices", + content: ( + setShowSettings(false)} + hideTitle + hideUsername + /> + ), + }, + { + id: "profile", + label: "Profile", + content: ( + setShowSettings(false)} + hideTitle + hideDevices + /> + ), + }, + ]; + const runExitAllCalls = async () => { setProductionId(null); playExitSound(); @@ -489,13 +518,8 @@ export const CallsPage = () => { )} {showSettings && ( - setShowSettings(false)} title="User Settings"> - setShowSettings(false)} - hideTitle - /> + setShowSettings(false)} title="Settings"> + )} diff --git a/src/components/tabs/tabs.test.tsx b/src/components/tabs/tabs.test.tsx new file mode 100644 index 00000000..fcbfe7ce --- /dev/null +++ b/src/components/tabs/tabs.test.tsx @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { Tabs, TTab } from "./tabs"; + +const sampleTabs: TTab[] = [ + { id: "devices", label: "Devices", content:

Devices content

}, + { id: "profile", label: "Profile", content:

Profile content

}, + { id: "hotkeys", label: "Hotkeys", content:

Hotkeys content

}, +]; + +describe("Tabs", () => { + it("renders a tab for each entry", () => { + render(); + + expect( + screen.getByRole("tablist", { name: "Settings" }) + ).toBeInTheDocument(); + expect(screen.getAllByRole("tab")).toHaveLength(3); + }); + + it("shows the first tab panel by default and hides the rest", () => { + render(); + + expect(screen.getByText("Devices content")).toBeVisible(); + expect(screen.getByText("Profile content")).not.toBeVisible(); + expect(screen.getByRole("tab", { name: "Devices" })).toHaveAttribute( + "aria-selected", + "true" + ); + }); + + it("respects defaultTabId when uncontrolled", () => { + render(); + + expect(screen.getByText("Profile content")).toBeVisible(); + expect(screen.getByText("Devices content")).not.toBeVisible(); + }); + + it("switches panels when a tab is clicked", async () => { + const user = userEvent.setup(); + const onTabChange = vi.fn(); + render(); + + await user.click(screen.getByRole("tab", { name: "Hotkeys" })); + + expect(screen.getByText("Hotkeys content")).toBeVisible(); + expect(screen.getByText("Devices content")).not.toBeVisible(); + expect(onTabChange).toHaveBeenCalledWith("hotkeys"); + }); + + it("moves between tabs with the arrow keys", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: "Devices" })); + await user.keyboard("{ArrowRight}"); + + expect(screen.getByRole("tab", { name: "Profile" })).toHaveFocus(); + expect(screen.getByText("Profile content")).toBeVisible(); + + await user.keyboard("{Home}"); + expect(screen.getByRole("tab", { name: "Devices" })).toHaveFocus(); + }); + + it("does not activate a disabled tab", async () => { + const user = userEvent.setup(); + const tabs: TTab[] = [ + { id: "a", label: "A", content:

A content

}, + { id: "b", label: "B", content:

B content

, disabled: true }, + ]; + render(); + + const disabledTab = screen.getByRole("tab", { name: "B" }); + expect(disabledTab).toBeDisabled(); + + await user.click(disabledTab); + expect(screen.getByText("A content")).toBeVisible(); + expect(screen.getByText("B content")).not.toBeVisible(); + }); + + it("supports controlled usage via activeTabId", async () => { + const user = userEvent.setup(); + + const Controlled = () => { + const [active, setActive] = useState("devices"); + return ( + + ); + }; + + render(); + expect(screen.getByText("Devices content")).toBeVisible(); + + await user.click(screen.getByRole("tab", { name: "Profile" })); + expect(screen.getByText("Profile content")).toBeVisible(); + }); +}); diff --git a/src/components/tabs/tabs.tsx b/src/components/tabs/tabs.tsx new file mode 100644 index 00000000..75dcc035 --- /dev/null +++ b/src/components/tabs/tabs.tsx @@ -0,0 +1,196 @@ +import styled from "@emotion/styled"; +import { + KeyboardEvent, + ReactNode, + Ref, + useCallback, + useId, + useRef, + useState, +} from "react"; + +export type TTab = { + id: string; + label: ReactNode; + content: ReactNode; + disabled?: boolean; +}; + +type TabsProps = { + tabs: TTab[]; + // Controlled active tab id. When omitted the component manages its own state. + activeTabId?: string; + // Initially active tab id when uncontrolled. Defaults to the first tab. + defaultTabId?: string; + onTabChange?: (id: string) => void; + className?: string; + ariaLabel?: string; + ref?: Ref; +}; + +const TabsWrapper = styled.div` + display: flex; + flex-direction: column; + min-width: 0; +`; + +const TabList = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + border-bottom: 0.1rem solid #868686; + margin-bottom: 1.5rem; +`; + +const TabButton = styled.button` + background: transparent; + border: none; + border-bottom: 0.2rem solid transparent; + color: #d6d3d1; + cursor: pointer; + font-size: 1.5rem; + font-weight: 600; + margin: 0; + padding: 0.8rem 1.2rem; + transition: + color 0.1s ease, + border-color 0.1s ease; + + &:hover:not(:disabled) { + color: #ffffff; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &[aria-selected="true"] { + color: #ffffff; + border-bottom-color: #1db954; + } +`; + +const TabPanel = styled.div` + min-width: 0; + + &[hidden] { + display: none; + } +`; + +export const Tabs = ({ + tabs, + activeTabId, + defaultTabId, + onTabChange, + className, + ariaLabel, + ref, +}: TabsProps) => { + const baseId = useId(); + const tabRefs = useRef>({}); + + const [internalActiveId, setInternalActiveId] = useState( + () => defaultTabId ?? tabs[0]?.id + ); + + const isControlled = activeTabId !== undefined; + const currentId = isControlled ? activeTabId : internalActiveId; + + const selectTab = useCallback( + (id: string) => { + if (!isControlled) { + setInternalActiveId(id); + } + onTabChange?.(id); + }, + [isControlled, onTabChange] + ); + + const focusableTabs = tabs.filter((tab) => !tab.disabled); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (focusableTabs.length === 0) return; + + const currentIndex = focusableTabs.findIndex( + (tab) => tab.id === currentId + ); + let nextIndex: number | null = null; + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + nextIndex = (currentIndex + 1) % focusableTabs.length; + break; + case "ArrowLeft": + case "ArrowUp": + nextIndex = + (currentIndex - 1 + focusableTabs.length) % focusableTabs.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = focusableTabs.length - 1; + break; + default: + return; + } + + event.preventDefault(); + const nextTab = focusableTabs[nextIndex]; + if (nextTab) { + selectTab(nextTab.id); + tabRefs.current[nextTab.id]?.focus(); + } + }, + [currentId, focusableTabs, selectTab] + ); + + if (tabs.length === 0) return null; + + return ( + + + {tabs.map((tab) => { + const isActive = tab.id === currentId; + return ( + { + tabRefs.current[tab.id] = node; + }} + onClick={() => selectTab(tab.id)} + onKeyDown={handleKeyDown} + > + {tab.label} + + ); + })} + + {tabs.map((tab) => { + const isActive = tab.id === currentId; + return ( + + ); + })} + + ); +}; diff --git a/src/components/user-settings/user-settings.tsx b/src/components/user-settings/user-settings.tsx index ef435825..1cbc05cd 100644 --- a/src/components/user-settings/user-settings.tsx +++ b/src/components/user-settings/user-settings.tsx @@ -14,6 +14,8 @@ interface UserSettingsProps { showBackButton?: boolean; onBack?: () => void; hideTitle?: boolean; + hideUsername?: boolean; + hideDevices?: boolean; } export const UserSettings: FC = (props) => { @@ -25,6 +27,8 @@ export const UserSettings: FC = (props) => { showBackButton, onBack, hideTitle, + hideUsername, + hideDevices, } = props; const [{ devices, userSettings }] = useGlobalState(); @@ -57,6 +61,8 @@ export const UserSettings: FC = (props) => { onSave={onSave} updateUserSettings needsConfirmation={needsConfirmation} + hideUsername={hideUsername} + hideDevices={hideDevices} /> )}