counter — Quantity input s +/- buttony, min/max/step, unit label. Custom event counter:change.

app/components/Form/counter.latte
{*
	Counter — quantity input s +/- buttony.

	Parametry:
	- $name      (string)        name attribute (default 'qty')
	- $value     (int)           počáteční hodnota (default 1)
	- $min       (int)           min hodnota (default 1)
	- $max       (int|null)      max hodnota
	- $step      (int)           krok (default 1)
	- $unit      (string|null)   text za inputem (např. 'ks', 'kg')
	- $id        (string|null)   id elementu
	- $disabled  (bool)          disable celý counter (default false)
	- $class     (string|null)   extra třída
*}
{var $name = $name ?? 'qty'}
{var $value = $value ?? 1}
{var $min = $min ?? 1}
{var $max = $max ?? null}
{var $step = $step ?? 1}
{var $unit = $unit ?? null}
{var $id = $id ?? null}
{var $disabled = $disabled ?? false}
{var $class = $class ?? null}

<div class="counter{if $class} {$class}{/if}"
     data-counter
     data-min="{$min}"
     {if $max !== null}data-max="{$max}"{/if}
     data-step="{$step}">

	<button type="button"
	        class="counter__button counter__button--minus"
	        data-counter-decrement
	        aria-label="Snížit"
	        {if $disabled || $value <= $min}disabled{/if}>
		<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
			<line x1="3" y1="8" x2="13" y2="8"/>
		</svg>
	</button>

	<input type="number"
	       class="counter__input"
	       name="{$name}"
	       value="{$value}"
	       min="{$min}"
	       {if $max !== null}max="{$max}"{/if}
	       step="{$step}"
	       {if $id}id="{$id}"{/if}
	       {if $disabled}disabled{/if}
	       data-counter-input>

	{if $unit}
		<span class="counter__unit">{$unit}</span>
	{/if}

	<button type="button"
	        class="counter__button counter__button--plus"
	        data-counter-increment
	        aria-label="Zvýšit"
	        {if $disabled || ($max !== null && $value >= $max)}disabled{/if}>
		<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
			<line x1="3" y1="8" x2="13" y2="8"/>
			<line x1="8" y1="3" x2="8" y2="13"/>
		</svg>
	</button>
</div>
resources/sass/components/_counter.scss
// ─── Configurable variables (override before @import) ──────────────────
$cgui-counter-bg:              $cgui-color-bg-gray-light !default;
$cgui-counter-border:          1px solid $cgui-color-border !default;
$cgui-counter-radius:          10px !default;
$cgui-counter-padding:         13px 19px !default;
$cgui-counter-gap:             10px !default;

$cgui-counter-button-size:     38px !default;
$cgui-counter-button-bg:       transparent !default;
$cgui-counter-button-color:    $cgui-color-text !default;
$cgui-counter-button-bg-hover: $cgui-color-primary !default;
$cgui-counter-button-color-hover: #fff !default;

$cgui-counter-input-width:     48px !default;
$cgui-counter-input-size:      24px !default;
$cgui-counter-input-color:     $cgui-color-text !default;

$cgui-counter-disabled-opacity: 0.4 !default;
// ───────────────────────────────────────────────────────────────────────

