レンジスライダー
stimeo--range-slider
下限と上限を 2 つのつまみで選ぶスライダー。互いを追い越しません。
2 つのつまみで範囲の下限と上限を選びます。下側が上側を追い越すことはありません。それぞれのつまみが今どこまで動けるかは、そのつど読み上げにも伝わるので、キーボードだけでも迷いません。両端の位置は割合として CSS から読めるので、つまみと選択範囲の描き方は自由です。最小値、最大値、刻み幅、最初の位置はマークアップで指定します。見た目は持ちません。
実行中
キーボード操作
| キー | 動作 |
|---|---|
| → / ↑ | フォーカス中のつまみを 1 ステップ増やす(相手のつまみは超えない)。logicalTrack を立てた RTL では左右が入れ替わる。 |
| ← / ↓ | フォーカス中のつまみを 1 ステップ減らす(相手のつまみは超えない)。logicalTrack を立てた RTL では左右が入れ替わる。 |
| PageUp / PageDown | フォーカス中のつまみを 10 ステップ分動かす。 |
| Home / End | フォーカス中のつまみを可動範囲の最小 / 最大へジャンプする。 |
<%# Markup for the range-slider (APG Slider, multi-thumb) demo.
stimeo--range-slider handles the two thumbs' (start / end) mutual constraint
(lower ≤ upper), keyboard / drag operation, and dynamic updates of each thumb's
aria-valuemin/max/now, and exposes the range as the root CSS variables
--stimeo--range-slider-start / --stimeo--range-slider-end (0..1). The thumb
and range placement is the Playground's CSS using those variables. %>
<div
class="range-slider"
data-controller="stimeo--range-slider"
data-stimeo--range-slider-min-value="0"
data-stimeo--range-slider-max-value="100"
data-stimeo--range-slider-step-value="1"
data-stimeo--range-slider-start-value="20"
data-stimeo--range-slider-end-value="80">
<%# The hidden fields carry the range into the form; both are optional. %>
<input type="hidden" name="price_min" data-stimeo--range-slider-target="startField" />
<input type="hidden" name="price_max" data-stimeo--range-slider-target="endField" />
<div class="range-slider__track" data-stimeo--range-slider-target="track"
data-action="pointerdown->stimeo--range-slider#onPointerDown">
<div class="range-slider__range" aria-hidden="true"></div>
<div class="range-slider__thumb" role="slider" tabindex="0"
aria-label="<%= t("components.range_slider.demo.min_label") %>"
data-stimeo--range-slider-target="startThumb"
data-action="keydown->stimeo--range-slider#onKeydown"></div>
<div class="range-slider__thumb" role="slider" tabindex="0"
aria-label="<%= t("components.range_slider.demo.max_label") %>"
data-stimeo--range-slider-target="endThumb"
data-action="keydown->stimeo--range-slider#onKeydown"></div>
</div>
<output class="range-slider__output" data-range-slider-output aria-live="polite">20 – 80</output>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* Presentation-only styles for the range-slider demo.
* The thumb and range positions are computed from the CSS variables the library
* exposes on the root: --stimeo--range-slider-start /
* --stimeo--range-slider-end (0..1).
*/
.range-slider {
width: min(22rem, 100%);
padding: 0.75rem 0.625rem;
}
.range-slider__track {
position: relative;
height: 0.375rem;
border-radius: 999px;
background: var(--border);
}
/* Fill from start to end with the accent color. */
.range-slider__range {
position: absolute;
top: 0;
height: 100%;
left: calc(var(--stimeo--range-slider-start, 0) * 100%);
width: calc(
(var(--stimeo--range-slider-end, 1) - var(--stimeo--range-slider-start, 0)) * 100%
);
border-radius: 999px;
background: var(--accent);
}
/* Place each thumb at its fraction position (translateX(-50%) to center it). */
.range-slider__thumb {
position: absolute;
top: 50%;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--surface-card);
border: 2px solid var(--accent);
transform: translate(-50%, -50%);
cursor: grab;
}
.range-slider__thumb[data-stimeo--range-slider-target="startThumb"] {
left: calc(var(--stimeo--range-slider-start, 0) * 100%);
}
.range-slider__thumb[data-stimeo--range-slider-target="endThumb"] {
left: calc(var(--stimeo--range-slider-end, 1) * 100%);
}
.range-slider__thumb:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.range-slider__output {
display: block;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--text-muted, var(--color-text-muted));
}
// Demo that reflects the selected range into a readout. The library provides behavior
// only, so the value display is the consumer's (this script's) job. `change` carries
// the user's moves and `reconcile` a pair the page moved (a runtime `min` / `max` /
// `step` / `start` / `end` change); both carry the same `{ start, end }` detail.
document.querySelectorAll('[data-controller~="stimeo--range-slider"]').forEach((root) => {
const output = root.querySelector('[data-range-slider-output]');
if (!output) return;
const showRange = (e) => {
output.textContent = `${e.detail.start} – ${e.detail.end}`;
};
root.addEventListener('stimeo--range-slider:change', showRange);
root.addEventListener('stimeo--range-slider:reconcile', showRange);
});
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a pair the page moved as
// `stimeo--range-slider:reconcile`. This demo reads both events, so the readout
// follows the user's moves and the page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSelector = [
"[data-stimeo--range-slider-target='startField']",
"[data-stimeo--range-slider-target='endField']",
].join(", ");
const fieldSources = demoStage.querySelectorAll(fieldSelector);
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll(fieldSelector))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--range-slider:reconcile", showSubmitted);
showSubmitted();
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--range-slider"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
track
必須
|
レール。押すと最寄りのつまみが動きドラッグが始まる。 | data-stimeo--range-slider-target="track" |
startThumb
必須
|
下限(最小)側のつまみ(role=slider)。end つまみで上限が制限される。 |
data-stimeo--range-slider-target="startThumb" |
endThumb
必須
|
上限(最大)側のつまみ(role=slider)。start つまみで下限が制限される。 |
data-stimeo--range-slider-target="endThumb" |
startField
|
start を写す任意の hidden input。 | data-stimeo--range-slider-target="startField" |
endField
|
end を写す任意の hidden input。 | data-stimeo--range-slider-target="endField" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
min
|
範囲の有限な最小値(既定0)。 | data-stimeo--range-slider-min-value |
max
|
範囲の有限な最大値(既定100)。min 以上が必須。 | data-stimeo--range-slider-max-value |
step
|
スナップ・刻み幅(既定1)。矢印は1刻み、Page は10倍。 | data-stimeo--range-slider-step-value |
start
|
下限つまみの有限な値(既定0)。公開するペアでは end 以下に保つ。値そのものは、利用者がつまみを動かすまで書かれたまま残る。 | data-stimeo--range-slider-start-value |
end
|
上限つまみの有限な値(既定100)。公開するペアでは start 以上に保つ。値そのものは、利用者がつまみを動かすまで書かれたまま残る。 | data-stimeo--range-slider-end-value |
logicalTrack
|
トラックを論理 CSS で組んでいて RTL でミラーするか(既定 false)。立てるとポインタ写像と横矢印の対が論理方向に従う。立てなければ direction は一切読まない。 | data-stimeo--range-slider-logical-track-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onKeydown
|
フォーカス中つまみのキー操作(矢印・Page・Home/End)。交差しない。 | stimeo--range-slider#onKeydown |
onPointerDown
|
トラックのドラッグを開始し、押下点に近いつまみを動かす。2 つまみが重なっていれば押下方向へ再展開する。 | stimeo--range-slider#onPointerDown |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
ユーザー操作の変更で発火。detail に start と end を載せる。 | stimeo--range-slider:change |
reconcile
|
ページ側の変更(実行時の min / max / step / start / end の変更や morph)で公開中のペアが動いたときに、1 バッチにつき 1 回発火。detail は change と同じ形。接続時は発火しない。 |
stimeo--range-slider:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-valuenow |
各つまみ | そのつまみの現在値。 |
aria-valuemin / aria-valuemax |
各つまみ | 相手のつまみで動的に制限した可動範囲。 |
--stimeo--range-slider-start / --stimeo--range-slider-end |
ルート要素 | 各値の割合(0..1)。つまみとレンジの配置に使う。 |