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 };