多拇指滑块:特殊的双拇指案例

Avatar of Ana Tudor
Ana Tudor

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

几年前,当 Lea Verou 撰写了一篇关于它的 文章 时,我第一次遇到了这个概念。 遗憾的是,多范围滑块已从规范中移除,但与此同时,CSS 也得到了改进——我本人也一样,因此我最近决定制作我自己的 2019 年版本。

在这篇分为两部分的文章中,我们将逐步了解如何构建一个带有两个拇指的示例,然后找出其中的问题。 我们将解决这些问题,首先针对双拇指案例,然后在第二部分中提出一个针对多拇指案例的更好解决方案。

请注意,拇指可以互相穿过,我们可以有任意可能的顺序,并且拇指之间的填充会相应地调整。 令人惊讶的是,整个过程只需要极少的 JavaScript 代码。

文章系列

  1. 多拇指滑块:特殊的双拇指案例 (此帖子)
  2. 多拇指滑块:一般案例

基本结构

我们需要将两个范围输入放在一个包装器中。 它们都具有相同的最小值和最大值(这非常重要,因为否则没有任何内容会正常工作),我们将其设置为包装器上的自定义属性(--min--max)。 我们还将它们的值设置为自定义属性(--a--b)。

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  input#a(type='range' min=min value=a max=max)
  input#b(type='range' min=min value=b max=max)

这将生成以下标记

<div class='wrap' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <input id='b' type='range' min='-50' value='20' max='50'/>
</div>

辅助功能注意事项

我们有两个范围输入,它们可能应该分别具有一个 <label>,但我们希望我们的多拇指滑块具有单个标签。 我们如何解决此问题? 我们可以将包装器设为 <fieldset>,使用其 <legend> 描述整个多拇指滑块,并为每个范围输入提供一个仅对屏幕阅读器可见的 <label>。(感谢 Zoltan 提供此绝妙建议。)

但是,如果我们希望在包装器上使用 flexgrid 布局呢? 这可能是我们想要的,因为唯一其他选择是绝对定位,而这会带来自身的一系列问题。 然后我们遇到了 Chromium 的一个 问题,其中 <fieldset> 不能是 flexgrid 容器。

为了解决此问题,我们使用以下 ARIA 等效项(我从 Steve Faulkner 的 这篇文章 中获取了该等效项)

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
  label.sr-only(for='a') Value A:
  input#a(type='range' min=min value=a max=max)
  label.sr-only(for='b') Value B:
  input#b(type='range' min=min value=b max=max)

生成的标记现在为

<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='a'>Value A:</label>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <label class='sr-only' for='b'>Value B:</label>
  <input id='b' type='range' min='-50' value='20' max='50'/>
</div>

如果我们在元素上设置 aria-labelaria-labelledby 属性,我们还需要 赋予它 一个 role

基本样式

我们将包装器设为一个中间对齐的 grid,它有两行一列。 底部网格单元格将获得我们想要的滑块尺寸,而顶部单元格将获得与滑块相同的宽度,但可以根据组标签的内容调整其高度。

$w: 20em;
$h: 1em;

.wrap {
  display: grid;
  grid-template-rows: max-content $h;
  margin: 1em auto;
  width: $w;
}

为了在视觉上隐藏 <label> 元素,我们对其进行绝对 position 并将其剪辑为空。

.wrap {
  // same as before
  overflow: hidden; // in case <label> elements overflow
  position: relative;
}

.sr-only {
  position: absolute;
  clip-path: inset(50%);
}

有些人可能会对 clip-path 的支持大惊小怪,例如使用它会裁剪掉 Chromium 之前的 Edge 和 Internet Explorer,但在这种特定情况下,这并不重要! 我们很快就会了解其背后的原因。

我们将滑块一个叠放在另一个上面,放在底部网格单元格中。

input[type='range'] {
  grid-column: 1;
  grid-row: 2;
}

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

但是,我们已经注意到一个问题:顶部滑块轨迹不仅显示在底部滑块的拇指上方,而且顶部滑块还使我们无法使用鼠标或触摸点击并与底部滑块进行交互。

为了解决此问题,我们删除了任何轨迹背景和边框,并通过在包装器上设置 background 来突出显示轨迹区域。 我们还在实际的 <input> 元素上设置了 pointer-events: none,然后在其拇指上恢复为 auto

@mixin track() {
  background: none; /* get rid of Firefox track background */
  height: 100%;
  width: 100%;
}

@mixin thumb() {
  background: currentcolor;
  border: none; /* get rid of Firefox thumb border */
  border-radius: 0; /* get rid of Firefox corner rounding */
  pointer-events: auto; /* catch clicks */
  width: $h; height: $h;
}

