Skip to content

Commit

Permalink
feat: headless carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
quentinderoubaix committed Aug 16, 2024
1 parent d6d2cb3 commit 000d900
Show file tree
Hide file tree
Showing 40 changed files with 2,146 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<div class="overflow-hidden relative cursor-grab active:cursor-grabbing" [auUse]="widget.directives.emblaDirective">
<div class="flex">
@for (item of items(); track item) {
<div class="basis-full min-w-0 shrink-0 grow-0 flex justify-center">
<ng-container *ngTemplateOutlet="slideRef().templateRef; context: {$implicit: item}"></ng-container>
</div>
}
</div>
@if (withNavArrows()) {
<div class="absolute left-5 right-5 top-1/2 flex -translate-y-1/2 transform justify-between">
<button
class="btn btn-sm md:btn-md btn-circle opacity-75 hover:opacity-100"
(pointerdown)="$event.preventDefault()"
[disabled]="!state().canScrollPrev"
(click)="widget.api.scrollPrev()"
aria-label="Go to previous slide"
>
</button>
<button
class="btn btn-sm md:btn-md btn-circle opacity-75 hover:opacity-100"
(pointerdown)="$event.preventDefault()"
[disabled]="!state().canScrollNext"
(click)="widget.api.scrollNext()"
aria-label="Go to next slide"
>
</button>
</div>
}
@if (withNavIndicators()) {
<div class="flex w-full justify-center gap-2 py-2 cursor-auto">
@for (item of items(); track item; let index = $index) {
<button
class="btn btn-xs md:btn-sm"
[class.btn-active]="state().selectedScrollSnap === index"
(click)="widget.api.scrollTo(index)"
attr.aria-label="Go to slide {{ index + 1 }}"
>
{{ index + 1 }}
</button>
}
</div>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
auBooleanAttribute,
BaseWidgetDirective,
callWidgetFactory,
createCarousel,
UseDirective,
type CarouselWidget,
} from '@agnos-ui/angular-headless';
import {NgTemplateOutlet} from '@angular/common';
import {Component, contentChild, Directive, effect, inject, input, TemplateRef} from '@angular/core';
import AutoPlay from 'embla-carousel-autoplay';

export interface CarouselSlideContext {
$implicit: string;
}

@Directive({standalone: true, selector: 'ng-template[appCarouselSlide]'})
export class CarouselSlideDirective {
public templateRef = inject(TemplateRef<CarouselSlideContext>);
static ngTemplateContextGuard(_dir: CarouselSlideDirective, context: unknown): context is CarouselSlideContext {
return true;
}
}

@Component({
selector: 'app-carousel',
standalone: true,
templateUrl: 'carousel.component.html',
imports: [UseDirective, NgTemplateOutlet],
})
export class CarouselComponent<Item> extends BaseWidgetDirective<CarouselWidget> {
readonly dragFree = input(false, {transform: auBooleanAttribute});
readonly loop = input(true, {transform: auBooleanAttribute});
readonly autoplay = input(true, {transform: auBooleanAttribute});
readonly withNavIndicators = input(true, {transform: auBooleanAttribute});
readonly withNavArrows = input(true, {transform: auBooleanAttribute});
readonly items = input.required<Item[]>();

readonly slideRef = contentChild.required(CarouselSlideDirective);

readonly _widget = callWidgetFactory({
factory: createCarousel,
widgetName: 'carousel',
});

constructor() {
super();
effect(() => {
this._widget.patch({
plugins: this.autoplay() ? [AutoPlay({playOnInit: true, stopOnInteraction: false, stopOnMouseEnter: true, stopOnFocusIn: true})] : [],
});
});
}
}
67 changes: 67 additions & 0 deletions angular/demo/daisyui/src/app/samples/carousel/default.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {Component} from '@angular/core';
import {CarouselComponent, CarouselSlideDirective} from './carousel.component';
import {FormsModule} from '@angular/forms';

