Skip to content

Commit

Permalink
Bugfix: Carousel swiping event listener error, Feat: Re-Expose Image …
Browse files Browse the repository at this point in the history
…Class styling for Carousel (themesberg#1079)

* Bugfix: Fix Carousel swipe error

* Feat: Add img class styling prop

* Bugfix: Unable to preventDefault inside passive event listener invocation

* Bugfix: Button click not working on iPad devices

* Feat: Re-designed and optimized Carousel transitions
  • Loading branch information
jsonMartin authored Sep 27, 2023
1 parent 1ac1eb1 commit 6f90ec5
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 51 deletions.
95 changes: 73 additions & 22 deletions src/lib/carousel/Carousel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,86 @@
export type State = {
images: HTMLImgAttributes[];
index: number;
lastSlideChange: Date;
slideDuration: number; // ms
forward: boolean;
};
</script>

<script lang="ts">
import { createEventDispatcher, onMount, setContext } from 'svelte';
import { quintOut } from 'svelte/easing';
import type { HTMLImgAttributes } from 'svelte/elements';
import { writable } from 'svelte/store';
import { fade, type TransitionConfig } from 'svelte/transition';
import type { TransitionConfig } from 'svelte/transition';
import { twMerge } from 'tailwind-merge';
import Controls from './Controls.svelte';
import Indicators from './Indicators.svelte';
import Slide from './Slide.svelte';
import { canChangeSlide } from './Carousel';
type TransitionFunc = (node: HTMLElement, params: any) => TransitionConfig;
const SLIDE_DURATION_RATIO = 0.25; // TODO: Expose one day?
export let images: HTMLImgAttributes[];
export let index: number = 0;
export let transition: TransitionFunc = (x) => fade(x, { duration: 700, easing: quintOut });
export let slideDuration: number = 1000;
export let transition: TransitionFunc | null;
export let duration: number = 0;
export let ariaLabel: string = 'Draggable Carousel';
// Carousel
let divClass: string = 'overflow-hidden relative rounded-lg h-56 sm:h-64 xl:h-80 2xl:h-96';
let divClass: string = 'grid overflow-hidden relative rounded-lg h-56 sm:h-64 xl:h-80 2xl:h-96';
export let imgClass: string = '';
const dispatch = createEventDispatcher();
const { set, subscribe, update } = writable<State>({ images, index });
const { set, subscribe, update } = writable<State>({ images, index, forward: true, slideDuration, lastSlideChange: new Date() });
const state = { set: (s: State) => set({ index: ((s.index % images.length) + images.length) % images.length, images: s.images }), subscribe, update };
const state = { set: (_state: State) => set({ index: _state.index, images: _state.images, lastSlideChange: new Date(), slideDuration, forward }), subscribe, update };
let forward = true;
setContext('state', state);
subscribe((s) => {
index = s.index;
subscribe((_state) => {
index = _state.index;
forward = _state.forward;
dispatch('change', images[index]);
});
onMount(() => {
dispatch('change', images[index]);
});
onMount(() => dispatch('change', images[index]));
let prevIndex: number = index;
$: {
if (!prevIndex || prevIndex < index) {
update((_state) => ({ ..._state, forward: true, index }));
} else {
update((_state) => ({ ..._state, forward: false, index }));
}
prevIndex = index;
}
$: state.set({ images, index });
const nextSlide = () => {
update((_state) => {
if (!canChangeSlide({ lastSlideChange: _state.lastSlideChange, slideDuration, slideDurationRatio: SLIDE_DURATION_RATIO })) return _state;
const nextSlide = () => (index += 1);
const prevSlide = () => (index -= 1);
_state.index = _state.index >= images.length - 1 ? 0 : _state.index + 1;
_state.lastSlideChange = new Date();
return { ..._state };
});
};
const prevSlide = () => {
update((_state) => {
if (!canChangeSlide({ lastSlideChange: _state.lastSlideChange, slideDuration, slideDurationRatio: SLIDE_DURATION_RATIO })) return _state;
_state.index = _state.index <= 0 ? images.length - 1 : _state.index - 1;
_state.lastSlideChange = new Date();
return { ..._state };
});
};
const loop = (node: HTMLElement, duration: number) => {
carouselDiv = node; // used by DragStart
Expand Down Expand Up @@ -89,7 +125,7 @@
const onDragStart = (evt: MouseEvent | TouchEvent) => {
touchEvent = evt;
evt.preventDefault();
evt.cancelable && evt.preventDefault();
const start = getPositionFromEvent(evt);
const width = carouselDiv.getBoundingClientRect().width;
if (start === undefined || width === undefined) return;
Expand Down Expand Up @@ -132,26 +168,39 @@
} else if (percentOffset > DRAG_MIN_PERCENT) prevSlide();
else if (percentOffset < -DRAG_MIN_PERCENT) nextSlide();
else {
// The gesture is a tap not drag, so manually issue a click event to trigger tap click gestures lost via preventDefault
touchEvent?.target?.dispatchEvent(
new Event('click', {
bubbles: true
})
);
// Only issue click event for touches
if (touchEvent?.constructor.name === 'TouchEvent') {
// The gesture is a tap not drag, so manually issue a click event to trigger tap click gestures lost via preventDefault
touchEvent?.target?.dispatchEvent(
new Event('click', {
bubbles: true
})
);
}
}
}
percentOffset = 0;
activeDragGesture = undefined;
touchEvent = null;
};
</script>

<!-- Preload all Carousel images for improved responsivity -->
<svelte:head>
{#if images.length > 0}
{#each images as image}
<link rel="preload" href={image.src} as="image" />
{/each}
{/if}
</svelte:head>

<!-- The move listeners go here, so things keep working if the touch strays out of the element. -->
<svelte:document on:mousemove={onDragMove} on:mouseup={onDragStop} on:touchmove={onDragMove} on:touchend={onDragStop} />
<div bind:this={carouselDiv} class="relative" on:mousedown={onDragStart} on:touchstart|passive={onDragStart} role="button" aria-label={ariaLabel} tabindex="0">
<div bind:this={carouselDiv} class="relative" on:mousedown|nonpassive={onDragStart} on:touchstart|nonpassive={onDragStart} on:mousemove={onDragMove} on:mouseup={onDragStop} on:touchmove={onDragMove} on:touchend={onDragStop} role="button" aria-label={ariaLabel} tabindex="0">
<div {...$$restProps} class={twMerge(divClass, activeDragGesture === undefined ? 'transition-transform' : '', $$props.class)} use:loop={duration}>
<slot name="slide" {Slide} {index}>
<Slide image={images[index]} {transition} />
<Slide image={images[index]} class={imgClass} {transition} />
</slot>
</div>
<slot {index} {Controls} {Indicators} />
Expand All @@ -163,7 +212,9 @@
## Props
@prop export let images: HTMLImgAttributes[];
@prop export let index: number = 0;
@prop export let transition: TransitionFunc = (x) => fade(x, { duration: 700, easing: quintOut });
@prop export let slideDuration: number = 1000;
@prop export let transition: TransitionFunc | null;
@prop export let duration: number = 0;
@prop export let ariaLabel: string = 'Draggable Carousel';
@prop export let imgClass: string = '';
-->
16 changes: 16 additions & 0 deletions src/lib/carousel/Carousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const canChangeSlide = ({
lastSlideChange,
slideDuration,
slideDurationRatio = 1,
}: {
lastSlideChange: Date,
slideDuration: number,
slideDurationRatio?: number, // Allows for starting a new transition before the previous completes
}) => {
if (lastSlideChange && new Date().getTime() - lastSlideChange.getTime() < slideDuration * slideDurationRatio) {
console.warn("Can't change slide yet, too soon");
return false;
}

return true;
}
36 changes: 30 additions & 6 deletions src/lib/carousel/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,43 @@
import type { Writable } from 'svelte/store';
import type { State } from './Carousel.svelte';
import ControlButton from './ControlButton.svelte';
import { twJoin } from 'tailwind-merge';
import { twMerge } from 'tailwind-merge';
import { canChangeSlide } from './Carousel';
const state = getContext<Writable<State>>('state');
const { update } = state;
function changeSlide(forward: boolean) {
return function (ev: Event) {
if (ev.isTrusted) $state.index = forward ? $state.index + 1 : $state.index - 1;
};
if (
!canChangeSlide({
lastSlideChange: $state.lastSlideChange,
slideDuration: $state.slideDuration,
slideDurationRatio: 0.75
})
) {
return;
}
if (forward) {
update((_state) => {
_state.forward = true;
_state.index = _state.index >= _state.images.length - 1 ? 0 : _state.index + 1;
_state.lastSlideChange = new Date();
return { ..._state };
});
} else {
update((_state) => {
_state.forward = false;
_state.index = _state.index <= 0 ? _state.images.length - 1 : _state.index - 1;
_state.lastSlideChange = new Date();
return { ..._state };
});
}
}
</script>

<!-- Slider controls -->
<slot {ControlButton} {changeSlide}>
<ControlButton name="Previous" forward={false} on:click={changeSlide(false)} class={twJoin($$props.class)} />
<ControlButton name="Next" forward={true} on:click={changeSlide(true)} class={twJoin($$props.class)} />
<ControlButton name="Previous" forward={false} on:click={() => changeSlide(false)} class={twMerge($$props.class)} />
<ControlButton name="Next" forward={true} on:click={() => changeSlide(true)} class={twMerge($$props.class)} />
</slot>
43 changes: 34 additions & 9 deletions src/lib/carousel/Slide.svelte
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import type { HTMLImgAttributes } from 'svelte/elements';
import { fade, type TransitionConfig } from 'svelte/transition';
import { fly, type TransitionConfig } from 'svelte/transition';
import { twMerge } from 'tailwind-merge';
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { State } from './Carousel.svelte';
const state = getContext<Writable<State>>('state');
type TransitionFunc = (node: HTMLElement, params: any) => TransitionConfig;
export let image: HTMLImgAttributes;
export let transition: TransitionFunc = (x) => fade(x, { duration: 700, easing: quintOut });
export let transition: TransitionFunc | null = null; // Optional transition function, overrides default slide transition
$: transitionSlideIn = {
x: $state.forward ? '100%' : '-100%',
opacity: 1,
width: '100%',
height: '100%',
duration: $state.slideDuration
};
$: transitionSlideOut = {
x: $state.forward ? '-100%' : '100%',
opacity: 0.9,
width: '100%',
height: '100%',
duration: $state.slideDuration
};
let imgClass: string;
$: imgClass = twMerge('absolute block w-full -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 object-cover', $$props.class);
$: imgClass = twMerge('absolute block !w-full h-full object-cover', $$props.class);
</script>

{#key image}
<img alt="..." {...image} transition:transition={{}} {...$$restProps} class={imgClass} />
{/key}
{#if transition}
{#key image}
<img alt="..." {...image} transition:transition={{}} {...$$restProps} class={imgClass} />
{/key}
{:else}
{#key image}
<img alt="..." {...image} {...$$restProps} out:fly={transitionSlideOut} in:fly={transitionSlideIn} class={imgClass} />
{/key}
{/if}

<!--
@component
[Go to docs](https://flowbite-svelte.com/)
## Props
@prop export let image: HTMLImgAttributes;
@prop export let transition: TransitionFunc = (x) => fade(x, { duration: 700, easing: quintOut });
@prop export let transition: TransitionFunc | null = null;
-->
25 changes: 22 additions & 3 deletions src/lib/carousel/Thumbnails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@
export let images: HTMLImgAttributes[] = [];
export let index: number = 0;
export let ariaLabel: string = 'Click to view image';
export let imgClass: string = '';
export let throttleDelay: number = 650; // ms
let lastClickedAt = new Date();
const btnClick = (idx: number) => {
if (new Date().getTime() - lastClickedAt.getTime() < throttleDelay) {
console.warn('Thumbnail action throttled');
return;
}
if (idx === index) {
return;
}
index = idx;
lastClickedAt = new Date();
};
$: index = (index + images.length) % images.length;
</script>
Expand All @@ -14,9 +31,9 @@
{#each images as image, idx}
{@const selected = index === idx}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<button on:click={() => (index = idx)} aria-label={ariaLabel}>
<slot {Thumbnail} {image} {selected}>
<Thumbnail {...image} {selected} />
<button on:click={() => btnClick(idx)} aria-label={ariaLabel}>
<slot {Thumbnail} {image} {selected} {imgClass}>
<Thumbnail {...image} {selected} class={imgClass} />
</slot>
</button>
{/each}
Expand All @@ -29,4 +46,6 @@
@prop export let images: HTMLImgAttributes[] = [];
@prop export let index: number = 0;
@prop export let ariaLabel: string = 'Click to view image';
@prop export let imgClass: string = '';
@prop export let throttleDelay: number = 650;
-->
Loading

0 comments on commit 6f90ec5

Please sign in to comment.