Skip to content

Commit

Permalink
feat: headless carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
quentinderoubaix committed Oct 25, 2024
1 parent 1247f00 commit 549e88e
Show file tree
Hide file tree
Showing 42 changed files with 2,147 additions and 25 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]="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)="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)="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)="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,53 @@
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);

constructor() {
const widget = callWidgetFactory({
factory: createCarousel,
widgetName: 'carousel',
});
super(widget);
effect(() => {
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, signal} 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',
];
readonly loop = signal(true);
readonly dragFree = signal(false);
readonly withNavArrows = signal(true);
readonly withNavIndicators = signal(true);
readonly autoplay = signal(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,41 @@
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()"
[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]="mainCarouselDirectives.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)="mainCarouselApi.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)="mainCarouselApi.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]="thumbCarouselDirectives.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>
101 changes: 101 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,101 @@
import {Component, computed, effect, type ElementRef, inject, Injector, input, type OnInit, signal, untracked, 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 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 {
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 mainCarouselApi() {
return this._mainCarousel.api;
}
get mainCarouselDirectives() {
return this._mainCarousel.directives;
}
get mainCarouselState() {
return this._mainCarousel.state;
}

private readonly _thumbCarousel = callWidgetFactory({
factory: createCarousel,
widgetName: 'carousel',
defaultConfig: {
dragFree: true,
containScroll: 'keepSnaps',
},
});
get thumbCarouselApi() {
return this._thumbCarousel.api;
}
get thumbCarouselState() {
return this._thumbCarousel.state;
}
get thumbCarouselDirectives() {
return this._thumbCarousel.directives;
}

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 readonly injector = inject(Injector);

ngOnInit() {
this._mainCarousel.ngInit();
this._thumbCarousel.ngInit();
effect(
() => {
const selectedSnap = this.mainCarouselState.selectedScrollSnap();
untracked(() => {
this.thumbCarouselApi.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);
}
});
},
{injector: this.injector},
);
}

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

scrollToSlide(index: number) {
this.mainCarouselApi.scrollTo(index, Math.abs(this.mainCarouselState.selectedScrollSnap() - index) > 1);
}

showImage(index: number) {
return Math.abs(this.mainCarouselState.selectedScrollSnap() - index) <= 1;
}
}
Loading

0 comments on commit 549e88e

Please sign in to comment.