@Component({
standalone: true,
template: `
<div class="w-full flex justify-center">
<div class="max-w-[600px] self-center">
<app-carousel
[items]="photos"
[withNavArrows]="withNavArrows"
[withNavIndicators]="withNavIndicators"
[loop]="loop"
[dragFree]="dragFree"
[autoplay]="autoplay && loop"
>
<img
*appCarouselSlide="let photo"
[src]="photo"
class="select-none object-contain aspect-[4/3] w-full"
alt="random picsum"
loading="lazy"
/>
</app-carousel>
<div class="form-control items-start">
<label class="label cursor-pointer gap-3">
<span class="label-text">Loop</span>
<input type="checkbox" class="toggle toggle-primary" [(ngModel)]="loop" />
</label>
<label class="label gap-3" [class.cursor-pointer]="loop">
<span class="label-text">Autoplay</span>
<input type="checkbox" class="toggle toggle-primary" [(ngModel)]="autoplay" [disabled]="!loop" />
</label>
<label class="label cursor-pointer gap-3">
<span class="label-text">Drag free</span>
<input type="checkbox" class="toggle toggle-primary" [(ngModel)]="dragFree" />
</label>
<label class="label cursor-pointer gap-3">
<span class="label-text">Navigation Indicators</span>
<input type="checkbox" class="toggle toggle-primary" [(ngModel)]="withNavIndicators" />
</label>
<label class="label cursor-pointer gap-3">
<span class="label-text">Navigation Arrows</span>
<input type="checkbox" class="toggle toggle-primary" [(ngModel)]="withNavArrows" />
</label>
</div>
</div>
</div>
`,
imports: [CarouselComponent, CarouselSlideDirective, FormsModule],
})
export default class DemoCarouselComponent {
readonly photos = [
'https://picsum.photos/600/450.webp?random=1',
'https://picsum.photos/600/450.webp?random=2',
'https://picsum.photos/450/600.webp?random=3',
'https://picsum.photos/600/450.webp?random=4',
'https://picsum.photos/600/450.webp?random=5',
'https://picsum.photos/600/450.webp?random=6',
];
loop = true;
dragFree = false;
withNavArrows = true;
withNavIndicators = true;
autoplay = true;
}
18 changes: 18 additions & 0 deletions angular/demo/daisyui/src/app/samples/carousel/demoGallery.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {Component} from '@angular/core';
import {GalleryComponent} from './gallery.component';
import {photos} from '@agnos-ui/common/samples/carousel/photo';

