slider — Horizontální carousel s šipkami pro produktové boxy nebo libovolné karty

app/components/Slider/slider.latte
{*
	Slider — carousel s šipkami.

	Parametry:
	- $id (string): unikátní ID slideru
	- $items (iterable): kolekce items (kompatibilní s {foreach})
	- $itemsPerView (int): počet items zobrazených najednou (default 3)
	- $itemTemplate (string|null): cesta k šabloně pro jednu položku
	                                pokud null, item se rendruje jako string

	Použití:
	{include slider.latte,
	    id => 'products',
	    items => $products,
	    itemsPerView => 4,
	    itemTemplate => __DIR__ . '/templates/product-card.latte'
	}
*}
{var $itemsPerView = $itemsPerView ?? 3}

<div class="slider"
     id="{$id}"
     data-slider
     data-items-per-view="{$itemsPerView}">
	<button class="slider__arrow slider__arrow--prev" data-slider-prev aria-label="Předchozí">
		<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
			<path d="M15 18l-6-6 6-6"/>
		</svg>
	</button>

	<div class="slider__viewport" data-slider-viewport>
		<div class="slider__track" data-slider-track>
			{foreach $items as $item}
				<div class="slider__slide" data-slider-slide>
					{if isset($itemTemplate) && $itemTemplate !== null}
						{include $itemTemplate, item => $item}
					{else}
						{$item|noescape}
					{/if}
				</div>
			{/foreach}
		</div>
	</div>

	<button class="slider__arrow slider__arrow--next" data-slider-next aria-label="Další">
		<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
			<path d="M9 18l6-6-6-6"/>
		</svg>
	</button>
</div>
resources/sass/components/_slider.scss
.slider {
	position: relative;
	display: flex;
	align-items: center;
	gap: 8px;

	&__viewport {
		flex: 1;
		overflow: hidden;
	}

	&__track {
		display: flex;
		transition: transform 0.3s ease-out;
		will-change: transform;
	}

	&__slide {
		flex-shrink: 0;
		padding: 0 8px;
	}

	// Default — desktop, dle data-items-per-view
	&[data-items-per-view="1"] &__slide { width: 100%; }
	&[data-items-per-view="2"] &__slide { width: 50%; }
	&[data-items-per-view="3"] &__slide { width: 33.3333%; }
	&[data-items-per-view="4"] &__slide { width: 25%; }

	// Mobile fallback — vždy 1
	@media (max-width: $cgui-bp-small) {
		&[data-items-per-view] &__slide { width: 100%; }
	}

	@media (min-width: $cgui-bp-small) and (max-width: $cgui-bp-medium) {
		&[data-items-per-view="3"] &__slide,
		&[data-items-per-view="4"] &__slide { width: 50%; }
	}

	&__arrow {
		flex-shrink: 0;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		border: 1px solid $cgui-color-border;
		background: $cgui-color-bg;
		color: $cgui-color-text;
		display: flex;
		align-items: center;
		justify-content: center;
		cursor: pointer;
		transition: all 0.15s;
		z-index: 1;

		@include cgui-hover() {
			background: $cgui-color-primary;
			color: white;
			border-color: $cgui-color-primary;
		}

		&:disabled {
			opacity: 0.3;
			cursor: not-allowed;
			pointer-events: none;
		}
	}
}
resources/js/components/slider.js
// Slider — carousel s šipkami. Auto-init pro všechny [data-slider] na stránce.

import { $, $$, on, debounce, clamp } from '../base/utils.js';

class Slider {
	constructor(root) {
		this.root = root;
		this.viewport = $('[data-slider-viewport]', root);
		this.track = $('[data-slider-track]', root);
		this.slides = $$('[data-slider-slide]', root);
		this.btnPrev = $('[data-slider-prev]', root);
		this.btnNext = $('[data-slider-next]', root);
		this.itemsPerView = parseInt(root.dataset.itemsPerView || '3', 10);
		this.index = 0;

		if (!this.slides.length) return;

		on(this.btnPrev, 'click', () => this.go(this.index - 1));
		on(this.btnNext, 'click', () => this.go(this.index + 1));

		const onResize = debounce(() => this.update(), 100);
		on(window, 'resize', onResize);

		this.update();
	}

	get visibleCount() {
		const w = window.innerWidth;
		if (w < 640) return 1;
		if (w < 960 && this.itemsPerView > 2) return 2;
		return this.itemsPerView;
	}

	get maxIndex() {
		return Math.max(0, this.slides.length - this.visibleCount);
	}

	go(target) {
		this.index = clamp(target, 0, this.maxIndex);
		this.update();
	}

	update() {
		const slideWidth = this.slides[0]?.offsetWidth || 0;
		this.track.style.transform = `translateX(${-this.index * slideWidth}px)`;
		this.btnPrev.disabled = this.index === 0;
		this.btnNext.disabled = this.index >= this.maxIndex;
	}
}

export function initSliders(root = document) {
	$$('[data-slider]', root).forEach(el => new Slider(el));
}

// Auto-init pokud je modul importován jako side-effect
if (typeof document !== 'undefined') {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', () => initSliders());
	} else {
		initSliders();
	}
}