Skip to content

ADR 0198: An action in a panel is one a key can reach

Status: Accepted — amended 2026-09-03, see Consequences

The contract already decided what Tab does inside an open panel, and said why: a panel with actions of its own keeps the key and walks its own ring, because a Tab that dismissed would leave those actions unreachable from a keyboard — WCAG 2.1.1, not a preference. A panel you only choose from lets the key keep its native meaning, closes, and lets the browser carry the person onward.

The rule was right. The predicate was narrower than the sentence written beside it:

const keepsFocus = "actions" in MDY_WIDGET_CONTRACTS[kind].parts;

That is the timepicker’s own anatomy standing in for the rule. It asks whether a kind has an actions bar, while the sentence beside it asks whether the popup holds controls of its own. One kind satisfies the second and not the first: the colours panel holds a button for entering a custom tint, declares no actions part, and was therefore classed with the panels you only choose from.

Measured, before anything was changed:

plain Tab from the open panel → closes arrows: 12 presses, never leave the swatch grid
lit Tab intercepted, closes anyway (the half-done work of someone who sensed the rule)
angular Tab from the open panel → closes arrows: 9 presses, never leave the grid

So the custom entry was operable with a pointer and with nothing else, in every renderer, for as long as the field has existed.

The predicate says what it means, derived from what the catalogue already declares. A kind keeps Tab when its popup holds a part that is drawn as a button, is not one of the choices, and is not repeated.

The last clause is the anatomy doing the deciding, and it is what separates the two shapes:

  • one action per panel → a tab stop. Tab knows where it arrives, so the ring can name it.
  • one action per row → a declared key on the active row. A stop that named the row would be one stop per row, and Tab would become a scroll. Those are reached by a key instead.