.counter {
	display: inline-flex;
	align-items: center;
	gap: $cgui-counter-gap;
	background: $cgui-counter-bg;
	border: $cgui-counter-border;
	border-radius: $cgui-counter-radius;
	padding: $cgui-counter-padding;

	&__button {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: $cgui-counter-button-size;
		height: $cgui-counter-button-size;
		padding: 0;
		background: $cgui-counter-button-bg;
		color: $cgui-counter-button-color;
		border: none;
		border-radius: 50%;
		cursor: pointer;
		transition: background 0.15s, color 0.15s;
		font-size: 22px;
		line-height: 1;

		&:hover,
		&:focus {
			background: $cgui-counter-button-bg-hover;
			color: $cgui-counter-button-color-hover;
		}

		&[disabled] {
			opacity: $cgui-counter-disabled-opacity;
			cursor: not-allowed;

			&:hover,
			&:focus {
				background: $cgui-counter-button-bg;
				color: $cgui-counter-button-color;
			}
		}

		svg {
			width: 16px;
			height: 16px;
		}
	}

	&__input {
		width: $cgui-counter-input-width;
		text-align: center;
		font-size: $cgui-counter-input-size;
		font-weight: 500;
		color: $cgui-counter-input-color;
		background: transparent;
		border: none;
		padding: 0;
		outline: none;
		appearance: textfield;
		-moz-appearance: textfield;

		&::-webkit-outer-spin-button,
		&::-webkit-inner-spin-button {
			-webkit-appearance: none;
			margin: 0;
		}

		&:focus {
			outline: none;
		}
	}

	&__unit {
		font-size: 21px;
		color: $cgui-color-text-gray;
		white-space: nowrap;
	}
}
resources/js/components/counter.js
// Counter — auto-init pro [data-counter]. Quantity input s +/- buttony.
// Emituje custom event 'counter:change' (detail: { value, oldValue }) na root.

const CHANGE_EVENT = 'counter:change';

function getInt(value, fallback = 0) {
	const n = parseInt(value, 10);
	return Number.isNaN(n) ? fallback : n;
}

function clamp(value, min, max) {
	if (max !== null && value > max) return max;
	if (value < min) return min;
	return value;
}

class Counter {
	constructor(root) {
		this.root = root;
		this.input = root.querySelector('[data-counter-input]');
		this.btnInc = root.querySelector('[data-counter-increment]');
		this.btnDec = root.querySelector('[data-counter-decrement]');

		if (!this.input) {
			console.warn('[counter] missing [data-counter-input]', root);
			return;
		}

		this.min = getInt(root.dataset.min, 1);
		this.max = root.dataset.max !== undefined ? getInt(root.dataset.max) : null;
		this.step = getInt(root.dataset.step, 1);

		this.btnInc?.addEventListener('click', () => this.increment());
		this.btnDec?.addEventListener('click', () => this.decrement());
		this.input.addEventListener('change', () => this._sync());
		this.input.addEventListener('input', () => this._sync({ silent: true }));
	}

	get value() {
		return getInt(this.input.value, this.min);
	}

	set value(v) {
		const oldValue = this.value;
		const newValue = clamp(v, this.min, this.max);
		if (newValue === oldValue) return;

		this.input.value = newValue;
		this._updateButtons();

		this.root.dispatchEvent(new CustomEvent(CHANGE_EVENT, {
			bubbles: true,
			detail: { value: newValue, oldValue },
		}));
	}

	increment() {
		this.value = this.value + this.step;
	}

	decrement() {
		this.value = this.value - this.step;
	}

	_sync({ silent = false } = {}) {
		const v = this.value;
		const clamped = clamp(v, this.min, this.max);
		if (clamped !== v) {
			this.input.value = clamped;
		}
		this._updateButtons();

		if (!silent && clamped !== v) {
			this.root.dispatchEvent(new CustomEvent(CHANGE_EVENT, {
				bubbles: true,
				detail: { value: clamped, oldValue: v },
			}));
		}
	}

	_updateButtons() {
		const v = this.value;
		if (this.btnDec) this.btnDec.disabled = v <= this.min;
		if (this.btnInc) this.btnInc.disabled = this.max !== null && v >= this.max;
	}
}

const instances = new WeakMap();

function init(root) {
	if (instances.has(root)) return instances.get(root);
	const c = new Counter(root);
	instances.set(root, c);
	return c;
}

export function initAll(scope = document) {
	scope.querySelectorAll('[data-counter]').forEach(init);
}

if (typeof document !== 'undefined') {
	if (document.readyState !== 'loading') {
		initAll();
	} else {
		document.addEventListener('DOMContentLoaded', () => initAll());
	}
}

export { Counter, init };