.wrap {
  /* same as before */
  background: /* emulate track with wrapper background */ 
    linear-gradient(0deg, #ccc $h, transparent 0);
}

input[type='range'] {
  &::-webkit-slider-runnable-track, 
  &::-webkit-slider-thumb, & { -webkit-appearance: none; }
  
  /* same as before */
  background: none; /* get rid of white Chrome background */
  color: #000;
  font: inherit; /* fix too small font-size in both Chrome & Firefox */
  margin: 0;
  pointer-events: none; /* let clicks pass through */
  
  &::-webkit-slider-runnable-track { @include track; }
  &::-moz-range-track { @include track; }
  
  &::-webkit-slider-thumb { @include thumb; }
  &::-moz-range-thumb { @include thumb; }
}

请注意,我们还在 input 本身以及轨迹和拇指上设置了一些其他样式,以便在支持让点击穿过实际 input 元素及其轨迹的浏览器中保持外观一致,同时允许在拇指上点击。 这排除了 Chromium 之前的 Edge 和 IE,这就是我们没有包含 -ms- 前缀的原因——为不会在这些浏览器中起作用的内容设置样式毫无意义。 这也是我们可以使用 clip-path 隐藏 <label> 元素的原因。

如果您想了解更多关于默认浏览器样式的信息,以便了解此处需要覆盖的内容,您可以查看 这篇文章,我在其中深入探讨了范围输入(以及我详细说明了此处使用 mixin 的原因)。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

好了,我们现在有了看起来正常运行的东西。 但是,为了真正使其正常运行,我们需要继续使用 JavaScript!

功能

JavaScript 代码非常简单。 我们需要更新我们在包装器上设置的自定义属性。(对于实际用例,它们将在 DOM 中更高的地方设置,以便它们也能被依赖于它们的样式的元素继承。)

addEventListener('input', e => {
  let _t = e.target;
  _t.parentNode.style.setProperty(`--${_t.id}`, +_t.value)
}, false);

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

但是,除非我们打开 DevTools 以查看这两个自定义属性的值确实在包装器 .wrapstyle 属性中发生了变化,否则并不明显这会产生任何作用。 因此,让我们对此做些什么!

显示值

我们可以做的一件事是,使拖动拇指实际上会改变某些内容变得明显,那就是显示当前值。 为此,我们为每个 input 使用一个 output 元素。

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
  label.sr-only(for='a') Value A:
  input#a(type='range' min=min value=a max=max)
  output(for='a' style='--c: var(--a)')
  label.sr-only(for='b') Value B:
  input#b(type='range' min=min value=b max=max)
  output(for='b' style='--c: var(--b)')

生成的 HTML 如下所示

<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='a'>Value A:</label>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <output for='a' style='--c: var(--a)'></output>
  <label class='sr-only' for='b'>Value B:</label>
  <input id='b' type='range' min='-50' value='20' max='50'/>
  <output for='b' style='--c: var(--b)'></output>
</div>

我们使用一个小巧的 counter 技巧在 ::after 伪元素中显示值。

output {
  &::after {
    counter-reset: c var(--c);
    content: counter(c);
  }
}

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

现在很明显,这些值在拖动滑块时会发生变化,但结果很丑,并且破坏了包装器 background 的对齐方式,因此让我们添加一些调整! 我们可以绝对定位 <output> 元素,但就目前而言,我们只需将它们挤在一行中,位于组标签和滑块之间。

.wrap {
  // same as before
  grid-template: repeat(2, max-content) #{$h}/ 1fr 1fr;
}

[id='multi-lbl'] { grid-column: 1/ span 2 }

input[type='range'] {
  // same as before
  grid-column: 1/ span 2;
  grid-row: 3;
}

output {
  grid-row: 2;
  
  &:last-child { text-align: right; }
  
  &::after {
    content: '--' attr(for) ': ' counter(c) ';'
    counter-reset: c var(--c);
  }
}

好多了!

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

设置单独的 :focus 样式甚至可以让我们得到一个看起来还不错的效果,还可以让我们看到我们当前正在修改的值。

input[type='range'] {
  /* same as before */
  z-index: 1;

  &:focus {
    z-index: 2;
    outline: dotted 1px currentcolor;
    
    &, & + output { color: darkorange }
  }
}

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

现在我们只需要在拇指之间创建填充即可。

棘手的部分

我们可以使用包装器上的::after伪元素重新创建填充,我们将其放置在底部网格行中,我们也在那里放置了范围输入。顾名思义,此伪元素出现在输入之后,但它仍然会显示在其下方,因为我们已在其上设置了正z-index值。请注意,设置z-index对输入有效(无需显式将其position设置为static以外的其他值),因为它们是grid子元素。

此伪元素的width应与较高input值和较低input值之间的差成正比。这里最大的问题是它们彼此传递,我们无法知道哪个值更高。

第一种方法

我对如何解决这个问题的第一个想法是将widthmin-width一起使用。为了更好地理解它是如何工作的,请考虑我们有两个百分比值--a--b,我们希望使元素的width成为它们之间差的绝对值。

这两个值中的任何一个都可以是较大的值,因此我们选择一个--b较大的示例和一个--a较大的示例。

<div style='--a: 30%; --b: 50%'><!-- first example, --b is bigger --></div>
<div style='--a: 60%; --b: 10%'><!-- second example, --a is bigger --></div>

我们将width设置为第二个值(--b)减去第一个值(--a),并将min-width设置为第一个值(--a)减去第二个值(--b)。

div {
  background: #f90;
  height: 4em;
  min-width: calc(var(--a) - var(--b));
  width: calc(var(--b) - var(--a));
}

如果第二个值(--b)较大,则width为正(使其有效)且min-width为负(使其无效)。这意味着计算出的值为通过width属性设置的值。这在第一个示例中是这种情况,其中--b70%--a50%。这意味着width计算为70% - 50% = 20%,而min-width计算为50% - 70% = -20%

如果第一个值较大,则width为负(使其无效)且min-width为正(使其有效),这意味着计算出的值为通过min-width属性设置的值。这在第二个示例中是这种情况,其中--a80%--b30%,这意味着width计算为30% - 80% = -50%,而min-width计算为80% - 30% = 50%

请参阅thebabydino在CodePen上的@thebabydinoPen

将此解决方案应用于我们的两个拇指滑块,我们有

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
  
  &::after {
    content: '';
    background: #95a;
    grid-column: 1/ span 2;
    grid-row: 3;
    min-width: calc((var(--a) - var(--b))/var(--dif)*100%);
    width: calc((var(--b) - var(--a))/var(--dif)*100%);
  }
}

