mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
3b92248309
commit
2111e95c7a
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user