Skip to content

Commit fbbc5ab

Browse files
Merge remote-tracking branch 'origin/development/1.0' into improvement/styled-components-v6
# Conflicts: # src/lib/components/navbar/Navbar.component.tsx # stories/navbar.stories.tsx
2 parents 917e39f + baadde4 commit fbbc5ab

15 files changed

Lines changed: 1891 additions & 290 deletions

src/lib/components/dropdown/Dropdown.component.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ type Props = {
3131
icon?: JSX.Element;
3232
caret?: boolean;
3333
placement?: Placement;
34+
/**
35+
* Accessible name for the trigger. Set this when the trigger is icon-only
36+
* (no visible `text`) so it still has a name; falls back to `title` in that
37+
* case. Overrides the label reference the underlying select would set.
38+
*/
39+
'aria-label'?: string;
3440
};
3541
const DropdownStyled = styled.div`
3642
position: relative;
@@ -104,8 +110,12 @@ function Dropdown({
104110
title,
105111
caret = true,
106112
placement = 'bottom',
113+
'aria-label': ariaLabel,
107114
...rest
108115
}: Props) {
116+
// Fall back to the tooltip text for an icon-only trigger so it still has an
117+
// accessible name when the visible label is hidden.
118+
const triggerAriaLabel = ariaLabel ?? (icon && !text ? title : undefined);
109119
const {
110120
isOpen,
111121
getToggleButtonProps,
@@ -138,6 +148,9 @@ function Dropdown({
138148
title={title}
139149
{...getToggleButtonProps()}
140150
{...getReferenceProps()}
151+
{...(triggerAriaLabel
152+
? { 'aria-label': triggerAriaLabel, 'aria-labelledby': undefined }
153+
: {})}
141154
>
142155
{icon && (
143156
<ButtonIcon $text={text} $size={size}>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { act, screen, render } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import React from 'react';
4+
import { Form, FormSection, FormGroup } from './Form.component';
5+
6+
describe('Form', () => {
7+
it('should submit when pressing Enter on a text input', async () => {
8+
const onSubmit = jest.fn((e) => e.preventDefault());
9+
render(
10+
<Form
11+
layout={{ kind: 'page', title: 'Test Form' }}
12+
onSubmit={onSubmit}
13+
rightActions={
14+
<button type="submit">Submit</button>
15+
}
16+
>
17+
<FormSection>
18+
<FormGroup
19+
id="text-field"
20+
label="Name"
21+
content={<input type="text" id="text-field" />}
22+
/>
23+
</FormSection>
24+
</Form>,
25+
);
26+
27+
const input = screen.getByRole('textbox');
28+
await act(() => userEvent.click(input));
29+
await act(() => userEvent.keyboard('{Enter}'));
30+
31+
expect(onSubmit).toHaveBeenCalled();
32+
});
33+
});
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { render, screen, within } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { ThemeProvider } from 'styled-components';
4+
import { Navbar, selectVisibleTabs, getInitials } from './Navbar.component';
5+
import { coreUIAvailableThemes } from '../../style/theme';
6+
7+
const theme = coreUIAvailableThemes.darkRebrand;
8+
9+
const renderNavbar = (props) =>
10+
render(
11+
<ThemeProvider theme={theme}>
12+
<Navbar {...props} />
13+
</ThemeProvider>,
14+
);
15+
16+
const tabs = [
17+
{ title: 'Groups', selected: true, link: <a href="/groups">Groups</a> },
18+
{ title: 'Users', link: <a href="/users">Users</a> },
19+
{ title: 'Policies', link: <a href="/policies">Policies</a> },
20+
];
21+
22+
const userAction = {
23+
type: 'dropdown' as const,
24+
text: 'Carlito',
25+
icon: <i className="fas fa-user" />,
26+
items: [{ label: 'Log out', onClick: () => {} }],
27+
};
28+
29+
// useContainerWidth reads getBoundingClientRect().width on mount (the global
30+
// ResizeObserver mock never fires), so we drive the available width by stubbing
31+
// it. jsdom has no layout, so every element reports offsetWidth 0 by default —
32+
// we pin a uniform per-tab width so the priority+ fit has something to measure.
33+
const TAB_WIDTH = 100;
34+
const stubNavbarWidth = (width: number) => {
35+
jest
36+
.spyOn(Element.prototype, 'getBoundingClientRect')
37+
.mockReturnValue({ width } as DOMRect);
38+
};
39+
40+
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
41+
HTMLElement.prototype,
42+
'offsetWidth',
43+
);
44+
beforeAll(() => {
45+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
46+
configurable: true,
47+
get: () => TAB_WIDTH,
48+
});
49+
});
50+
afterAll(() => {
51+
if (originalOffsetWidth) {
52+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth);
53+
}
54+
});
55+
56+
afterEach(() => {
57+
jest.restoreAllMocks();
58+
});
59+
60+
describe('getInitials', () => {
61+
it('takes the first letter of a single-word name', () => {
62+
expect(getInitials('Carlito')).toBe('C');
63+
});
64+
65+
it('takes the first letters of the first two words', () => {
66+
expect(getInitials('Carlito Gonzalez')).toBe('CG');
67+
});
68+
69+
it('uses only the first letter for an unspaced token', () => {
70+
expect(getInitials('jean-marc.millet')).toBe('J');
71+
});
72+
73+
it('ignores surrounding and repeated whitespace', () => {
74+
expect(getInitials(' Carlito Gonzalez ')).toBe('CG');
75+
});
76+
77+
it('returns an empty string for empty input', () => {
78+
expect(getInitials('')).toBe('');
79+
});
80+
});
81+
82+
describe('selectVisibleTabs (priority+ fit)', () => {
83+
it('keeps every tab inline when they all fit', () => {
84+
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
85+
[100, 100, 100],
86+
[false, false, false],
87+
80,
88+
1000,
89+
);
90+
expect(visibleTabIndices).toEqual([0, 1, 2]);
91+
expect(overflowTabIndices).toEqual([]);
92+
});
93+
94+
it('drops tabs from the right and reserves room for the menu trigger', () => {
95+
// availableWidth 260, menu trigger 80 -> budget 180: the first tab (100)
96+
// fits, a second (200) would not, so one stays and the rest overflow.
97+
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
98+
[100, 100, 100],
99+
[false, false, false],
100+
80,
101+
260,
102+
);
103+
expect(visibleTabIndices).toEqual([0]);
104+
expect(overflowTabIndices).toEqual([1, 2]);
105+
});
106+
107+
it('keeps a pinned tab inline even when earlier tabs overflow', () => {
108+
// The last tab is pinned; the budget only fits one tab beside the menu
109+
// trigger, so the pinned tab wins and the earlier ones overflow.
110+
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
111+
[100, 100, 100],
112+
[false, false, true],
113+
80,
114+
260,
115+
);
116+
expect(visibleTabIndices).toEqual([2]);
117+
expect(overflowTabIndices).toEqual([0, 1]);
118+
});
119+
120+
it('preserves original tab order across both rows', () => {
121+
const { visibleTabIndices, overflowTabIndices } = selectVisibleTabs(
122+
[100, 100, 100, 100],
123+
[false, true, false, false],
124+
80,
125+
300,
126+
);
127+
expect(visibleTabIndices).toEqual([...visibleTabIndices].sort((a, b) => a - b));
128+
expect(overflowTabIndices).toEqual([...overflowTabIndices].sort((a, b) => a - b));
129+
});
130+
});
131+
132+
describe('Navbar responsiveness', () => {
133+
it('shows every navigation tab inline when the navbar is wide', () => {
134+
stubNavbarWidth(1200);
135+
renderNavbar({ tabs, rightActions: [userAction] });
136+
137+
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
138+
expect(screen.getByRole('tab', { name: 'Users' })).toBeInTheDocument();
139+
expect(screen.getByRole('tab', { name: 'Policies' })).toBeInTheDocument();
140+
expect(
141+
screen.queryByRole('button', { name: /more/i }),
142+
).not.toBeInTheDocument();
143+
});
144+
145+
it('overflows tabs that do not fit into a "More" menu, keeping the rest inline', async () => {
146+
// available 220: first tab (100) fits alongside the More trigger (100),
147+
// the next would not — so 1 stays inline and 2 overflow into the menu.
148+
stubNavbarWidth(220);
149+
renderNavbar({ tabs, rightActions: [userAction] });
150+
151+
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
152+
expect(screen.queryByRole('tab', { name: 'Users' })).not.toBeInTheDocument();
153+
154+
const menuTrigger = screen.getByRole('button', { name: /more/i });
155+
expect(menuTrigger).toHaveAttribute('aria-expanded', 'false');
156+
await userEvent.click(menuTrigger);
157+
expect(menuTrigger).toHaveAttribute('aria-expanded', 'true');
158+
159+
const menu = screen.getByRole('navigation', { name: /more/i });
160+
expect(within(menu).getByRole('link', { name: 'Users' })).toBeInTheDocument();
161+
expect(within(menu).getByRole('link', { name: 'Policies' })).toBeInTheDocument();
162+
expect(within(menu).queryByRole('link', { name: 'Groups' })).not.toBeInTheDocument();
163+
});
164+
165+
it('keeps the username label visible when the navbar is wide', () => {
166+
stubNavbarWidth(1200);
167+
renderNavbar({ tabs, rightActions: [userAction] });
168+
169+
expect(screen.getByText('Carlito')).toBeInTheDocument();
170+
});
171+
172+
it('abbreviates the username to its initials when narrow, keeping the full name accessible', () => {
173+
stubNavbarWidth(360);
174+
renderNavbar({
175+
tabs,
176+
rightActions: [
177+
{
178+
type: 'dropdown' as const,
179+
text: 'Carlito Gonzalez',
180+
icon: <i className="fas fa-user" />,
181+
items: [{ label: 'Log out', onClick: () => {} }],
182+
},
183+
],
184+
});
185+
186+
expect(screen.getByText('CG')).toBeInTheDocument();
187+
expect(screen.queryByText('Carlito Gonzalez')).not.toBeInTheDocument();
188+
expect(screen.getByLabelText('Carlito Gonzalez')).toBeInTheDocument();
189+
});
190+
191+
it('keeps an icon-only action icon-only when narrow instead of inventing initials', () => {
192+
stubNavbarWidth(360);
193+
renderNavbar({
194+
tabs,
195+
rightActions: [
196+
{
197+
type: 'dropdown' as const,
198+
icon: <i className="fas fa-th" />,
199+
items: [{ label: 'App 1', onClick: () => {} }],
200+
},
201+
],
202+
});
203+
204+
// No text label was provided, so nothing is abbreviated and no stray
205+
// initial chip appears next to the icon.
206+
expect(screen.queryByText(/^[A-Z]{1,2}$/)).not.toBeInTheDocument();
207+
});
208+
209+
it('gives the condensed name precedence over an aria-label on the action', () => {
210+
// The action type is porous — a consumer can pass extra props. A stray
211+
// aria-label must not clobber the condensed accessible name.
212+
stubNavbarWidth(360);
213+
renderNavbar({
214+
tabs,
215+
// @ts-ignore - exercising the porous action-prop boundary on purpose
216+
rightActions: [{ ...userAction, 'aria-label': 'wrong name' }],
217+
});
218+
219+
expect(screen.getByLabelText('Carlito')).toBeInTheDocument();
220+
expect(screen.queryByLabelText('wrong name')).not.toBeInTheDocument();
221+
});
222+
223+
it('can condense the actions to icons while tabs are still shown inline', () => {
224+
// 900 < 1000 condenses the actions, but all three tabs (300 + More) still
225+
// fit inline at 900.
226+
stubNavbarWidth(900);
227+
renderNavbar({
228+
tabs,
229+
rightActions: [userAction],
230+
condenseActionsBreakpoint: 1000,
231+
});
232+
233+
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
234+
expect(screen.getByRole('tab', { name: 'Policies' })).toBeInTheDocument();
235+
expect(screen.queryByText('Carlito')).not.toBeInTheDocument();
236+
});
237+
238+
it('keeps the selected tab inline even when earlier tabs overflow', async () => {
239+
// 220px only fits one tab alongside the More trigger. The selected tab sits
240+
// last, so a plain prefix fit would hide it — it must stay inline instead.
241+
stubNavbarWidth(220);
242+
renderNavbar({
243+
tabs: [
244+
{ title: 'Groups', link: <a href="/groups">Groups</a> },
245+
{ title: 'Users', link: <a href="/users">Users</a> },
246+
{ title: 'Policies', selected: true, link: <a href="/policies">Policies</a> },
247+
],
248+
rightActions: [userAction],
249+
});
250+
251+
expect(screen.getByRole('tab', { name: 'Policies' })).toBeInTheDocument();
252+
expect(screen.queryByRole('tab', { name: 'Groups' })).not.toBeInTheDocument();
253+
254+
await userEvent.click(screen.getByRole('button', { name: /more/i }));
255+
const menu = screen.getByRole('navigation', { name: /more/i });
256+
expect(within(menu).getByRole('link', { name: 'Groups' })).toBeInTheDocument();
257+
expect(within(menu).queryByRole('link', { name: 'Policies' })).not.toBeInTheDocument();
258+
});
259+
260+
it('keeps a custom render tab inline at every width instead of hiding it', async () => {
261+
// An editable instance-name field cannot live inside a dropdown, so a
262+
// render tab is pinned: it stays visible while plain nav tabs overflow.
263+
stubNavbarWidth(220);
264+
renderNavbar({
265+
tabs: [
266+
{ render: <span>My instance</span> },
267+
{ title: 'Groups', selected: true, link: <a href="/groups">Groups</a> },
268+
{ title: 'Users', link: <a href="/users">Users</a> },
269+
{ title: 'Policies', link: <a href="/policies">Policies</a> },
270+
],
271+
rightActions: [userAction],
272+
});
273+
274+
expect(screen.getByText('My instance')).toBeInTheDocument();
275+
expect(screen.getByRole('tab', { name: 'Groups' })).toBeInTheDocument();
276+
277+
await userEvent.click(screen.getByRole('button', { name: /more/i }));
278+
const menu = screen.getByRole('navigation', { name: /more/i });
279+
expect(within(menu).getByRole('link', { name: 'Users' })).toBeInTheDocument();
280+
expect(within(menu).queryByText('My instance')).not.toBeInTheDocument();
281+
});
282+
283+
it('does not render a navigation menu when there are no tabs', () => {
284+
stubNavbarWidth(360);
285+
renderNavbar({ tabs: [], rightActions: [userAction] });
286+
287+
expect(
288+
screen.queryByRole('button', { name: /more/i }),
289+
).not.toBeInTheDocument();
290+
});
291+
});

0 commit comments

Comments
 (0)