Skip to content

Commit e7c9652

Browse files
committed
feat(a11y): add .sf-touch-target opt-in class for the WCAG 44px hit area
Scoping the automatic coarse-pointer floor to class-less controls (prev commit) left classed controls — i.e. almost all real-world controls — with no first-class way to request the 44px target beyond hand-rolling min-sizes or the .sf-btn-only --sf-btn-min-height knob. Add the explicit opt-in counterpart so the floor stays a safety net for bare markup while the everyday mechanism is a class. .sf-touch-target enforces min-block-size/min-inline-size: var(--sf-touch-target) on both axes and centres a short label via inline-flex (so it also works on inline elements like <a>). It lives in core/accessibility.css alongside .sr-only / .skip-link / .sf-focus-shadow, so it ships in every bundle, not just full. It is a single class (specificity 0,1,0) with no !important, so a component rule can still opt a specific control back out. Unlike the automatic floor it is not gated to a coarse pointer — an explicit opt-in is predictable on every device. - core/accessibility.css: add the .sf-touch-target helper + rationale - tests/a11y-patterns.spec.js: enforce-on-classed-control and overridable-without-!important guards - docs/migration.md: document .sf-touch-target as the primary remedy Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgL6mqSxe2ihjaUCxx78eR
1 parent fd8db15 commit e7c9652

8 files changed

Lines changed: 118 additions & 17 deletions

File tree

configurator/src/data/classes.generated.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3074,6 +3074,16 @@
30743074
"optional": false,
30753075
"layer": "slashed.themes"
30763076
},
3077+
{
3078+
"name": "sf-touch-target",
3079+
"selector": ".sf-touch-target",
3080+
"kind": "accessibility",
3081+
"category": "Accessibility",
3082+
"group": "",
3083+
"description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add…",
3084+
"optional": false,
3085+
"layer": "slashed.accessibility"
3086+
},
30773087
{
30783088
"name": "sf-truncate",
30793089
"selector": ".sf-truncate",

core/accessibility.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,29 @@
127127
}
128128
}
129129

