CollaborationNumber field
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callNumber field
Type a number or step it with minus and plus, with units, limits and a word when a value is changed to fit. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add number-fieldReact
npx designme add number-field --reactAstro
npx designme add number-field --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/number-field.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/number-field.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
A field for a number you can type or step. Minus and plus sit at the ends of the field, a unit or a currency sign sits beside the value, and a hint under it gives the range. Press and hold minus or plus to keep going, faster the longer you hold; the arrow keys move a step, Shift or Page Up and Page Down ten. A typed value is rounded to the step and kept inside the range when you press Enter or leave the field, and the hint says what changed, like "$20.00 is the most" or "Rounded to 35 steps". The demo sets an agent's run limits.
- For limits and quantities with a sensible range: an agent's steps, budget and time per run, retries, seats, a page size.
- When people often move a value by a step or two, and sometimes type it outright.
- Instead of a slider when the exact number matters more than a feel for where it sits in its range.
min,max,stepandbigStep(10 by default) set the range and how far the keys move it;decimalssets how the value shows, like 2 for money.prefixandunitshow beside the value ("$", "steps", "min"); the value and its unit stay centred between minus and plus.- Minus is disabled at
minand plus atmax. Pressing and holding either one steps at once, then repeats, faster as you hold. - What you type is checked as you go (anything but digits and a point marks the field in red) and kept on Enter or when you leave: rounded to the step and kept inside the range. Escape puts back the value before.
- The hint shows the range ("5 to 100, in steps of 5") or, after a value was changed to fit, what changed. Set your own with
hint. NumberFieldGroupis the panel from the demo: a title, a line under it, the fields and a summary at the foot.
- The input is a spinbutton with aria-valuemin, aria-valuemax, aria-valuenow and the value in words as aria-valuetext ("10 minutes"); it is labelled by the field's label.
- Up and Down move a step, Shift with an arrow or Page Up and Page Down move ten. Enter keeps what was typed and Escape puts back the value before.
- Minus and plus are named buttons ("Fewer steps", "More steps") left out of the tab order, since the keys do the same; a person using a pointer presses or holds them.
- A value that isn't a number is marked with aria-invalid and a red edge, not colour alone: the hint asks for a number in the range.
- Clicking the label or beside the value puts the cursor in the field.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { NumberField } from '@/components/designme/number-field/NumberField';React example
The demo above as a React component, 31 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React components: two more steps, a budget raised to the most it
// allows, then a minute less of time.
import { useRef, useState } from 'react';
import { NumberField, NumberFieldGroup } from './NumberField';
import { useStory } from '../lib/useScript';
export default function NumberFieldDemo() {
const ref = useRef<HTMLDivElement>(null);
const [steps, setSteps] = useState(25);
const [budget, setBudget] = useState(2.5);
const [time, setTime] = useState(10);
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('[aria-label="More steps"]');
await wait(500); press('[aria-label="More steps"]');
await wait(2000); setBudget(20);
await wait(2500); press('[aria-label="Less time"]');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<NumberFieldGroup title="Run limits" description="For each run of the release agent"
summary={`A run stops at ${steps} steps, $${budget.toFixed(2)} or ${time} min, whichever comes first.`}>
<NumberField label="Max steps" min={5} max={100} step={5} value={steps} onValueChange={setSteps} unit="steps" buttonLabels={['Fewer steps', 'More steps']} />
<NumberField label="Budget per run" min={0.25} max={20} step={0.25} decimals={2} prefix="$" value={budget} onValueChange={setBudget}
valueText={v => `$${v.toFixed(2)}`} buttonLabels={['Lower budget', 'Higher budget']} />
<NumberField label="Time limit" min={1} max={60} value={time} onValueChange={setTime} unit="min" valueText={v => `${v} minutes`} buttonLabels={['Less time', 'More time']} />
</NumberFieldGroup>
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its number-field.js to show your own data. Attributes like class go on its root element.
---
import NumberField from '../components/designme/number-field/NumberField.astro';
---
<NumberField />
<NumberField data-style="soft" class="my-class" />Props
Everything NumberField takes.
| Prop | Type | What it does |
|---|---|---|
labelrequired | string | Like "Max steps" |
min | number | |
max | number | |
step | number | |
bigStep | number | Steps that Shift with an arrow, Page Up and Page Down move by (10) |
value | number | |
defaultValue | number | |
onValueChange | (value: number) => void | |
decimals | number | Decimal places the value shows with, like 2 for money (0) |
prefix | string | Shown before the value, like "$" |
unit | string | Shown after the value, like "min" |
valueText | (value: number) => string | The value in words for screen readers, like "10 minutes". Defaults to the prefix, value and unit |
hint | string | The line under the field. Defaults to the range, like "5 to 100, in steps of 5" |
buttonLabels | [string, string] | What the minus and plus buttons are called, like ["Fewer steps", "More steps"] |
className | string |
Code
The full source, as the CLI installs it.
NumberField.tsxReact · 123 lines
'use client';
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap } from '../lib/motion';
import '../designme.css';
import './number-field.css';
export interface NumberFieldProps {
/** Like "Max steps" */
label: string;
min?: number;
max?: number;
step?: number;
/** Steps that Shift with an arrow, Page Up and Page Down move by (10) */
bigStep?: number;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
/** Decimal places the value shows with, like 2 for money (0) */
decimals?: number;
/** Shown before the value, like "$" */
prefix?: string;
/** Shown after the value, like "min" */
unit?: string;
/** The value in words for screen readers, like "10 minutes". Defaults to the prefix, value and unit */
valueText?: (value: number) => string;
/** The line under the field. Defaults to the range, like "5 to 100, in steps of 5" */
hint?: string;
/** What the minus and plus buttons are called, like ["Fewer steps", "More steps"] */
buttonLabels?: [string, string];
className?: string;
}
const fix = (x: number) => +x.toFixed(4);
/** A number to type or step: minus and plus (press and hold to keep going), the arrow keys, Shift or Page Up/Down for ten
steps. A typed value is rounded to the step and kept inside the range when you press Enter or leave, and the hint says so. */
export function NumberField({ label, min = 0, max = 100, step = 1, bigStep = 10, value, defaultValue = min, onValueChange, decimals = 0, prefix, unit, valueText, hint, buttonLabels = ['Decrease', 'Increase'], className }: NumberFieldProps) {
const [v, setV] = useControllable(value, defaultValue, onValueChange);
const shown = (x: number) => x.toFixed(decimals);
const say = valueText ?? ((x: number) => `${prefix ?? ''}${shown(x)}${unit ? ' ' + unit : ''}`);
const range = hint ?? `${shown(min)} to ${shown(max)}${step !== 1 ? `, in steps of ${step.toFixed(decimals)}` : ''}`;
const [text, setText] = useState(shown(v));
const [note, setNote] = useState('');
const [bad, setBad] = useState(false);
const [bump, setBump] = useState(0);
const editing = useRef(false);
useEffect(() => { if (!editing.current) setText(shown(v)); }, [v, decimals]); // eslint-disable-line react-hooks/exhaustive-deps
const vRef = useRef(v); vRef.current = v;
const set = (x: number, why?: string) => {
const next = fix(Math.min(max, Math.max(min, x)));
if (next !== vRef.current) { setV(next); vRef.current = next; setBump(b => b + 1); }
setText(shown(next));
setNote(why ?? (x > max ? `${say(max)} is the most` : x < min ? `${say(min)} is the least` : ''));
};
const stepBy = (n: number) => set(vRef.current + n * step);
const commit = () => {
editing.current = false; setBad(false);
const raw = text.replace(/[^0-9.]/g, ''), x = parseFloat(raw);
if (!raw || isNaN(x)) { setText(shown(vRef.current)); setNote(`Enter a number from ${shown(min)} to ${shown(max)}`); return; }
const snapped = fix(Math.round((x - min) / step) * step + min);
if (x > max || x < min) set(x);
else set(snapped, Math.abs(snapped - x) > 1e-9 ? `Rounded to ${say(snapped)}` : '');
};
// press and hold minus or plus: one step at once, then more and more quickly until you let go
const hold = useRef<ReturnType<typeof setTimeout>>(undefined);
const stop = () => clearTimeout(hold.current);
useEffect(() => stop, []);
const press = (d: number) => {
stepBy(d); stop();
const rep = (ms: number) => { hold.current = setTimeout(() => { const at = vRef.current; if ((d < 0 && at <= min) || (d > 0 && at >= max)) return; stepBy(d); rep(Math.max(40, ms * .8)); }, ms); };
rep(420);
};
const input = useRef<HTMLInputElement>(null), box = useRef<HTMLDivElement>(null);
// a nudge of the value each time it changes
useEffect(() => { const b = box.current; if (!b || !bump) return; b.classList.remove('dm-bump'); void b.offsetWidth; b.classList.add('dm-bump'); }, [bump]);
const id = useId();
const btn = (d: number, name: string): ReactNode => (
<button type="button" className={cx('dm-ib', d < 0 ? 'dm-nmf-dn' : 'dm-nmf-up')} tabIndex={-1} aria-label={name} disabled={d < 0 ? v <= min : v >= max}
onPointerDown={e => { if (e.button) return; e.preventDefault(); press(d); }} onPointerUp={stop} onPointerLeave={stop} onPointerCancel={stop}
onClick={e => { if (e.detail === 0) stepBy(d); }}>
<Icon name={d < 0 ? 'minus' : 'plus'} size="xs" />
</button>
);
return (
<div data-dm="" className={cx('dm-nmf-f', note && 'dm-noted', className)}>
<span className="dm-nmf-l" id={id} onClick={() => input.current?.focus()}>{label}</span>
<div ref={box} className="dm-nmf-in">
{btn(-1, buttonLabels[0])}
<span className="dm-nmf-v" onClick={e => { if (e.target !== input.current) input.current?.focus(); }}>
{prefix && <span className="dm-nmf-u">{prefix}</span>}
<input ref={input} className="dm-num" role="spinbutton" inputMode="decimal" autoComplete="off" spellCheck={false} aria-labelledby={id}
aria-valuemin={min} aria-valuemax={max} aria-valuenow={v} aria-valuetext={say(v)} aria-invalid={bad || undefined} value={text}
onChange={e => { editing.current = true; setText(e.target.value); setBad(!/^\$?\d*\.?\d*$/.test(e.target.value.trim())); }}
onBlur={commit}
onKeyDown={e => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); editing.current = false; stepBy((e.key === 'ArrowUp' ? 1 : -1) * (e.shiftKey ? bigStep : 1)); }
else if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); editing.current = false; stepBy(e.key === 'PageUp' ? bigStep : -bigStep); }
else if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { editing.current = false; setBad(false); setText(shown(vRef.current)); }
}} />
{unit && <span className="dm-nmf-u">{unit}</span>}
</span>
{btn(1, buttonLabels[1])}
</div>
<Swap className="dm-nmf-hint dm-muted">{note || range}</Swap>
</div>
);
}
/** The panel the fields sit in on designme.dev: a title, a line under it, the fields, and an optional summary at the foot */
export function NumberFieldGroup({ title, description, summary, children, className }: { title?: string; description?: string; summary?: string; children: ReactNode; className?: string }) {
return (
<div data-dm="" className={cx('dm-nmf dm-panel', className)}>
{title && <div className="dm-nmf-h"><b>{title}</b>{description && <span className="dm-muted">{description}</span>}</div>}
{children}
{summary != null && <p className="dm-nmf-sum dm-muted" aria-live="polite">{summary}</p>}
</div>
);
}
number-field.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Number field ---------- */
/* a number you can type or step: minus and plus at the ends of the field, a unit beside the value, and a hint under it that
gives the range, or says what changed when a typed value was rounded to the step or kept inside the range */
.dm-nmf{width:100%;max-width:320px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-nmf-h{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-nmf-h b{font-weight:500}
.dm-nmf-h .dm-muted{font-size:12px}
.dm-nmf-f{display:flex;flex-direction:column;gap:6px}
.dm-nmf-l{font-size:13px;color:var(--dm-fg2);cursor:default;align-self:flex-start}
.dm-nmf-in{display:flex;align-items:center;gap:2px;height:36px;padding:0 3px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
.dm-nmf-in:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-nmf-in:has(input[aria-invalid]){box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-nmf-in .dm-ib{width:28px;height:28px;flex:none;border-radius:calc(5px * var(--dm-rk));touch-action:manipulation}
/* the steppers carry the accent (owner, 4 Nov: the field read empty), as small squircles, not circles */
.dm-nmf-in .dm-ib:not(:disabled){background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.dm-nmf-in .dm-ib:not(:disabled):hover{background:color-mix(in srgb,var(--dm-accent) 18%,transparent)}}
.dm-nmf-in .dm-ib:disabled{color:var(--dm-dis);background:none;cursor:not-allowed}
.dm-nmf-v{flex:1;min-width:0;align-self:stretch;display:flex;align-items:center;justify-content:center;gap:3px;cursor:text}
.dm-nmf-v input{field-sizing:content;min-width:1ch;max-width:100%;height:100%;border:0;outline:0;padding:0;background:none;font:inherit;font-size:14px;color:var(--dm-fg);text-align:center}
@supports not (field-sizing:content){.dm-nmf-v input{width:7ch}}
.dm-nmf-u{flex:none;font-size:13px;color:var(--dm-muted)}
.dm-nmf-in.dm-bump input{animation:dm-nmfbump .22s var(--dm-ease)}
@keyframes dm-nmfbump{40%{transform:translateY(-1px)}}
.dm-nmf-hint{font-size:12px;line-height:1.4}
.dm-nmf-f.dm-noted .dm-nmf-hint{color:var(--dm-fg2)}
.dm-nmf-sum{margin:0;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;line-height:1.5}
NumberField.astroAstro · 42 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Number field: Type a number or step it with minus and plus, with units, limits and a word when a value is changed to fit.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change number-field.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './number-field.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="number-field" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="nmf panel">
<div class="nmf-h"><b>Run limits</b><span class="muted">For each run of the release agent</span></div>
<div class="nmf-f" data-k="steps">
<span class="nmf-l">Max steps</span>
<div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Fewer steps"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Max steps"><span class="nmf-u">steps</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More steps"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
<span class="nmf-hint muted"></span>
</div>
<div class="nmf-f" data-k="budget">
<span class="nmf-l">Budget per run</span>
<div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Lower budget"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><span class="nmf-u">$</span><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Budget per run"></span><button class="ib nmf-up" tabindex="-1" aria-label="Higher budget"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
<span class="nmf-hint muted"></span>
</div>
<div class="nmf-f" data-k="time">
<span class="nmf-l">Time limit</span>
<div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Less time"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Time limit"><span class="nmf-u">min</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More time"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
<span class="nmf-hint muted"></span>
</div>
<p class="nmf-sum muted" aria-live="polite"></p>
</div>
</Fragment></div>
</div>
<script>
import './number-field.js';
</script>
number-field.htmlHTML · 24 lines
<div class="card" data-c="number-field">
<div class="stage-in">
<div class="nmf panel">
<div class="nmf-h"><b>Run limits</b><span class="muted">For each run of the release agent</span></div>
<div class="nmf-f" data-k="steps">
<span class="nmf-l">Max steps</span>
<div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Fewer steps"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Max steps"><span class="nmf-u">steps</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More steps"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
<span class="nmf-hint muted"></span>
</div>
<div class="nmf-f" data-k="budget">
<span class="nmf-l">Budget per run</span>
<div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Lower budget"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><span class="nmf-u">$</span><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Budget per run"></span><button class="ib nmf-up" tabindex="-1" aria-label="Higher budget"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
<span class="nmf-hint muted"></span>
</div>
<div class="nmf-f" data-k="time">
<span class="nmf-l">Time limit</span>
<div class="nmf-in"><button class="ib nmf-dn" tabindex="-1" aria-label="Less time"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="nmf-v"><input class="num" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false" aria-label="Time limit"><span class="nmf-u">min</span></span><button class="ib nmf-up" tabindex="-1" aria-label="More time"><svg class="i xs"><use href="#i-plus"/></svg></button></div>
<span class="nmf-hint muted"></span>
</div>
<p class="nmf-sum muted" aria-live="polite"></p>
</div>
</div>
</div>
number-field.cssHTML · 27 lines
/* ---------- Number field ---------- */
/* a number you can type or step: minus and plus at the ends of the field, a unit beside the value, and a hint under it that
gives the range, or says what changed when a typed value was rounded to the step or kept inside the range */
.nmf{width:100%;max-width:320px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.nmf-h{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.nmf-h b{font-weight:500}
.nmf-h .muted{font-size:12px}
.nmf-f{display:flex;flex-direction:column;gap:6px}
.nmf-l{font-size:13px;color:var(--dm-fg2);cursor:default;align-self:flex-start}
.nmf-in{display:flex;align-items:center;gap:2px;height:36px;padding:0 3px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
.nmf-in:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.nmf-in:has(input[aria-invalid]){box-shadow:inset 0 0 0 1px var(--dm-bad)}
.nmf-in .ib{width:28px;height:28px;flex:none;border-radius:calc(5px * var(--dm-rk));touch-action:manipulation}
/* the steppers carry the accent (owner, 4 Nov: the field read empty), as small squircles, not circles */
.nmf-in .ib:not(:disabled){background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
@media (hover:hover) and (pointer:fine){.nmf-in .ib:not(:disabled):hover{background:color-mix(in srgb,var(--dm-accent) 18%,transparent)}}
.nmf-in .ib:disabled{color:var(--dm-dis);background:none;cursor:not-allowed}
.nmf-v{flex:1;min-width:0;align-self:stretch;display:flex;align-items:center;justify-content:center;gap:3px;cursor:text}
.nmf-v input{field-sizing:content;min-width:1ch;max-width:100%;height:100%;border:0;outline:0;padding:0;background:none;font:inherit;font-size:14px;color:var(--dm-fg);text-align:center}
@supports not (field-sizing:content){.nmf-v input{width:7ch}}
.nmf-u{flex:none;font-size:13px;color:var(--dm-muted)}
.nmf-in.bump input{animation:nmfbump .22s var(--dm-ease)}
@keyframes nmfbump{40%{transform:translateY(-1px)}}
.nmf-hint{font-size:12px;line-height:1.4}
.nmf-f.noted .nmf-hint{color:var(--dm-fg2)}
.nmf-sum{margin:0;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;line-height:1.5}
number-field.jsHTML · 77 lines
DM.def('number-field', (r, c) => {
r.innerHTML = r.innerHTML.replace(/\{minus\}/g, I('minus','xs')).replace(/\{plus\}/g, I('plus','xs'));
// each field: its range and step, how its value reads, and the words for its unit
const F = {
steps:{ min:5, max:100, step:5, v:25, dp:0, say:x => `${x} steps` },
budget:{ min:.25, max:20, step:.25, v:2.5, dp:2, say:x => `$${x.toFixed(2)}` },
time:{ min:1, max:60, step:1, v:10, dp:0, say:x => `${x} minutes` }
};
const sum = $('.nmf-sum', r);
const fix = x => +x.toFixed(4);
const line = () => `A run stops at ${F.steps.v} steps, $${F.budget.v.toFixed(2)} or ${F.time.v} min, whichever comes first.`;
sum.textContent = line();
$$('.nmf-f', r).forEach(f => {
const o = F[f.dataset.k], inp = $('input', f), dn = $('.nmf-dn', f), up = $('.nmf-up', f), hint = $('.nmf-hint', f), box = $('.nmf-in', f);
const base = `${o.dp ? o.min.toFixed(o.dp) : o.min} to ${o.dp ? o.max.toFixed(o.dp) : o.max}${o.step !== 1 ? `, in steps of ${o.dp ? o.step.toFixed(o.dp) : o.step}` : ''}`;
let note = '';
const tell = t => { if (t === note) return; note = t; swap(hint, t || base); f.classList.toggle('noted', !!t); };
hint.textContent = base;
const paint = () => {
inp.value = o.v.toFixed(o.dp); inp.setAttribute('aria-valuenow', o.v); inp.setAttribute('aria-valuetext', o.say(o.v));
dn.disabled = o.v <= o.min; up.disabled = o.v >= o.max; sum.textContent = line();
};
inp.setAttribute('aria-valuemin', o.min); inp.setAttribute('aria-valuemax', o.max);
// a change from the buttons or the keys: one step, or ten with Shift or Page Up/Down; it stops at the ends and says so
const set = (x, why) => {
const was = o.v; o.v = fix(Math.min(o.max, Math.max(o.min, x)));
if (o.v !== was){ box.classList.remove('bump'); void box.offsetWidth; box.classList.add('bump'); }
tell(why || (x > o.max ? `${o.say(o.max)} is the most` : x < o.min ? `${o.say(o.min)} is the least` : ''));
paint();
};
const stepBy = n => set(o.v + n * o.step);
// what was typed is kept when you press Enter or leave the field: rounded to the step and kept inside the range, with a
// word about what changed; something that isn't a number puts the last value back
const commit = () => {
const raw = inp.value.replace(/[^0-9.]/g, ''), x = parseFloat(raw);
if (!raw || isNaN(x)){ inp.removeAttribute('aria-invalid'); tell(`Enter a number from ${base.split(',')[0]}`); paint(); return; }
const snapped = fix(Math.round((x - o.min) / o.step) * o.step + o.min);
if (x > o.max) set(x);
else if (x < o.min) set(x);
else set(snapped, Math.abs(snapped - x) > 1e-9 ? `Rounded to ${o.say(snapped)}` : '');
};
inp.addEventListener('input', () => { const ok = /^\$?\d*\.?\d*$/.test(inp.value.trim()); inp.toggleAttribute('aria-invalid', !ok); });
inp.addEventListener('keydown', e => {
const big = e.shiftKey ? 10 : 1;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown'){ e.preventDefault(); stepBy((e.key === 'ArrowUp' ? 1 : -1) * big); }
else if (e.key === 'PageUp' || e.key === 'PageDown'){ e.preventDefault(); stepBy(e.key === 'PageUp' ? 10 : -10); }
else if (e.key === 'Enter'){ e.preventDefault(); commit(); if (document.activeElement === inp) inp.select(); }
else if (e.key === 'Escape'){ inp.removeAttribute('aria-invalid'); paint(); }
});
inp.addEventListener('blur', commit);
// the label, and a press beside the value, put the cursor in the field
$('.nmf-l', f).onclick = () => inp.focus();
$('.nmf-v', f).onclick = e => { if (e.target !== inp) inp.focus(); };
// press and hold − or +: one step at once, then more and more quickly until you let go
[[dn, -1], [up, 1]].forEach(([b, d]) => {
let t = 0;
const stop = () => { clearTimeout(t); t = 0; };
const rep = ms => { t = setTimeout(() => { if (b.disabled) return stop(); stepBy(d); rep(Math.max(40, ms * .8)); }, ms); c.t.push(t); };
b.addEventListener('pointerdown', e => { if (e.button) return; e.preventDefault(); stepBy(d); stop(); rep(420); });
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => b.addEventListener(ev, stop));
// the keyboard and the demo press it with a click, which has no pointer down before it
b.addEventListener('click', e => { if (e.detail === 0) stepBy(d); });
});
paint();
});
// the preview's story: two more steps, a budget typed past the most it allows, then a minute less of time
if (r === c.st) c.story(async s => {
const up = $('.nmf-f[data-k="steps"] .nmf-up', r), bi = $('.nmf-f[data-k="budget"] input', r), td = $('.nmf-f[data-k="time"] .nmf-dn', r);
await s.beat(3);
await s.tap(up); await s.beat(1);
await s.tap(up); await s.beat(3);
await s.point(bi, false); bi.value = ''; await s.type(bi, '25', { n:.3 }); await s.beat(1);
bi.dispatchEvent(new KeyboardEvent('keydown', { key:'Enter' })); s.away(); await s.beat(4);
await s.tap(td); s.away(); await s.beat(4);
});
});
Questions
What people ask about it.
- Why not an input with type="number"?
- Its spinner is tiny, looks different in every browser, changes the value when you scroll over it, and can't show a unit. This keeps the same keys and the spinbutton role, with buttons you can hit and a unit beside the value.
- What happens to a value that is out of range?
- It is kept at the nearest end when you press Enter or leave the field, and the hint says so, like "100 steps is the most".
onValueChangeonly ever gets values inside the range and on a step. - Can it show money?
- Yes. Use
prefix="$"anddecimals={2}, with a step like 0.25. For other currencies, change the prefix or useunitfor a code after the value.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.