Skip to content

Round 4: menu-button, radio-group and stepper - #22

Merged
ecropolis merged 3 commits into
mainfrom
claude/round-4-controls
Sep 26, 2026
Merged

ecropolis merged 3 commits into
mainfrom
claude/round-4-controls

Conversation

@ecropolis

Copy link
Copy Markdown
Owner

Chip F of library round 4: three controls whose patterns come from Rocketbelt, reimplemented in the library's shape. Each is one self-contained .astro file with no runtime dependencies and no imports of other elements. Each has --<prefix>-* theming with fallbacks, a keyboard and screen-reader contract, reduced motion, a sensible no-JS render, a demo, and a catalogue entry with pitch, usedOn and a unique search.query. Icons are Font Awesome Free only. There is one commit per element, and npm run check passes at each commit.

scripts/check-controls.mjs joins the npm run check chain. It reads each element's built demo page and the gallery index, which renders every demo on one page. It pins the no-JS render, the roles and keyboard contract in the emitted script, and each once-per-page runtime. It also requires inlined <path>s to be Font Awesome Free byte for byte, one fallback per CSS variable, and contrast on the fallback colours. It then runs a mutation test: 25 deliberately broken copies of the pages, and every one must be caught.

menu-button

What it does. A button that opens a short menu of actions or links: Share, Export, Sort by, the ⋮ on a card. Items are links, or buttons that dispatch a cancelable, bubbling data-selected event with { value, label, href }. mode="radio" makes a settings menu with menuitemradio and aria-checked. iconOnly gives a 44px ⋮ button, and align sets start or end. notFor sends site navigation to mega-menu and choosing a form value to <select>.

Accessibility contract. It follows the WAI-ARIA Authoring Practices menu button.

  • The button has aria-haspopup="menu", aria-expanded and aria-controls. The list is role="menu" labelled by the button, with role="none" on each <li> and tabindex="-1" on each item.
  • Enter, Space and ↓ open the menu on the first item, or on the checked one in a radio menu. ↑ opens it on the last item.
  • ↓ and ↑ wrap. Home and End jump. A letter moves to the next item starting with it. Enter and Space activate.
  • Escape closes the menu and returns focus to the button. Tab closes it and moves on. A click outside or focus leaving also closes it.
  • Disabled items become aria-disabled: they stay focusable but cannot be activated.
  • The menu opens above the button when the room below is short and there is more above, measured on every open. It shifts to the other edge rather than leave the viewport.
  • Under reduced motion, the 120 ms fade is dropped.

No-JS render. The same items sit in a closed <details><summary> with the same label, and link items work. The real button is rendered hidden. No menu role is rendered until the script can honour its keys.

What the check pins.

  • The <details> fallback is present and closed, and the summary and button carry the same name.
  • The hidden button carries aria-haspopup="menu" and aria-expanded="false", and its aria-controls names the list.
  • The static HTML has no role or tabindex, and button items are type="button".
  • The runtime appears once per page.
  • The emitted script sets the menu roles, aria-checked, aria-disabled and aria-expanded, dispatches data-selected, closes on outside pointerdown and focusout, and returns focus to the button.
  • The script's <mb-keys> block is run from the built page against the keyboard contract (18 cases), the opening keys and the placement rule.
  • There are 10 mutants, including a role rendered early, ↓ remapped, ↑ not wrapping, type-ahead matching mid-label, Escape removed, menuitemradio dropped, focus not returned, a wrong flip rule and a doubled runtime.

Search. The target is menu button: 720/mo, difficulty 23. alsoRanks holds dropdown button (190/27), action menu (170/6) and dropdown menu button (10/60). The last is the pre-assigned phrase, too small to target. All figures are SE Ranking US, 2026-09-26.

Attribution. MIT. Pattern from Rocketbelt (Pier 1 Imports, 2020, MIT); reimplemented, no code copied. Rocketbelt's dropdown is a listbox-style select, so only its intent carried over: keyboard navigation, Escape, click outside and decoration after load.

radio-group

What it does. One choice from a few, as real <input type="radio">s in a <fieldset> with a <legend>. There are three styles:

  • default is a styled list.
  • chunky is a card per option, with a Font Awesome Free icon, a title and a line of text.
  • segmented is a pill row.

Options are { value, label, text?, icon?, disabled? }. Other props are value, name, required, hint and hideLegend, plus --rg-* tokens. For hosts, the script sets data-value on the fieldset and dispatches a bubbling data-changed event with { name, value, label }. Chunky icons are Font Awesome Free names read at build time from @fortawesome/fontawesome-free, as the icon element does, and only when an option has one. An <svg> string is accepted for a site without the package.