130+
/* .sf-touch-target — the explicit opt-IN counterpart to the class-less floor
131+
above. Because the automatic floor deliberately never touches a control
132+
that carries a class, put this class on any control you own (a button with
133+
your own class, a link, a third-party widget you can add a class to) to
134+
guarantee the WCAG 2.5.5 44px minimum hit area on both axes. Unlike the
135+
automatic floor it is NOT gated to a coarse pointer — an explicit opt-in
136+
is predictable everywhere. Size follows --sf-touch-target, so a wrapper (or
137+
the element itself) can retune it locally; the flex centring keeps a short
138+
label centred inside the enlarged box instead of pinned top-left.
139+
140+
<button class="my-menu-toggle sf-touch-target" aria-label="Menu">…</button>
141+
142+
No !important: it's a single class (specificity 0,1,0), so your own
143+
component rule can still override it when a specific control needs to opt
144+
back out. */
145+
.sf-touch-target {
146+
display: inline-flex;
147+
align-items: center;
148+
justify-content: center;
149+
min-block-size: var(--sf-touch-target);
150+
min-inline-size: var(--sf-touch-target);
151+
}
152+
130153
/* Disabled states */
131154
[disabled],
132155
[aria-disabled="true"] {

docs/api-index.json

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,27 +25,27 @@
2525
"optimal"
2626
],
2727
"counts": {
28-
"total": 1053,
28+
"total": 1054,
2929
"by_type": {
30-
"class": 312,
30+
"class": 313,
3131
"token": 741
3232
},
3333
"by_tier": {
3434
"INTERNAL": 1,
35-
"PUBLIC": 1003,
35+
"PUBLIC": 1004,
3636
"PUBLIC-ADVANCED": 49
3737
},
3838
"by_role": {
3939
"consumption": 488,
4040
"knob": 253
4141
},
4242
"tokens": 741,
43-
"classes": 312,
44-
"sf_classes": 289,
43+
"classes": 313,
44+
"sf_classes": 290,
4545
"is_classes": 15,
4646
"unprefixed_classes": 8,
4747
"by_category": {
48-
"Accessibility": 8,
48+
"Accessibility": 9,
4949
"Component tokens": 46,
5050
"Components": 30,
5151
"Core tokens": 610,
@@ -26314,6 +26314,29 @@
2631426314
"optimal"
2631526315
]
2631626316
},
26317+
{
26318+
"name": "sf-touch-target",
26319+
"type": "class",
26320+
"tier": "PUBLIC",
26321+
"selector": ".sf-touch-target",
26322+
"prefix": "sf",
26323+
"kind": "accessibility",
26324+
"category": "Accessibility",
26325+
"area": "accessibility",
26326+
"group": "",
26327+
"description": ".sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add…",
26328+
"isVariant": false,
26329+
"baseClass": null,
26330+
"optional": false,
26331+
"layer": "slashed.accessibility",
26332+
"sourceFiles": [
26333+
"core/accessibility.css"
26334+
],
26335+
"bundles": [
26336+
"full",
26337+
"optimal"
26338+
]
26339+
},
2631726340
{
2631826341
"name": "sf-truncate",
2631926342
"type": "class",

docs/api-index.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ and a short description. The machine-readable companion (with all columns) is
1010
[registry.json](registry.json); for the tier contract see
1111
[architecture.md](architecture.md).
1212

13-
**1053 elements** — 741 tokens, 312 classes.
13+
**1054 elements** — 741 tokens, 313 classes.
1414

1515
| Tier | Count | Meaning |
1616
|---|---|---|
17-
| PUBLIC | 1003 | Everyday surface. SemVer-stable. |
17+
| PUBLIC | 1004 | Everyday surface. SemVer-stable. |
1818
| PUBLIC-ADVANCED | 49 | Same SemVer guarantee; niche/powerful. |
1919
| INTERNAL | 1 | Implementation detail; may change without a major bump. |
2020

@@ -781,9 +781,9 @@ and a short description. The machine-readable companion (with all columns) is
781781
| `--sf-surface-bg-size` | PUBLIC | knob | surface | `cover` | background-size for the .sf-surface-bg image. Default: cover. |
782782
| `--sf-surface-color` | PUBLIC | consumption | surface | `var(--sf-color-base)` | Input for the generic .sf-surface macro. Set any color (including palette shades); the macro derives background, auto-contrast foreground, and the contextual token set from it. |
783783

784-
## Classes (312)
784+
## Classes (313)
785785

786-
### Accessibility (8)
786+
### Accessibility (9)
787787

788788
| Class | Tier | Kind | Group | Description |
789789
|---|---|---|---|---|
@@ -792,6 +792,7 @@ and a short description. The machine-readable companion (with all columns) is
792792
| `.sf-clickable-parent__overlay` | PUBLIC | accessibility || The full-bleed overlay link inside an sf-clickable-parent. Stretches to cover the parent and uses pointer-events to let interactive children keep their own clicks. |
793793
| `.sf-focus-parent` | PUBLIC | accessibility || Forwards focus-visible styling to this container when any descendant is keyboard-focused. Useful for custom controls that wrap a visually-hidden <input>. |
794794
| `.sf-focus-shadow` | PUBLIC | accessibility || Applies the standard focus ring as a box-shadow to any element. Use on custom controls where the native :focus-visible ring doesn't render correctly. |
795+
| `.sf-touch-target` | PUBLIC | accessibility || .sf-touch-target — the explicit opt-IN counterpart to the class-less floor above. Because the automatic floor deliberately never touches a control that carries a class, put this class on any control you own (a button with your own class, a link, a third-party widget you can add… |
795796
| `.skip-link` | PUBLIC | accessibility || Accessible skip-navigation link. Hidden until focused; jumps keyboard users past repeated navigation blocks. Place as the very first element in <body>. |
796797
| `.sr-only` | PUBLIC | accessibility || Visually hides content while keeping it accessible to screen readers. Use for labels, descriptions, and off-screen text that assistive technology needs. |
797798
| `.sr-only-focusable` | PUBLIC | accessibility || Extends sr-only — also reveals the element when it receives keyboard focus. Typical use: skip links and visually-hidden focus targets. |

docs/classes.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Generated** from source by `scripts/gen-class-reference.js`
44
> run `npm run docs:classes` to refresh. Do not edit by hand.
55
6-
**289 .sf-classes, 15 .sf-is-classes.** Every `.sf-*` layout/macro class and `.sf-is-*` state class.
6+
**290 .sf-classes, 15 .sf-is-classes.** Every `.sf-*` layout/macro class and `.sf-is-*` state class.
77
See [architecture.md](architecture.md) for layer order and naming conventions.
88

99
## Layout primitives (`core/layout.css`)
@@ -240,7 +240,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions.
240240

241241
## Accessibility (`core/accessibility.css`)
242242

243-
6 classes.
243+
7 classes.
244244

245245
| Class |
246246
|---|
@@ -250,6 +250,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions.
250250
| `.sf-focus-shadow` |
251251
| `.sf-is-loading` |
252252
| `.sf-text-gradient` |
253+
| `.sf-touch-target` |
253254

254255
## Motion / entrances (`core/motion.css`)
255256

docs/migration.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,27 @@ keeps whatever size its owner gives it. Genuinely bare, un-classed controls
100100
This generalises the earlier `.sf-btn` carve-out (see 0.7.8 → 0.8.0 below) into
101101
a single rule: *the framework never re-sizes a control someone else has styled.*
102102

103+
Because most real controls carry a class, the automatic floor is now a
104+
**safety net for bare markup**, not the everyday mechanism. The everyday
105+
mechanism is the new opt-in class:
106+
107+
**`.sf-touch-target` (new)** — the explicit counterpart to the class-less
108+
floor. Put it on any control you own to guarantee the WCAG 2.5.5 44px hit
109+
area on both axes; it lives in `core/accessibility.css` so it's in every
110+
bundle. Unlike the automatic floor it is not gated to a coarse pointer, and it
111+
centres a short label inside the enlarged box:
112+
113+
```html
114+
<button class="my-menu-toggle sf-touch-target" aria-label="Menu">☰</button>
115+
```
116+
103117
**What changed for you:**
104118
- A bare, class-less `<button>` / native control is unaffected — it still gets
105119
the 44px floor on touch.
106-
- A control that carries **any** class no longer gets the automatic floor on
107-
touch. If you were relying on it for a class-bearing control, restore it
108-
explicitly — set `min-block-size`/`min-inline-size` on that control, or on a
109-
`.sf-btn` use `:root { --sf-btn-min-height: var(--sf-touch-target); }`.
120+
- A control that carries **any** class no longer gets the automatic floor. If
121+
you want the 44px hit area on a class-bearing control, add `.sf-touch-target`
122+
(or set `min-block-size`/`min-inline-size` yourself, or on a `.sf-btn` use
123+
`:root { --sf-btn-min-height: var(--sf-touch-target); }`).
110124
- To opt a control **out** of the floor (e.g. a custom icon button that was
111125
being stretched), give it any class — no `!important` needed.
112126

docs/registry.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
],
2121
"counts": {
2222
"tokens": 741,
23-
"sf_classes": 289,
23+
"sf_classes": 290,
2424
"is_classes": 15
2525
}
2626
},
@@ -1055,6 +1055,7 @@
10551055
"sf-theme-dark",
10561056
"sf-theme-light",
10571057
"sf-theme-transition",
1058+
"sf-touch-target",
10581059
"sf-truncate",
10591060
"sf-visible"
10601061
],