为了将widthmin-width值表示为百分比,我们需要将两个值之间的差除以范围输入的最大值和最小值之间的差(--dif),然后将得到的结果乘以100%

请参阅thebabydino在CodePen上的@thebabydinoPen

到目前为止,一切顺利……那又怎样呢?

::after始终具有正确的计算width,但我们还需要根据较小的值将其从轨道的最小值偏移,并且我们无法对它的margin-left属性使用相同的技巧。

我在这里的第一个直觉是使用left,但实际的偏移量本身不起作用。为了使其工作,我们还必须在::after伪元素上显式设置position: relative。我对此感觉有点不爽,所以我选择改用margin-left

问题是我们对这个第二个属性可以采取什么方法。我们用于width的方法不起作用,因为没有min-margin-left这样的东西。

CSS 规范中现在有了min()函数,但在我在编码这些多拇指滑块时,它仅由 Safari 实现(它后来也出现在 Chrome 中)。仅 Safari 支持对我来说是不够的,因为我不拥有任何 Apple 设备,也不认识现实生活中拥有 Apple 设备的人……所以我无法使用此函数!而且无法想出我可以实际测试的解决方案意味着必须更改方法。

第二种方法

这涉及使用包装器(.wrap)的两个伪元素:一个伪元素的margin-leftwidth设置为第二个值较大,另一个设置为第一个值较大。

使用此技术,如果第二个值较大,则我们在::before上设置的width为正,我们在::after上设置的width为负(这意味着它无效,并且应用了默认值0,隐藏了此伪元素)。同时,如果第一个值较大,则我们在::before上设置的width为负(因此在此情况下,此伪元素的计算width0且未显示),我们在::after上设置的width为正。

类似地,我们使用第一个值(--a)来设置::before上的margin-left属性,因为我们假设第二个值--b对于此伪元素来说更大。这意味着--a是左端的值,--b是右端的值。

对于::after,我们使用第二个值(--b)来设置margin-left属性,因为我们假设第一个值--a对于此伪元素来说更大。这意味着--b是左端的值,--a是右端的值。

让我们看看我们如何将其放入与我们之前拥有的两个相同示例的代码中,其中一个的--b较大,另一个的--a较大。

<div style='--a: 30%; --b: 50%'></div>
<div style='--a: 60%; --b: 10%'></div>
div {
  &::before, &::after {
    content: '';
    height: 5em;
  }
  
  &::before {
    margin-left: var(--a);
    width: calc(var(--b) - var(--a));
  }

  &::after {
    margin-left: var(--b);
    width: calc(var(--a) - var(--b));
  }
}

请参阅thebabydino在CodePen上的@thebabydinoPen

