import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from 'styled-components';
import { Navbar, selectVisibleTabs, getInitials } from './Navbar.component';
import { coreUIAvailableThemes } from '../../style/theme';
const theme = coreUIAvailableThemes.darkRebrand;
const renderNavbar = (props) =>
render(
,
);
const tabs = [
{ title: 'Groups', selected: true, link: Groups },
{ title: 'Users', link: Users },
{ title: 'Policies', link: Policies },
];
const userAction = {
type: 'dropdown' as const,
text: 'Carlito',
icon: ,
items: [{ label: 'Log out', onClick: () => {} }],
};
// useContainerWidth reads getBoundingClientRect().width on mount (the global
// ResizeObserver mock never fires), so we drive the available width by stubbing
// it. jsdom has no layout, so every element reports offsetWidth 0 by default —
// we pin a uniform per-tab width so the priority+ fit has something to measure.
const TAB_WIDTH = 100;
const stubNavbarWidth = (width: number) => {
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width } as DOMRect);
};
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth',
);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get: () => TAB_WIDTH,
});
});
afterAll(() => {
if (originalOffsetWidth) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth);
}
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getInitials', () => {
it('takes the first letter of a single-word name', () => {
expect(getInitials('Carlito')).toBe('C');
});
it('takes the first letters of the first two words', () => {
expect(getInitials('Carlito Gonzalez')).toBe('CG');
});
it('uses only the first letter for an unspaced token', () => {
expect(getInitials('jean-marc.millet')).toBe('J');
});
it('ignores surrounding and repeated whitespace', () => {
expect(getInitials(' Carlito Gonzalez ')).toBe('CG');
});
it('returns an empty string for empty input', () => {
expect(getInitials('')).toBe('');
});
});
describe('selectVisibleTabs (priority+ fit)', () => {
it('keeps every tab inline when they all fit', () => {
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
[100, 100, 100],
[false, false, false],
80,
1000,
);
expect(visibleTabIndices).toEqual([0, 1, 2]);
expect(overflowTabIndices).toEqual([]);
});
it('drops tabs from the right and reserves room for the menu trigger', () => {
// availableWidth 260, menu trigger 80 -> budget 180: the first tab (100)
// fits, a second (200) would not, so one stays and the rest overflow.
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
[100, 100, 100],
[false, false, false],
80,
260,
);
expect(visibleTabIndices).toEqual([0]);
expect(overflowTabIndices).toEqual([1, 2]);
});
it('keeps a pinned tab inline even when earlier tabs overflow', () => {
// The last tab is pinned; the budget only fits one tab beside the menu
// trigger, so the pinned tab wins and the earlier ones overflow.
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
[100, 100, 100],
[false, false, true],
80,
260,
);
expect(visibleTabIndices).toEqual([2]);
expect(overflowTabIndices).toEqual([0, 1]);
});
it('preserves original tab order across both rows', () => {
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
[100, 100, 100, 100],
[false, true, false, false],
80,
300,
);
expect(visibleTabIndices).toEqual([...visibleTabIndices].sort((a, b) => a - b));
expect(overflowTabIndices).toEqual([...overflowTabIndices].sort((a, b) => a - b));
});
});
describe('Navbar responsiveness', () => {
it('shows every navigation tab inline when the navbar is wide', () => {
stubNavbarWidth(1200);
renderNavbar({ tabs, rightActions: [userAction] });
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Users' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Policies' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /more/i }),
).not.toBeInTheDocument();
});
it('overflows tabs that do not fit into a "More" menu, keeping the rest inline', async () => {
// available 220: first tab (100) fits alongside the More trigger (100),
// the next would not — so 1 stays inline and 2 overflow into the menu.
stubNavbarWidth(220);
renderNavbar({ tabs, rightActions: [userAction] });
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Users' })).not.toBeInTheDocument();
const menuTrigger = screen.getByRole('button', { name: /more/i });
expect(menuTrigger).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(menuTrigger);
expect(menuTrigger).toHaveAttribute('aria-expanded', 'true');
const menu = screen.getByRole('navigation', { name: /more/i });
expect(within(menu).getByRole('link', { name: 'Users' })).toBeInTheDocument();
expect(within(menu).getByRole('link', { name: 'Policies' })).toBeInTheDocument();
expect(within(menu).queryByRole('link', { name: 'Groups' })).not.toBeInTheDocument();
});
it('keeps the username label visible when the navbar is wide', () => {
stubNavbarWidth(1200);
renderNavbar({ tabs, rightActions: [userAction] });
expect(screen.getByText('Carlito')).toBeInTheDocument();
});
it('abbreviates the username to its initials when narrow, keeping the full name accessible', () => {
stubNavbarWidth(360);
renderNavbar({
tabs,
rightActions: [
{
type: 'dropdown' as const,
text: 'Carlito Gonzalez',
icon: ,
items: [{ label: 'Log out', onClick: () => {} }],
},
],
});
expect(screen.getByText('CG')).toBeInTheDocument();
expect(screen.queryByText('Carlito Gonzalez')).not.toBeInTheDocument();
expect(screen.getByLabelText('Carlito Gonzalez')).toBeInTheDocument();
});
it('keeps an icon-only action icon-only when narrow instead of inventing initials', () => {
stubNavbarWidth(360);
renderNavbar({
tabs,
rightActions: [
{
type: 'dropdown' as const,
icon: ,
items: [{ label: 'App 1', onClick: () => {} }],
},
],
});
// No text label was provided, so nothing is abbreviated and no stray
// initial chip appears next to the icon.
expect(screen.queryByText(/^[A-Z]{1,2}$/)).not.toBeInTheDocument();
});
it('gives the condensed name precedence over an aria-label on the action', () => {
// The action type is porous — a consumer can pass extra props. A stray
// aria-label must not clobber the condensed accessible name.
stubNavbarWidth(360);
renderNavbar({
tabs,
// @ts-ignore - exercising the porous action-prop boundary on purpose
rightActions: [{ ...userAction, 'aria-label': 'wrong name' }],
});
expect(screen.getByLabelText('Carlito')).toBeInTheDocument();
expect(screen.queryByLabelText('wrong name')).not.toBeInTheDocument();
});
it('can condense the actions to icons while tabs are still shown inline', () => {
// 900 < 1000 condenses the actions, but all three tabs (300 + More) still
// fit inline at 900.
stubNavbarWidth(900);
renderNavbar({
tabs,
rightActions: [userAction],
condenseActionsBreakpoint: 1000,
});
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Policies' })).toBeInTheDocument();
expect(screen.queryByText('Carlito')).not.toBeInTheDocument();
});
it('keeps the selected tab inline even when earlier tabs overflow', async () => {
// 220px only fits one tab alongside the More trigger. The selected tab sits
// last, so a plain prefix fit would hide it — it must stay inline instead.
stubNavbarWidth(220);
renderNavbar({
tabs: [
{ title: 'Groups', link: Groups },
{ title: 'Users', link: Users },
{ title: 'Policies', selected: true, link: Policies },
],
rightActions: [userAction],
});
expect(screen.getByRole('tab', { name: 'Policies' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Groups' })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /more/i }));
const menu = screen.getByRole('navigation', { name: /more/i });
expect(within(menu).getByRole('link', { name: 'Groups' })).toBeInTheDocument();
expect(within(menu).queryByRole('link', { name: 'Policies' })).not.toBeInTheDocument();
});
it('keeps a custom render tab inline at every width instead of hiding it', async () => {
// An editable instance-name field cannot live inside a dropdown, so a
// render tab is pinned: it stays visible while plain nav tabs overflow.
stubNavbarWidth(220);
renderNavbar({
tabs: [
{ render: My instance },
{ title: 'Groups', selected: true, link: Groups },
{ title: 'Users', link: Users },
{ title: 'Policies', link: Policies },
],
rightActions: [userAction],
});
expect(screen.getByText('My instance')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /more/i }));
const menu = screen.getByRole('navigation', { name: /more/i });
expect(within(menu).getByRole('link', { name: 'Users' })).toBeInTheDocument();
expect(within(menu).queryByText('My instance')).not.toBeInTheDocument();
});
it('does not render a navigation menu when there are no tabs', () => {
stubNavbarWidth(360);
renderNavbar({ tabs: [], rightActions: [userAction] });
expect(
screen.queryByRole('button', { name: /more/i }),
).not.toBeInTheDocument();
});
});