tests/a11y-patterns.spec.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,34 @@ test.describe('a11y: touch target token', () => {
199199
expect(px).toBeGreaterThanOrEqual(44);
200200
});
201201

202+
// .sf-touch-target — explicit opt-in for a classed control the automatic
203+
// class-less floor no longer reaches. Not gated to a coarse pointer, so it
204+
// enforces the 44px hit area here on a normal (fine) pointer too.
205+
test('.sf-touch-target enforces a ≥44px hit area on both axes on a classed control', async ({ page }) => {
206+
await setup(page, `<button id="t" class="my-toggle sf-touch-target"
207+
style="inline-size:20px;block-size:20px;padding:0">x</button>`);
208+
const box = await page.locator('#t').evaluate(el => {
209+
const r = el.getBoundingClientRect();
210+
return { w: r.width, h: r.height };
211+
});
212+
expect(box.w).toBeGreaterThanOrEqual(44);
213+
expect(box.h).toBeGreaterThanOrEqual(44);
214+
});
215+
216+
// A single class (0,1,0) — a component rule can still override it without
217+
// !important, so a specific control can opt back out.
218+
test('.sf-touch-target is overridable by a plain class rule (no !important)', async ({ page }) => {
219+
await setup(page, `<style>.small{min-block-size:20px;min-inline-size:20px}</style>
220+
<button id="t" class="sf-touch-target small"
221+
style="inline-size:20px;block-size:20px;padding:0">x</button>`);
222+
const box = await page.locator('#t').evaluate(el => {
223+
const r = el.getBoundingClientRect();
224+
return { w: r.width, h: r.height };
225+
});
226+
expect(box.w).toBeLessThan(44);
227+
expect(box.h).toBeLessThan(44);
228+
});
229+
202230
// #582: the WCAG floor must NOT track the configurable --sf-size-* scale.
203231
// Shrinking a size rung must not drag the touch target below spec.
204232
test('--sf-touch-target is independent of the --sf-size-* scale', async ({ page }) => {

0 commit comments

Comments
 (0)