Skip to content

Commit 2e43800

Browse files
authored
Merge pull request #16 from codebar-ag/fix/icon-button-touch-target
Give small icon-only controls a 44px touch target
2 parents 44fb83b + 98785dc commit 2e43800

10 files changed

Lines changed: 298 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,70 @@ All notable changes to `@codebar-ag/storybook`.
55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
66
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v1.9.7
9+
10+
### Fixed
11+
12+
- **The kit shipped two control families whose reach differed by a factor of
13+
five, and they sit next to each other in real UI.** Every `Button` is `h-11`
14+
(44px) at *every* size — `sm`, `md` and `lg` all resolve to the same height,
15+
which is a deliberate, documented decision. The small icon-only controls never
16+
got the same treatment, and measured against the built Storybook they came out
17+
at:
18+
19+
| Control | Target before | Target after |
20+
|---|---|---|
21+
| `Modal` close | **9.6 x 24 px** | 44 x 44 |
22+
| `Drawer` close | **9.6 x 24 px** | 44 x 44 |
23+
| `CopyButton` | 24 x 24 px | 44 x 44 |
24+
| `Navbar` menu toggle | 36 x 36 px | 44 x 44 |
25+
26+
The two dialog close buttons are the serious ones: they were an unstyled
27+
`×` glyph with no box of their own, so the whole hit area was the width
28+
of the character — **9.6px**, which is not just below this kit's 44px but
29+
below the 24x24 floor WCAG 2.5.8 Target Size (Minimum, AA) sets. Closing a
30+
dialog on a touch screen was a coin flip, and the control most likely to be
31+
reached for in a hurry was the hardest one in the kit to hit.
32+
33+
All four now carry `touchTargetClasses` from the new
34+
`src/helpers/touchTarget.ts`, which hangs a transparent 44x44 `::after` off
35+
the control, centred on it. The pseudo-element is out of flow, so **nothing
36+
about the rendering changes**: the copy chip is still 24px of visible box and
37+
still does not out-weigh the text beside it, no neighbour shifts, no row grows.
38+
But a pseudo-element hit-tests as part of the element that generates it, so a
39+
pointer or finger anywhere inside the 44px square activates the control.
40+
41+
A fixed centred `after:size-11` is used rather than the more familiar
42+
`after:-inset-2`. An inset is measured from the control's own border box, so
43+
it lands on 44px for exactly one control size and silently under- or
44+
over-shoots for every other — with four controls at three different sizes
45+
sharing one helper, that difference is the whole point. The technique is also
46+
purely additive: it can only grow a hit area, never shrink one, so it stays
47+
safe on a control that is already big enough.
48+
49+
Two limits are documented in the helper. Replaced elements (`<input>`,
50+
`<img>`) render no pseudo-elements, so `DataTable`'s bare `size-4` row
51+
checkbox — 16x16, also under the WCAG floor — cannot be fixed this way and
52+
still needs a padded label wrapper. And targets grow outward, so two of them
53+
closer than 44px apart overlap; `InputNumber`'s edge-to-edge `-`/`+` steppers
54+
are therefore left at 36x36 (above the WCAG floor, `tabindex="-1"`, and fully
55+
operable from the input itself) rather than have them steal each other's
56+
clicks. `Toaster`'s dismiss button already reserves real layout space with
57+
`min-h-11 min-w-11` and is left alone — a control that can afford 44px of box
58+
should just have 44px of box.
59+
60+
`touchTargetClasses` is exported, so consuming apps can give their own
61+
icon-only buttons the same reach instead of re-deriving the trick per
62+
component.
63+
64+
The geometry is asserted, not assumed. `tests/touch-target.spec.ts` reads the
65+
pseudo-element's computed box **and** probes the four corners of the 44px
66+
square with `elementFromPoint`, checking first that each probe genuinely falls
67+
outside the control's own border box — so what passes is the browser's own
68+
hit-testing, not a restatement of the CSS it was generated from. A final test
69+
clicks 6px *below* the copy chip's bottom edge and asserts the clipboard write
70+
actually happens.
71+
872
## v1.9.6
973

1074
### Fixed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codebar-ag/storybook",
3-
"version": "1.9.6",
3+
"version": "1.9.7",
44
"description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.",
55
"license": "MIT",
66
"author": "codebar Solutions AG",

src/components/molecules/CopyButton.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { push } from '../../composables/useToast';
3+
import { touchTargetClasses } from '../../helpers/touchTarget';
34
import Icon from '../atoms/Icon.vue';
45
56
// Icon-only "copy to clipboard" button. Writes `value` to the clipboard and
@@ -56,7 +57,10 @@ async function copy(): Promise<void> {
5657
<button
5758
type="button"
5859
:aria-label="label"
59-
class="shrink-0 inline-flex items-center justify-center p-1 transition rounded focus:outline-none focus-visible:ring-2"
60+
:class="[
61+
'shrink-0 inline-flex items-center justify-center p-1 transition rounded focus:outline-none focus-visible:ring-2',
62+
touchTargetClasses,
63+
]"
6064
@click="copy"
6165
>
6266
<Icon

