diff --git a/.babelrc b/.babelrc index cf55185..1c2bd59 100644 --- a/.babelrc +++ b/.babelrc @@ -7,6 +7,9 @@ } }] ], + "plugins": [ + "@babel/plugin-proposal-class-properties" + ], "env": { "test": { "presets": [["@babel/preset-env"]] diff --git a/README.md b/README.md index 528b28d..85d3a01 100644 --- a/README.md +++ b/README.md @@ -34,18 +34,19 @@ Import component ``` ## Props -| Prop | Type | Default | Description | -|----------------------|------------|-----------------|-----------------------------------------------| -| `arrows` | `boolean` | `true` | Enable Next/Prev arrows | -| `infinite` | `boolean` | `true` | Infinite looping | -| `initialPageIndex` | `number` | `0` | Page to start on | -| `duration` | `number` | `500` | Transition duration (ms) | -| `autoplay` | `boolean` | `false` | Enables autoplay of pages | -| `autoplayDuration` | `number` | `3000` | Autoplay change interval (ms) | -| `autoplayDirection` | `string` | `'next'` | Autoplay change direction (`next` or `prev`) | -| `pauseOnFocus` | `boolean` | `false` | Pause autoplay on focus | -| `dots` | `boolean` | `true` | Current page indicator dots | -| `timingFunction` | `string` | `'ease-in-out'` | CSS animation timing function | +| Prop | Type | Default | Description | +|---------------------------|------------|-----------------|-----------------------------------------------| +| `arrows` | `boolean` | `true` | Enable Next/Prev arrows | +| `infinite` | `boolean` | `true` | Infinite looping | +| `initialPageIndex` | `number` | `0` | Page to start on | +| `duration` | `number` | `500` | Transition duration (ms) | +| `autoplay` | `boolean` | `false` | Enables auto play of pages | +| `autoplayDuration` | `number` | `3000` | Autoplay change interval (ms) | +| `autoplayDirection` | `string` | `'next'` | Autoplay change direction (`next` or `prev`) | +| `pauseOnFocus` | `boolean` | `false` | Pause autoplay on focus | +| `autoplayProgressVisible` | `boolean` | `false` | Show autoplay duration progress indicator | +| `dots` | `boolean` | `true` | Current page indicator dots | +| `timingFunction` | `string` | `'ease-in-out'` | CSS animation timing function | ## Events diff --git a/src/components/Carousel/Carousel.svelte b/src/components/Carousel/Carousel.svelte index 6aa01b1..21d0393 100644 --- a/src/components/Carousel/Carousel.svelte +++ b/src/components/Carousel/Carousel.svelte @@ -3,6 +3,7 @@ import { createStore } from '../../store' import Dots from '../Dots/Dots.svelte' import Arrow from '../Arrow/Arrow.svelte' + import Progress from '../Progress/Progress.svelte' import { NEXT, PREV } from '../../direction' import { swipeable } from '../../actions/swipeable' import { focusable } from '../../actions/focusable' @@ -12,12 +13,30 @@ } from '../../utils/event' import { getAdjacentIndexes } from '../../utils/page' import { get } from '../../utils/object' + import { ProgressManager } from '../../utils/ProgressManager.js' const dispatch = createEventDispatcher() + const autoplayDirectionFnDescription = { + [NEXT]: () => { + progressManager.start(() => { + showNextPage() + }) + }, + [PREV]: () => { + progressManager.start(() => { + showPrevPage() + }) + } + } + const directionFnDescription = { - [NEXT]: showNextPage, - [PREV]: showPrevPage + [NEXT]: () => { + showNextPage() + }, + [PREV]: () => { + showPrevPage() + } } /** @@ -67,6 +86,11 @@ */ export let pauseOnFocus = false + /** + * Show autoplay duration progress indicator + */ + export let autoplayProgressVisible = false + /** * Current page indicator dots */ @@ -103,13 +127,20 @@ let pagesElement let focused = false - let autoplayInterval = null + let progressValue + const progressManager = new ProgressManager({ + autoplayDuration, + onProgressValueChange: (value) => { + progressValue = 1 - value + } + }) + $: { if (pauseOnFocus) { if (focused) { - clearAutoplay() + progressManager.pause() } else { - applyAutoplay() + progressManager.resume() } } } @@ -130,19 +161,6 @@ offsetPage(false) } - - function applyAutoplay() { - if (autoplay && !autoplayInterval) { - autoplayInterval = setInterval(() => { - directionFnDescription[autoplayDirection]() - }, autoplayDuration) - } - } - - function clearAutoplay() { - clearInterval(autoplayInterval) - autoplayInterval = null - } function addClones() { const first = pagesElement.children[0] @@ -151,13 +169,38 @@ pagesElement.append(first.cloneNode(true)) } + function applyAutoplayIfNeeded(options) { + // prevent progress change if not infinite for first and last page + if ( + !infinite && ( + (autoplayDirection === NEXT && currentPageIndex === pagesCount - 1) || + (autoplayDirection === PREV && currentPageIndex === 0) + ) + ) { + progressManager.reset() + return + } + if (autoplay) { + const delayMs = get(options, 'delayMs', 0) + if (delayMs) { + setTimeout(() => { + autoplayDirectionFnDescription[autoplayDirection]() + }, delayMs) + } else { + autoplayDirectionFnDescription[autoplayDirection]() + } + } + } + let cleanupFns = [] + onMount(() => { (async () => { await tick() cleanupFns.push(store.subscribe(value => { currentPageIndex = value.currentPageIndex })) + cleanupFns.push(() => progressManager.reset()) if (pagesElement && pageWindowElement) { // load first and last child to clone them loaded = [0, pagesElement.children.length - 1] @@ -167,13 +210,14 @@ store.init(initialPageIndex + Number(infinite)) applyPageSizes() } - applyAutoplay() + + applyAutoplayIfNeeded() + addResizeEventListener(applyPageSizes) })() }) onDestroy(() => { - clearAutoplay() removeResizeEventListener(applyPageSizes) cleanupFns.filter(fn => fn && typeof fn === 'function').forEach(fn => fn()) }) @@ -218,14 +262,14 @@ function showPage(pageIndex, options) { const animated = get(options, 'animated', true) - const offsetDelayMs = get(options, 'offsetDelayMs', true) + const offsetDelayMs = get(options, 'offsetDelayMs', 0) safeChangePage(() => { store.moveToPage({ pageIndex, pagesCount }) // delayed page transition, used for infinite autoplay to jump to real page setTimeout(() => { offsetPage(animated) const jumped = jumpIfNeeded() - !jumped && applyAutoplay() + !jumped && applyAutoplayIfNeeded({ delayMs: _duration }) // while offset animation is in progress (delayMs = _duration ms) wait for it }, offsetDelayMs) }, { animated }) } @@ -235,7 +279,7 @@ store.prev({ infinite, pagesCount }) offsetPage(animated) const jumped = jumpIfNeeded() - !jumped && applyAutoplay() + !jumped && applyAutoplayIfNeeded({ delayMs: _duration }) }, { animated }) } function showNextPage(options) { @@ -244,7 +288,7 @@ store.next({ infinite, pagesCount }) offsetPage(animated) const jumped = jumpIfNeeded() - !jumped && applyAutoplay() + !jumped && applyAutoplayIfNeeded({ delayMs: _duration }) }, { animated }) } @@ -300,7 +344,12 @@ bind:this={pagesElement} > - + + {#if autoplayProgressVisible} + + {/if} {#if arrows} @@ -353,6 +402,7 @@ display: flex; overflow: hidden; box-sizing: border-box; + position: relative; } .sc-carousel__pages-container { width: 100%; @@ -366,4 +416,11 @@ align-items: center; justify-content: center; } - \ No newline at end of file + .sc-carousel-progress__container { + width: 100%; + height: 5px; + background-color: var(--sc-color-rgb-light-50p); + position: absolute; + bottom: 0; + } + diff --git a/src/components/Carousel/stories/CarouselView.svelte b/src/components/Carousel/stories/CarouselView.svelte index 2cdaa38..02743d5 100644 --- a/src/components/Carousel/stories/CarouselView.svelte +++ b/src/components/Carousel/stories/CarouselView.svelte @@ -4,7 +4,7 @@ /** * CSS animation timing function */ - export let timingFunction = "ease-in-out"; + export let timingFunction = 'ease-in-out'; /** * Enable Next/Previos arrows @@ -46,6 +46,11 @@ */ export let pauseOnFocus = false + /** + * Show autoplay duration progress indicator + */ + export let autoplayProgressVisible = false + /** * Current page indicator dots */ @@ -82,6 +87,7 @@ {autoplayDuration} {autoplayDirection} {pauseOnFocus} + {autoplayProgressVisible} {dots} on:pageChange={ event => console.log(`Current page index: ${event.detail}`) @@ -107,6 +113,7 @@ {autoplayDuration} {autoplayDirection} {pauseOnFocus} + {autoplayProgressVisible} {dots} > {#each colors2 as { color, text } (color)} diff --git a/src/components/Carousel/stories/CarouselViewCustomArrows.svelte b/src/components/Carousel/stories/CarouselViewCustomArrows.svelte index df01022..70e1f61 100644 --- a/src/components/Carousel/stories/CarouselViewCustomArrows.svelte +++ b/src/components/Carousel/stories/CarouselViewCustomArrows.svelte @@ -4,7 +4,7 @@ /** * CSS animation timing function */ - export let timingFunction = "ease-in-out"; + export let timingFunction = 'ease-in-out'; /** * Enable Next/Previos arrows @@ -46,6 +46,11 @@ */ export let pauseOnFocus = false + /** + * Show autoplay duration progress indicator + */ + export let autoplayProgressVisible = false + /** * Current page indicator dots */ @@ -76,6 +81,7 @@ {autoplayDuration} {autoplayDirection} {pauseOnFocus} + {autoplayProgressVisible} {dots} let:showPrevPage let:showNextPage diff --git a/src/components/Carousel/stories/CarouselViewCustomDots.svelte b/src/components/Carousel/stories/CarouselViewCustomDots.svelte index d9d8cc3..8e0cc72 100644 --- a/src/components/Carousel/stories/CarouselViewCustomDots.svelte +++ b/src/components/Carousel/stories/CarouselViewCustomDots.svelte @@ -4,7 +4,7 @@ /** * CSS animation timing function */ - export let timingFunction = "ease-in-out"; + export let timingFunction = 'ease-in-out'; /** * Enable Next/Previos arrows @@ -46,13 +46,18 @@ */ export let pauseOnFocus = false + /** + * Show autoplay duration progress indicator + */ + export let autoplayProgressVisible = false + /** * Current page indicator dots */ export let dots = true function onPageChange(event, showPage) { - showPage(event.target.value) + showPage(Number(event.target.value)) } const colors = [ @@ -80,6 +85,7 @@ {autoplayDuration} {autoplayDirection} {pauseOnFocus} + {autoplayProgressVisible} {dots} let:currentPageIndex let:pagesCount diff --git a/src/components/Progress/Progress.svelte b/src/components/Progress/Progress.svelte new file mode 100644 index 0000000..0e2efbd --- /dev/null +++ b/src/components/Progress/Progress.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/Progress/stories/Progress.stories.js b/src/components/Progress/stories/Progress.stories.js new file mode 100644 index 0000000..be1d74d --- /dev/null +++ b/src/components/Progress/stories/Progress.stories.js @@ -0,0 +1,13 @@ +import ProgressView from './ProgressView.svelte'; + +export default { + title: 'Default Components/Progress', + component: ProgressView +}; + +const Template = ({ ...args }) => ({ + Component: ProgressView, + props: args +}); + +export const Primary = Template.bind({}); diff --git a/src/components/Progress/stories/ProgressView.svelte b/src/components/Progress/stories/ProgressView.svelte new file mode 100644 index 0000000..25f2d86 --- /dev/null +++ b/src/components/Progress/stories/ProgressView.svelte @@ -0,0 +1,23 @@ + + +
+ + +
+ + diff --git a/src/docs/Carousel.svx b/src/docs/Carousel.svx index cc5b26a..33ea865 100644 --- a/src/docs/Carousel.svx +++ b/src/docs/Carousel.svx @@ -55,7 +55,7 @@ ## Autoplay {#each colors as { color, text } (color)} @@ -65,7 +65,7 @@ ```jsx {#each colors as { color, text } (color)} @@ -76,6 +76,58 @@ +## Autoplay with duration progress + + {#each colors as { color, text } (color)} + + {/each} + + +```jsx + + {#each colors as { color, text } (color)} + + {/each} + +``` + + + +## Autoplay with pause on focus + + {#each colors as { color, text } (color)} + + {/each} + + +```jsx + + {#each colors as { color, text } (color)} + + {/each} + +``` + + + ## Lazy loading of images diff --git a/src/utils/ProgressManager.js b/src/utils/ProgressManager.js new file mode 100644 index 0000000..457b333 --- /dev/null +++ b/src/utils/ProgressManager.js @@ -0,0 +1,52 @@ +import { setIntervalImmediate } from './interval' + +const STEP_MS = 35 +export class ProgressManager { + #autoplayDuration + #onProgressValueChange + + #interval + #paused = false + + constructor({ + autoplayDuration, + onProgressValueChange, + }) { + this.#autoplayDuration = autoplayDuration + this.#onProgressValueChange = onProgressValueChange + } + + start(onFinish) { + this.reset() + + const stepMs = Math.min(STEP_MS, this.#autoplayDuration) + let progress = -stepMs + + this.#interval = setIntervalImmediate(() => { + if (this.#paused) { + return + } + progress += stepMs + + const value = progress / this.#autoplayDuration + this.#onProgressValueChange(value) + + if (value > 1) { + this.reset() + onFinish() + } + }, stepMs) + } + + pause() { + this.#paused = true + } + + resume() { + this.#paused = false + } + + reset() { + clearInterval(this.#interval) + } +} diff --git a/src/utils/interval.js b/src/utils/interval.js new file mode 100644 index 0000000..49a9978 --- /dev/null +++ b/src/utils/interval.js @@ -0,0 +1,4 @@ +export const setIntervalImmediate = (fn, ms) => { + fn(); + return setInterval(fn, ms); +} diff --git a/src/utils/interval.test.js b/src/utils/interval.test.js new file mode 100644 index 0000000..9aa209f --- /dev/null +++ b/src/utils/interval.test.js @@ -0,0 +1,30 @@ +import { + setIntervalImmediate, +} from './interval.js' + +describe('setIntervalImmediate', () => { + beforeEach(() => { + jest.useFakeTimers(); + }) + + it('runs callback immediately and them each n ms', () => { + let interval + const durationMs = 1000 + + const callNumbersToStopTimer = 3 + let calledTimes = 0 + const callback = () => { + calledTimes++ + if (calledTimes === callNumbersToStopTimer) { + clearInterval(interval) + } + } + + interval = setIntervalImmediate(callback, durationMs) + jest.runAllTimers() + + expect(calledTimes).toBe(callNumbersToStopTimer) + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), durationMs) + expect(clearInterval).toHaveBeenCalledWith(interval) + }) +})