From 000d900f6b30d6db27e3e1a35724cb6f35c17738 Mon Sep 17 00:00:00 2001 From: Quentin Deroubaix Date: Mon, 5 Aug 2024 09:03:46 +0200 Subject: [PATCH] feat: headless carousel --- .../samples/carousel/carousel.component.html | 45 ++ .../samples/carousel/carousel.component.ts | 54 ++ .../src/app/samples/carousel/default.route.ts | 67 +++ .../app/samples/carousel/demoGallery.route.ts | 18 + .../carousel/gallery-image.component.ts | 42 ++ .../samples/carousel/gallery.component.html | 64 +++ .../app/samples/carousel/gallery.component.ts | 94 ++++ common/samples/carousel/compress.svg | 1 + common/samples/carousel/expand.svg | 1 + common/samples/carousel/photo.ts | 46 ++ core/package.json | 1 + core/src/components/carousel/carousel.ts | 250 +++++++++ core/src/components/carousel/index.ts | 1 + core/src/config.ts | 5 + demo/src/lib/components-metadata.ts | 28 +- demo/src/lib/layout/StatusAlert.svelte | 2 +- demo/src/lib/server/index.ts | 4 +- .../angular-daisyui/package-lock.json | 19 + .../stackblitz/angular-daisyui/package.json | 1 + .../react-daisyui/package-lock.json | 19 + .../lib/stackblitz/react-daisyui/package.json | 1 + .../svelte-daisyui/package-lock.json | 19 + .../stackblitz/svelte-daisyui/package.json | 1 + .../docs/[framework]/components/getMenu.ts | 2 +- .../daisyUI/carousel/+layout.server.ts | 3 + .../daisyUI/carousel/headless/+page.svelte | 22 + .../docs/[framework]/daisyUI/getMenu.ts | 2 +- .../daisyui-carousel-default.html | 214 ++++++++ .../daisyui-carousel-demogallery.html | 501 ++++++++++++++++++ package-lock.json | 19 + .../src/daisyui/samples/carousel/Carousel.tsx | 80 +++ .../samples/carousel/Default.route.tsx | 74 +++ .../samples/carousel/DemoGallery.route.tsx | 11 + .../src/daisyui/samples/carousel/Gallery.tsx | 165 ++++++ svelte/demo/package.json | 1 + .../daisyui/samples/carousel/Carousel.svelte | 78 +++ .../samples/carousel/Default.route.svelte | 49 ++ .../samples/carousel/DemoGallery.route.svelte | 10 + .../daisyui/samples/carousel/Gallery.svelte | 120 +++++ .../samples/carousel/GalleryImage.svelte | 36 ++ 40 files changed, 2146 insertions(+), 24 deletions(-) create mode 100644 angular/demo/daisyui/src/app/samples/carousel/carousel.component.html create mode 100644 angular/demo/daisyui/src/app/samples/carousel/carousel.component.ts create mode 100644 angular/demo/daisyui/src/app/samples/carousel/default.route.ts create mode 100644 angular/demo/daisyui/src/app/samples/carousel/demoGallery.route.ts create mode 100644 angular/demo/daisyui/src/app/samples/carousel/gallery-image.component.ts create mode 100644 angular/demo/daisyui/src/app/samples/carousel/gallery.component.html create mode 100644 angular/demo/daisyui/src/app/samples/carousel/gallery.component.ts create mode 100644 common/samples/carousel/compress.svg create mode 100644 common/samples/carousel/expand.svg create mode 100644 common/samples/carousel/photo.ts create mode 100644 core/src/components/carousel/carousel.ts create mode 100644 core/src/components/carousel/index.ts create mode 100644 demo/src/routes/docs/[framework]/daisyUI/carousel/+layout.server.ts create mode 100644 demo/src/routes/docs/[framework]/daisyUI/carousel/headless/+page.svelte create mode 100644 e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-default.html create mode 100644 e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-demogallery.html create mode 100644 react/demo/src/daisyui/samples/carousel/Carousel.tsx create mode 100644 react/demo/src/daisyui/samples/carousel/Default.route.tsx create mode 100644 react/demo/src/daisyui/samples/carousel/DemoGallery.route.tsx create mode 100644 react/demo/src/daisyui/samples/carousel/Gallery.tsx create mode 100644 svelte/demo/src/daisyui/samples/carousel/Carousel.svelte create mode 100644 svelte/demo/src/daisyui/samples/carousel/Default.route.svelte create mode 100644 svelte/demo/src/daisyui/samples/carousel/DemoGallery.route.svelte create mode 100644 svelte/demo/src/daisyui/samples/carousel/Gallery.svelte create mode 100644 svelte/demo/src/daisyui/samples/carousel/GalleryImage.svelte diff --git a/angular/demo/daisyui/src/app/samples/carousel/carousel.component.html b/angular/demo/daisyui/src/app/samples/carousel/carousel.component.html new file mode 100644 index 0000000000..afeb769222 --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/carousel.component.html @@ -0,0 +1,45 @@ +
+
+ @for (item of items(); track item) { +
+ +
+ } +
+ @if (withNavArrows()) { +
+ + +
+ } + @if (withNavIndicators()) { +
+ @for (item of items(); track item; let index = $index) { + + } +
+ } +
diff --git a/angular/demo/daisyui/src/app/samples/carousel/carousel.component.ts b/angular/demo/daisyui/src/app/samples/carousel/carousel.component.ts new file mode 100644 index 0000000000..620f4c96c5 --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/carousel.component.ts @@ -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); + 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 extends BaseWidgetDirective { + 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(); + + 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})] : [], + }); + }); + } +} diff --git a/angular/demo/daisyui/src/app/samples/carousel/default.route.ts b/angular/demo/daisyui/src/app/samples/carousel/default.route.ts new file mode 100644 index 0000000000..bb01a5e56d --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/default.route.ts @@ -0,0 +1,67 @@ +import {Component} from '@angular/core'; +import {CarouselComponent, CarouselSlideDirective} from './carousel.component'; +import {FormsModule} from '@angular/forms'; + +@Component({ + standalone: true, + template: ` +
+
+ + random picsum + +
+ + + + + +
+
+
+ `, + 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; +} diff --git a/angular/demo/daisyui/src/app/samples/carousel/demoGallery.route.ts b/angular/demo/daisyui/src/app/samples/carousel/demoGallery.route.ts new file mode 100644 index 0000000000..39cb64d076 --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/demoGallery.route.ts @@ -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: ` +
+
+ +
+
+ `, + imports: [GalleryComponent], +}) +export default class DemoGalleryComponent { + readonly photos = photos; +} diff --git a/angular/demo/daisyui/src/app/samples/carousel/gallery-image.component.ts b/angular/demo/daisyui/src/app/samples/carousel/gallery-image.component.ts new file mode 100644 index 0000000000..e761288064 --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/gallery-image.component.ts @@ -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()) { + + } + + @for (source of sources(); track source) { + + } + + + } @else { +
+ } + `, + host: { + style: 'display: contents;', + }, +}) +export class GalleryImageComponent { + readonly src = input.required(); + readonly alt = input.required(); + readonly sources = input.required(); + readonly loadRequested = input.required(); + readonly aspectRatio = input.required(); + readonly toShow = input.required(); + readonly imageLoaded = signal(false); +} diff --git a/angular/demo/daisyui/src/app/samples/carousel/gallery.component.html b/angular/demo/daisyui/src/app/samples/carousel/gallery.component.html new file mode 100644 index 0000000000..fe80a23587 --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/gallery.component.html @@ -0,0 +1,64 @@ +
+
+
+ @for (photoWithLoadState of photosWithLoadState(); track photoWithLoadState; let index = $index) { +
+ +
+ } +
+ @if (withNavArrows()) { +
+ + +
+ } + @if (canFullScreen()) { +
+ +
+ } +
+
+
+ @for (photo of photos(); track photo; let index = $index) { + + } +
+
+
diff --git a/angular/demo/daisyui/src/app/samples/carousel/gallery.component.ts b/angular/demo/daisyui/src/app/samples/carousel/gallery.component.ts new file mode 100644 index 0000000000..ad13223a2d --- /dev/null +++ b/angular/demo/daisyui/src/app/samples/carousel/gallery.component.ts @@ -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(); + 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('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; + } +} diff --git a/common/samples/carousel/compress.svg b/common/samples/carousel/compress.svg new file mode 100644 index 0000000000..0dc2af2089 --- /dev/null +++ b/common/samples/carousel/compress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/samples/carousel/expand.svg b/common/samples/carousel/expand.svg new file mode 100644 index 0000000000..24d7f33a4d --- /dev/null +++ b/common/samples/carousel/expand.svg @@ -0,0 +1 @@ + diff --git a/common/samples/carousel/photo.ts b/common/samples/carousel/photo.ts new file mode 100644 index 0000000000..f06665ee0a --- /dev/null +++ b/common/samples/carousel/photo.ts @@ -0,0 +1,46 @@ +export type Source = { + media: string; + srcset: string; +}; + +export type Photo = { + thumbnail: string; + src: string; + alt: string; + sources: Source[]; +}; + +const ids = [36, 43, 70, 102, 124, 142, 156, 242, 288, 289, 295, 361, 400, 488, 496, 515, 557, 607, 623, 645]; +const verticalIds = [124, 289, 400, 488, 607]; + +const _photos: Photo[] = []; +const formatUrl = (id: number, longAxis: number, smallAxis: number, isVertical: boolean) => + `https://picsum.photos/id/${id}/${isVertical ? smallAxis : longAxis}/${isVertical ? longAxis : smallAxis}.webp`; + +for (const id of ids) { + const isVertical = verticalIds.includes(id); + _photos.push({ + thumbnail: formatUrl(id, 120, 90, isVertical), + src: formatUrl(id, 1600, 1200, isVertical), + alt: `random picsum`, + sources: [ + { + media: '(max-width: 599px)', + srcset: formatUrl(id, 600, 450, isVertical), + }, + { + media: '(min-width: 600px) and (max-width: 799px)', + srcset: formatUrl(id, 800, 600, isVertical), + }, + { + media: '(min-width: 800px) and (max-width: 1199px)', + srcset: formatUrl(id, 1200, 900, isVertical), + }, + { + media: '(min-width: 1200px)', + srcset: formatUrl(id, 1600, 1200, isVertical), + }, + ], + }); +} +export const photos = [..._photos]; diff --git a/core/package.json b/core/package.json index 08ed2a6412..4ba9b882fe 100644 --- a/core/package.json +++ b/core/package.json @@ -133,6 +133,7 @@ "peerDependencies": { "@amadeus-it-group/tansu": "^1.0.0", "@floating-ui/dom": "^1.6.10", + "embla-carousel": "^8.1.7", "esm-env": "^1.0.0" }, "sideEffects": false diff --git a/core/src/components/carousel/carousel.ts b/core/src/components/carousel/carousel.ts new file mode 100644 index 0000000000..f44a5fd3f3 --- /dev/null +++ b/core/src/components/carousel/carousel.ts @@ -0,0 +1,250 @@ +import {stateStores, writablesForProps} from '../../utils/stores'; +import type {ConfigValidator, Directive, PropsConfig, Widget} from '../../types'; +import {bindDirective, browserDirective} from '../../utils/directive'; +import type {EmblaCarouselType, EmblaPluginType, EmblaPluginsType} from 'embla-carousel'; +import EmblaCarousel from 'embla-carousel'; +import {computed, writable} from '@amadeus-it-group/tansu'; +import {typeBoolean} from '../../utils/writables'; + +interface EmblaOptions { + /** + * Align the slides relative to the carousel viewport + * @defaultValue `'center'` + */ + align: 'start' | 'center' | 'end'; + /** + * Choose scroll axis between `x` and `y` + * @defaultValue `'x'` + */ + axis: 'x' | 'y'; + /** + * Clear leading and trailing empty space that causes excessive scrolling + * @defaultValue `'trimSnaps'` + */ + containScroll: false | 'trimSnaps' | 'keepSnaps'; + /** + * Choose content direction between `ltr` and `rtl` + * @defaultValue `'ltr'` + */ + direction: 'ltr' | 'rtl'; + /** + * Enables momentum scrolling + * @defaultValue `false` + */ + dragFree: boolean; + /** + * Drag threshold in pixels + * @defaultValue `10` + */ + dragThreshold: number; + /** + * Set scroll duration when triggered by any of the API methods + * @defaultValue `25` + */ + duration: number; + /** + * Intersetion Observer threshold applied to compute slidesInView + * @defaultValue `0` + */ + inViewThreshold: number; + /** + * Enables infinite looping + * @defaultValue `false` + */ + loop: boolean; + /** + * Allow the carousel to skip scroll snaps if it's dragged vigorously + * @defaultValue `false` + */ + skipSnaps: boolean; +} + +export interface CarouselProps extends EmblaOptions { + /** + * Plugins to extend the carousel with additional features + * @defaultValue `[]` + */ + plugins: EmblaPluginType[]; +} + +export interface CarouselState { + /** + * is the carousel currently scrolling + */ + scrolling: boolean; + /** + * slides in view + */ + slidesInView: number[]; + /** + * can carousel scroll to previous slide + */ + canScrollPrev: boolean; + /** + * can carousel scroll to next slide + */ + canScrollNext: boolean; + /** + * selected scroll snap + */ + selectedScrollSnap: number; +} + +export interface CarouselApi { + /** + * Scroll to the previous snap point if possible. + */ + scrollPrev: () => void; + /** + * Scroll to the next snap point if possible. + */ + scrollNext: () => void; + /** + * Scroll to a snap point by index + * @param index the snap point index + * @param jump scroll instantly + */ + scrollTo: (index: number, jump?: boolean) => void; + /** + * Retrieve the enabled plugins + */ + plugins: () => EmblaPluginsType | undefined; +} + +export interface CarouselDirectives { + /** + * the embla directive + */ + emblaDirective: Directive; +} + +export type CarouselWidget = Widget; + +const defaultConfig: CarouselProps = { + align: 'center', + axis: 'x', + containScroll: 'trimSnaps', + direction: 'ltr', + dragFree: false, + dragThreshold: 10, + duration: 25, + inViewThreshold: 0, + loop: false, + skipSnaps: false, + plugins: [], +}; + +/** + * Retrieve a shallow copy of the default Carousel config + * @returns the default Carousel config + */ +export function getCarouselDefaultConfig(): CarouselProps { + return {...defaultConfig}; +} + +// TODO add more validators. probably need a new issue to add an enum validator ? +const configValidator: ConfigValidator = { + dragFree: typeBoolean, +}; + +/** + * Create an CarouselWidget with given config props + * @param config - an optional carousel config + * @returns a CarouselWidget + */ +export function createCarousel(config?: PropsConfig): CarouselWidget { + const [ + {align$, axis$, containScroll$, direction$, dragFree$, dragThreshold$, duration$, inViewThreshold$, loop$, skipSnaps$, plugins$, ...stateProps}, + patch, + ] = writablesForProps(defaultConfig, config, configValidator); + let emblaApi: EmblaCarouselType | undefined; + + const scrolling$ = writable(false); + const slidesInView$ = writable([] as number[]); + const canScrollPrev$ = writable(false); + const canScrollNext$ = writable(false); + const selectedScrollSnap$ = writable(0); + + const emblaOptions$ = computed(() => ({ + align: align$(), + axis: axis$(), + containScroll: containScroll$(), + direction: direction$(), + dragFree: dragFree$(), + dragThreshold: dragThreshold$(), + duration: duration$(), + inViewThreshold: inViewThreshold$(), + loop: loop$(), + skipSnaps: skipSnaps$(), + })); + + return { + ...stateStores({ + scrolling$, + slidesInView$, + canScrollNext$, + canScrollPrev$, + selectedScrollSnap$, + ...stateProps, + }), + patch, + api: { + scrollPrev: () => { + emblaApi?.scrollPrev?.(); + }, + scrollNext: () => { + emblaApi?.scrollNext?.(); + }, + scrollTo: (index: number, jump?: boolean) => { + emblaApi?.scrollTo?.(index, jump); + }, + plugins: () => emblaApi?.plugins(), + }, + directives: { + emblaDirective: bindDirective( + browserDirective((element: HTMLElement, {options, plugins}: {options: EmblaOptions; plugins: EmblaPluginType[]}) => { + if (emblaApi) { + throw new Error('Only one Embla directive can be attached per carousel widget !'); + } + emblaApi = EmblaCarousel(element, options, plugins); + emblaApi.on('scroll', () => { + scrolling$.set(true); + }); + emblaApi.on('settle', () => { + scrolling$.set(false); + }); + emblaApi.on('slidesInView', (api) => { + slidesInView$.set(api.slidesInView()); + }); + emblaApi.on('select', (api) => { + canScrollNext$.set(api.canScrollNext()); + canScrollPrev$.set(api.canScrollPrev()); + selectedScrollSnap$.set(api.selectedScrollSnap()); + }); + emblaApi.on('reInit', (api) => { + canScrollNext$.set(api.canScrollNext()); + canScrollPrev$.set(api.canScrollPrev()); + scrolling$.set(false); + selectedScrollSnap$.set(api.selectedScrollSnap()); + }); + canScrollNext$.set(emblaApi.canScrollNext()); + canScrollPrev$.set(emblaApi.canScrollPrev()); + return { + update: ({options, plugins}: {options: EmblaOptions; plugins: EmblaPluginType[]}) => { + emblaApi!.reInit(options, plugins); + }, + destroy: () => { + emblaApi?.destroy(); + emblaApi = undefined; + }, + }; + }), + computed(() => ({ + options: emblaOptions$(), + plugins: plugins$(), + })), + ), + }, + actions: {}, + }; +} diff --git a/core/src/components/carousel/index.ts b/core/src/components/carousel/index.ts new file mode 100644 index 0000000000..5f66438933 --- /dev/null +++ b/core/src/components/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel'; diff --git a/core/src/config.ts b/core/src/config.ts index 0b3eda5de2..4063f1b127 100644 --- a/core/src/config.ts +++ b/core/src/config.ts @@ -10,6 +10,7 @@ import type {ProgressbarProps} from './components/progressbar/progressbar'; import {identity} from './utils/internal/func'; import type {SliderProps} from './components/slider/slider'; import type {ToastProps} from './components/toast/toast'; +import type {CarouselProps} from './components/carousel'; export type Partial2Levels = Partial<{ [Level1 in keyof T]: Partial; @@ -121,4 +122,8 @@ export type WidgetsConfig = { * toast widget config */ toast: ToastProps; + /** + * carousel widget config + */ + carousel: CarouselProps; }; diff --git a/demo/src/lib/components-metadata.ts b/demo/src/lib/components-metadata.ts index 77ea6e6511..34ebdd2b64 100644 --- a/demo/src/lib/components-metadata.ts +++ b/demo/src/lib/components-metadata.ts @@ -1,10 +1,11 @@ import type {WidgetsConfig} from '@agnos-ui/svelte-bootstrap/config'; +// eslint-disable-next-line @agnos-ui/check-replace-imports +import type {WidgetsConfig as HeadlessConfig} from '@agnos-ui/svelte-headless/config'; export type ComponentStatus = 'stable' | 'beta' | 'inprogress' | 'deprecated'; export type ComponentType = 'bootstrap' | 'standalone' | 'daisyUI'; export interface ComponentMetadata { - title: string; status: ComponentStatus; since: string; type: ComponentType; @@ -14,19 +15,18 @@ export interface ComponentMetadata { // TODO once Select is added to the headless examples, remove the Partial type Metadata = Partial<{[WidgetName in keyof WidgetsConfig as Capitalize]: ComponentMetadata}>; +type DaisyMetadata = Partial<{[WidgetName in keyof HeadlessConfig as Capitalize]: ComponentMetadata}>; /** * Metadata for each component */ export const componentsMetadata: Metadata = { Accordion: { - title: 'Accordion', status: 'stable', since: 'v0.1.1', type: 'bootstrap', }, Alert: { - title: 'Alert', status: 'stable', since: 'v0.1.1', type: 'bootstrap', @@ -35,7 +35,6 @@ export const componentsMetadata: Metadata = { }, }, Modal: { - title: 'Modal', status: 'stable', since: 'v0.1.1', type: 'bootstrap', @@ -46,38 +45,32 @@ export const componentsMetadata: Metadata = { }, }, Pagination: { - title: 'Pagination', status: 'stable', since: 'v0.1.1', type: 'bootstrap', }, Progressbar: { - title: 'Progressbar', status: 'stable', since: 'v0.1.1', type: 'bootstrap', }, Rating: { - title: 'Rating', status: 'stable', since: 'v0.1.1', type: 'standalone', }, Select: { - title: 'Select', status: 'inprogress', since: 'v0.1.1', type: 'standalone', }, Slider: { - title: 'Slider', status: 'stable', since: 'v0.1.1', type: 'standalone', includeStyles: true, }, Toast: { - title: 'Toast', status: 'stable', since: 'v0.2.0', type: 'bootstrap', @@ -92,51 +85,48 @@ export const componentsMetadata: Metadata = { /** * Metadata for each component */ -export const daisyUIMetadata: Metadata = { +export const daisyUIMetadata: DaisyMetadata = { Accordion: { - title: 'Accordion', status: 'stable', since: 'v0.4.0', type: 'daisyUI', }, Alert: { - title: 'Alert', status: 'stable', since: 'v0.3.0', type: 'daisyUI', }, + Carousel: { + status: 'beta', + since: 'v0.5.0', + type: 'standalone', + }, Modal: { - title: 'Modal', status: 'stable', since: 'v0.4.0', type: 'daisyUI', }, Pagination: { - title: 'Pagination', status: 'stable', since: 'v0.3.0', type: 'daisyUI', }, Progressbar: { - title: 'Progressbar', status: 'stable', since: 'v0.3.0', type: 'daisyUI', }, Rating: { - title: 'Rating', status: 'stable', since: 'v0.3.0', type: 'daisyUI', }, Slider: { - title: 'Slider', status: 'stable', since: 'v0.3.0', type: 'daisyUI', }, Toast: { - title: 'Toast', status: 'stable', since: 'v0.3.0', type: 'daisyUI', diff --git a/demo/src/lib/layout/StatusAlert.svelte b/demo/src/lib/layout/StatusAlert.svelte index 6d4e6b671d..52b8744670 100644 --- a/demo/src/lib/layout/StatusAlert.svelte +++ b/demo/src/lib/layout/StatusAlert.svelte @@ -5,7 +5,7 @@ import {page} from '$app/stores'; import Svg from './Svg.svelte'; - const regex = /\/(components|services)\/([^/]+)/; + const regex = /\/(components|services|daisyUI)\/([^/]+)/; const typeIcon: Record = { info: biInfoCircleFill, warning: biExclamationTriangleFill, diff --git a/demo/src/lib/server/index.ts b/demo/src/lib/server/index.ts index 64945c541a..dd0fc8c9f3 100644 --- a/demo/src/lib/server/index.ts +++ b/demo/src/lib/server/index.ts @@ -4,14 +4,14 @@ import frontMatter from 'front-matter'; const validMdRegex = /^\d{2}-[a-zA-Z-]*\.md$/g; const componentsSubMenu = Object.entries(componentsMetadata).map(([key, val]) => ({ - title: val.title, + title: key, status: val.status, slug: `components/${key.toLowerCase()}/`, subpath: 'examples', })); const daisyUISubMenu = Object.entries(daisyUIMetadata).map(([key, val]) => ({ - title: val.title, + title: key, status: val.status, slug: `daisyUI/${key.toLowerCase()}/`, subpath: 'headless', diff --git a/demo/src/lib/stackblitz/angular-daisyui/package-lock.json b/demo/src/lib/stackblitz/angular-daisyui/package-lock.json index 43171ab9fb..092256a8cd 100644 --- a/demo/src/lib/stackblitz/angular-daisyui/package-lock.json +++ b/demo/src/lib/stackblitz/angular-daisyui/package-lock.json @@ -23,6 +23,7 @@ "@floating-ui/dom": "^1.6.10", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "postcss": "^8.4.41", "rxjs": "^7.8.1", "tailwindcss": "^3.4.9", @@ -6634,6 +6635,24 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.8.tgz", + "integrity": "sha512-KuHPA8qcAts6YE6ELtt38XYAb26hnKw8Ga0lSXmrhm1oI97t6oACFkqSsy33dfeZQEhaZB6VwWvaWQJRJVgSgA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.1.8.tgz", + "integrity": "sha512-H3vVKt4HR2PGeMlCutE3+a8wv7Jq1rg31Fjb8ZDZaiuSnT/1XTwA83KpkJ02BdImVJci9RS0uEBiXBax2kfnMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.1.8" + } + }, "node_modules/emoji-regex": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", diff --git a/demo/src/lib/stackblitz/angular-daisyui/package.json b/demo/src/lib/stackblitz/angular-daisyui/package.json index fb068cd4b3..748ee0977a 100644 --- a/demo/src/lib/stackblitz/angular-daisyui/package.json +++ b/demo/src/lib/stackblitz/angular-daisyui/package.json @@ -26,6 +26,7 @@ "@floating-ui/dom": "^1.6.10", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "postcss": "^8.4.41", "rxjs": "^7.8.1", "tailwindcss": "^3.4.9", diff --git a/demo/src/lib/stackblitz/react-daisyui/package-lock.json b/demo/src/lib/stackblitz/react-daisyui/package-lock.json index fa778970d8..11a6611ac1 100644 --- a/demo/src/lib/stackblitz/react-daisyui/package-lock.json +++ b/demo/src/lib/stackblitz/react-daisyui/package-lock.json @@ -15,6 +15,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "postcss": "^8.4.41", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1948,6 +1949,24 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.8.tgz", + "integrity": "sha512-KuHPA8qcAts6YE6ELtt38XYAb26hnKw8Ga0lSXmrhm1oI97t6oACFkqSsy33dfeZQEhaZB6VwWvaWQJRJVgSgA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.1.8.tgz", + "integrity": "sha512-H3vVKt4HR2PGeMlCutE3+a8wv7Jq1rg31Fjb8ZDZaiuSnT/1XTwA83KpkJ02BdImVJci9RS0uEBiXBax2kfnMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.1.8" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/demo/src/lib/stackblitz/react-daisyui/package.json b/demo/src/lib/stackblitz/react-daisyui/package.json index 4585a3694c..1d15679c6d 100644 --- a/demo/src/lib/stackblitz/react-daisyui/package.json +++ b/demo/src/lib/stackblitz/react-daisyui/package.json @@ -16,6 +16,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "postcss": "^8.4.41", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/demo/src/lib/stackblitz/svelte-daisyui/package-lock.json b/demo/src/lib/stackblitz/svelte-daisyui/package-lock.json index 6e12e5aee6..97854c729c 100644 --- a/demo/src/lib/stackblitz/svelte-daisyui/package-lock.json +++ b/demo/src/lib/stackblitz/svelte-daisyui/package-lock.json @@ -14,6 +14,7 @@ "@tsconfig/svelte": "^5.0.4", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "postcss": "^8.4.41", "sass": "^1.77.8", "svelte": "^4.2.18", @@ -1361,6 +1362,24 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.8.tgz", + "integrity": "sha512-KuHPA8qcAts6YE6ELtt38XYAb26hnKw8Ga0lSXmrhm1oI97t6oACFkqSsy33dfeZQEhaZB6VwWvaWQJRJVgSgA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.1.8.tgz", + "integrity": "sha512-H3vVKt4HR2PGeMlCutE3+a8wv7Jq1rg31Fjb8ZDZaiuSnT/1XTwA83KpkJ02BdImVJci9RS0uEBiXBax2kfnMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.1.8" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/demo/src/lib/stackblitz/svelte-daisyui/package.json b/demo/src/lib/stackblitz/svelte-daisyui/package.json index 28594a1684..d0696919c8 100644 --- a/demo/src/lib/stackblitz/svelte-daisyui/package.json +++ b/demo/src/lib/stackblitz/svelte-daisyui/package.json @@ -15,6 +15,7 @@ "@tsconfig/svelte": "^5.0.4", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "postcss": "^8.4.41", "sass": "^1.77.8", "svelte": "^4.2.18", diff --git a/demo/src/routes/docs/[framework]/components/getMenu.ts b/demo/src/routes/docs/[framework]/components/getMenu.ts index 943ed1e7a8..2d1abf9bcd 100644 --- a/demo/src/routes/docs/[framework]/components/getMenu.ts +++ b/demo/src/routes/docs/[framework]/components/getMenu.ts @@ -11,7 +11,7 @@ export function getMenu(component: string) { const componentMetadata = componentsMetadata[componentCapitalized]!; return { - title: componentMetadata.title, + title: componentCapitalized, status: componentMetadata.status, since: componentMetadata.since, tabs: [ diff --git a/demo/src/routes/docs/[framework]/daisyUI/carousel/+layout.server.ts b/demo/src/routes/docs/[framework]/daisyUI/carousel/+layout.server.ts new file mode 100644 index 0000000000..bbddfc15f2 --- /dev/null +++ b/demo/src/routes/docs/[framework]/daisyUI/carousel/+layout.server.ts @@ -0,0 +1,3 @@ +import {getMenu} from '../getMenu'; + +export const load = () => getMenu('carousel'); diff --git a/demo/src/routes/docs/[framework]/daisyUI/carousel/headless/+page.svelte b/demo/src/routes/docs/[framework]/daisyUI/carousel/headless/+page.svelte new file mode 100644 index 0000000000..cff9713521 --- /dev/null +++ b/demo/src/routes/docs/[framework]/daisyUI/carousel/headless/+page.svelte @@ -0,0 +1,22 @@ + + +
+

