|
| 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