Skip to content

Commit 62d2bcd

Browse files
bloveclaude
andauthored
fix(ui): give the filter funnel a 24x24 touch target (#407)
Measured on an iPhone 13 profile, the column-header filter funnel drew an 18x18 CSS px tap target. WCAG 2.5.8 (Target Size, Minimum) asks for 24x24, so every filterable column header on every consumer's mobile app was under the floor. The glyph is unchanged. The extra 6px is a transparent `::after` on the button: out of flow, so it cannot grow the header box that `readDensity` and the surface's virtualisation geometry measure in JS, and it leaves the hover chip and focus ring drawn on the same 18px box they were before. Padding plus a negative margin would have grown the button's border box and taken both of those with it. The pseudo-element is anchored `right: 0` rather than centred. The 4px resize strip sits flush against the funnel's right edge, and while the strip protects itself either way (it carries `z-index: 2` inside the overlay anchor's stacking context, and a centred box measurably takes none of it), a centred box puts 3 of the funnel's own 24px underneath the strip where nothing can tap them — 21px reachable, still short. Growing leftward spends all 6px on the header cell, a sort target hundreds of px wide. Verified by hit-test sweep (`document.elementFromPoint` over every pixel around the control) on an iPhone 13 profile in Chromium and WebKit: density funnel target glyph box resize strip header/row compact 18x18 -> 24x24 18x18 unchanged 44 / 40 standard 18x18 -> 24x24 18x18 unchanged 52 / 48 spacious 18x18 -> 24x24 18x18 unchanged 60 / 56 Header and row heights are byte-identical with the rule applied and with it mutated away, at all three densities, and the header cell loses exactly 6 x 24 = 144 hit-testable px to the funnel while the resize strip loses none. A 24px header (Excel's compact tier, the tightest shipped) still contains the target without it reaching the first data row. Known gap, left alone deliberately: with the drag-to-group panel enabled the surface also renders an 18px column-menu button immediately left of the funnel, which paints above the funnel's hit area. The funnel is then 24 tall but ~17 wide. Three controls cannot each have 24px in the 40px the overlay anchor allots; closing that needs the slot offsets in pretable-surface.tsx to move, not another CSS rule. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f7ec6e7 commit 62d2bcd

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

‎packages/ui/src/__tests__/css-cascade.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,102 @@ describe("grid.css cascade contract", () => {
739739
).toEqual([]);
740740
});
741741

742+
describe("filter funnel touch target (WCAG 2.5.8)", () => {
743+
/**
744+
* The funnel used to be an 18x18 button and nothing else, which is the tap
745+
* target a phone got on every filterable column header. WCAG 2.5.8 (Target
746+
* Size, Minimum) asks for 24x24 CSS px.
747+
*
748+
* These are source assertions, not measurements, because jsdom refuses to
749+
* compute pseudo-element styles at all ("Not implemented: Window's
750+
* getComputedStyle() method: with pseudo-elements") and lays nothing out
751+
* regardless. The measurement that actually proved the fix was a hit-test
752+
* sweep — `document.elementFromPoint` over every pixel around the funnel —
753+
* run on an iPhone 13 profile in both Chromium and WebKit. What is pinned
754+
* here is the geometry that measurement depends on, so a later edit that
755+
* quietly breaks one of these invariants fails in CI rather than on a
756+
* phone.
757+
*/
758+
const funnelRule = (css: string) =>
759+
css.match(
760+
/:where\(\[data-pretable-filter-funnel\]\)\s*\{([\s\S]*?)\}/,
761+
)?.[1];
762+
const afterRule = (css: string) =>
763+
css.match(
764+
/:where\(\[data-pretable-filter-funnel\]\)::after\s*\{([\s\S]*?)\}/,
765+
)?.[1];
766+
767+
test("the hit area is a 24x24 pseudo-element, and the glyph stays 18x18", () => {
768+
const css = fs.readFileSync(GRID_CSS, "utf8");
769+
const base = funnelRule(css);
770+
expect(base, "no [data-pretable-filter-funnel] rule found").toBeDefined();
771+
// The drawn control must NOT grow: the header is dense by design, and the
772+
// hover chip and focus ring are painted on this box.
773+
expect(base).toMatch(/width:\s*18px/);
774+
expect(base).toMatch(/height:\s*18px/);
775+
776+
const after = afterRule(css);
777+
expect(after, "no funnel ::after hit-area rule found").toBeDefined();
778+
expect(after).toMatch(/content:\s*""/);
779+
expect(after).toMatch(/width:\s*24px/);
780+
expect(after).toMatch(/height:\s*24px/);
781+
});
782+
783+
test("the hit area is out of flow, so it cannot resize the header box", () => {
784+
// @pretable/ui's readDensity and @pretable/react's virtualisation both
785+
// measure header and row height in JS. Anything that grows the header's
786+
// painted box desynchronises painted layout from measured layout, so the
787+
// extra 6px has to come from an absolutely positioned box — not from
788+
// padding on the button, and not from a taller button.
789+
const css = fs.readFileSync(GRID_CSS, "utf8");
790+
expect(afterRule(css)).toMatch(/position:\s*absolute/);
791+
// ...which needs the button as its containing block.
792+
expect(funnelRule(css)).toMatch(/position:\s*relative/);
793+
// No padding on the button itself — that WOULD grow its border box.
794+
expect(funnelRule(css)).toMatch(/padding:\s*0/);
795+
});
796+
797+
test("the hit area grows leftward only, clear of the 4px resize strip", () => {
798+
// @pretable/react parks the funnel slot at `left: -22` and the resize
799+
// strip at `left: -4` off the column's trailing edge, so the funnel's
800+
// right edge is flush against the strip.
801+
//
802+
// The strip itself is never at risk: it declares `z-index: 2` inside the
803+
// overlay anchor's stacking context, so it hit-tests above the funnel
804+
// whatever the funnel does. Measured on an iPhone 13, a centred hit area
805+
// takes exactly zero of the strip's pixels. What it takes is the
806+
// FUNNEL's: 3 of its 24px land under the strip, unreachable, and the
807+
// usable target comes back 21px wide — short of the 24 this rule exists
808+
// to reach. `right: 0` spends all 6 extra px on the header cell instead,
809+
// which is a sort target hundreds of px wide.
810+
const after = afterRule(fs.readFileSync(GRID_CSS, "utf8"));
811+
expect(after).toMatch(/right:\s*0/);
812+
// A `left` here would either re-centre the box or stretch it back over
813+
// the strip; `right: 0` plus a width has to be the whole horizontal
814+
// constraint.
815+
expect(after, "funnel hit area must not be left-anchored").not.toMatch(
816+
/(^|[;{\s])left:/,
817+
);
818+
});
819+
820+
test("the hover-reveal rules still address the funnel, not the hit area", () => {
821+
// The funnel is invisible until the header row is hovered, the button
822+
// takes focus, or a filter is active. The hit area rides on the button's
823+
// own opacity, so those three rules must keep targeting the button.
824+
const css = fs.readFileSync(GRID_CSS, "utf8");
825+
expect(css).toMatch(
826+
/:where\(\[data-pretable-header-row\]:hover \[data-pretable-filter-funnel\]\)/,
827+
);
828+
expect(css).toMatch(
829+
/:where\(\[data-pretable-filter-funnel\]:focus-visible\)/,
830+
);
831+
expect(css).toMatch(
832+
/:where\(\[data-pretable-filter-funnel\]\[data-pretable-filter-active="true"\]\)/,
833+
);
834+
expect(funnelRule(css)).toMatch(/opacity:\s*0/);
835+
});
836+
});
837+
742838
test("every grid.css rule selector is wrapped in :where()", () => {
743839
const css = fs.readFileSync(GRID_CSS, "utf8");
744840
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");

‎packages/ui/src/grid.css‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,49 @@
760760
cursor: pointer;
761761
opacity: 0;
762762
transition: opacity 0.1s ease;
763+
/* Containing block for the ::after hit area below. */
764+
position: relative;
765+
}
766+
/* WCAG 2.5.8 (Target Size, Minimum) asks for 24x24 CSS px. The funnel drew
767+
an 18x18 tap target on every filterable column, so a phone got an 18px
768+
target on every header.
769+
The GLYPH stays 18x18 — the header is dense by design and a bigger icon
770+
would change the look of every grid — and the missing 6px is bought with a
771+
transparent ::after. A pseudo-element rather than padding + negative
772+
margin: padding would grow the button's own border box, and the button is
773+
the thing the hover background and the focus ring are drawn on, so an
774+
18px glyph would suddenly wear a 24px hover chip. Out-of-flow also means
775+
the header box cannot change size — @pretable/ui's `readDensity` and the
776+
surface's virtualisation geometry both measure header/row height in JS,
777+
and a change there would desynchronise painted from measured layout.
778+
`right: 0`, NOT centred: the funnel's right edge is flush against the 4px
779+
resize strip (the slots are parked at left:-22 and left:-4 off the
780+
column's trailing edge — see pretable-surface.tsx). The strip is not at
781+
risk — it carries z-index: 2 inside the overlay anchor's stacking context,
782+
so it hit-tests above the funnel either way, and a centred box measurably
783+
steals none of it. What a centred box costs is the FUNNEL: 3 of its 24px
784+
land under the strip where nothing can ever tap them, and the reachable
785+
target measures 21px — still short of the 24 this rule exists to reach.
786+
(iPhone 13, hit-test sweep: right:0 → 24 wide, left:-3px → 21 wide.)
787+
Growing leftward spends all 6px on the header cell instead, which is a
788+
sort target hundreds of px wide.
789+
Height is a flat 24px, not 100%: the tightest shipped header is Excel's
790+
compact tier at exactly 24px, so 24 fits every tier without spilling into
791+
the first data row.
792+
KNOWN GAP: with the drag-to-group panel on, @pretable/react also renders an
793+
18px column-menu button immediately left of the funnel, and it paints above
794+
the funnel's hit area (later in tree order, same stacking context). The
795+
funnel then measures 24 tall but only ~17 wide. Three controls in the 40px
796+
the overlay anchor allots cannot each have 24; closing that one needs the
797+
slot offsets in pretable-surface.tsx to move, not another CSS rule. */
798+
:where([data-pretable-filter-funnel])::after {
799+
content: "";
800+
position: absolute;
801+
top: 50%;
802+
right: 0;
803+
width: 24px;
804+
height: 24px;
805+
transform: translateY(-50%);
763806
}
764807
/* Reveal funnels on header-row hover, on funnel keyboard focus, or when a
765808
filter is active. The header row is a flat list of sibling header cells,

0 commit comments

Comments
 (0)