将此技术应用于我们的两个拇指滑块,我们有

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
  
  &::before, &::after {
    grid-column: 1/ span 2;
    grid-row: 3;
    height: 100%;
    background: #95a;
    content: ''
  }
  
  &::before {
    margin-left: calc((var(--a) - var(--min))/var(--dif)*100%);
    width: calc((var(--b) - var(--a))/var(--dif)*100%)
  }
  
  &::after {
    margin-left: calc((var(--b) - var(--min))/var(--dif)*100%);
    width: calc((var(--a) - var(--b))/var(--dif)*100%)
  }
}

请参阅thebabydino在CodePen上的@thebabydinoPen

我们现在有一个很好的功能滑块,带两个拇指。但是此解决方案远非完美。

问题

第一个问题是我们没有完全正确地获得这些margin-leftwidth值。由于拇指样式(例如其形状、相对于轨道的尺寸以及完全不透明)的原因,在本演示中这并不明显。

但是假设我们的拇指是圆形的,甚至可能小于轨道的height

请参阅thebabydino在CodePen上的@thebabydinoPen

我们现在可以看到问题所在:填充的端线与拇指的垂直中线不重合。

这是由于拇指端到端移动的方式造成的。在 Chrome 中,拇指的border-box在轨道的content-box的限制内移动,而在 Firefox 中,它在滑块的content-box的限制内移动。这可以在下面的录制中看到,其中paddingtransparent,而content-boxborder是半透明的。我们对实际滑块使用了橙色,对轨道使用了红色,对拇指使用了紫色。

Animated gif. Chrome only moves the thumb within the left and right limits of the track's content-box.
Chrome 中拇指从滑块一端到另一端运动的录制。

请注意,Chrome 中轨道的width始终由父滑块的width决定——我们在轨道本身上设置的任何width值都会被忽略。Firefox 中的情况并非如此,在 Firefox 中,轨道也可以比其父<input>宽或窄。正如我们下面看到的,这使得拇指的运动范围仅取决于此浏览器中的滑块width这一点更加清晰。

Animated gif. Firefox moves the thumb within the left and right limits of the actual range input's content-box.
Firefox 中拇指从滑块一端到另一端运动的录制。这三个案例从上到下显示。轨道的border-box在水平方向上完美地适应滑块的content-box。它更长,它更短)。

在我们的特定情况下(公平地说,在许多其他情况下),我们可以避免在轨道上设置任何marginborderpadding。这意味着它的content-box与实际范围inputcontent-box重合,因此浏览器之间没有不一致之处。

但我们需要记住的是,拇指的垂直中线(我们需要与填充端点重合)在距轨道的开始处半个拇指width(如果我们有圆形拇指,则为拇指半径)和距轨道的末尾处半个拇指width之间移动。这是一个等于轨道width减去拇指width(在圆形拇指的情况下为拇指直径)的区间。

这可以在下面的交互式演示中看到,其中拇指可以拖动以更好地查看其垂直中线(我们需要与填充的端线重合)在其中移动的区间。

请参阅thebabydino在CodePen上的@thebabydinoPen

最好在 Chrome 和 Firefox 中查看演示。

填充的widthmargin-left值不是相对于100%(或轨道的width),而是相对于轨道width减去拇指width(在圆形拇指的特定情况下,它也是直径)。此外,margin-left值不是从0开始,而是从半个拇指width(在我们的特定情况下为拇指半径)开始。

$d: .5*$h; // thumb diameter
$r: .5*$d; // thumb radius
$uw: $w - $d; // useful width

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
	
  &::before {
    margin-left: calc(#{$r} + (var(--a) - var(--min))/var(--dif)*#{$uw});
    width: calc((var(--b) - var(--a))/var(--dif)*#{$uw});
  }
  
  &::after {
    margin-left: calc(#{$r} + (var(--b) - var(--min))/var(--dif)*#{$uw});
    width: calc((var(--a) - var(--b))/var(--dif)*#{$uw});
  }
}

现在填充开始和结束的位置完全正确,沿着两个拇指的中线。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

这个问题已经解决了,但我们还有更大的问题。假设我们想要更多拇指,比如四个。

Animated gif. Shows a slider with four thumbs which can pass each other and be in any order, while the fills are always between the two thumbs with the two smallest values and between the two thumbs with the two biggest values, regardless of their order in the DOM.
一个包含四个拇指的示例。

现在我们有四个拇指,它们都可以互相穿过,并且可以以我们无法确定的任何顺序排列。此外,我们只有两个伪元素,因此无法应用相同的技术。我们还能找到仅使用 CSS 的解决方案吗?

答案是肯定的!但这意味着要放弃这个解决方案,并采用另一种更巧妙的方法——在本文的第二部分!

文章系列

  1. 多拇指滑块:特殊的双拇指案例 (此帖子)
  2. 多拇指滑块:通用案例 (明天发布!)