Skip to content

fix(bulma-ui): name each range Slider thumb from its label - #1006

Merged
allxsmith merged 9 commits into
mainfrom
fix/981-range-slider-names
Oct 9, 2026
Merged

allxsmith merged 9 commits into
mainfrom
fix/981-range-slider-names

Conversation

@allxsmith

@allxsmith allxsmith commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

A range Slider never let its label into a thumb's name. Each thumb carried an aria-label, either from ariaLabel or the "Minimum value" / "Maximum value" defaults, and aria-label beats label[for]. So <Slider range label="Price range" /> and <Field label="Price range"><Slider range /></Field> both announced plain "Minimum value" and "Maximum value", and a form with two range sliders gave a screen reader user no way to tell their thumbs apart. The label's for only ever reached the low thumb anyway, and even there it lost.

Now, when a label names a range Slider, each thumb points aria-labelledby at that label and at a small hidden span holding the thumb's own text. The names come out as "Price range Minimum value" and "Price range Maximum value". A hidden element still counts when aria-labelledby points straight at it, so the spans need no CSS and render nothing. This works the same for the Slider's own label prop and for a surrounding Field's label, including through a Control.

The wiring lives in useAutoLabelId rather than in Slider, since the form folder's rule is that inputs go through the label hooks instead of juggling ids themselves. It takes a new needsLabelId option and hands back the id of whichever label names the control: its own label (which gets labelProps.id or a generated id) or the Field's label id that #974 already provides for groups. The hook only returns one when that label actually names the control, so a Slider with its own id inside a labeled Field, or a label opted out with htmlFor: undefined, keeps the old names.

On precedence, I kept the caller in charge:

  • An ariaLabel entry is that thumb's whole name, with no label in front. Someone who writes ariaLabel={['Lowest price', 'Highest price']} has said exactly what they want read. Each entry stands on its own, and an empty one counts as unset.
  • An aria-label or aria-labelledby passed straight to the Slider names the whole Slider, taking the label's place at the start of both thumbs' names: aria-label="Budget" reads "Budget Minimum value" and "Budget Maximum value". A non-empty aria-labelledby beats aria-label, as it does in the browser, and aria-labelledby={undefined} keeps every label out, so both thumbs read just "Minimum value" and "Maximum value". Before this, a caller's attribute rode the rest props onto the low thumb alone and left the pair named two different ways.

The label's for still points at the low thumb, so clicking the label focuses it like before and nothing is left pointing at an element that isn't there. Single-thumb Sliders are untouched: no aria-labelledby, no id on the label, no spans.

One case stays as it was. A Field label wired by hand with labelProps={{ htmlFor }}, like the horizontal layouts in the Slider docs, hands out no label id, so a range Slider there keeps its default thumb names. The docs say so and point at ariaLabel for that layout. Teaching Field to pass its label id through a hand-wired setup felt like its own change.

Worth knowing for anyone's tests: getByLabelText('Price range') on a range Slider now matches both thumbs, so it needs getAllByLabelText, or getByRole('slider', { name: 'Price range Minimum value' }).

Docs: the Slider page gets a labeled range example plus one with ariaLabel, and its Accessibility section is rewritten around the new names. The Field page and its label/labelProps TSDoc mention the range Slider, and the form skill teaches it too (its range pattern now has a label). There are two new stories, a range Slider with its own label and one inside a labeled Field.

Tests cover both label sources, ariaLabel winning over each, a caller's aria-label and aria-labelledby winning on the low thumb, labelProps.id on both the Slider and the Field, the hand-wired and opted-out cases keeping the defaults, and single thumbs staying as they were.

pnpm all passes locally.

Fixes #981

A range thumb's default aria-label outranked the label's for, so neither
the Slider's own label nor a surrounding Field's reached a thumb's name and
every range slider announced "Minimum value" and "Maximum value". Each
thumb now points aria-labelledby at the label and at a hidden span holding
its own text, so it reads as "Price range Minimum value". An ariaLabel
entry is still that thumb's whole name, and an aria-label or
aria-labelledby the caller spreads onto the low thumb still wins there.
The label's for keeps landing on the low thumb.

Fixes #981
Adds a labeled range example to the Slider page, rewrites its
Accessibility notes for the new thumb names and how ariaLabel and a
hand-wired Field label interact with them, notes the range Slider on the
Field page, and teaches the form skill the same.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 26 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: allxsmith/bestax/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 953897e6-8712-48d6-82ed-c23d43d216fc
📥 Commits

