数値入力
stimeo--number-input
数を増減する入力欄。刻み幅で上下し、範囲の外へは出ません。
決めた刻みで数を増減する入力欄です。矢印キーで 1 刻み、ページのキーで大きく動き、Home と End で下限と上限へ飛びます。打ち込んだ値も、範囲の中へ収めたうえで刻みに揃えます。刻みの計算は自前で持っているので、ブラウザ標準の数値入力でも、自作の見た目でも、動きは同じです。下限や上限に達したボタンは押せなくなります。押せなくなる前にフォーカスを入力欄へ戻すので、キーボードの居場所が消えません。入力中に範囲や刻みが変わっても、打ちかけの文字とキャレットを保ち、確定時に最新の条件を当てます。確定した数が前回と異なるときにイベントが出ます。
実行中
キーボード操作
| キー | 動作 |
|---|---|
| ↑ / ↓ | 1 ステップ増やす / 減らす。 |
| PageUp / PageDown | ページステップ(既定は step × 10)で増減。 |
| Home / End | 最小 / 最大へジャンプ。 |
<%# Markup for the number-input (APG Spinbutton) demo.
Increment/decrement buttons and the arrow keys step by `step`, clamped to min/max.
The input is the only tab stop (buttons are tabindex="-1"); a button is disabled at
its boundary.
The widget owns the stepping, so it reports a step the way the browser reports its
own: a bubbling `input` then a bubbling `change` on its own input. That is why the
surrounding `stimeo--auto-submit` reacts to a button or an arrow key with nothing
wired between them. %>
<form class="number-input-demo__form"
data-controller="stimeo--auto-submit"
data-stimeo--auto-submit-debounce-value="400"
data-action="change->stimeo--auto-submit#submit">
<div class="number-input-demo" data-controller="stimeo--number-input"
data-stimeo--number-input-min-value="0"
data-stimeo--number-input-max-value="10"
data-stimeo--number-input-step-value="1">
<button type="button" class="number-input-demo__btn"
aria-label="<%= t('components.number_input.demo.decrease') %>" tabindex="-1"
data-stimeo--number-input-target="decrement"
data-action="click->stimeo--number-input#decrement">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<input type="number" class="number-input-demo__input" name="quantity"
min="0" max="10" step="1" value="1"
aria-label="<%= t('components.number_input.demo.quantity') %>"
data-stimeo--number-input-target="input"
data-action="change->stimeo--number-input#onInput
keydown->stimeo--number-input#onKeydown" />
<button type="button" class="number-input-demo__btn"
aria-label="<%= t('components.number_input.demo.increase') %>" tabindex="-1"
data-stimeo--number-input-target="increment"
data-action="click->stimeo--number-input#increment">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
</form>
<%# The readout is aria-hidden: the value is already in the field the screen reader
reads, so repeating it here would say it 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 number-input demo.
* Disabling at the boundary is expressed with native :disabled (the library sets disabled).
*/
.number-input-demo {
display: inline-flex;
align-items: stretch;
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
overflow: hidden;
}
.number-input-demo__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
color: var(--fg);
background: var(--bg);
border: 0;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.number-input-demo__btn:hover:not(:disabled) {
background: var(--color-primary-soft);
color: var(--accent-700);
}
.number-input-demo__btn:disabled {
color: var(--slate-300);
cursor: not-allowed;
}
.number-input-demo__input {
width: 3.5rem;
padding: 0.5rem 0.25rem;
font-size: 1rem;
text-align: center;
color: var(--fg);
background: var(--bg);
border: 0;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
/* Hide the native spin buttons (this demo uses its own buttons). */
-moz-appearance: textfield;
appearance: textfield;
}
.number-input-demo__input::-webkit-outer-spin-button,
.number-input-demo__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.number-input-demo__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.number-input-demo__btn .demo-icon {
width: 1.1rem;
height: 1.1rem;
}
/* The form only groups the widget with the auto-submit controller; it adds no box. */
.number-input-demo__form {
display: contents;
}
// Consumer-side JS for the number-input demo (demo-only).
//
// The widget owns the stepping, so a button, an arrow key or a press-and-hold is a
// value it wrote itself — and it reports that the way the browser reports its own:
// a bubbling `input` then a bubbling `change` on its own input. The surrounding
// `stimeo--auto-submit` therefore reacts without knowing what this component is.
//
// This Playground has no endpoint, so the native submit is cancelled and the Turbo
// lifecycle is finished with a synthetic `turbo:submit-end` (which is what clears
// the controller's `aria-busy` and emits `stimeo--auto-submit:done`). The readout
// then shows exactly what would have been submitted.
const demoStage = document.querySelector(".demo-stage") ?? document;
const form = demoStage.querySelector(".number-input-demo__form");
const readout = demoStage.querySelector("[data-readout]");
const submitted = () => {
const field = form?.querySelector("[name]");
if (!field?.name) return readout?.dataset.emptyText ?? "";
return `${field.name}=${field.value}`;
};
if (form) {
form.addEventListener("submit", (event) => {
event.preventDefault(); // No real navigation in the demo.
if (readout) readout.textContent = submitted();
// Complete the Turbo cycle the controller is waiting on (clears aria-busy).
form.dispatchEvent(new CustomEvent("turbo:submit-end"));
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--number-input"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
input
必須
|
数値入力欄(ネイティブ input または role=spinbutton)。唯一のタブストップで値を保持する。 |
data-stimeo--number-input-target="input" |
increment
|
増加ボタン。step 分だけ増やし、上限で disabled になる。 | data-stimeo--number-input-target="increment" |
decrement
|
減少ボタン。step 分だけ減らし、下限で disabled になる。 | data-stimeo--number-input-target="decrement" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
min
|
下限値(既定は -Infinity)。スナップの基準兼 Home の移動先。 | data-stimeo--number-input-min-value |
max
|
上限値(既定は +Infinity)。End の移動先。 | data-stimeo--number-input-max-value |
step
|
矢印・ボタン1回あたりの増減幅(既定1)。スナップ刻みも兼ねる。 | data-stimeo--number-input-step-value |
pageStep
|
PageUp/PageDown の増減幅。0(既定)なら step × 10 を使う。 | data-stimeo--number-input-page-step-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
decrement
|
値を1ステップ減らし、フォーカスを input に保つ。 | stimeo--number-input#decrement |
increment
|
値を1ステップ増やし、フォーカスを input に保つ。 | stimeo--number-input#increment |
onInput
|
change 時に入力値をクランプし step 刻みにスナップする。 | stimeo--number-input#onInput |
onKeydown
|
APG スピンボタンのキー操作(矢印・Page・Home/End)を処理する。 | stimeo--number-input#onKeydown |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
利用者の確定後の数値が前回と異なるときに1回発火。detail に新しい値を載せる。 | stimeo--number-input:change |
reconcile
|
min / max / step の実行時変更や input 差し替えで確定値が動いたときに発火。detail は change と同じ形。 | stimeo--number-input:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
value |
入力欄 | クランプ・ステップ正規化後の現在値。 |
disabled |
増加 / 減少ボタン | 範囲外になる方向のボタンに付与。 |