From 2111e95c7acd961889547ddcb729bc5a63de7068 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:51 +0200 Subject: [PATCH] feat(ui): replace native number spinners with a branded stepper Co-Authored-By: Claude Opus 5.5 --- app/View/Components/Forms/Input.php | 3 ++ resources/css/app.css | 39 +++++++++++++++++++ resources/js/app.js | 2 + resources/js/number-stepper.js | 36 +++++++++++++++++ resources/js/number-stepper.test.js | 25 ++++++++++++ .../views/components/forms/input.blade.php | 18 ++++++++- resources/views/components/reicon.blade.php | 1 + 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 resources/js/number-stepper.js create mode 100644 resources/js/number-stepper.test.js diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 3824bfe46d..0c85d5c3e7 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -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. diff --git a/resources/css/app.css b/resources/css/app.css index e41d8f654c..4cab827a4a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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, diff --git a/resources/js/app.js b/resources/js/app.js index 7e54193431..6831848b38 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); /** diff --git a/resources/js/number-stepper.js b/resources/js/number-stepper.js new file mode 100644 index 0000000000..4fa5209a8a --- /dev/null +++ b/resources/js/number-stepper.js @@ -0,0 +1,36 @@ +// Alpine data provider for the −/+ buttons of (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 })); + }, + })); +} diff --git a/resources/js/number-stepper.test.js b/resources/js/number-stepper.test.js new file mode 100644 index 0000000000..f704e09d92 --- /dev/null +++ b/resources/js/number-stepper.test.js @@ -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'); +}); diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 7b5b610295..96df72ff69 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -73,7 +73,9 @@ @else - @if ($copyable) + @if ($type === 'number') +
+ @elseif ($copyable)
@endif - @if ($copyable) + @if ($type === 'number') +
+ + +
+
+ @elseif ($copyable)
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 189dd27cd6..1e34af3089 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -37,6 +37,7 @@ 'feedback' => '', 'logout' => '', 'search' => '', + 'minus' => '', 'plus' => '', 'grid' => '', 'eye' => '',