+ The headless Carousel simply wraps the Embla Carousel as an AgnosUI widget, allowing the options and + plugins to be set through our configuration. +

+ +
+ + diff --git a/demo/src/routes/docs/[framework]/daisyUI/getMenu.ts b/demo/src/routes/docs/[framework]/daisyUI/getMenu.ts index 9237b6da6a..785ee02c5e 100644 --- a/demo/src/routes/docs/[framework]/daisyUI/getMenu.ts +++ b/demo/src/routes/docs/[framework]/daisyUI/getMenu.ts @@ -11,7 +11,7 @@ export function getMenu(component: string) { const componentMetadata = daisyUIMetadata[componentCapitalized]!; return { - title: componentMetadata.title, + title: componentCapitalized, status: componentMetadata.status, since: componentMetadata.since, tabs: [ diff --git a/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-default.html b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-default.html new file mode 100644 index 0000000000..2d44d155df --- /dev/null +++ b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-default.html @@ -0,0 +1,214 @@ + +
+
+
+
+
+
+ random picsum +
+
+ random picsum +
+
+ random picsum +
+
+ random picsum +
+
+ random picsum +
+
+ random picsum +
+
+
+ + +
+
+ + + + + + +
+
+
+ + + + + +
+
+
+
+ \ No newline at end of file diff --git a/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-demogallery.html b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-demogallery.html new file mode 100644 index 0000000000..21a5f35ecf --- /dev/null +++ b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/daisyui-carousel-demogallery.html @@ -0,0 +1,501 @@ + +
+
+
+
+
+
+
+ + + + + + random picsum + +
+
+ + + + + + random picsum + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 404b2efcb3..11b8c595ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -231,6 +231,7 @@ "peerDependencies": { "@amadeus-it-group/tansu": "^1.0.0", "@floating-ui/dom": "^1.6.10", + "embla-carousel": "^8.1.7", "esm-env": "^1.0.0" } }, @@ -10726,6 +10727,23 @@ "version": "1.5.4", "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.8.tgz", + "integrity": "sha512-KuHPA8qcAts6YE6ELtt38XYAb26hnKw8Ga0lSXmrhm1oI97t6oACFkqSsy33dfeZQEhaZB6VwWvaWQJRJVgSgA==", + "license": "MIT", + "peer": true + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.1.8.tgz", + "integrity": "sha512-H3vVKt4HR2PGeMlCutE3+a8wv7Jq1rg31Fjb8ZDZaiuSnT/1XTwA83KpkJ02BdImVJci9RS0uEBiXBax2kfnMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.1.8" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "dev": true, @@ -23594,6 +23612,7 @@ "@sveltejs/vite-plugin-svelte": "^3.1.1", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "eslint-plugin-svelte": "^2.43.0", "postcss": "^8.4.41", "prettier-plugin-svelte": "^3.2.6", diff --git a/react/demo/src/daisyui/samples/carousel/Carousel.tsx b/react/demo/src/daisyui/samples/carousel/Carousel.tsx new file mode 100644 index 0000000000..e5861ee59f --- /dev/null +++ b/react/demo/src/daisyui/samples/carousel/Carousel.tsx @@ -0,0 +1,80 @@ +import {type CarouselProps, createCarousel} from '@agnos-ui/react-headless/components/carousel'; +import {useWidgetWithConfig} from '@agnos-ui/react-headless/config'; +import {useDirective} from '@agnos-ui/react-headless/utils/directive'; +import classNames from 'classnames'; +import AutoPlay from 'embla-carousel-autoplay'; +import {useMemo, type ReactNode} from 'react'; + +export function Carousel( + props: Partial> & { + items: Item[]; + withNavArrows?: boolean; + withNavIndicators?: boolean; + autoplay?: boolean; + slide: (props: {item: Item}) => ReactNode; + }, +) { + const [ + state, + { + patch, + directives: {emblaDirective}, + api: {scrollPrev, scrollNext, scrollTo}, + }, + ] = useWidgetWithConfig(createCarousel, props, 'carousel'); + useMemo(() => { + if (props.autoplay) { + patch({plugins: [AutoPlay({playOnInit: true, stopOnInteraction: false, stopOnMouseEnter: true, stopOnFocusIn: true})]}); + } else { + patch({plugins: []}); + } + }, [props.autoplay]); + const Slide = props.slide; + return ( +
+
+ {props.items.map((item, index) => ( +
+ +
+ ))} +
+ {props.withNavArrows && ( +
+ + +
+ )} + {props.withNavIndicators && ( +
+ {props.items.map((_, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/react/demo/src/daisyui/samples/carousel/Default.route.tsx b/react/demo/src/daisyui/samples/carousel/Default.route.tsx new file mode 100644 index 0000000000..f846822f8f --- /dev/null +++ b/react/demo/src/daisyui/samples/carousel/Default.route.tsx @@ -0,0 +1,74 @@ +import classNames from 'classnames'; +import {Carousel} from './Carousel'; +import {useMemo, useState} from 'react'; + +const 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', +]; + +const Slide = ({item}: {item: string}) => ( + random picsum +); + +const DemoCarousel = () => { + const [loop, setLoop] = useState(true); + const [withNavArrows, setWithNavArrows] = useState(true); + const [withNavIndicators, setWithNavIndicators] = useState(true); + const [dragFree, setDragFree] = useState(false); + const [_autoplay, setAutoplay] = useState(true); + const autoPlay = useMemo(() => loop && _autoplay, [_autoplay, loop]); + return ( +
+
+ +
+ + + + + +
+
+
+ ); +}; +export default DemoCarousel; diff --git a/react/demo/src/daisyui/samples/carousel/DemoGallery.route.tsx b/react/demo/src/daisyui/samples/carousel/DemoGallery.route.tsx new file mode 100644 index 0000000000..3284bbae95 --- /dev/null +++ b/react/demo/src/daisyui/samples/carousel/DemoGallery.route.tsx @@ -0,0 +1,11 @@ +import {Gallery} from './Gallery'; +import {photos} from '@agnos-ui/common/samples/carousel/photo'; + +const DemoGallery = () => ( +
+
+ +
+
+); +export default DemoGallery; diff --git a/react/demo/src/daisyui/samples/carousel/Gallery.tsx b/react/demo/src/daisyui/samples/carousel/Gallery.tsx new file mode 100644 index 0000000000..00968ad503 --- /dev/null +++ b/react/demo/src/daisyui/samples/carousel/Gallery.tsx @@ -0,0 +1,165 @@ +import type {Photo} from '@agnos-ui/common/samples/carousel/photo'; +import {createCarousel} from '@agnos-ui/react-headless/components/carousel'; +import {useDirective} from '@agnos-ui/react-headless/utils/directive'; +import {useObservable} from '@agnos-ui/react-headless/utils/stores'; +import classNames from 'classnames'; +import {useEffect, useMemo, useRef, useState} from 'react'; +import ExpandSvg from '@agnos-ui/common/samples/carousel/expand.svg?react'; +import CompressSvg from '@agnos-ui/common/samples/carousel/compress.svg?react'; + +const ImageSlide = ({ + sources, + src, + alt, + loadRequested, + aspectRatio, + toShow, +}: Photo & {loadRequested: boolean; aspectRatio: number; toShow: boolean}) => { + const spinnerRef = useRef(null); + const [imgLoaded, setImgLoaded] = useState(false); + return ( +
+ {loadRequested ? ( + <> + {!imgLoaded && ( + + )} + + {sources.map(({media, srcset}, index) => ( + + ))} + {alt} setImgLoaded(true)} + /> + + + ) : ( +
+ )} +
+ ); +}; + +export const Gallery = ({ + photos, + withNavArrows = false, + withShowFullscreen = false, + aspectRatio = 4 / 3, +}: { + photos: Photo[]; + withNavArrows?: boolean; + withShowFullscreen?: boolean; + aspectRatio?: number; +}) => { + const [photosWithLoadState, setPhotosWithLoadState] = useState(photos.map((photo, index) => ({...photo, loadRequested: index === 0}))); + + const mainCarousel = useMemo(() => createCarousel(), []); + const mainCarouselState = useObservable(mainCarousel.state$); + const thumbCarousel = useMemo( + () => + createCarousel({ + props: { + dragFree: true, + containScroll: 'keepSnaps', + }, + }), + [], + ); + + const refMainContainer = useRef(null); + const canFullScreen = !!document?.fullscreenEnabled && withShowFullscreen; + const [isFullScreen, setIsFullScreen] = useState(false); + const toggleFullScreen = () => { + if (!isFullScreen) { + refMainContainer.current!.requestFullscreen(); + } else { + document.exitFullscreen(); + } + setIsFullScreen(!isFullScreen); + }; + useEffect( + () => + mainCarousel.stores.selectedScrollSnap$.subscribe((selectedSnap: number) => { + thumbCarousel.api.scrollTo(selectedSnap); + setPhotosWithLoadState( + photosWithLoadState.map((photoWithLoadState, index) => + Math.abs(index - selectedSnap) <= 1 && !photoWithLoadState.loadRequested + ? {...photoWithLoadState, loadRequested: true} + : photoWithLoadState, + ), + ); + }), + [], + ); + + return ( +
+
+
+ {photosWithLoadState.map((photoWithLoadState, index) => ( + + ))} +
+ {withNavArrows && ( +
+ + +
+ )} + {canFullScreen && ( +
+ +
+ )} +
+
+
+ {photos.map(({thumbnail}, index) => ( + + ))} +
+
+
+ ); +}; diff --git a/svelte/demo/package.json b/svelte/demo/package.json index 833db67ae4..be8e7eb3eb 100644 --- a/svelte/demo/package.json +++ b/svelte/demo/package.json @@ -88,6 +88,7 @@ "@sveltejs/vite-plugin-svelte": "^3.1.1", "autoprefixer": "^10.4.20", "daisyui": "^4.12.10", + "embla-carousel-autoplay": "^8.1.8", "eslint-plugin-svelte": "^2.43.0", "postcss": "^8.4.41", "prettier-plugin-svelte": "^3.2.6", diff --git a/svelte/demo/src/daisyui/samples/carousel/Carousel.svelte b/svelte/demo/src/daisyui/samples/carousel/Carousel.svelte new file mode 100644 index 0000000000..322008b528 --- /dev/null +++ b/svelte/demo/src/daisyui/samples/carousel/Carousel.svelte @@ -0,0 +1,78 @@ + + +
+
+ {#each items as item, index (index)} +
+ +
+ {/each} +
+ {#if withNavArrows} +
+ + +
+ {/if} + {#if withNavIndicators} +
+ {#each items as _, index} + + {/each} +
+ {/if} +
diff --git a/svelte/demo/src/daisyui/samples/carousel/Default.route.svelte b/svelte/demo/src/daisyui/samples/carousel/Default.route.svelte new file mode 100644 index 0000000000..6216bb9367 --- /dev/null +++ b/svelte/demo/src/daisyui/samples/carousel/Default.route.svelte @@ -0,0 +1,49 @@ + + +
+
+ + random picsum + +
+ + + + + +
+
+
diff --git a/svelte/demo/src/daisyui/samples/carousel/DemoGallery.route.svelte b/svelte/demo/src/daisyui/samples/carousel/DemoGallery.route.svelte new file mode 100644 index 0000000000..7896a1756b --- /dev/null +++ b/svelte/demo/src/daisyui/samples/carousel/DemoGallery.route.svelte @@ -0,0 +1,10 @@ + + +
+
+ +
+
diff --git a/svelte/demo/src/daisyui/samples/carousel/Gallery.svelte b/svelte/demo/src/daisyui/samples/carousel/Gallery.svelte new file mode 100644 index 0000000000..145c0de60a --- /dev/null +++ b/svelte/demo/src/daisyui/samples/carousel/Gallery.svelte @@ -0,0 +1,120 @@ + + +
+
+
+ {#each photosWithLoadState as { src, alt, sources, loadRequested }, index (index)} + + {/each} +
+ {#if withNavArrows} +
+ + +
+ {/if} + {#if canFullScreen} +
+ +
+ {/if} +
+
+
+ {#each photos as { thumbnail }, index} + + {/each} +
+
+
diff --git a/svelte/demo/src/daisyui/samples/carousel/GalleryImage.svelte b/svelte/demo/src/daisyui/samples/carousel/GalleryImage.svelte new file mode 100644 index 0000000000..509f2dbad0 --- /dev/null +++ b/svelte/demo/src/daisyui/samples/carousel/GalleryImage.svelte @@ -0,0 +1,36 @@ + + +
+ {#if loadRequested} + {#if !imageLoaded} + + {/if} + + {#each sources as { media, srcset }} + + {/each} + (imageLoaded = true)} + /> + + {:else} +
+ {/if} +