web-platform-tests / interop

web-platform-tests Interop project
275 stars 28 forks source link

`input[type="range"]` styling #527

Closed jgraham closed 6 months ago

jgraham commented 10 months ago

Description

The ability to style input type=range controls, as per https://github.com/w3c/csswg-drafts/issues/4410#issuecomment-1720875895

Although this is still just a WG resolution at this stage, we think the overall author demand for better form control styling warrants consideration of possible low-hanging fruit for Interop 2024. This proposal is a blocker to more significant form styling work such as two-state switch controls. As part of this proposal we will commit to helping progress the spec and test situation before the start of the Interop period.

Specification

https://drafts.csswg.org/css-pseudo-4/ (but not yet)

Open Issues

https://github.com/w3c/csswg-drafts/issues/4410

Tests

None yet

Current Implementations

Standards Positions

No response

Browser bug reports

No response

Developer discussions

No response

Polls & Surveys

https://2023.stateofcss.com/en-US/usage/#css_pain_points shows form element styling as by far the most significant concrete pain point for CSS authors, behind only the general "browser compatibility".

Existing Usage

No response

Workarounds

https://github.com/w3c/csswg-drafts/issues/4410#issuecomment-1087244943 and followups for some of the approaches authors currently use, and their limitations.

Accessibility Impact

No response

Privacy Impact

No response

Other

No response

jgraham commented 10 months ago

CC @emilio

thebabydino commented 10 months ago

pew

I have a ton on this, scattered in way too many places, so just dumping a couple of things most people may not have seen already.

One, a couple of months ago, there was a CodePen slider challenge and I went through the tagged challenge pens, collected those that seemed finished and looked at what people were doing to achieve the desired designs.

Copy-pasting some numeric findings:

Related to value displays (most often as tooltips): 75% of the sliders had their value displayed at least once (some twice)

Out of these:

None of them used an output, which I would have thought makes most sense to use here as it can be tied to the input via the for attribute. The only ones tying the value to the range input were those displaying the value in a label. I initially thought displaying the value should be left to an output tied to the input, but going through all those demos made me wonder whether it wouldn't be better to have a pseudo for this, like IE/ pre-Chromium Edge had ::-ms-tooltip.

Related to ruler display: 32% of sliders had a ruler

Out of these:

None of these rulers were tied to the sliders unless you count the ::before/::after on range input abomination. Again, while I would have thought datalist was the way to go, as it can be tied to the range input... none of the demos I saw was using that, which makes me wonder if the IE/ pre-Chromium Edge approach of putting everything but the kitchen sink in there wasn't the better way to go. I'd like to pick the mind of whoever came up with that and get the reasoning behind it.

Related to labels: 43% of sliders had at least a label

Out of these:

Related to how the actual sliders were made (excluding the components mentioned above):

Related to usability:

Miscellaneous findings:


Two, the below is a copy-paste of a case study that illustrates the problems when styling a simple flat slider:

Let's say we want to have the following slider.

example1

Looks simple enough, doesn't it? What problems could we possibly run into with such a plain, common slider?

Well, brace yourselves...

Firstly, we start with a simple range input with the default 0, 100 and 50 values for the min, max and value. We'll be making a lot of assumptions that simplify things as we want to focus on the problems with structure/ range of motion that current implementations have.

<input type='range'/>

Let's say this range input will ocupy a 20em x 1.25em box. In practice, this is probably going to be responsive, but here we're keeping everything as simple as possible.

$input-w: 20em;
$input-h: 1.25em;

[type='range'] {
    width: $input-w;
    height: $input-h;
    color: #424e82
}

Next, a bit of custom styling for the thumb, which includes setting dimensions, making it round in all browsers (only round by default in Firefox), giving it a background and getting rid of the default border it comes with in Firefox.

$thumb-d: $input-h;

@mixin thumb {
    border: none;
    width: $thumb-d;
    height: $thumb-d;
    border-radius: 50%;
    background: currentColor
}

/* allow styling the slider in WebKit browsers */
[type='range'], ::-webkit-slider-thumb { appearance: none }

::-moz-range-thumb { @include thumb }
::-webkit-slider-thumb { @include thumb }

Note that we're using a mixin to avoid writing the same thing twice, as this selector:

::-webkit-slider-thumb, 
::-moz-range-thumb { /* thumb styles */ }

... is invalid in WebKit browsers. It's valid in Firefox because in Firefox, the -webkit- selector is an alias for the -moz- one, due the widespread practice of only including -webkit- prefixes. Pre-Chromium Edge used to do the same, btw.

All this stuff is detailed in my in-depth article on range inputs from over half a decade ago, though a lot of the default browser styles have changed still then.

Side note, wish accentColor was a thing so we could set accent-color on the body and then use accentColor for various components instead of setting color on the actual input and then inheriting it from there.

Now it looks like we don't need to do much beside giving the track a height, a border-radius that's half of it and a background:

$track-h: 10px;
$track-r: .5*track-h;

@mixin track {
    height: $track-h;
    border-radius: $track-r;
    background: #e4e7f5
}

