feat(ui): replace native number spinners with a branded stepper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 12:36:51 +02:00
co-authored by Claude Opus 5.5
parent 3b92248309
commit 2111e95c7a
7 changed files with 122 additions and 2 deletions
+3
View File
@@ -80,6 +80,9 @@ class Input extends Component
if ($hasPeek) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
if ($this->type === 'number') {
$this->defaultClass = $this->defaultClass.' input-with-stepper';
}
if ($this->copyable) {
// Reserve clearance for a single copy button, or for both the peek eye
// and the copy button when the field is a maskable password.
+39
View File
@@ -587,6 +587,35 @@ tr td:first-child {
padding-right: 2.5rem;
}
/* Number fields use the brand −/+ stepper (components/forms/input) instead of the native spinner. */
.input.input-with-stepper {
padding-right: 4.25rem;
appearance: textfield;
-moz-appearance: textfield;
}
.input.input-with-stepper::-webkit-outer-spin-button,
.input.input-with-stepper::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.number-stepper {
@apply absolute inset-y-px right-px flex;
}
.number-stepper button {
@apply flex w-8 cursor-pointer items-center justify-center border-l border-neutral-200 text-neutral-500 transition-colors last:rounded-r-[5px] hover:bg-neutral-100 hover:text-black active:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-white dark:active:bg-white/[0.1];
}
.number-stepper button svg {
@apply transition-transform duration-150 ease-out;
}
.number-stepper button:active:not(:disabled) svg {
@apply scale-75;
}
/* Room for both the peek eye and the copy button on maskable read-only fields. */
.input.input-with-copy-and-peek {
padding-right: 4.25rem;
@@ -2261,6 +2290,16 @@ html[data-theme="custom"]:not(.dark) :is(.application-settings-section, .applica
padding-right: 4.25rem;
}
.application-settings-workspace .input.input-with-stepper,
.application-settings-form .input.input-with-stepper {
padding-right: 4.25rem;
}
.application-settings-workspace .number-stepper button:last-child,
.application-settings-form .number-stepper button:last-child {
border-radius: 0 7px 7px 0;
}
.application-settings-workspace .input:focus-visible,
.application-settings-workspace .select:focus-visible,
.application-settings-form .input:focus-visible,
+2
View File
@@ -1,4 +1,5 @@
import { initializeCopyButtonComponent } from './copy-button.js';
import { initializeNumberStepperComponent } from './number-stepper.js';
import { initializeRuntimeLogsComponent } from './runtime-logs.js';
import { initializeTerminalComponent } from './terminal.js';
import './traffic-globe.js';
@@ -48,6 +49,7 @@ document.addEventListener('livewire:navigated', () => {
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
document.addEventListener('alpine:init', initializeCopyButtonComponent);
document.addEventListener('alpine:init', initializeNumberStepperComponent);
document.addEventListener('alpine:init', initializeRuntimeLogsComponent);
/**
+36
View File
@@ -0,0 +1,36 @@
// Alpine data provider for the −/+ buttons of <x-forms.input type="number"> (x-data="numberStepper").
/**
* Returns the input value one step up (direction 1) or down (direction -1), clamped to min/max.
* Uses the same strings the input element exposes: an empty or "any" step counts as 1.
*/
export function nextStepValue({ value, step, min, max }, direction) {
const stepSize = Number.parseFloat(step) > 0 ? Number.parseFloat(step) : 1;
const current = Number.parseFloat(value);
let next = (Number.isFinite(current) ? current : 0) + direction * stepSize;
if (min !== '' && Number.isFinite(Number(min))) {
next = Math.max(next, Number(min));
}
if (max !== '' && Number.isFinite(Number(max))) {
next = Math.min(next, Number(max));
}
// Remove floating point noise such as 0.30000000000000004.
return String(Number.parseFloat(next.toFixed(10)));
}
export function initializeNumberStepperComponent() {
window.Alpine.data('numberStepper', () => ({
step(direction) {
const input = this.$root.querySelector('input');
if (input.disabled || input.readOnly) {
return;
}
input.value = nextStepValue(input, direction);
// wire:model and x-model listen to these events.
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
},
}));
}
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { nextStepValue } from './number-stepper.js';
const field = (value, { step = '', min = '', max = '' } = {}) => ({ value, step, min, max });
test('steps by 1 when the step is empty or "any"', () => {
assert.equal(nextStepValue(field('5'), 1), '6');
assert.equal(nextStepValue(field('5', { step: 'any' }), -1), '4');
});
test('uses the step attribute without floating point noise', () => {
assert.equal(nextStepValue(field('0.1', { step: '0.2' }), 1), '0.3');
});
test('starts from 0 when the field is empty', () => {
assert.equal(nextStepValue(field(''), 1), '1');
assert.equal(nextStepValue(field(''), -1), '-1');
});
test('clamps to min and max', () => {
assert.equal(nextStepValue(field('0', { min: '0' }), -1), '0');
assert.equal(nextStepValue(field('99', { max: '99' }), 1), '99');
assert.equal(nextStepValue(field('', { min: '60' }), 1), '60');
});
@@ -73,7 +73,9 @@
</div>
@else
@if ($copyable)
@if ($type === 'number')
<div class="relative" x-data="numberStepper">
@elseif ($copyable)
<div class="relative">
@endif
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
@@ -86,7 +88,19 @@
@if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" autofocus @endif>
@if ($copyable)
@if ($type === 'number')
<div class="number-stepper">
<button type="button" tabindex="-1" aria-label="Decrease" x-on:click="step(-1)"
@disabled($disabled || $readonly)>
<x-reicon name="minus" class="size-3.5" />
</button>
<button type="button" tabindex="-1" aria-label="Increase" x-on:click="step(1)"
@disabled($disabled || $readonly)>
<x-reicon name="plus" class="size-3.5" />
</button>
</div>
</div>
@elseif ($copyable)
<x-copy-button :resolve="$copyResolve" label="Copy to clipboard"
class="absolute top-1/2 right-1 z-10 -translate-y-1/2" />
</div>
File diff suppressed because one or more lines are too long