fix(webkit): bring the calendar onto the shared field contract - #827
Open
gabriel-lisboa-azion wants to merge 15 commits into
Open
fix(webkit): bring the calendar onto the shared field contract#827gabriel-lisboa-azion wants to merge 15 commits into
gabriel-lisboa-azion wants to merge 15 commits into
Conversation
gabriel-lisboa-azion
force-pushed
the
fix/calendar-field-parity
branch
from
July 29, 2026 10:34
d98501f to
dcbafcd
Compare
The focus ring's offset colour must match the surface BEHIND the field, but it was hard-coded to var(--bg-canvas). A field placed on anything else - a raised popover, a drawer - drew its ring gap in the wrong colour, and the only way out was for the consumer to pass a competing ring-offset utility and hope it won on source order. It now resolves var(--input-ring-offset, var(--bg-canvas)), so a host retunes it by declaring one custom property on any ancestor. Custom properties inherit, so a single declaration covers a whole group of fields, and nothing has to out-specify anything. Deliberately NOT solved with cn/tailwind-merge: input-text is the most widely imported field primitive here, and pulling tailwind-merge into it took the entry from 1.45 KB to 11.43 KB against a 2 KB budget (and calendar-root from 15.6 to 26.4 against 17). Budgets ratchet down, so the fix is the cheaper mechanism, not a raised ceiling. Default rendering is unchanged.
Calendar's field surfaces had drifted from the inputs they sit beside: values rendered at .text-body-sm where a text field uses .text-label-sm, disabled was an opacity wash instead of the disabled background/text tokens, and hover put a background on whichever segment the pointer was over - so a single-bordered control lit up in pieces. Hover now raises only the shared border, through the same selector input-text uses, and is suppressed while focused or disabled. Preset and period rows take the menu-row height and shape. Fields are labelled: Start/End/Date were bare spans, leaving each input named only by a placeholder that vanishes on the first keystroke. They are <label for> bound with useId(), and each time field carries its own aria-label. The fields container declares --input-ring-offset once so every field's ring gap matches the raised popover instead of the canvas. The month strip is restructured so only the days move: the label and the prev/next controls sit outside the transition, so the control the user pressed stays put and keeps focus while the month travels. The slide itself needs the shared panel-transition preset to interpolate translate (separate PR) - without it the swap still reads as a fade.
axe cannot catch this: placeholder is a valid accname fallback per HTML-AAM, so the label rule passes on a placeholder-only field, and label-title-only covers only title / aria-describedby. Empirically both a bare placeholder-only input and the '<span>Start</span> + placeholder' shape produce zero axe violations. It is still a defect - the placeholder disappears on the first keystroke, so the field loses its name mid-interaction, and a visible span that reads as a label is associated with nothing. expectNoPlaceholderOnlyLabels runs inside expectNoA11yViolations, so every suite already calling it gets the check. It is tested in both directions: a guard that never fires reads as coverage without being any. Running it across the suite is what surfaced the Calendar fields this branch labels.
InputText, InputNumber and InputPassword are the bare field - the label belongs to the wrapping form-field layer, which binds one with useId(). Their fixtures passed only a placeholder, so under the new guard they read as unnamed fields. Supplying aria-label is what the wrapper would do, so the render stays realistic rather than being weakened to pass.
All ten existing stories render the CLOSED trigger, so nothing inside the popover had ever been snapshotted - the Start/End fields and their labels, the month strip, the preset rows and the timezone selector were invisible to the visual layer. That is most of what this branch changes, so the field-contract work had almost no coverage: only the trigger's own typography and hover. The Open story pins the popover via the controlled open prop. Two details the first local run forced: the wrapper needs an explicit WIDTH (the capture crops to #storybook-root's box, and an unsized root shrink-wraps to the 256px trigger, slicing the time fields and timezone chevron out of frame), and the story shoots desktop + tablet only, because the panel is wider than the 375px mobile capture box. That is a capture limit, not a dodged mode - the ten closed-trigger stories still cover all six. The test asserts the fixture really mounts the panel and that each field resolves by its visible label, so a fixture that stops opening fails a test instead of quietly shooting an empty canvas.
robsongajunior
force-pushed
the
fix/calendar-field-parity
branch
from
July 29, 2026 15:13
0c548a9 to
322d394
Compare
…ts accname subset
robsongajunior
previously approved these changes
Jul 29, 2026
robsongajunior
previously approved these changes
Jul 29, 2026
herbert-julio-azion
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Calendar's field surfaces had drifted from the inputs they sit beside. Placed next to a text field, a Calendar did not read as the same control:
.text-body-smwhere every other field uses.text-label-sm;disabledwas an opacity wash instead of--bg-disabled/--text-disabled/cursor-not-allowed;Hover now raises only the one shared border to
--border-strong, through the same selectorinput-textuses, suppressed while focused (the ring is the active affordance) or disabled.hover:bg-*survives only on the preset menu rows, which are menu items inside the popover, not field surfaces. Preset and period rows take the menu-row height and shape.Fields are named. Start/End/Date were bare
<span>s, leaving each input named only by a placeholder that vanishes on the first keystroke. They are now<label for>bound withuseId(), and each time field carries its ownaria-label(Start time/End time/Time).The month strip is restructured so only the days move. The month label and the prev/next controls sit outside the transition, so the control the user just pressed stays put and keeps focus while the month travels.
The focus-ring offset became a variable, not a class fight. The fields sit on the popover's raised surface, so their ring gap has to match it — but
input-texthard-codedvar(--bg-canvas). It now resolvesvar(--input-ring-offset, var(--bg-canvas)), and the fields container declares that property once; custom properties inherit, so one declaration covers all four fields and nothing has to out-specify anything.Tests
Full webkit suite green (84 files / 1710 passing, 6 skipped), eslint +
vue-tscclean,catalog:checkin sync. New coverage:getByLabelTextin bothsingleandrangemode, with distinct ids;The axe guard
This branch also adds
expectNoPlaceholderOnlyLabels, which runs insideexpectNoA11yViolations— so every suite already calling it gets the check.axe cannot catch a placeholder-only field:
placeholderis a valid accname fallback per HTML-AAM, so thelabelrule passes, andlabel-title-onlycovers onlytitle/aria-describedby. Empirically both a bare placeholder-only input and the<span>Start</span> + placeholdershape produce zero axe violations. It is still a defect — the name disappears mid-interaction, and a visible span that reads as a label is associated with nothing.The guard ships here rather than on its own branch because running it across the suite is what surfaced these Calendar fields: on
mainit fails on 4 real defects, all of them fixed above. A guard that cannot go green by itself is not a separate PR. It is tested in both directions (13 cases) — a guard that never fires reads as coverage without being any.input-text/input-number/input-passwordfixtures gain anaria-label: those components are the bare field, whose label belongs to the wrapping form-field layer, so supplying the name the wrapper would is what keeps the render realistic instead of weakening the assertion to pass.Visual regression
Baselines are regenerated and committed (ubuntu-latest, via the
app-storybook-generate-baselinedispatch). Delta: 64 files, all Calendar, no orphans..text-body-smto.text-label-smand the per-segment hover background is gone.Openstory at desktop and tablet.Openstory had to be addedAll ten existing Calendar stories snapshot the closed trigger.
WithTimeis a 256px pill readingOct 8 – 19and nothing more. So the popover internals — the labelled Start/End fields, the field ring offset, the preset row heights, the month strip, the timezone selector, the chevron rotation — had never been snapshotted, which is most of what this branch changes. Of the 60 moved baselines, all 60 reflect only the trigger's own typography and hover.The new
Openstory pins the popover via the controlledopenprop. Previewing it locally (darwin, gitignored) before spending a CI run caught two things:#storybook-root's box, and an unsized root shrink-wraps to the 256px trigger, slicing the time fields and timezone chevron out of frame. Fixed with an explicitw-[560px].visual.modes). A capture limit, per the documented exception; the ten closed-trigger stories still cover all six modes.Reviewed in both themes: in light the panel reads as a white card with the range band in grey and the endpoints inverted, and each field carries the same border and height as a standalone
input-text— the parity this branch set out to deliver, now actually visible. CI renders the timezone asUTC +00:00rather than a local offset, which is stable because the runner is always UTC.Notes
fix/tab-view-panel-translatefirst. The month strip renders through the shared panel transition preset; without that fix the slide offset is not interpolated and the swap still reads as a fade. This branch is correct and green either way — the order only decides whether the animation lands complete.input-text's root class composition tocn. That pullstailwind-mergeinto the most widely imported field primitive in the package:input-textwent 1.45 KB → 11.43 KB against a 2 KB budget, andcalendar-root15.57 → 26.42 KB against 17 KB. Budgets only ratchet down, so the right move was a cheaper mechanism rather than a raised ceiling — hence the inherited custom property. Nowinput-textis 1.46 KB andcalendar-root16.18 KB, both inside budget, with the +0.6 KB being the actual feature work. History was rewritten rather than patched on top, so no commit here addscnonly for a later one to remove it.input-textrendering is unchanged, and all six InputText consumers were run (137 tests) plus the full suite.Review follow-ups (2026-07-29)
A review pass over this branch confirmed the approach and surfaced four small corrections, now applied:
Openstory's "Show code" snippet is self-contained. It built its source withtoSfc(IMPORT, …)while the template bindsv-model="range", so the pasted SFC referenced an undeclaredrange. It now routes throughsfcScript(…)like every sibling story, declaring the same range the canvas renders.--input-ring-offsetis documented where it lives. The variable isinput-text's public contract, but only the calendar spec and a code comment described it..specs/input-text.mdnow carries it in the Tokens table and the a11y section, with the checksum recomputed.startTimeId/endTimeIdfromcalendar-fields. The time fields are named byaria-label; nothing referenced those ids.ownerDocumentfallback for non-HTMLElementcontainers silently widened the scan to the whole page;querySelectorAllexists on everyElement, so the branch is gone. Its docblocks now state the piggyback onexpectNoA11yViolationsand the accname subset it checks (anaria-labelledbytarget is credited by itstextContentonly), with thedom-accessibility-apiupgrade path if that false positive ever bites.Validated: the two affected suites green in browser mode (52 tests),
validate-story-source.mjs --allOK (87 files), eslint + prettier clean on the touched files. The merge-order note above is settled —fix/tab-view-panel-translate(#826) is merged and already an ancestor of this branch.Docs-page regression in the
Openstory — fixedThe pinned-open
Openstory leaked its panel onto the autodocs page: the popover teleports tobodywithposition: fixedcoordinates measured once at mount, and on the docs page (every story inline in one document) that measurement happens while the page is still laying out — so the panel froze ~3000px away from its own trigger and floated over the Default story. Clicking Default's input then opened its own (correct) popover beside the orphan, reading as a duplicated calendar. The story canvas and the visual baselines were never affected — the bug only exists in inline docs rendering, which is why every gate passed.Fixed with the same treatment Dropdown's forced-open stories already use:
docs.story = { inline: false, iframeHeight: '720px' }, so on the docs page the story renders in its own iframe and the panel's mount-time geometry is its real geometry. Verified with Playwright against the running docs page: zero leaked panels in the docs document, the panel anchored 47px below its own trigger inside the iframe, and Default's popover opening next to its trigger and closing onEscapewith no orphans. (The underlyingusePlacementnot tracking scroll/resize is pre-existing component behavior, noted for a possible follow-up outside this PR.)