::-moz-range-track { @include track }
::-webkit-slider-runnable-track { @include track }

And it looks the same in all current browsers so it seems like there's nothing else to do except add in the progress. Except that's wrong. Our slider doesn't work as desired.

What this design (like most slider designs on the web, as this is by far the most common motion pattern encountered in designs) wants to achieve is the following:

Animated gif. Shows the desired motion described below.

That is: when the slider is at the minimum or at the maximum value, the outline of the round thumb and the circle along which we have the track rounding are concentric circles, their vertical midlines coincide.

What our code so far gives us is the following:

Animated gif. Shows the deafault motion as described below.

This is because, by default, the thumb's border-box moves within the limits of its parent's content-box. Its parent is the track in WebKit browsers and the actual slider in Firefox.

It only looks the same here as, in this very particular simple case, we don't have a margin, a border or a padding on the track. If we did, the Firefox result would be different from what we get in WebKit browsers.

But whether we're in this particular simple case or not, the different internal structure of the slider requires one approach for Firefox and another different approach for WebKit browsers.

That is... unless we done of the following:

  1. we give the thumb a diameter equal to the track's height and then scale it up by a factor that's $thumb-d/$track-r - a problematic one as long as calc() doesn't have unit division and a preprocessor cannot perform division between an em value and a px value
  2. we don't care about the actual dimensions of the track component and we just emulate the visual track using a linear-gradient() rectangle and two radial-gradient() discs - also problematic because of radial-gradient() jaggedness (which requires a bit of edge blurring to fix, which is going to cause an awkward transition from the linear-gradient() in the middle to the radial-gradient() caps and every fix to that comes with its own set of problems) and because it fails when the visual track needs to have a box-shadow or a gradient background

So we're pretty much stuck with different approaches for Firefox and WebKit browsers.

In Firefox, where the thumb is a sibling of the track and moves within the limits of the slider's content-box, we can just shrink the track horizontally by an amount equal to the thumb radius minus the track radius at each end. This can be achieved by either seiing a margin on it or by giving it a width that's the $input-w minus this amount - both approaches work to achieve the desired thumb motion with respect to the track in Firefox.

Or we could set both the margin and the width to later simplify our life when it comes to styling things in WebKit browsers.

