Skip to content

feat: semantic locators for dragSlider/moveCursorTo and a new setSliderValue - #5705

Closed
DavertMik wants to merge 2 commits into
4.xfrom
feat/slider-semantic-locators
Closed

feat: semantic locators for dragSlider/moveCursorTo and a new setSliderValue#5705
DavertMik wants to merge 2 commits into
4.xfrom
feat/slider-semantic-locators

Conversation

@DavertMik

@DavertMik DavertMik commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Problem

dragSlider and moveCursorTo resolved their locator with _locateElement, which wraps the argument as new Locator(locator, 'css'). A human-readable string was therefore treated as a CSS selector — there was no findFields / findClickable / role step anywhere in either chain. This is not a component-library problem, it fails on plain HTML:

I.dragSlider('Legacy volume', 40)  // <input type="range"> + <label for>
// → Element "Legacy volume" was not found
I.moveCursorTo('Show details')     // <button>Show details</button>
// → Element "Show details" was not found

docs/webapi/dragSlider.mustache has always claimed "For fuzzy locators, fields are matched by label text, the name attribute, CSS, and XPath". It was not true until now.

Separately, there was no way to set a slider to a value. fill() cannot be delegated to for every slider — measured:

target fill('60') pixel drag
visible native <input type=range> works works
Base UI range input collapsed to 0×0 times out, not visible impossible, nothing to grab
Radix <span role="slider"> not an input works

focus() + Home/End + arrow keys is the one mechanism that reaches all three, including elements with no bounding box.

Commit 1 — semantic locators for dragSlider and moveCursorTo

  • dragSlider resolves a fuzzy string through a new findSlider: getByRole('slider', { name, exact: true })findFields → non-exact role → CSS/XPath. Role-exact runs first on purpose: Locator.field.labelContains ends in unrestricted .//*[@aria-labelledby…] branches, so on Base UI findFields returns [div[role=group], input] and the container wins.
  • moveCursorTo resolves through findClickable, the same path click uses, in both the plain and the context branch.
  • Puppeteer and WebDriver have no getByRole, so they use a new Locator.slider.byLabel XPath (aria-label, <label for>, aria-labelledby, wrapping <label>) restricted to input[type=range] | *[role=slider], then findFields, then CSS/XPath.
  • Non-fuzzy locators (CSS, XPath, strict, {role:…}) short-circuit to the existing lookup, so nothing changes for them.

WebDriver drag fix, found on the way. dragSlider resolved the locator three times (moveCursorTo + two grabElementBoundingRect calls) and then issued pointerMove with origin: 'pointer' and the element's page coordinates as the offset. performActions starts its input source at (0,0), so this only landed on the slider when the slider was full-width — which is exactly what the existing fixture is. It now resolves once and moves to the element origin. The existing #dragSlider test is unchanged and still green.

Commit 2 — setSliderValue

I.setSliderValue('Volume', 60)
I.setSliderValue('#slider', 0)
I.setSliderValue({ role: 'slider', name: 'Brightness' }, 40)

Reads aria-valuemin / aria-valuemax / aria-valuenow / step, falling back to the min / max / value attributes of a native range and then to 0 / 100. Focuses the element, moves to the cheapest of { current value, Home, End } and presses ArrowRight/ArrowLeft (or ArrowUp/ArrowDown when aria-orientation="vertical") the required number of times, then asserts aria-valuenow actually reached the target.

Widgets that are not form fields expose no step — Radix's <span role="slider"> has none — so the increment is measured by pressing one arrow key and reading the delta. Starting from the current value rather than always from Home matters over the wire: on WebDriver the Radix case went from 23.7s to 0.9s.

Errors say what happened rather than timing out:

Slider ("Quality") can not be set to 33, with step 5 the nearest values are 30 and 35
Slider ("Legacy volume") can not be set to 200, its range is 0..100
Slider ("Volume") stopped at 55 instead of 60, the value was clamped or stepped differently

dragSlider on an element with no bounding box no longer throws; it applies offsetX as that many keyboard steps and says so in the debug log.

Needs your call — setSliderValue vs extending fillField