@Component({
standalone: true,
template: `
<div class="w-full flex justify-center">
<div class="max-w-[600px] lg:max-w-[1000px]">
<app-gallery [photos]="photos" withNavArrows withShowFullscreen />
</div>
</div>
`,
imports: [GalleryComponent],
})
export default class DemoGalleryComponent {
readonly photos = photos;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type {Source} from '@agnos-ui/common/samples/carousel/photo';
import {Component, input, signal} from '@angular/core';

@Component({
selector: 'app-gallery-image',
standalone: true,
template: `
@if (loadRequested()) {
@if (!imageLoaded()) {
<span class="absolute top-1/2 -translate-y-1/2 left-1/2 loading loading-spinner loading-lg text-primary"></span>
}
<picture class="flex justify-center">
@for (source of sources(); track source) {
<source [media]="source.media" [srcset]="source.srcset" />
}
<img
class="select-none object-contain transition-opacity ease-in-out duration-300 opacity-0"
[class.opacity-100]="toShow()"
[alt]="alt()"
[src]="src()"
loading="lazy"
[style.aspect-ratio]="aspectRatio()"
(load)="imageLoaded.set(true)"
/>
</picture>
} @else {
<div class="skeleton w-full h-full"></div>
}
`,
host: {
style: 'display: contents;',
},
})
export class GalleryImageComponent {
readonly src = input.required<string>();
readonly alt = input.required<string>();
readonly sources = input.required<Source[]>();
readonly loadRequested = input.required<boolean>();
readonly aspectRatio = input.required<number>();
readonly toShow = input.required<boolean>();
readonly imageLoaded = signal(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<div #mainContainer class="grid grid-flow-row max-h-dvh">
<div class="overflow-hidden relative cursor-grab active:cursor-grabbing" [auUse]="mainCarouselWidget.directives.emblaDirective">
<div class="flex max-h-full">
@for (photoWithLoadState of photosWithLoadState(); track photoWithLoadState; let index = $index) {
<div class="relative basis-full min-w-0 shrink-0 grow-0 flex justify-center">
<app-gallery-image
[src]="photoWithLoadState.src"
[alt]="photoWithLoadState.alt"
[aspectRatio]="aspectRatio()"
[loadRequested]="photoWithLoadState.loadRequested()"
[sources]="photoWithLoadState.sources"
[toShow]="showImage(index)"
/>
</div>
}
</div>
@if (withNavArrows()) {
<div class="absolute left-5 right-5 top-1/2 flex -translate-y-1/2 transform justify-between">
<button
class="btn btn-sm md:btn-md btn-circle opacity-75 hover:opacity-100"
(pointerdown)="$event.preventDefault()"
[disabled]="!mainCarouselState().canScrollPrev"
(click)="mainCarouselWidget.api.scrollPrev()"
aria-label="Go to previous photo"
>
</button>
<button
class="btn btn-sm md:btn-md btn-circle opacity-75 hover:opacity-100"
(pointerdown)="$event.preventDefault()"
[disabled]="!mainCarouselState().canScrollNext"
(click)="mainCarouselWidget.api.scrollNext()"
aria-label="Go to next photo"
>
</button>
</div>
}
@if (canFullScreen()) {
<div class="absolute right-5 bottom-5 flex">
<button
class="btn btn-sm md:btn-md opacity-75 hover:opacity-100"
(click)="toggleFullScreen()"
[attr.aria-label]="isFullScreen() ? 'leave fullscreen' : 'open photo in fullscreen'"
[innerHTML]="isFullScreen() ? compressSvg : expandSvg"
></button>
</div>
}
</div>
<div class="overflow-hidden relative mt-1 mb-2" [auUse]="thumbCarouselWidget.directives.emblaDirective">
<div class="grid grid-flow-col auto-cols-max gap-2 mx-1 my-1">
@for (photo of photos(); track photo; let index = $index) {
<button
class="shadow-primary"
(click)="scrollToSlide(index)"
[class.ring]="mainCarouselState().selectedScrollSnap === index"
attr.aria-label="Go to photo {{ index + 1 }}"
>
<img class="select-none" alt="random picsum" [src]="photo.thumbnail" loading="lazy" [style.aspect-ratio]="aspectRatio()" />
</button>
}
</div>
</div>
</div>
94 changes: 94 additions & 0 deletions angular/demo/daisyui/src/app/samples/carousel/gallery.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {Component, computed, type ElementRef, inject, input, type OnDestroy, type OnInit, signal, viewChild} from '@angular/core';
import type {Photo} from '@agnos-ui/common/samples/carousel/photo';
import {auBooleanAttribute, callWidgetFactory, createCarousel, UseDirective} from '@agnos-ui/angular-headless';
import {GalleryImageComponent} from './gallery-image.component';
import type {UnsubscribeFunction, UnsubscribeObject} from '@amadeus-it-group/tansu';
import expandSvg from '@agnos-ui/common/samples/carousel/expand.svg';
import compressSvg from '@agnos-ui/common/samples/carousel/compress.svg';
import {DomSanitizer} from '@angular/platform-browser';

@Component({
selector: 'app-gallery',
standalone: true,
templateUrl: 'gallery.component.html',
imports: [UseDirective, GalleryImageComponent],
})
export class GalleryComponent implements OnInit, OnDestroy {
readonly photos = input.required<Photo[]>();
readonly withNavArrows = input(false, {transform: auBooleanAttribute});
readonly withShowFullscreen = input(false, {transform: auBooleanAttribute});
readonly aspectRatio = input(4 / 3);

private readonly domSanitizer = inject(DomSanitizer);
readonly expandSvg = this.domSanitizer.bypassSecurityTrustHtml(expandSvg);
readonly compressSvg = this.domSanitizer.bypassSecurityTrustHtml(compressSvg);

private readonly _mainCarousel = callWidgetFactory({
factory: createCarousel,
widgetName: 'carousel',
});
get mainCarouselWidget() {
return this._mainCarousel.widget;
}
get mainCarouselState() {
return this._mainCarousel.ngState;
}

private readonly _thumbCarousel = callWidgetFactory({
factory: createCarousel,
widgetName: 'carousel',
defaultConfig: {
dragFree: true,
containScroll: 'keepSnaps',
},
});
get thumbCarouselWidget() {
return this._thumbCarousel.widget;
}
get thumbCarouselState() {
return this._thumbCarousel.ngState;
}

readonly photosWithLoadState = computed(() => this.photos().map((photo, index) => ({...photo, loadRequested: signal(index === 0)})));
readonly canFullScreen = computed(() => this.withShowFullscreen() && document?.fullscreenEnabled);
readonly isFullScreen = signal(false);
readonly mainContainer = viewChild.required<ElementRef>('mainContainer');
private selectedScrollSnapSubscription?: UnsubscribeFunction & UnsubscribeObject;

ngOnInit() {
this._mainCarousel.ngInit();
this._thumbCarousel.ngInit();
this.selectedScrollSnapSubscription = this._mainCarousel.stores.selectedScrollSnap$.subscribe((selectedSnap: number) => {
this.thumbCarouselWidget.api.scrollTo(selectedSnap);
const photosWithLoadState = this.photosWithLoadState();
photosWithLoadState[selectedSnap].loadRequested.set(true);
if (selectedSnap > 0) {
photosWithLoadState[selectedSnap - 1].loadRequested.set(true);
}
if (selectedSnap < photosWithLoadState.length - 1) {
photosWithLoadState[selectedSnap + 1].loadRequested.set(true);
}
});
}

ngOnDestroy() {
this.selectedScrollSnapSubscription?.();
}

toggleFullScreen() {
if (!this.isFullScreen()) {
this.mainContainer().nativeElement.requestFullscreen();
} else {
void document.exitFullscreen();
}
this.isFullScreen.update((val) => !val);
}

scrollToSlide(index: number) {
this.mainCarouselWidget.api.scrollTo(index, Math.abs(this.mainCarouselState().selectedScrollSnap - index) > 2);
}

showImage(index: number) {
return Math.abs(this.mainCarouselState().selectedScrollSnap - index) <= 2;
}
}
1 change: 1 addition & 0 deletions common/samples/carousel/compress.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions common/samples/carousel/expand.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 000d900

Please sign in to comment.