[type='range'] {
    --dif: calc(#{$thumb-r} - #{$track-r})
}

@mixin track() {
    margin: var(--dif);
    width: calc(#{$input-w} - 2*var(--dif);
    /* same styles as before */
}

But in WebKit browsers, the thumb is a child of the track. It always moves within the limits of the track's content-box and there is nothing we can do about it.

Except for not setting the visual track styles on the actual track component.

Which is hacky.

But so far, it has been the only way of achieving the result we're after here.

Since we have a different selector for the track in WebKit browsers, we just don't set the track styles on it. Where do we set them then?

Well, we could set them on the actual slider.

Basically, it's like this... in Firefox, where the thumb moves within the limit of the slider's content-box, the slider is the big inviible box and the track the one with the visual track styles. In WebKit browsers, where the thumb moves within the limits of the track's content-box, it's the other way around. The track is the big invisible box, while the actual slider gets the visual track styles set by the track mixin.

However, this is also going to affect the slider's styles in Firefox, which is something we don't want. We could only set these styles in WebKit browsers by using @supports selector():

@supports selector(::-webkit-slider-runnable-track) {
    [type='range'] { @include track }
}

This looks pretty unreliable because maybe one day Firefox starts passing the support test. Maybe this is better?

@supports not selector(::-moz-range-track) {
    [type='range'] { @include track }
}

But isn't the use of @supports selector() cutting off support for a lot of browsers?

There's still one more option. When looking at the internal structure of our slider in WebKit browser, we see that the track is not directly inside the slider, but in a container inside the slider:

Screenshot. Shows the internal range input structure in WebKit browsers. The track is inside a container, targeted with input[type='range' i]::-webkit-slider-container in the browser styles. This container has -webkit-user-modify set to read-only !important.

Now, if we are to use this to set the visual track styles on it, we need to override that one declaration:

-webkit-user-modify: read-only !important;

Which again feels hacky and yucky.

input[type='range']::-webkit-slider-container {
    -webkit-user-modify: read-write !important;
    @include track
}

Then, regardless of whether we've set the visual track styles on the actual slider or on this container component, we need to expand the actual track component to cover the margin-box on its ancestor that got the visual track styles.

::-webkit-slider-runnable-track { margin: calc(-1*var(--dif)) }

Note

I started my deep dive into prettifying native range inputs in January 2015. Both CSS and I got better in the meanwhile, but at the time, the only solution I saw in order to be able to reproduce certain designs was to use pseudo-elements on the track and thumb slider components.

This was an awful hack that only ever worked in Chromium browsers.

I first got it working via the /deep/ combinator, but then that got renamed to >>> (so I had to change the code for close to a hundred sliders at the time) and then it got removed from the spec altogether (for reference) and Chrome binned it too before March 2015.

Then later that year, these selectors:

::-webkit-slider-runnable-track::before, 
::-webkit-slider-runnable-track::after, 
::-webkit-slider-thumb::before, 
::-webkit-slider-thumb::after {}

started working, so I changed the code of what had become over a hundreds sliders once more. The selectors above working was short-lived too and at the end of 2015, I had a massive CodePen collection of styled range inputs, most of which were broken due to this and almost half of which remain broken to this day.

To make matters worse, this collection of styled range inputs is by far the most popular thing I have ever made.

If you've ever seen range inputs that were trying to style the ::before and ::after pseudos of the track or thumb and wondered what monster originated it... I'm afraid I'm that monster.

I have made the most popular demos in that collection private as they kept getting hearted, broken as they were. Plus people were forking them and creating their own demos based on them. And probably even more people just mindlessly copy-pasted the code.

This is one of the main reasons why I haven't gone for a full collection remake using one of the hacky WebKit tactics discussed above. Just like the earlier hacks, they could stop working and leave me with way too many broken demos once more.


Anyway, let's get back to styling our slider.

We still need to add the progress. WebKit browsers don't have a dedicated element for this, so we need to rely on a bit of JS controling a gradient on the track. And by that, I don't mean the track component in WebKit, I mean the visual track, which can be either the input element or that container... containing the actual track component, which needs to be bigger that the visual track because its content-box is the box that the thumb's border-box is constrained to.

The JS is pretty straightforward, we're just setting a --val custom property to the current slider value.

addEventListener('input', e => {
    let _t = e.target;

    _t.style.setProperty('--v', +_t.value)
})

This custom property gets the default slider value 50 at the beginning:

<input type='range' style='--val: 50'/>

The position of the thumb's vertical midline with respect to the visual track is also easily calculated.

At the slider's minimum value, the thumb's vertical midline is one track radius $track-r to the left of the visual track's left edge. At the slider's maximum value, the thumb's vertical midline is one track radius $track-r to the right of the visual track's right edge.

The distance between the position of the thumb's vertical midline at the maximum value and its position of the minimum value is 100% of the width of the visual track minus one track radius $track-r at one end and another track radius $track-r at the other end.

Since the slider's minimum value is 0, the generic position --pos of the thumb's vertical midline is its position at the minimum value ($track-r) plus how much the current value --val represents of the maximum value (100 here) multiplied with the distance between its two extreme positions (at the maximum and at the minimum).

--pos: calc(#{$track-r} + var(--val)/100*(100% - 2*#{$track-r}));

This position is used to set a gradient on the visual track, but only for WebKit browsers:

@mixin track($progr: 0) {
    /* same styles as before */

    @if $progr != 0 {
        background-image: 
            linear-gradient(90deg, currentColor var(--pos), tranparent 0)
    }
}

::-moz-range-track { @include track }

input[type='range']::-webkit-slider-container {
    -webkit-user-modify: read-write !important;
    @include track(1)
}

Of course, we're not done. If JS is disabled, the visual progress is always at 50%, regardless of where we've dragged the slider to. So let's fix this with a --js "flag": a custom property that's 0 if JS is disabled and 1 otherwise.

document.documentElement.classList.add('js')
.js { --js: 1 }

@mixin track($progr: 0) {
    /* same styles as before */

    @if $progr != 0 {
        background-image: 
            linear-gradient(90deg, currentColor calc(var(--js)*var(--pos)), tranparent 0)
    }
}

/* same as before */

This means we'll have no visual progress in the no JS case for WebKit browsers, but that's still better than having a broken one.

Now let's move on to Firefox, which has an actual progress component. And you'd think it's as easy as giving it a background and maybe a height and a border-radius... but it isn't!

This is what happens:

Animated gif. Shows the progress increase from the minimum value t the maximum one as described below.

When the slider is at its minimum value, the width of the progress is 0 (it should be one track radius $track-r). When the slider is at its maximum value, the width of the progress is the same as that of its parent $input-w (it should be the width of its parent minus twice the amount var(--dif) by which the visual track is offset inwards from the slider's edges). What's worse, the left edge of this progress is always attached to the left edge of the actual slider, outside the visial track. And as detailed here, the problem is even worse when the thumb is smaller and all tricks that may be used so solve the problem come with problematic side effects themselves.

So here's what we have for the initial slider: a solution that's hacky in WebKit browsers where the thumb is a child of the track and doesn't show a progress without JS in any browser.

And this is for a very simple flat slider. It gets worse for realistic-looking ones.

zcorpan commented 9 months ago

Related spec issue: https://github.com/whatwg/html/issues/7570

nairnandu commented 6 months ago

Thank you for proposing input[type="range"] styling for inclusion in Interop 2024.

We wanted to let you know that this proposal was not selected to be part of Interop 2024. This is because we got many more proposals than we could include in this year's project. Note that individual vendors may nevertheless choose to advance work in this area during the forthcoming year. We would welcome this proposal being resubmitted again next year, if necessary.

For an overview of our process, see proposal selection. Thank you again for contributing to Interop 2024!

Posted on behalf of the Interop team.