The plan left this open and asked me to implement (a) and flag (b) for review:

  • (a) I.setSliderValue(locator, value) — what this PR ships. Explicit, honest about the mechanism, no overloading.
  • (b) extend fillField to accept role=slider targetsI.fillField('Volume', 60) reads more naturally, but it silently switches mechanism from fill() to key presses, and fillField already branches into rich-text and (with feat: fillField works with role=combobox widgets #5701) combobox handling; a third special case makes it harder to reason about.

Say the word and I will move it to fillField — nothing else in the PR depends on the name.

Second judgement call: on a 0×0 slider dragSlider(locator, 40) moves 40 steps, not 40 pixels, since there are no pixels to move along. If you would rather it threw and pointed at setSliderValue, that is a two-line change.

Notes for review

  • moveCursorTo now goes through selectElement, so it honours strict mode and the elementIndex step option like click does. It used to take els[0] unconditionally.
  • Radix needs aria-label on Slider.Thumb. A <label for> on Slider.Root targets a <span>, which is not labelable, so the thumb has no accessible name and no framework can synthesise one. Documented in docs/basics.md.
  • Base UI's 0×0 range input is a CSS outcome, not a library constant. An unsized Slider.Thumb collapses and takes the input with it (fill() times out, dragging is impossible); a sized thumb yields a 16×16 clipped input that both reach. The fixture covers both, so the keyboard path is exercised against a genuinely zero-size element.
  • Fixtures follow feat: fillField works with role=combobox widgets #5701 (importmap + esm.sh + window.__ready). Base UI is pinned to @base-ui/react@1.8.0 — the package was renamed from @base-ui-components/react, which feat: fillField works with role=combobox widgets #5701 pins.
  • Two-thumb / range sliders are not addressed. Both libraries support them; a single-thumb API cannot address the second thumb, so that needs its own design.
  • typings/types.d.ts is gitignored and regenerated by the dtslint job; npm run def and npm run dtslint pass locally.

Tests

test/helper/webapi.js, so all three helpers run them: #dragSlider - semantic locators, #moveCursorTo - semantic locators, #setSliderValue. Every case asserts observable state — aria-valuenow / value reaching the target, the tooltip text appearing — never "no error thrown".

Fixtures: test/data/app/view/form/slider/{native,radix,baseui}.php and test/data/app/view/form/hover/radix.php, plus two named triggers added to test/data/app/view/form/hover.php.

Local: 25/25 slider and hover cases on Playwright, Puppeteer and WebDriver (Selenium 4.27 in Docker); 770 unit tests; lint clean; npm run def and npm run dtslint clean. Full helper belts: WebDriver 408/408. Playwright and Puppeteer each showed a handful of failures unrelated to this change - the shared test/data/app/db race and cookie/3rd-party-network cases - which reproduce on 4.x with this branch stashed, and pass when the affected suites are run on their own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi

DavertMik and others added 2 commits September 8, 2026 21:34
Both actions resolved their locator with _locateElement, which wraps the
argument as new Locator(locator, 'css'), so a human readable string was
treated as a CSS selector. There was no findFields / findClickable / role
step anywhere in either chain, and both failed on plain HTML:
dragSlider('Legacy volume', 40) could not find an <input type="range">
labelled by <label for>, and moveCursorTo('Show details') could not find
a <button>.

dragSlider now resolves a fuzzy string through getByRole('slider', {name,
exact: true}), then findFields, then a non-exact role match, then CSS or
XPath. Role-exact runs first because Locator.field.labelContains ends in
unrestricted aria-labelledby branches, which return the wrapping container
ahead of the input for widgets that mirror their label onto both.

moveCursorTo resolves through findClickable, the same path click uses, in
both the plain and the context branch. Non-fuzzy locators short-circuit to
the existing lookup, so CSS, XPath, strict and role locators behave exactly
as before.

Puppeteer and WebDriver have no getByRole, so they use a new
Locator.slider.byLabel XPath covering aria-label, <label for>,
aria-labelledby and a wrapping <label>, restricted to input[type=range]
and [role=slider].

Also fixes the WebDriver drag itself: it resolved the locator three times
and then moved the pointer by the element's page coordinates from wherever
the pointer happened to be, since performActions starts its input source at
the origin. That only landed on the slider when the slider spanned the page.
It now resolves once and moves to the element origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
There was no way to set a slider to a value. fillField can not be delegated
to fill() for every slider: a range input that a component library collapses
to 0x0 is not visible, so fill() times out, and a <span role="slider"> is not
an input at all. Pixel dragging can not reach a zero size element either.
focus() plus Home/End and arrow keys works for all of them.

setSliderValue(locator, value) reads aria-valuemin, aria-valuemax,
aria-valuenow and step, falling back to the min, max and value attributes of
a native range and then to 0 and 100. It focuses the element, moves to
whichever of the current value, Home or End is cheapest, presses the arrow
key for the axis given by aria-orientation the required number of times, and
asserts the value actually reached the target.

Widgets that are not form fields expose no step, so the increment is measured
by pressing one arrow key and reading the delta. Starting from the current
value rather than always from Home matters over the wire: on WebDriver the
same case went from 23.7s to 0.9s.

An unreachable or out of range value raises a message naming the step and the
nearest reachable values instead of timing out.

dragSlider on an element with no bounding box no longer throws; it applies
offsetX as that many keyboard steps and says so in the debug log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
@DavertMik

Copy link
Copy Markdown
Contributor Author

not great implementation

@DavertMik DavertMik closed this Sep 8, 2026
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