|
| 1 | +import { test, expect, type Page, type Locator } from '@playwright/test'; |
| 2 | + |
| 3 | +// The kit's small icon-only controls (a copy chip, a dialog close glyph, the |
| 4 | +// mobile nav toggle) are drawn far smaller than the 44px `h-11` every `Button` |
| 5 | +// uses, on purpose — they sit inline next to body text. `touchTargetClasses` |
| 6 | +// hangs a transparent 44x44 `::after` off them so the *hit area* reaches 44px |
| 7 | +// without the control growing. |
| 8 | +// |
| 9 | +// A pseudo-element has no `boundingBox()`, and `getComputedStyle` alone would |
| 10 | +// only prove the declaration exists, not that it produces a box the browser |
| 11 | +// hit-tests. So each control is checked twice: |
| 12 | +// |
| 13 | +// 1. the pseudo-element's computed box is 44x44, and |
| 14 | +// 2. `elementFromPoint` at ±20px from the control's centre — well outside its |
| 15 | +// own border box — resolves back to the control. |
| 16 | +// |
| 17 | +// (2) is the load-bearing assertion: it is the browser's own hit-testing. |
| 18 | +const TARGET = 44; |
| 19 | + |
| 20 | +async function gotoStory(page: Page, id: string): Promise<void> { |
| 21 | + await page.goto(`/iframe.html?id=${id}&viewMode=story`); |
| 22 | + const root = page.locator('#storybook-root'); |
| 23 | + await expect |
| 24 | + .poll(() => root.evaluate((el) => el.childElementCount), { timeout: 5000 }) |
| 25 | + .toBeGreaterThan(0); |
| 26 | +} |
| 27 | + |
| 28 | +// Modal and Drawer slide their panel in. `boundingBox()` does not wait for |
| 29 | +// actionability, so measuring straight after the open click catches the panel |
| 30 | +// mid-transform (the Drawer's close glyph reads ~24px right of its resting |
| 31 | +// position). Poll until the box stops moving. |
| 32 | +async function settle(control: Locator): Promise<void> { |
| 33 | + let previous = Number.NaN; |
| 34 | + await expect |
| 35 | + .poll( |
| 36 | + async () => { |
| 37 | + const x = (await control.boundingBox())?.x ?? Number.NaN; |
| 38 | + const stable = x === previous; |
| 39 | + previous = x; |
| 40 | + return stable; |
| 41 | + }, |
| 42 | + { timeout: 5000, intervals: [100] }, |
| 43 | + ) |
| 44 | + .toBe(true); |
| 45 | +} |
| 46 | + |
| 47 | +async function expectTouchTarget(control: Locator): Promise<void> { |
| 48 | + await settle(control); |
| 49 | + |
| 50 | + const pseudo = await control.evaluate((node) => { |
| 51 | + const style = getComputedStyle(node, '::after'); |
| 52 | + return { content: style.content, width: style.width, height: style.height }; |
| 53 | + }); |
| 54 | + |
| 55 | + expect(pseudo.content, 'the ::after must actually be generated').not.toBe('none'); |
| 56 | + expect(pseudo.width).toBe(`${TARGET}px`); |
| 57 | + expect(pseudo.height).toBe(`${TARGET}px`); |
| 58 | + |
| 59 | + // The control's own box must stay small — the point of the technique is |
| 60 | + // that the target grows and the visual does not. |
| 61 | + const box = (await control.boundingBox())!; |
| 62 | + expect(box.width).toBeLessThan(TARGET); |
| 63 | + expect(box.height).toBeLessThanOrEqual(TARGET); |
| 64 | + |
| 65 | + // Hit-test the four corners of the 44x44 target, minus a 2px inset so the |
| 66 | + // probe can't fall on a rounding boundary. |
| 67 | + const cx = box.x + box.width / 2; |
| 68 | + const cy = box.y + box.height / 2; |
| 69 | + const reach = TARGET / 2 - 2; |
| 70 | + const corners = [ |
| 71 | + { x: cx - reach, y: cy - reach }, |
| 72 | + { x: cx + reach, y: cy - reach }, |
| 73 | + { x: cx - reach, y: cy + reach }, |
| 74 | + { x: cx + reach, y: cy + reach }, |
| 75 | + ]; |
| 76 | + |
| 77 | + const viewport = await control.page().evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })); |
| 78 | + |
| 79 | + for (const point of corners) { |
| 80 | + // Sanity: the probe really is outside the control's own border box in |
| 81 | + // at least one axis, otherwise the assertion below proves nothing. |
| 82 | + const outside = point.x < box.x || point.x > box.x + box.width || point.y < box.y || point.y > box.y + box.height; |
| 83 | + expect(outside, `probe ${JSON.stringify(point)} must sit outside the control's own box`).toBe(true); |
| 84 | + |
| 85 | + // …and inside the viewport, or `elementFromPoint` returns null and the |
| 86 | + // assertion would fail for a reason that has nothing to do with the |
| 87 | + // target. A control whose 44px target does not fit on screen is a |
| 88 | + // layout problem worth failing on. |
| 89 | + const onScreen = point.x >= 0 && point.y >= 0 && point.x <= viewport.w && point.y <= viewport.h; |
| 90 | + expect(onScreen, `probe ${JSON.stringify(point)} must sit inside the viewport`).toBe(true); |
| 91 | + |
| 92 | + const hitsControl = await control.evaluate( |
| 93 | + (node, p) => node.contains(document.elementFromPoint(p.x, p.y)) || node === document.elementFromPoint(p.x, p.y), |
| 94 | + point, |
| 95 | + ); |
| 96 | + expect(hitsControl, `point ${JSON.stringify(point)} must activate the control`).toBe(true); |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +test('CopyButton reaches a 44px target without growing', async ({ page }) => { |
| 101 | + await gotoStory(page, 'molecules-copybutton--default'); |
| 102 | + await expectTouchTarget(page.locator('button[aria-label="Copy to clipboard"]').first()); |
| 103 | +}); |
| 104 | + |
| 105 | +// Both dialog stories carry a play function that opens the panel and closes it |
| 106 | +// again with Escape, and both end by restoring focus to the opener. Waiting for |
| 107 | +// that focus is the one deterministic "the script is done" signal — without it |
| 108 | +// this test races the play function for the same dialog. |
| 109 | +async function openAfterPlay(page: Page, opener: string): Promise<void> { |
| 110 | + const button = page.getByRole('button', { name: opener }); |
| 111 | + await expect(button).toBeFocused({ timeout: 15_000 }); |
| 112 | + await expect(page.getByRole('dialog')).toHaveCount(0); |
| 113 | + await button.click(); |
| 114 | + await expect(page.getByRole('dialog')).toBeVisible(); |
| 115 | +} |
| 116 | + |
| 117 | +test("Modal's close button reaches a 44px target", async ({ page }) => { |
| 118 | + await gotoStory(page, 'organisms-modal--default'); |
| 119 | + await openAfterPlay(page, 'Open modal'); |
| 120 | + await expectTouchTarget(page.locator('button[aria-label="Close"]').first()); |
| 121 | +}); |
| 122 | + |
| 123 | +test("Drawer's close button reaches a 44px target", async ({ page }) => { |
| 124 | + await gotoStory(page, 'organisms-drawer--default'); |
| 125 | + await openAfterPlay(page, 'Show document details'); |
| 126 | + await expectTouchTarget(page.locator('button[aria-label="Close"]').first()); |
| 127 | +}); |
| 128 | + |
| 129 | +test("Navbar's mobile menu toggle reaches a 44px target", async ({ page }) => { |
| 130 | + // The toggle is `lg:hidden`, so it only exists below the lg breakpoint. |
| 131 | + await page.setViewportSize({ width: 500, height: 800 }); |
| 132 | + await gotoStory(page, 'layouts-appshell--default'); |
| 133 | + await expectTouchTarget(page.locator('button[aria-label="Open navigation"]').first()); |
| 134 | +}); |
| 135 | + |
| 136 | +test("Toaster's dismiss button is already 44px of real box", async ({ page }) => { |
| 137 | + // The counter-example: this one can afford the layout space, so it uses |
| 138 | + // `min-h-11 min-w-11` rather than the pseudo-element and needs no helper. |
| 139 | + // |
| 140 | + // The `wide` story, not `default` — `default`'s play function clicks |
| 141 | + // Dismiss itself, and the toast can vanish between the visibility check and |
| 142 | + // the measurement. Toasts also auto-dismiss, so the rect is read in one |
| 143 | + // atomic evaluate rather than via `boundingBox()` on a second round trip. |
| 144 | + await gotoStory(page, 'organisms-toaster--wide'); |
| 145 | + await page.getByRole('button', { name: 'Success' }).click(); |
| 146 | + const dismiss = page.locator('button[aria-label="Dismiss"]').first(); |
| 147 | + await expect(dismiss).toBeVisible(); |
| 148 | + const box = await dismiss.evaluate((node) => { |
| 149 | + const rect = node.getBoundingClientRect(); |
| 150 | + return { width: rect.width, height: rect.height }; |
| 151 | + }); |
| 152 | + expect(box.width).toBeGreaterThanOrEqual(TARGET); |
| 153 | + expect(box.height).toBeGreaterThanOrEqual(TARGET); |
| 154 | +}); |
| 155 | + |
| 156 | +test('the expanded target still activates the control it belongs to', async ({ page, context }) => { |
| 157 | + // End to end, through a real click rather than a hit-test: clicking 18px |
| 158 | + // below the copy chip's centre — outside its 24px box, inside the 44px |
| 159 | + // target — must copy. |
| 160 | + await context.grantPermissions(['clipboard-read', 'clipboard-write']); |
| 161 | + await gotoStory(page, 'molecules-copybutton--default'); |
| 162 | + const button = page.locator('button[aria-label="Copy to clipboard"]').first(); |
| 163 | + const box = (await button.boundingBox())!; |
| 164 | + |
| 165 | + await page.mouse.click(box.x + box.width / 2, box.y + box.height + 6); |
| 166 | + await expect(page.getByText('Copied to clipboard').first()).toBeVisible(); |
| 167 | +}); |
0 commit comments