fix: manifest-aware layout picker, real carousel items-per-page, hero arrows/swipe/2-panel
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Sprint E: homepage section editor now filters the layout-strategy picker
to each widget's widget-manifest.json supportedLayouts instead of always
showing all 5 strategies. columns field gated to widgets that read it
(hero, product-collection carousel).

Sprint F: closes client bug report (no items-per-page control, hero
carousel not manually/automatically scrollable, no 1-2 slide big-carousel
option). Product carousel item width now driven by layout.columns
(reused, was already editable but dead). Hero widget gains prev/next
arrows, touch swipe, and 1-2 panel mode via the same field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-05 19:25:34 +04:00
parent 3e3185cb6e
commit 55b379bd6d
9 changed files with 217 additions and 20 deletions

View File

@@ -5,6 +5,7 @@ import { HeroSlideData, HeroWidgetData } from '../contracts/widget-data.contract
import { TranslatePipe } from '../../i18n/translate.pipe';
const AUTOPLAY_INTERVAL_MS = 5000;
const SWIPE_THRESHOLD_PX = 50;
@Component({
selector: 'app-hero-widget',
@@ -12,14 +13,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
imports: [CommonModule, TranslatePipe],
template: `
<section class="hero-widget">
@if (activeSlide(); as slide) {
<h1 class="hero-widget__title">{{ slide.title }}</h1>
@if (slide.subtitle) {
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
}
@if (slide.ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
}
@if (visibleSlides().length) {
<div class="hero-widget__nav">
@if (allSlides().length > 1) {
<button
type="button"
class="hero-widget__arrow hero-widget__arrow--prev"
[attr.aria-label]="'common.previousSlide' | translate"
(click)="prevSlide()"
>&larr;</button>
}
<div class="hero-widget__slides" (touchstart)="onSwipeStart($event)" (touchend)="onSwipeEnd($event)">
@for (slide of visibleSlides(); track $index) {
<article class="hero-widget__slide">
<h2 class="hero-widget__title">{{ slide.title }}</h2>
@if (slide.subtitle) {
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
}
@if (slide.ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
}
</article>
}
</div>
@if (allSlides().length > 1) {
<button
type="button"
class="hero-widget__arrow hero-widget__arrow--next"
[attr.aria-label]="'common.nextSlide' | translate"
(click)="nextSlide()"
>&rarr;</button>
}
</div>
@if (allSlides().length > 1) {
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
@@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
animation: hero-widget-in 420ms ease-out both;
}
.hero-widget__nav {
display: flex;
align-items: stretch;
gap: var(--space-sm, 8px);
}
.hero-widget__slides {
flex: 1;
min-width: 0;
display: flex;
gap: var(--space-lg, 24px);
}
.hero-widget__slide {
flex: 1 1 0;
min-width: 0;
}
.hero-widget__arrow {
flex-shrink: 0;
align-self: center;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border-color, #d3dad9);
background: #fff;
color: var(--text-primary, #1e3c38);
cursor: pointer;
font-size: 1rem;
&:hover { background: var(--primary-color, #497671); color: #fff; }
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
}
.hero-widget__title {
margin: 0 0 var(--space-sm, 8px);
font-size: 2rem;
@@ -140,6 +201,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
readonly activeIndex = signal(0);
private readonly dataSignal = signal<HeroWidgetData | null>(null);
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
private swipeStartX: number | null = null;
readonly allSlides = computed<HeroSlideData[]>(() => {
const current = this.dataSignal();
@@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
/** 1 or 2, reusing `layout.columns` (no dedicated "slides per page" field). 2 has nothing to show a second panel with when there's only one slide. */
get panelCount(): number {
const requested = this.section?.layout?.columns === 2 ? 2 : 1;
return requested === 2 && this.allSlides().length > 1 ? 2 : 1;
}
visibleSlides(): HeroSlideData[] {
const slides = this.allSlides();
if (slides.length === 0) {
return [];
}
if (this.panelCount === 2) {
const nextIndex = (this.activeIndex() + 1) % slides.length;
return [slides[this.activeIndex()], slides[nextIndex]];
}
const current = slides[this.activeIndex()];
return current ? [current] : [];
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['data']) {
this.dataSignal.set(this.data);
@@ -169,6 +250,41 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
this.setupAutoplay();
}
prevSlide(): void {
const total = this.allSlides().length;
if (total <= 1) {
return;
}
this.goTo((this.activeIndex() - 1 + total) % total);
}
nextSlide(): void {
const total = this.allSlides().length;
if (total <= 1) {
return;
}
this.goTo((this.activeIndex() + 1) % total);
}
onSwipeStart(event: TouchEvent): void {
this.swipeStartX = event.touches[0]?.clientX ?? null;
}
onSwipeEnd(event: TouchEvent): void {
const startX = this.swipeStartX;
this.swipeStartX = null;
if (startX === null) {
return;
}
const endX = event.changedTouches[0]?.clientX ?? startX;
const diff = startX - endX;
if (diff > SWIPE_THRESHOLD_PX) {
this.nextSlide();
} else if (diff < -SWIPE_THRESHOLD_PX) {
this.prevSlide();
}
}
onCtaClick(): void {
this.ctaClicked.emit();
}

View File

@@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
standalone: true,
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
template: `
<section class="product-carousel-widget">
<section class="product-carousel-widget" [style.--items-per-page]="itemsPerPage()">
@if (data?.title) {
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
}
@@ -83,7 +83,8 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
::ng-deep .catalog-product-shell {
flex: 0 0 auto;
width: 220px;
width: calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4));
min-width: 160px;
}
}
@@ -121,6 +122,12 @@ export class ProductCarouselWidgetComponent {
return this.section?.layout?.strategy === 'carousel';
}
/** Items visible per page - reuses `layout.columns` (default 4, min 1) rather than a dedicated field. */
itemsPerPage(): number {
const columns = this.section?.layout?.columns;
return columns && columns >= 1 ? Math.floor(columns) : 4;
}
scrollBy(direction: -1 | 1): void {
const el = this.scroller?.nativeElement;
if (!el) {