Reviewing files that changed from the base of the PR and between 7f56cc5 and 908544c.

📒 Files selected for processing (15)
  • bestax-mcp/data/catalog.json
  • bestax-mcp/data/components/Field.json
  • bestax-mcp/data/components/Slider.json
  • bestax-mcp/data/skills.json
  • bulma-ui/src/form/Field.tsx
  • bulma-ui/src/form/FormContext.tsx
  • bulma-ui/src/form/Slider.stories.tsx
  • bulma-ui/src/form/Slider.tsx
  • bulma-ui/src/form/__tests__/Field.test.tsx
  • bulma-ui/src/form/__tests__/Slider.test.tsx
  • bulma-ui/src/form/useAutoLabelId.ts
  • docs/docs/api/form/field.md
  • docs/docs/api/form/slider.md
  • skills/bestax-form/SKILL.md
  • skills/bestax-form/references/patterns.md
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://44db27b5.bestax.pages.dev

Comment thread bulma-ui/src/form/Slider.tsx Outdated
Comment thread bulma-ui/src/form/__tests__/Slider.test.tsx

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — 0 blocking · 2 advisory

# Severity Area Finding Location
1 🔵 Advisory API A caller's aria-label/aria-labelledby renames only the low thumb, so the pair reads with no shared stem ("Budget floor" / "Price Maximum value") — deliberate per the PR body, on the record bulma-ui/src/form/Slider.tsx:373
2 🔵 Advisory Coverage The 'aria-labelledby' in callerAria presence semantics are unenforced — only a defined value is tested, so a truthiness rewrite stays green; the group path pins this at Field.test.tsx:912 bulma-ui/src/form/__tests__/Slider.test.tsx:1426

Overall: The change is sound and the mechanism is the right one. I chased #981's claim to its source and confirmed the failure shape (a range thumb's unconditional aria-label outranking label[for]), then confirmed the fix empirically: the full src/form suite is green (2827 tests), tsc -p tsconfig.test.json is clean, and coverage on the three touched files is 100% statements/lines, with useAutoLabelId.ts and Field.tsx at 100% branches. Putting the wiring in useAutoLabelId rather than in Slider follows this folder's stated rule, and needsLabelId defaults to false, so every other caller's behaviour is byte-identical (ownLabelId stays undefined, and fieldLabelProps collapses to the old ownLabelProps). The riskiest part is the aria-labelledby → <span hidden> technique, since jsdom cannot tell you what a real screen reader does; that is where a human should look first, and the second place is the precedence table, which is now four-way (own label / Field label / ariaLabel entry / spread aria-*) and asymmetric across the two thumbs.

Residual risk:

  • <span hidden> as a name part. Refuted as a spec matter: AccName 1.2 step 2A skips a hidden node only when it is not directly referenced by aria-labelledby, which is exactly what the code's comment claims, and the tests resolve "Price range Minimum value" through dom-accessibility-api. Refuted as a CSS matter too: [hidden] { display: none } is a UA rule, and nothing in scss/form/_slider.scss sets display on a bare descendant — the only author display declarations are on .slider, .slider-input and .slider-output. The vertical modifier makes the wrapper inline-flex, which does not resurrect a display: none child, since such a child generates no box and is never a flex item.
  • DOM-position fragility from the two new spans. Refuted: _slider.scss contains no +, ~, :nth-child, :first-child or :last-child selector, and Slider.tsx does no children / nextElementSibling / querySelector traversal — tooltip placement is computed from progress and wrapperRef.getBoundingClientRect().
  • Dangling IDREFs. Refuted by construction: Field only publishes FieldLabelElementIdProvider value={targetId ? labelId : undefined}, and targetId is non-undefined only when it also renders the <label id={labelId}>; the own-label path hands ownLabelId to that same <label> through fieldLabelProps. The spans are emitted under the identical wired flag that produces the reference.
  • Presence-vs-truthiness on the caller's keys. Refuted for rest: useBulmaClasses derives it by object rest destructuring (useBulmaClasses.tsx:167), which preserves a key whose value is undefined, so 'aria-labelledby' in callerAria behaves as intended. Left open only as the test gap in finding 2.
  • Open — a hand-wired Field label still leaves a range Slider's thumbs indistinguishable. Field publishes no label element id when labelProps.htmlFor is present, even when the caller also supplied labelProps.id, so <Field label="Price" labelProps={{ htmlFor: 'price', id: 'price-label' }}> keeps "Minimum value"/"Maximum value". This is pre-existing (the #939 group path has the same gap), is pinned by a test, and the Slider docs prescribe ariaLabel there — but it is the shape of the Slider page's own horizontal "Inside Field" example, so a reader following that pattern with range reproduces #981's symptom. The PR scopes it out without naming a tracking issue.
  • Open — two range Sliders in one plain labeled Field. Both low thumbs adopt the same generated id and all four thumbs resolve to the same two names, which is #981's exact failure mode. Pre-existing and documented on the Field page ("Two controls in one plain labeled Field would both adopt the id"); grouped/hasAddons fields likewise publish no id, so a range Slider there keeps the defaults.
  • Generated artefacts. skills.json's patterns.md byte count (7058) matches the file on disk, and catalog.json's Slider exampleCount moves 11 → 13 for the two new docs examples. The two new MCP examples share the title "Range Slider" because they sit under one ### heading — harmless, since get_examples filters rather than looks up, and Slider.json already shipped a duplicate title ("Context-Aware Rendering") before this PR.
  • Unverified by me: pnpm check:conformance, gen:mcp:check and the React 18 half of the matrix — those commands were not runnable in this session, so I am relying on the PR's "pnpm all passes locally" plus CI for them. Nothing in the diff looks likely to trip them: no inline style={{}} in the new stories or docs examples, and no React-19-only API (useId is 18+).

