diff --git a/configurator/src/data/classes.generated.json b/configurator/src/data/classes.generated.json index 2f134350..e09ad391 100644 --- a/configurator/src/data/classes.generated.json +++ b/configurator/src/data/classes.generated.json @@ -3074,6 +3074,16 @@ "optional": false, "layer": "slashed.themes" }, + { + "name": "sf-touch-target", + "selector": ".sf-touch-target", + "kind": "accessibility", + "category": "Accessibility", + "group": "", + "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 third-party widget you can add a class…", + "optional": false, + "layer": "slashed.accessibility" + }, { "name": "sf-truncate", "selector": ".sf-truncate", diff --git a/core/accessibility.css b/core/accessibility.css index 24ab4b36..31fc00ba 100644 --- a/core/accessibility.css +++ b/core/accessibility.css @@ -90,36 +90,75 @@ } } - /* Minimum touch targets. - .sf-btn is excluded on purpose: it owns a deliberate XS–XL size ladder - (--sf-btn-min-height / --sf-size-* rungs) whose default already clears the - WCAG 2.2 AA 24px target, and a blanket floor here would silently override - that ladder on every touch device — collapsing the whole size scale to - 44px. Consumers who want the AAA 44px target on buttons opt in explicitly - with `--sf-btn-min-height: var(--sf-touch-target)`. Bare ` and un-classed native controls — almost always + the author's own quick markup — keep the WCAG 2.5.5 44px floor on both axes. + To opt a control OUT, give it any class. To put the floor back ON an owned + control, add the .sf-touch-target opt-in class below, set its own + min-block/inline-size, or on .sf-btn use + `--sf-btn-min-height: var(--sf-touch-target)`. */ @media (pointer: coarse) { - button:not([class~="sf-btn"]), - input[type="button"], - input[type="submit"], - input[type="reset"], - select, - summary { + :where( + button, + input[type="button"], input[type="submit"], input[type="reset"], + select, summary, + input[type="checkbox"], input[type="radio"] + ):not([class]:not([class=""])) { min-block-size: var(--sf-touch-target); min-inline-size: var(--sf-touch-target); } + } - input[type="checkbox"], - input[type="radio"] { - min-block-size: var(--sf-touch-target); - min-inline-size: var(--sf-touch-target); - } + /* .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 third-party widget you can add a class to) to guarantee + the WCAG 2.5.5 44px minimum hit area on both axes. Unlike the automatic + floor it is NOT gated to a coarse pointer — an explicit opt-in is + predictable everywhere. Size follows --sf-touch-target, so a wrapper (or + the element itself) can retune it locally. + + + + It sets ONLY the two min-size properties — deliberately no `display` — so it + never strips a native affordance (e.g. a 's list-item marker, a + control's baseline alignment). It therefore expects a target whose display + already honours min-inline-size: buttons and native form controls + (inline-block/flex) do. On a purely inline element (a bare ) add your own + `display: inline-flex` (or inline-block) alongside it. + + No !important: it's a single class (specificity 0,1,0), so your own + component rule can still override it when a specific control needs to opt + back out. */ + .sf-touch-target { + min-block-size: var(--sf-touch-target); + min-inline-size: var(--sf-touch-target); } /* Disabled states */ diff --git a/demo/index.html b/demo/index.html index 444b6af6..f66d0efd 100644 --- a/demo/index.html +++ b/demo/index.html @@ -2125,7 +2125,7 @@

Disabled States

Touch Targets (pointer: coarse)

-

On touch devices, a, button, input, select, summary automatically get min-height: var(--sf-touch-target) = var(--sf-size-l) = 2.75rem (≈44px). No extra classes needed.

+

On touch devices, class-less button, form inputs, select, and summary automatically get min-block-size/min-inline-size: var(--sf-touch-target) = 2.75rem (≈44px). Give a control any class (e.g. .sf-btn, or a third-party widget class) and it owns its own sizing — the floor never overrides styled or third-party markup.

High Contrast & Reduced Transparency

@@ -4222,6 +4222,10 @@

Accessibility .sf-focus-shadow public

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.

+
+
.sf-touch-target public

Opt-in WCAG 2.5.5 minimum hit area — enforces a 44px min-block/inline-size on a control you own. Counterpart to the automatic floor, which only backstops controls with no class.

+
+
.skip-link public

Accessible skip-navigation link. Hidden until focused; jumps keyboard users past repeated navigation blocks. Place as the very first element in <body>.

@@ -5017,7 +5021,7 @@

Design tokens 7
--sf-z-tooltip
- + diff --git a/docs/api-index.json b/docs/api-index.json index a97e1ddb..fb0e5cd6 100644 --- a/docs/api-index.json +++ b/docs/api-index.json @@ -25,14 +25,14 @@ "optimal" ], "counts": { - "total": 1053, + "total": 1054, "by_type": { - "class": 312, + "class": 313, "token": 741 }, "by_tier": { "INTERNAL": 1, - "PUBLIC": 1003, + "PUBLIC": 1004, "PUBLIC-ADVANCED": 49 }, "by_role": { @@ -40,12 +40,12 @@ "knob": 253 }, "tokens": 741, - "classes": 312, - "sf_classes": 289, + "classes": 313, + "sf_classes": 290, "is_classes": 15, "unprefixed_classes": 8, "by_category": { - "Accessibility": 8, + "Accessibility": 9, "Component tokens": 46, "Components": 30, "Core tokens": 610, @@ -26314,6 +26314,29 @@ "optimal" ] }, + { + "name": "sf-touch-target", + "type": "class", + "tier": "PUBLIC", + "selector": ".sf-touch-target", + "prefix": "sf", + "kind": "accessibility", + "category": "Accessibility", + "area": "accessibility", + "group": "", + "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 third-party widget you can add a class…", + "isVariant": false, + "baseClass": null, + "optional": false, + "layer": "slashed.accessibility", + "sourceFiles": [ + "core/accessibility.css" + ], + "bundles": [ + "full", + "optimal" + ] + }, { "name": "sf-truncate", "type": "class", diff --git a/docs/api-index.md b/docs/api-index.md index 9a9f0373..624e573b 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -10,11 +10,11 @@ and a short description. The machine-readable companion (with all columns) is [registry.json](registry.json); for the tier contract see [architecture.md](architecture.md). -**1053 elements** — 741 tokens, 312 classes. +**1054 elements** — 741 tokens, 313 classes. | Tier | Count | Meaning | |---|---|---| -| PUBLIC | 1003 | Everyday surface. SemVer-stable. | +| PUBLIC | 1004 | Everyday surface. SemVer-stable. | | PUBLIC-ADVANCED | 49 | Same SemVer guarantee; niche/powerful. | | INTERNAL | 1 | Implementation detail; may change without a major bump. | @@ -781,9 +781,9 @@ and a short description. The machine-readable companion (with all columns) is | `--sf-surface-bg-size` | PUBLIC | knob | surface | `cover` | background-size for the .sf-surface-bg image. Default: cover. | | `--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. | -## Classes (312) +## Classes (313) -### Accessibility (8) +### Accessibility (9) | Class | Tier | Kind | Group | Description | |---|---|---|---|---| @@ -792,6 +792,7 @@ and a short description. The machine-readable companion (with all columns) is | `.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. | | `.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 . | | `.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. | +| `.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 third-party widget you can add a class… | | `.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 . | | `.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. | | `.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. | diff --git a/docs/classes.md b/docs/classes.md index ba5e1ea9..84cf7b29 100644 --- a/docs/classes.md +++ b/docs/classes.md @@ -3,7 +3,7 @@ > **Generated** from source by `scripts/gen-class-reference.js` — > run `npm run docs:classes` to refresh. Do not edit by hand. -**289 .sf-classes, 15 .sf-is-classes.** Every `.sf-*` layout/macro class and `.sf-is-*` state class. +**290 .sf-classes, 15 .sf-is-classes.** Every `.sf-*` layout/macro class and `.sf-is-*` state class. See [architecture.md](architecture.md) for layer order and naming conventions. ## Layout primitives (`core/layout.css`) @@ -240,7 +240,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions. ## Accessibility (`core/accessibility.css`) -6 classes. +7 classes. | Class | |---| @@ -250,6 +250,7 @@ See [architecture.md](architecture.md) for layer order and naming conventions. | `.sf-focus-shadow` | | `.sf-is-loading` | | `.sf-text-gradient` | +| `.sf-touch-target` | ## Motion / entrances (`core/motion.css`) diff --git a/docs/llm-guide.md b/docs/llm-guide.md index e2409cb2..6c255b7d 100644 --- a/docs/llm-guide.md +++ b/docs/llm-guide.md @@ -1072,6 +1072,17 @@ Ready-made `animation` values — keyframe + duration + easing + fill-mode. --sf-blur: 12px /* frosted glass backdrop-filter */ ``` +On a coarse (touch) pointer the framework auto-applies a `--sf-touch-target` +(44px) minimum on both axes — but **only to controls with no effective class** +(no `class` attribute, or an empty `class=""`). Any control carrying a real +class is treated as *owned* (by `.sf-btn`, by you, or by a third-party widget) +and keeps its own sizing, so the floor never re-sizes styled or third-party +markup. To request the 44px hit area on a control you own, add the **`.sf-touch-target`** +opt-in class (`core/accessibility.css`, every bundle) — it sets only the two +min-sizes (no `display`, so it never strips a native affordance) and, unlike the +automatic floor, applies regardless of pointer type. On `.sf-btn` the size ladder +governs instead; opt a button into 44px with `--sf-btn-min-height: var(--sf-touch-target)`. + ### 9.9 Form field tokens ```css diff --git a/docs/migration.md b/docs/migration.md index 895ba496..24a5dcd3 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -79,6 +79,53 @@ you're on a bundle that includes `optional/utilities.css` (the `full` bundle, or your own custom build — the `optimal` bundle does not include it). Full rationale for every class: [states.md § "Prefer native state"](states.md). +### Coarse-pointer touch-target floor scoped to class-less controls (breaking) + +The `@media (pointer: coarse)` minimum-touch-target floor in +`core/accessibility.css` used to target **every** bare ` +``` + +**What changed for you:** +- A bare, class-less ``); + const box = await page.locator('#t').evaluate(el => { + const r = el.getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + expect(box.w).toBeGreaterThanOrEqual(44); + expect(box.h).toBeGreaterThanOrEqual(44); + }); + + // A single class (0,1,0) — a component rule can still override it without + // !important, so a specific control can opt back out. + test('.sf-touch-target is overridable by a plain class rule (no !important)', async ({ page }) => { + await setup(page, ` + `); + const box = await page.locator('#t').evaluate(el => { + const r = el.getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + expect(box.w).toBeLessThan(44); + expect(box.h).toBeLessThan(44); + }); + + // .sf-touch-target sets only min-sizes (no display), so it must not strip a + // native affordance — e.g. a 's list-item display / disclosure + // marker. Guards the review finding about replacing native display modes. + test('.sf-touch-target does not override a native control display (summary stays list-item)', async ({ page }) => { + await setup(page, `
Morebody
`); + const display = await page.locator('#t').evaluate(el => getComputedStyle(el).display); + expect(display).toBe('list-item'); + }); + // #582: the WCAG floor must NOT track the configurable --sf-size-* scale. // Shrinking a size rung must not drag the touch target below spec. test('--sf-touch-target is independent of the --sf-size-* scale', async ({ page }) => { diff --git a/tests/button-touch.spec.js b/tests/button-touch.spec.js index 33d64db1..bf478108 100644 --- a/tests/button-touch.spec.js +++ b/tests/button-touch.spec.js @@ -1,13 +1,16 @@ // @ts-check // Touch-device (pointer: coarse) behaviour for .sf-btn. // -// The blanket WCAG 44px touch-target floor in core/accessibility.css -// deliberately EXCLUDES .sf-btn (`button:not(.sf-btn)`), so the XS–XL size -// ladder stays visible on phones/tablets instead of every rung collapsing to -// 44px. This is the regression guard for the "all button sizes look identical -// on mobile" report: the ladder rendered correctly with a mouse but flattened -// under a coarse pointer. Bare ` }, async (page) => { const h = await heights(page, ['b']); expect(h[0]).toBeGreaterThanOrEqual(44); }); }); + + // Renderers routinely emit an empty class="" for a control the author never + // styled. That is still an unstyled control, so it must keep the floor — + // hence :not([class]:not([class=""])) rather than a plain :not([class]). + test('a ` }, async (page) => { + const h = await heights(page, ['b']); + expect(h[0]).toBeGreaterThanOrEqual(44); + }); + }); + + // Regression guard: the blanket floor must not reach into markup the framework + // does not own. Any class marks a control as owned/styled (a third-party + // page-builder hamburger toggle, a plugin control), so the floor is scoped to + // classless controls only — a classed button escapes it on both axes without + // needing !important. See core/accessibility.css "Minimum touch targets". + test('a classed `; + await withTouch(browser, { html }, async (page) => { + const box = await page.evaluate(() => { + const r = document.getElementById('t').getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + // Neither axis is stretched to the 44px floor — the author's 32px wins. + expect(box.w).toBeLessThan(44); + expect(box.h).toBeLessThan(44); + }); + }); });