Skip to content

Commit b867ed0

Browse files
feat(mosaic): add ProviderButton and ProviderButtonGroup (#10549)
1 parent 4f2a95d commit b867ed0

13 files changed

Lines changed: 1196 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
import { cleanup, render, screen } from '@testing-library/react';
2+
import React from 'react';
3+
import { afterEach, describe, expect, it } from 'vitest';
4+
5+
import { ProviderButton, ProviderButtonGroup } from '../index';
6+
7+
afterEach(() => cleanup());
8+
9+
function renderGroup(count: number, { width = 600, layout }: { width?: number; layout?: 'inline' | 'stacked' } = {}) {
10+
render(
11+
<div style={{ width }}>
12+
<ProviderButtonGroup layout={layout}>
13+
{Array.from({ length: count }, (_, index) => (
14+
<ProviderButton
15+
key={index}
16+
icon={<svg style={{ width: 16, height: 16 }} />}
17+
longLabel={`Continue with Provider ${index}`}
18+
shortLabel={`Provider ${index}`}
19+
/>
20+
))}
21+
</ProviderButtonGroup>
22+
</div>,
23+
);
24+
return screen.getAllByRole('button');
25+
}
26+
27+
function rowLengths(buttons: HTMLElement[]) {
28+
const rows = new Map<number, number>();
29+
for (const button of buttons) {
30+
const top = Math.round(button.getBoundingClientRect().top);
31+
rows.set(top, (rows.get(top) ?? 0) + 1);
32+
}
33+
return [...rows.values()];
34+
}
35+
36+
const isShown = (text: string) => getComputedStyle(screen.getByText(text)).display !== 'none';
37+
38+
describe('inline rows', () => {
39+
it.each([
40+
[1, [1]],
41+
[2, [2]],
42+
[3, [3]],
43+
[4, [4]],
44+
[5, [5]],
45+
[6, [3, 3]],
46+
[7, [4, 3]],
47+
[8, [4, 4]],
48+
[9, [5, 4]],
49+
[10, [5, 5]],
50+
[11, [4, 4, 3]],
51+
[12, [4, 4, 4]],
52+
[13, [5, 5, 3]],
53+
[14, [5, 5, 4]],
54+
[15, [5, 5, 5]],
55+
])('balances %i buttons like the legacy distribution', (count, rows) => {
56+
expect(rowLengths(renderGroup(count))).toEqual(rows);
57+
});
58+
59+
it.each([7, 9, 11, 13])('keeps a short last row at full button width and centered (%i)', count => {
60+
const buttons = renderGroup(count);
61+
const first = buttons[0].getBoundingClientRect();
62+
const last = buttons[buttons.length - 1].getBoundingClientRect();
63+
expect(last.width).toBeCloseTo(first.width, 0);
64+
65+
const parent = buttons[0].parentElement?.getBoundingClientRect();
66+
const lastRow = buttons.filter(b => b.getBoundingClientRect().top === last.top);
67+
const left = lastRow[0].getBoundingClientRect().left - (parent?.left ?? 0);
68+
const right = (parent?.right ?? 0) - last.right;
69+
expect(left).toBeCloseTo(right, 0);
70+
});
71+
72+
it('fills the row with equal widths', () => {
73+
const [a, b, c] = renderGroup(3).map(button => button.getBoundingClientRect().width);
74+
expect(a).toBeCloseTo(b, 0);
75+
expect(b).toBeCloseTo(c, 0);
76+
expect((a + b + c) / 600).toBeGreaterThan(0.95);
77+
});
78+
79+
it('collapses two buttons to one column in a narrow container', () => {
80+
expect(rowLengths(renderGroup(2, { width: 280 }))).toEqual([1, 1]);
81+
});
82+
83+
it('keeps three icon buttons in one row in a narrow container', () => {
84+
expect(rowLengths(renderGroup(3, { width: 280 }))).toEqual([3]);
85+
});
86+
});
87+
88+
describe('label forms', () => {
89+
it('shows only the long label for one inline button', () => {
90+
renderGroup(1);
91+
expect(isShown('Continue with Provider 0')).toBe(true);
92+
expect(isShown('Provider 0')).toBe(false);
93+
});
94+
95+
it('shows only the short label for two inline buttons', () => {
96+
renderGroup(2);
97+
expect(isShown('Continue with Provider 0')).toBe(false);
98+
expect(isShown('Provider 0')).toBe(true);
99+
});
100+
101+
it('shows neither label for three inline buttons, keeping the name', () => {
102+
const buttons = renderGroup(3);
103+
expect(isShown('Continue with Provider 0')).toBe(false);
104+
expect(isShown('Provider 0')).toBe(false);
105+
expect(buttons[0]).toHaveAccessibleName('Continue with Provider 0');
106+
});
107+
108+
it('always shows the long label when stacked, one button per row', () => {
109+
const buttons = renderGroup(4, { layout: 'stacked' });
110+
expect(rowLengths(buttons)).toEqual([1, 1, 1, 1]);
111+
expect(isShown('Continue with Provider 3')).toBe(true);
112+
expect(isShown('Provider 3')).toBe(false);
113+
});
114+
115+
it('keeps the button height when stacked', () => {
116+
const [inlineButton] = renderGroup(1);
117+
const inlineHeight = inlineButton.getBoundingClientRect().height;
118+
cleanup();
119+
const buttons = renderGroup(3, { layout: 'stacked' });
120+
for (const button of buttons) {
121+
expect(button.getBoundingClientRect().height).toBe(inlineHeight);
122+
}
123+
});
124+
125+
it('truncates a long label rather than overflowing a narrow stacked group', () => {
126+
const buttons = renderGroup(2, { layout: 'stacked', width: 80 });
127+
for (const button of buttons) {
128+
expect(button.getBoundingClientRect().width).toBeLessThanOrEqual(80);
129+
}
130+
});
131+
132+
it('shows the long label outside a group', () => {
133+
render(
134+
<ProviderButton
135+
icon={<svg />}
136+
longLabel='Continue with Google'
137+
shortLabel='Google'
138+
/>,
139+
);
140+
expect(isShown('Continue with Google')).toBe(true);
141+
expect(isShown('Google')).toBe(false);
142+
});
143+
144+
it('keeps the pending button at full opacity while the group dims the rest', async () => {
145+
render(
146+
<ProviderButtonGroup disabled>
147+
<ProviderButton
148+
icon={<svg />}
149+
longLabel='Continue with Google'
150+
shortLabel='Google'
151+
isPending
152+
/>
153+
<ProviderButton
154+
icon={<svg />}
155+
longLabel='Continue with GitHub'
156+
shortLabel='GitHub'
157+
/>
158+
</ProviderButtonGroup>,
159+
);
160+
const [pending, sibling] = screen.getAllByRole('button');
161+
await expect.poll(() => getComputedStyle(sibling).opacity).toBe('0.5');
162+
expect(getComputedStyle(pending).opacity).toBe('1');
163+
});
164+
165+
it.each(['xs', 'sm', 'md', 'lg'] as const)('does not move anything when the spinner appears (%s)', size => {
166+
const boxes = () => {
167+
const { width, height } = screen.getByRole('button').getBoundingClientRect();
168+
const label = screen.getByText('Continue with Google').getBoundingClientRect();
169+
return { width, height, labelLeft: label.left, labelTop: label.top };
170+
};
171+
const { rerender } = render(
172+
<ProviderButton
173+
icon={<svg style={{ width: 16, height: 16 }} />}
174+
longLabel='Continue with Google'
175+
shortLabel='Google'
176+
size={size}
177+
/>,
178+
);
179+
const before = boxes();
180+
rerender(
181+
<ProviderButton
182+
icon={<svg style={{ width: 16, height: 16 }} />}
183+
longLabel='Continue with Google'
184+
shortLabel='Google'
185+
size={size}
186+
isPending
187+
spinDelay={{ delay: 0 }}
188+
/>,
189+
);
190+
expect(screen.getByRole('button').querySelector('.cl-spinner')).not.toBeNull();
191+
expect(boxes()).toEqual(before);
192+
});
193+
});
194+
195+
describe('last used provider', () => {
196+
function renderWithLastUsed(
197+
count: number,
198+
{
199+
width = 600,
200+
layout,
201+
dir,
202+
disabled,
203+
isPending,
204+
longLabel = 'Continue with Google',
205+
}: {
206+
width?: number;
207+
layout?: 'inline' | 'stacked';
208+
dir?: 'rtl';
209+
disabled?: boolean;
210+
isPending?: boolean;
211+
longLabel?: string;
212+
} = {},
213+
) {
214+
render(
215+
<div
216+
dir={dir}
217+
style={{ width, paddingBlockStart: 24 }}
218+
>
219+
<ProviderButtonGroup
220+
layout={layout}
221+
disabled={disabled}
222+
lastUsed={
223+
<ProviderButton
224+
icon={<svg style={{ width: 16, height: 16 }} />}
225+
longLabel={longLabel}
226+
shortLabel='Google'
227+
badge='Last used'
228+
isPending={isPending}
229+
/>
230+
}
231+
>
232+
{Array.from({ length: count }, (_, index) => (
233+
<ProviderButton
234+
key={index}
235+
icon={<svg style={{ width: 16, height: 16 }} />}
236+
longLabel={`Continue with Provider ${index}`}
237+
shortLabel={`Provider ${index}`}
238+
/>
239+
))}
240+
</ProviderButtonGroup>
241+
</div>,
242+
);
243+
return screen.getAllByRole('button');
244+
}
245+
246+
const badgeBox = () => {
247+
const badge = screen.getByText('Last used').closest('.cl-badge');
248+
if (!badge) {
249+
throw new Error('badge not rendered');
250+
}
251+
return badge;
252+
};
253+
254+
it.each([
255+
[1, [1, 1]],
256+
[2, [1, 2]],
257+
[3, [1, 3]],
258+
[6, [1, 3, 3]],
259+
[7, [1, 4, 3]],
260+
])('puts it on its own row and balances the %i others', (count, rows) => {
261+
expect(rowLengths(renderWithLastUsed(count))).toEqual(rows);
262+
});
263+
264+
it('renders it first and at the full width of the group', () => {
265+
const buttons = renderWithLastUsed(2);
266+
expect(buttons[0]).toHaveAccessibleName(/Continue with Google/);
267+
expect(buttons[0].getBoundingClientRect().width).toBe(600);
268+
});
269+
270+
it('keeps its long label however many others there are', () => {
271+
renderWithLastUsed(3);
272+
expect(isShown('Continue with Google')).toBe(true);
273+
expect(isShown('Google')).toBe(false);
274+
});
275+
276+
it.each([
277+
[1, 'Continue with Provider 0'],
278+
[2, 'Provider 0'],
279+
])('picks the labels of the %i others from their own count', (count, label) => {
280+
renderWithLastUsed(count);
281+
expect(isShown(label)).toBe(true);
282+
});
283+
284+
it('shows only icons for three others', () => {
285+
renderWithLastUsed(3);
286+
expect(isShown('Provider 0')).toBe(false);
287+
expect(isShown('Continue with Provider 0')).toBe(false);
288+
});
289+
290+
it('leaves no gap under it when it is the only provider', () => {
291+
const [button] = renderWithLastUsed(0);
292+
const group = button.parentElement;
293+
expect(group?.getBoundingClientRect().height).toBe(button.getBoundingClientRect().height);
294+
});
295+
296+
it('gives every button its own row when stacked', () => {
297+
expect(rowLengths(renderWithLastUsed(2, { layout: 'stacked' }))).toEqual([1, 1, 1]);
298+
});
299+
300+
it('keeps the same gap between the rows as inside them', () => {
301+
const buttons = renderWithLastUsed(6);
302+
const [lastUsed, second, , , fifth] = buttons.map(button => button.getBoundingClientRect());
303+
expect(second.top - lastUsed.bottom).toBe(fifth.top - second.bottom);
304+
});
305+
306+
it('disables it with the group', () => {
307+
const [lastUsed] = renderWithLastUsed(1, { disabled: true });
308+
expect(lastUsed).toBeDisabled();
309+
});
310+
311+
describe('badge', () => {
312+
it('adds the badge to the accessible name', () => {
313+
const [lastUsed] = renderWithLastUsed(1);
314+
expect(lastUsed).toHaveAccessibleName('Continue with Google, Last used');
315+
});
316+
317+
it('overhangs the top corner at the inline end', () => {
318+
const [lastUsed] = renderWithLastUsed(1);
319+
const button = lastUsed.getBoundingClientRect();
320+
const badge = badgeBox().getBoundingClientRect();
321+
expect(badge.top).toBeLessThan(button.top);
322+
expect(badge.bottom).toBeGreaterThan(button.top);
323+
expect(badge.right).toBeGreaterThan(button.right);
324+
expect(badge.left).toBeGreaterThan(button.left + button.width / 2);
325+
});
326+
327+
it('overhangs the left corner in RTL', () => {
328+
const [lastUsed] = renderWithLastUsed(1, { dir: 'rtl' });
329+
const button = lastUsed.getBoundingClientRect();
330+
const badge = badgeBox().getBoundingClientRect();
331+
expect(badge.left).toBeLessThan(button.left);
332+
expect(badge.right).toBeLessThan(button.left + button.width / 2);
333+
});
334+
335+
it('is not clipped by the button or the group', () => {
336+
const [lastUsed] = renderWithLastUsed(1);
337+
const group = lastUsed.parentElement;
338+
for (const element of [lastUsed, group]) {
339+
expect(element && getComputedStyle(element).overflow).toBe('visible');
340+
}
341+
});
342+
343+
it('does not change the height of the button', () => {
344+
const [lastUsed] = renderWithLastUsed(1);
345+
const [other] = screen.getAllByRole('button').slice(1);
346+
expect(lastUsed.getBoundingClientRect().height).toBe(other.getBoundingClientRect().height);
347+
});
348+
349+
it('stays while pending, at full opacity', () => {
350+
const [lastUsed] = renderWithLastUsed(1, { disabled: true, isPending: true });
351+
expect(screen.getByText('Last used')).toBeVisible();
352+
expect(getComputedStyle(lastUsed).opacity).toBe('1');
353+
});
354+
355+
it('keeps a truncated label inside the button', () => {
356+
const longLabel = 'Continue with a very long enterprise identity provider name';
357+
const [lastUsed] = renderWithLastUsed(1, { width: 180, longLabel });
358+
const label = screen.getByText(longLabel);
359+
expect(lastUsed.getBoundingClientRect().width).toBe(180);
360+
expect(label.scrollWidth).toBeGreaterThan(label.clientWidth);
361+
expect(label.getBoundingClientRect().right).toBeLessThanOrEqual(lastUsed.getBoundingClientRect().right);
362+
});
363+
364+
it('renders no badge without the prop', () => {
365+
renderGroup(1);
366+
expect(screen.queryByText('Last used')).toBeNull();
367+
});
368+
});
369+
});

0 commit comments

Comments
 (0)