🏄 Clean little set of waves here — the label finally rides all the way out to both thumbs instead of wiping out on a hardcoded aria-label, and the tests actually paddle out and check it. Only froth left is that one gnarly asymmetry when you spread your own aria-label, plus a missing test on the opt-out. Good to go.

… range Slider thumbs

An `aria-label` or `aria-labelledby` passed to a range Slider reached only the
low thumb, through the rest spread, so it renamed that thumb while the high one
kept the label's name: `<Slider range label="Price" aria-label="Budget floor" />`
read "Budget floor" and "Price Maximum value", two unrelated names for one
widget.

The Slider now routes those two attributes itself instead of spreading them.
Either one names the Slider in the label's place, so both thumbs read it before
their own text: "Budget Minimum value" and "Budget Maximum value". An
`aria-labelledby` reaches the thumbs the way the label does, through
`aria-labelledby` and the hidden span, and an `aria-label` is joined to each
thumb's text. A non-empty `aria-labelledby` wins over an `aria-label`, as it
does in the browser. An `ariaLabel` entry is still its thumb's whole name and
wins over both.

The presence rule from groups (#974) carries over: any `aria-labelledby` key,
even an undefined one, takes the naming over, so `aria-labelledby={undefined}`
keeps every label out and leaves both thumbs "Minimum value" and "Maximum
value". An empty `aria-label` still counts as unset.

Single-thumb Sliders keep spreading the caller's attributes as before.
The range Slider decides whether the caller named it the way a group does
(#974): any `aria-labelledby` key counts, even an undefined one, while an
`aria-label` counts only when it is non-empty. Every test so far passed a
defined `aria-labelledby`, which a plain truthiness check satisfies just as
well, so swapping the presence test for one would have kept the suite green
while dropping the opt-out.

Pin both sides. `aria-labelledby={undefined}`, under the Slider's own label and
under a surrounding Field's, leaves both thumbs "Minimum value" and "Maximum
value" with no `aria-labelledby` and no hidden spans, while the label's `for`
still lands on the low thumb. An empty `aria-label` leaves the label in both
names.
…lider thumbs

The Slider page's Accessibility section said an `aria-label` or
`aria-labelledby` passed to a range Slider lands on the low thumb and wins
there. It now names the whole Slider, in the label's place, so the section says
that, with the example names, and gives `aria-labelledby={undefined}` as the way
to keep a label out of both thumbs' names. The Field page's range Slider line
gains the same clause, and both pages' Props tables pick up the new TSDoc.
@allxsmith

Copy link
Copy Markdown
Owner Author

deep-review: verify

Every open thread has a reply: fixed, with the commit, or refuted with a reason. This settles them on 908544c.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment

Preview URL: https://fb4deba3.bestax.pages.dev

@claude claude Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review (verify) — 2 resolved · 0 open

# Severity Area Thread Location Outcome
1 🔵 Advisory API A caller's aria-label/aria-labelledby renamed only the low thumb, leaving a mismatched pair bulma-ui/src/form/Slider.tsx:373 ✅ Verified fixed (6f82455, b8846f0)
2 🔵 Advisory Coverage Nothing pinned the aria-labelledby presence semantics on a range thumb bulma-ui/src/form/__tests__/Slider.test.tsx:1488 ✅ Verified fixed (46f7cd4)

Overall: This pass settled threads and reviewed no commits. Both previously-open advisory threads are addressed in the current code and are now resolved. Thread 1 was answered with a code change rather than only the documentation line it asked for: Slider.tsx:351-382 destructures a caller's aria-label/aria-labelledby out of rest, derives a single stemRef/stemText, and feeds it to nameThumb for both thumbs, while thumbRest keeps the low-thumb-only spread for everything else — so a range Slider can no longer announce two unrelated names. Thread 2's missing case is now an it.each at Slider.test.tsx:1515 covering aria-labelledby={undefined} under both the Slider's own label and a surrounding Field label, with a companion test at :1543 pinning that an empty aria-label counts as unset; together they fence both halves of the presence-vs-truthiness rule. Verified empirically: 286 Slider + Field tests pass on 908544c.

Residual risk: not assessed — a verify pass settles open threads and raises no new findings. Ask for a fresh review (a steer starting deep-review: fresh) if you want the commits pushed since the original review looked at on their own.

🏄 Clean set, both waves ridden out — the author did not just paddle around the mismatched-name problem, he turned and took it. Threads closed, tests green, nothing left bobbing in the lineup.

@github-actions github-actions Bot added the review-converged Deep review converged: no blocking findings, all review threads resolved, checks green label Oct 9, 2026
@allxsmith allxsmith added the needs-human-review Loop converged (or contested): awaiting owner review + manual merge label Oct 9, 2026
@github-actions github-actions Bot removed the review-converged Deep review converged: no blocking findings, all review threads resolved, checks green label Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📸 Story screenshots at handoff — 908544c

12 stories (light + dark) from this PR's changed files under bulma-ui/src/ (workflow run · artifact story-screenshots-pr-1006, 30-day retention).

form-field--default · Form/Field · Default
Light Dark
form-field--default light form-field--default dark
form-field--with-help-text · Form/Field · With Help Text
Light Dark
form-field--with-help-text light form-field--with-help-text dark
form-field--labels-custom-controls · Form/Field · Labels Custom Controls
Light Dark
form-field--labels-custom-controls light form-field--labels-custom-controls dark
form-field--validation-states · Form/Field · Validation States
Light Dark
form-field--validation-states light form-field--validation-states dark
form-field--label-sizes · Form/Field · Label Sizes
Light Dark
form-field--label-sizes light form-field--label-sizes dark
form-field--horizontal · Form/Field · Horizontal
Light Dark
form-field--horizontal light form-field--horizontal dark
form-slider--default · Form/Slider · Default
Light Dark
form-slider--default light form-slider--default dark
form-slider--with-output · Form/Slider · With Output
Light Dark
form-slider--with-output light form-slider--with-output dark
form-slider--colors · Form/Slider · Colors
Light Dark
form-slider--colors light form-slider--colors dark
form-slider--sizes · Form/Slider · Sizes
Light Dark
form-slider--sizes light form-slider--sizes dark
form-slider--rounded · Form/Slider · Rounded
Light Dark
form-slider--rounded light form-slider--rounded dark
form-slider--circle · Form/Slider · Circle
Light Dark
form-slider--circle light form-slider--circle dark
> ✂ 40 more matched stories skipped by the cap: `form-field--horizontal-explicit-body`, `form-field--horizontal-narrow`, `form-field--grouped`, `form-field--grouped-centered`, `form-field--grouped-right`, `form-field--grouped-expanded`, `form-field--grouped-multiline`, `form-field--addons`, `form-field--addons-with-select`, `form-field--addons-centered`, … — full set in the artifact.

github-actions Bot added a commit that referenced this pull request Oct 9, 2026
@github-actions github-actions Bot added the review-converged Deep review converged: no blocking findings, all review threads resolved, checks green label Oct 9, 2026
@allxsmith
allxsmith merged commit 4c70d19 into main Oct 9, 2026
78 checks passed
@allxsmith
allxsmith deleted the fix/981-range-slider-names branch October 9, 2026 12:14
@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 5.27.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 4.2.15 🎉

The release is available on:

Your semantic-release bot 📦🚀

@bestax-release-bot

Copy link
Copy Markdown

🎉 This PR is included in version 1.14.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deep-review needs-human-review Loop converged (or contested): awaiting owner review + manual merge released review-converged Deep review converged: no blocking findings, all review threads resolved, checks green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slider: a range Slider's thumbs are named "Minimum value" and "Maximum value", never by its label

1 participant