Colours joins the timepicker’s family by derivation, not by exception. Its ring is: the swatch grid as a single stop — the arrows are what move within it — then the custom entry, wrapping. Escape is still the way out.

  • Enumerated over all seventeen kinds, the widened predicate moves exactly one: colours. The rule is unchanged for everything else, and a kind that grows a control in its panel inherits the ring without anyone remembering to grant it.

  • The multiselect’s optionStep is the second shape: one action per row, so it does not change family — it gains a key declared when: "open" instead, and Tab goes on closing that panel.

  • Amendment, 2026-09-03: that key is ArrowRight and ArrowLeft, and the choice was measured. What this kind already declares while its panel is open: Escape, Tab, Enter, Space on an option, ArrowUp/ArrowDown/Home/End for moving, and any printable character for type-ahead. The vertical axis walks the list, so the horizontal one is free for the control on the row the walk is standing on. + and were the alternative and are not available: they are printable, and the type-ahead binding would take them before this one was asked. The declaration lands first and the renderers follow it — until they do, the stepper is still pointer-only, which is recorded here rather than left to be rediscovered.

  • Amendment, 2026-09-03: a pointer-only control that duplicates an act a declared key of the same kind already performs is not a defect. WCAG 2.1.1 asks that the function be operable from a keyboard, not that every control be. The calendar’s month arrows are the case: the month already moves on PageUp and PageDown, so the two buttons drawn beside the header are an affordance for a pointer, not the only way to the act. Bringing them into the tab order was the alternative and was rejected — a header that took stops would put the datepicker in the family that holds Tab, against unanimous reports since made law, and it would buy nothing the declared keys do not already buy.

    The exemption is granted in a shape that can expire. Each recorded control names the binding that justifies it, and the check fails when that binding leaves the contract — not only when the button does. An exemption that outlives the reason it was granted for is a decision nobody took, read later as one that was.

  • And the same amendment records what the rule does not excuse. Applied honestly it produces a finding rather than an exemption: the controls that open the months and years views have no declared key at all. Measured against the current contract, datepicker and daterange declare Escape, Tab, Enter, a commit on a gridcell, PageUp/PageDown, the four arrows, Home and End — every one of them moving within a view. No binding declares a change of view. So the act behind those buttons is the colours entry’s species, not the month arrows’: operable with a pointer and with nothing else. They are recorded as open, not excused, because calling them exempt would be the rule granting itself the decision it exists to ask for.

    The census, so the two are not read as one: of the eight controls the guardian records across the three renderers, four are month arrows naming PageUp, and four are view openers naming nothing — header-label in one renderer and view-toggle in the other two, which is why the record is kept per renderer rather than as a single list.

  • Where this shape is not yet enforced, stated so the record is not read as a whole guarantee. The expiring exemption above binds only the controls the guardian has recorded. A repeated part is absolved earlier and unconditionally: the guardian excuses one whenever its kind declares any arrow with a move intent, without asking whether that arrow names that part. Measured on the multiselect’s stepper, which is repeated and whose class no binding names: it was excused by the arrows that walk between options, which do not reach it. That branch would have excused it identically before this record’s key existed, so a green there said nothing about this species either way — and the stepper was drawn only in the counter variant, which the guardian did not mount.

    Both are now repaired (1c645f27), and the guarantee above holds beyond the recorded controls. The guardian separates a repeated choice from a button drawn inside one — an arrow lands on the first and never reaches the second — and it absolves a repeated button only through a binding whose intent is step. That narrowness is the load-bearing part: any binding naming the parent row would have absolved the stepper through the space bar, which names the same row to toggle it, and then removing the step keys would have changed nothing. It now goes red on the stepper’s class when they are removed. The roster derives from each kind’s declared variants, so a kind is mounted once per variant and the unmounted-variant blindness falls for every kind rather than this one.

  • A spec that asserted “Tab closes what it tabs out of” for every kind failed three renderers for a correct change. It was deriving already — from anatomy (parts.actions) rather than from the declaration — and agreed with the contract for one kind by coincidence. It now reads the binding it is about, so a kind that changes family moves between its two claims by itself.

  • Declare an actions part for colours. Anatomy that lies: every check reading parts would expect a bar that is not drawn.
  • A new field on the part, or a per-kind list of action parts. Both add surface to say something three declarations already say between them — and a parallel list is the shape this whole cycle has been removing.
  • Put the custom entry in the swatch grid’s roving index. It would make a cell that acts rather than chooses: a screen reader announces it as an option and Enter does something its siblings do not. The anatomy should not bend to make the rule look simple.
  • A panelTabOrder function beside the existing rule. Written, then deleted before it was wired: it would have been a third answer to a question the contract already answers twice.

packages/plain/test/an-action-a-key-can-reach.test.mjs presses the key and asserts where focus is — not whether the element could take focus. That distinction is the whole defect: a <button> is focusable in sequence on every day of its life, and the panel was gone before Tab arrived, so a check reading the element would have passed throughout.

Restoring the narrow predicate turns all three of its checks red, with the message naming what is lost. The precondition runs first: the panel opened, and something in it holds focus.

The multiselect’s key is verified the same way, in all three renderers, by a-quantity-a-key-can-change — it presses the key and reads the number on the row the cursor is on. Removing one renderer’s branch turns that renderer’s checks red in the same run. The counter reads ×0, so the digits are extracted and the parse asserted: comparing the labels unparsed is green whether the quantity moved or not, because a strict check holds NaN equal to itself.

The claims this record makes about the guardian are derived from the contract rather than from one of its runs: which classes a binding names, which parts are repeated, and which branch of the guardian’s decision fires first. A run of it would agree, and would agree just as readily with the defect in place — that is the point being recorded.

The same ring is asserted in Lit and Angular, and a browser-tier confirmation is owed before this is called closed — the local measurements are in a DOM without native Tab, which sees interception but not the document’s own sequence.

None. Where a key can travel inside a panel the person opened changes no value, no stored data and no trust boundary. It changes who can operate a control that was already on the page: everyone, rather than only those using a pointer.