Accessibility contract.

  • Nothing is replaced, given a role or hidden from assistive technology. The group is named by its legend and each radio by its title.
  • A chunky card's line of text is its description, through aria-describedby. The hint describes the group.
  • The keyboard is the browser's own: Tab into the group, arrows move and select, Space selects.
  • There is a 3px focus ring on the radio, or around the whole card or segment. Every option is at least 44px tall.
  • Checked state is never colour alone: the dot, the border and the fill all change, and forced-colors mode adds a Highlight outline.
  • Under reduced motion there are no colour transitions.

No-JS render. It is a form control and fully works: it posts name=value in a plain form, with required validation from the browser. The catalogue says so.

What the check pins.

  • The legend is the fieldset's first child, and every radio has the group's name.
  • Ids and values are unique, at most one radio is checked, and never a disabled one.
  • Every label, aria-labelledby and aria-describedby resolves to text, and there is no role or tabindex.
  • The demo form's post is computed as a browser would send it: delivery=pickup&billing=monthly, and every contact radio carries required.
  • Chunky icons are Font Awesome Free, data-changed is dispatched, and the runtime appears once per page.
  • There are 8 mutants, including a renamed radio, a second checked radio, a checkbox, a missing label, a tabindex, required dropped, the legend removed and the event renamed.

Search. The target is radio button css: 590/mo, difficulty 21. alsoRanks holds radio group (480/30), radio button design (390/28), segmented control (260/7, volatile) and styled radio buttons (210/22). The pre-assigned segmented control css has no measurable volume.

Attribution. MIT. Pattern from Rocketbelt (Pier 1 Imports, 2020, MIT); reimplemented, no code copied.

stepper

What it does. An <ol> of steps, each done, current or upcoming, joined by connector lines.

  • It is horizontal from 40rem of its own width and vertical below. This is a container query, so a sidebar gets the vertical layout too. orientation="vertical" always stacks.
  • Steps take an optional href and text. current is the 1-based step number, and steps.length + 1 means every step is done.
  • window.__superheroStepper.go(id, n) advances it for a form that does not reload.

Accessibility contract.

  • aria-current="step" is on exactly the current <li>, and on none once every step is done.
  • Done steps say "Completed: …" to screen readers and show a Font Awesome Free check. They link back only when they have an href. Current and upcoming steps never link.
  • It is a <nav> named by label when any step can link. Otherwise the list itself is named, so a purely visual stepper adds no landmark.
  • go() moves states, aria-current, the hidden text and the links. It moves no focus: the form should focus its next section's heading.
  • Links have a 3px focus ring. Forced-colors mode uses Highlight, and reduced motion drops the colour transitions.

No-JS render. It is static as rendered, which is right for the common case of one page per step.

What the check pins.

  • The script's <st-states> rule is run from the built page against golden cases. Every rendered stepper's states must equal that rule for its data-current.
  • aria-current is exactly "step" and on exactly the current step. Done steps carry "Completed:", the check and the right link. No other step links.
  • The root is a <nav> exactly when a step links, and markers are aria-hidden.
  • The go() clamp and the aria-current moves are in the emitted script, and the runtime appears once per page.
  • There are 7 mutants, including aria-current removed, set to page, or duplicated, the state rule shifted by one, a link on the current step, "Completed:" dropped and the clamp removed.

Search. The target is step indicator: 210/mo, difficulty 12. alsoRanks holds wizard steps (170/12), progress indicator (260/42) and steps ui (90/7). The pre-assigned progress steps html has no measurable volume, and stepper alone (14,800/78) is mostly exercise machines.

Attribution. MIT. Pattern from Rocketbelt (Pier 1 Imports, 2020, MIT); reimplemented, no code copied.

Verification

  • npm run check passes at each of the three commits, and npm run build passes.
  • Website type check against this branch: a scratch clone of ecropolis/SuperheroTech main, then ELEMENTS_SOURCE=<this checkout> npm run check:types. It reports 0 errors and 0 warnings, and none of its 12 hints are in the new files. astro build there also renders /elements/menu-button/, /elements/radio-group/ and /elements/stepper/, with the chunky icons resolved from the vendored path.
  • 78 behavioural checks passed in headless Chromium, run with Playwright outside the repo:
    • every key in the menu contract, click outside, one menu open at a time, a cancelled link, the upward flip and a disabled item;
    • no-JS: the <details> opens, the form posts ?delivery=pickup&billing=yearly&contact=phone, and required blocks the post;
    • native radio arrows, which skip the disabled option;
    • go(), including its clamp and unknown ids;
    • no horizontal scroll at 375px, the vertical stepper at 375px, and reduced motion.
  • The query checks in the catalogue pass. None of the new queries or variants appears in the sibling round-4 branches (feedback, structure, small pieces).
  • No Element fields were added and no source was set.