src/components/organisms/Drawer.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { computed, ref, useId } from 'vue';
77
import { useEscapeKey } from '../../composables/useEscapeKey';
88
import { useFocusTrap } from '../../composables/useFocusTrap';
99
import { useScrollLock } from '../../composables/useScrollLock';
10+
import { touchTargetClasses } from '../../helpers/touchTarget';
1011
1112
const props = withDefaults(
1213
defineProps<{
@@ -88,7 +89,7 @@ useFocusTrap(panel, open);
8889
</div>
8990
<button
9091
type="button"
91-
class="text-muted hover:text-ink"
92+
:class="['text-muted hover:text-ink', touchTargetClasses]"
9293
aria-label="Close"
9394
@click="close"
9495
>

src/components/organisms/Modal.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { computed, ref, useId } from 'vue';
33
import { useEscapeKey } from '../../composables/useEscapeKey';
44
import { useFocusTrap } from '../../composables/useFocusTrap';
55
import { useScrollLock } from '../../composables/useScrollLock';
6+
import { touchTargetClasses } from '../../helpers/touchTarget';
67
78
// Centered dialog with a scrim. Open state is v-model-driven so callers keep
89
// ownership of visibility. Escape and backdrop clicks request a close. While
@@ -93,7 +94,7 @@ useFocusTrap(panel, open, {
9394
</div>
9495
<button
9596
type="button"
96-
class="text-muted hover:text-ink"
97+
:class="['text-muted hover:text-ink', touchTargetClasses]"
9798
aria-label="Close"
9899
@click="close"
99100
>

src/components/organisms/Navbar.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Top app bar: brand (mobile), centered content and trailing actions.
33
// The menu button only shows below lg and asks the shell to open the sidebar.
44
import Icon from '../atoms/Icon.vue';
5+
import { touchTargetClasses } from '../../helpers/touchTarget';
56
67
withDefaults(
78
defineProps<{
@@ -19,7 +20,10 @@ defineEmits<{ 'toggle-sidebar': [] }>();
1920
<button
2021
v-if="menuButton"
2122
type="button"
22-
class="flex size-9 items-center justify-center rounded-control text-muted transition hover:text-ink lg:hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
23+
:class="[
24+
'flex size-9 items-center justify-center rounded-control text-muted transition hover:text-ink lg:hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
25+
touchTargetClasses,
26+
]"
2327
aria-label="Open navigation"
2428
@click="$emit('toggle-sidebar')"
2529
>

src/helpers/touchTarget.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Single source of truth for the pointer/touch target of the kit's small,
3+
* icon-only controls.
4+
*
5+
* Every `Button` in this kit is `h-11` (44px) at *every* size — `sm`, `md` and
6+
* `lg` all resolve to the same height, on purpose. But a copy chip, a dialog
7+
* close glyph or a mobile nav toggle is deliberately drawn much smaller: it
8+
* sits inline next to body text and must not out-weigh it. That left the kit
9+
* with two control families of wildly different reach — a 44px `Button` and a
10+
* 24px `CopyButton` — sitting side by side in the same row.
11+
*
12+
* `touchTargetClasses` reconciles them. It hangs a transparent 44x44 `::after`
13+
* off the control, centred on the control's own box. The pseudo-element is
14+
* absolutely positioned, so it takes no layout space and shifts no neighbour:
15+
* the control still *looks* exactly as small as before. But a pseudo-element
16+
* hit-tests as part of the element that generates it, so a pointer or finger
17+
* anywhere inside the 44px square activates the control.
18+
*
19+
* Why a fixed centred square rather than the more familiar `after:-inset-2`:
20+
* an inset is measured from the control's own border box, so it only lands on
21+
* 44px for one particular control size and silently under- or over-shoots for
22+
* every other one. `after:size-11` is 44px regardless of what it wraps, which
23+
* is what makes it safe to share between components of different sizes.
24+
*
25+
* The target is purely additive — it can only ever grow the hit area, never
26+
* shrink it — so it is harmless on a control that is already large enough.
27+
*
28+
* Two constraints on where this can be applied:
29+
*
30+
* - The control must be able to carry pseudo-elements. Replaced elements
31+
* (`<input>`, `<img>`, `<select>`) render none, so a bare checkbox needs a
32+
* padded label wrapper instead of this helper.
33+
* - Targets grow outward and will overlap if two of them sit closer than 44px
34+
* apart. That is fine for controls separated by normal row spacing, but
35+
* controls packed edge to edge (a stepper's `-`/`+` pair) must keep their
36+
* own bounds so neither steals the other's clicks.
37+
*
38+
* A control that can afford the layout space should just be 44px for real —
39+
* `Toaster`'s dismiss button uses `min-h-11 min-w-11` and needs nothing from
40+
* here. This helper exists for the cases where 44px of real box would push the
41+
* surrounding text around.
42+
*
43+
* Reference: WCAG 2.5.8 Target Size (Minimum, AA) sets the floor at 24x24 CSS
44+
* px; 44x44 is the AAA-level 2.5.5 figure and the number both platform HIGs
45+
* use.
46+
*/
47+
export const touchTargetClasses =
48+
"relative after:absolute after:left-1/2 after:top-1/2 after:size-11 " +
49+
"after:-translate-x-1/2 after:-translate-y-1/2 after:content-['']";

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export { cx } from './helpers/cx';
193193
export { resolveTone } from './helpers/tone';
194194
export type { Tone, LegacyTone } from './helpers/tone';
195195
export { formControlClasses } from './helpers/formControlClasses';
196+
export { touchTargetClasses } from './helpers/touchTarget';
196197
export { areaChartOptions, chartBaseOptions, cssToken } from './helpers/chartTheme';
197198
export type { SelectOption } from './components/atoms/Select.vue';
198199
export type { Step } from './components/molecules/Stepper.vue';

tests/touch-target.spec.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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

Comments
 (0)