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
38 changes: 31 additions & 7 deletions src/components/calls-page/calls-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -431,6 +432,34 @@ export const CallsPage = () => {

const { playExitSound } = useAudioCue();

const settingsTabs: TTab[] = [
{
id: "devices",
label: "Devices",
content: (
<UserSettings
buttonText="Save"
needsConfirmation
onSave={() => setShowSettings(false)}
hideTitle
hideUsername
/>
),
},
{
id: "profile",
label: "Profile",
content: (
<UserSettings
buttonText="Save"
onSave={() => setShowSettings(false)}
hideTitle
hideDevices
/>
),
},
];

const runExitAllCalls = async () => {
setProductionId(null);
playExitSound();
Expand Down Expand Up @@ -489,13 +518,8 @@ export const CallsPage = () => {
)}

{showSettings && (
<Modal onClose={() => setShowSettings(false)} title="User Settings">
<UserSettings
buttonText="Save"
needsConfirmation
onSave={() => setShowSettings(false)}
hideTitle
/>
<Modal onClose={() => setShowSettings(false)} title="Settings">
<Tabs ariaLabel="Settings" tabs={settingsTabs} />
</Modal>
)}

Expand Down
99 changes: 99 additions & 0 deletions src/components/tabs/tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <p>Devices content</p> },
{ id: "profile", label: "Profile", content: <p>Profile content</p> },
{ id: "hotkeys", label: "Hotkeys", content: <p>Hotkeys content</p> },
];

describe("Tabs", () => {
it("renders a tab for each entry", () => {
render(<Tabs tabs={sampleTabs} ariaLabel="Settings" />);

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(<Tabs tabs={sampleTabs} />);

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(<Tabs tabs={sampleTabs} defaultTabId="profile" />);

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(<Tabs tabs={sampleTabs} onTabChange={onTabChange} />);

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(<Tabs tabs={sampleTabs} />);

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: <p>A content</p> },
{ id: "b", label: "B", content: <p>B content</p>, disabled: true },
];
render(<Tabs tabs={tabs} />);

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 (
<Tabs tabs={sampleTabs} activeTabId={active} onTabChange={setActive} />
);
};

render(<Controlled />);
expect(screen.getByText("Devices content")).toBeVisible();

await user.click(screen.getByRole("tab", { name: "Profile" }));
expect(screen.getByText("Profile content")).toBeVisible();
});
});
196 changes: 196 additions & 0 deletions src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>;
};

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<Record<string, HTMLButtonElement | null>>({});

const [internalActiveId, setInternalActiveId] = useState<string>(
() => 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<HTMLButtonElement>) => {
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 (
<TabsWrapper className={className} ref={ref}>
<TabList role="tablist" aria-label={ariaLabel}>
{tabs.map((tab) => {
const isActive = tab.id === currentId;
return (
<TabButton
key={tab.id}
type="button"
role="tab"
id={`${baseId}-tab-${tab.id}`}
aria-selected={isActive}
aria-controls={`${baseId}-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
disabled={tab.disabled}
ref={(node) => {
tabRefs.current[tab.id] = node;
}}
onClick={() => selectTab(tab.id)}
onKeyDown={handleKeyDown}
>
{tab.label}
</TabButton>
);
})}
</TabList>
{tabs.map((tab) => {
const isActive = tab.id === currentId;
return (
<TabPanel
key={tab.id}
role="tabpanel"
id={`${baseId}-panel-${tab.id}`}
aria-labelledby={`${baseId}-tab-${tab.id}`}
hidden={!isActive}
>
{tab.content}
</TabPanel>
);
})}
</TabsWrapper>
);
};
6 changes: 6 additions & 0 deletions src/components/user-settings/user-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface UserSettingsProps {
showBackButton?: boolean;
onBack?: () => void;
hideTitle?: boolean;
hideUsername?: boolean;
hideDevices?: boolean;
}

export const UserSettings: FC<UserSettingsProps> = (props) => {
Expand All @@ -25,6 +27,8 @@ export const UserSettings: FC<UserSettingsProps> = (props) => {
showBackButton,
onBack,
hideTitle,
hideUsername,
hideDevices,
} = props;
const [{ devices, userSettings }] = useGlobalState();

Expand Down Expand Up @@ -57,6 +61,8 @@ export const UserSettings: FC<UserSettingsProps> = (props) => {
onSave={onSave}
updateUserSettings
needsConfirmation={needsConfirmation}
hideUsername={hideUsername}
hideDevices={hideDevices}
/>
)}
</ResponsiveFormContainer>
Expand Down
Loading