Merge note. The sibling round-4 PRs also append to the end of catalog.ts and demos/index.ts and extend the check chain in package.json. Whichever merges second gets a trivial conflict there: keep both sides.

🤖 Generated with Claude Code

ecropolis and others added 3 commits September 26, 2026 11:42
A <button aria-haspopup="menu" aria-expanded aria-controls> that opens a
role="menu" of links or buttons (menuitemradio with aria-checked for a
setting such as Sort by). ↓ ↑ wrap, Home and End jump, a letter moves to
the next item starting with it, Enter and Space activate, Escape closes and
returns focus to the button, Tab closes and moves on; a click outside or
focus leaving closes it; disabled items stay focusable as aria-disabled.
It opens above the button when the room below is short (measured on each
open) and shifts to the other edge rather than leave the viewport.
Activating an item dispatches a cancelable, bubbling data-selected.

Without JavaScript the same items sit in a closed <details><summary>, and
the button is rendered hidden; menu roles appear only once the script can
honour their keys. notFor points site navigation at mega-menu and form
values at <select>. Search: "menu button" (720/mo, difficulty 23).

scripts/check-controls.mjs joins npm run check: the no-JS render, the roles
the emitted script sets, and the <mb-keys> block run from the built page
against the keyboard contract and the placement rule; FA Free paths,
fallbacks and contrast; and ten mutants of the page, every one caught.

Pattern from Rocketbelt (Pier 1 Imports, 2020, MIT); reimplemented, no code
copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
<input type="radio">s in a <fieldset> named by its <legend>, never replaced
or given a role, in three looks: default (a styled list), chunky (a card per
option with a Font Awesome Free icon, a title and a line of text, which is
the radio's description) and segmented (a pill row). The keyboard is the
browser's; the focus ring is 3px on the radio or around the card or
segment; states come from :checked sibling selectors, with a Highlight
outline in forced-colors mode. It posts name=value in a plain form, and
`required` is the browser's own validation, so without JavaScript nothing
is missing. The one script (once per page) sets data-value on the fieldset
and dispatches a bubbling data-changed with { name, value, label }.

Chunky icons are Font Awesome Free names read at build from
@fortawesome/fontawesome-free, as the icon element does, lazily and only
when an option has one; an <svg> string is accepted for a site without the
package. Search: "radio button css" (590/mo, difficulty 21); the
pre-assigned "segmented control css" has no measurable volume, so
"segmented control" (260/7) is a variant.

check-controls.mjs gains the radio-group section: names, ids, values, one
checked, labels and descriptions resolving to text, no role or tabindex,
the demo form's post computed as a browser would send it, FA Free icons,
contrast, and eight mutants, every one caught.

Pattern from Rocketbelt (Pier 1 Imports, 2020, MIT); reimplemented, no code
copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…JavaScript

An ordered list of steps joined by connector lines, horizontal from 40rem
of its own width and vertical below (a container query, so a sidebar gets
the vertical layout too; orientation="vertical" always stacks). The current
step's <li> carries aria-current="step"; a done step says "Completed:" to
screen readers, shows a Font Awesome Free check and links back when it has
an href; current and upcoming steps never link. It is a <nav> named by
`label` when any step can link, and a named list otherwise.

What the build renders is right without JavaScript. The one script (once
per page) defines window.__superheroStepper.go(id, n) for a form that
advances in place: it clamps n, moves the states, aria-current, the hidden
text and the links, and moves no focus (the form should focus its next
section's heading). Search: "step indicator" (210/mo, difficulty 12); the
pre-assigned "progress steps html" has no measurable volume.

check-controls.mjs gains the stepper section: the <st-states> rule run from
the built page and compared with every rendered stepper, aria-current on
exactly the current step, "Completed:", checks and links on done steps only,
the go() clamp, contrast, and seven mutants, every one caught.

Pattern from Rocketbelt (Pier 1 Imports, 2020, MIT); reimplemented, no code
copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ecropolis
ecropolis merged commit dff8e2e into main Sep 26, 2026
2 checks passed
@ecropolis
ecropolis deleted the claude/round-4-controls branch September 26, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant