Commit 7faf59f4 authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt
Browse files

add swipe navigation for paginator. fixes #7

parent fa68b89c
Loading
Loading
Loading
Loading
Loading
+56 −0
Original line number Diff line number Diff line
<template>
  <div class="paginator-indicator mt-4" v-if="shouldDisplay">
    <div class="paginator-indicator-bubbles" v-if="paginator.numberOfPages <= 10">
      <span class="paginator-indicator-bubble" :class="{ 'is-active': paginator.page === page }"
            v-for="page in pages" :key="page"></span>
    </div>
    <span class="opaque-7" v-else>{{ paginator.page }} / {{ paginator.numberOfPages }}</span>
  </div>
</template>

<script>
  export default {
    computed: {
      pages () {
        return new Array(this.paginator.numberOfPages).fill(0).map((_, index) => index + 1)
      },
      shouldDisplay () {
        console.log(this.paginator.collection, this.paginator.useTouch, this.paginator.behavior === 'replace', this.paginator.numberOfPages)
        return this.paginator.useTouch && this.paginator.behavior === 'replace' && this.paginator.numberOfPages > 1
      }
    },
    inject: ['paginator']
  }
</script>

<style lang="scss">
  @import '../../styles/variables';

  $size: .95rem;
  $inactive-color: $color-surface;
  $active-color: lighten($color-surface, 30%);

  .paginator-indicator {
    text-align: center;
  }

  .paginator-indicator-bubbles {
    display: grid;
    grid-template-columns: repeat(auto-fit, $size);
    grid-gap: ($size * .65);
    justify-content: center;
  }

  .paginator-indicator-bubble {
    background: $inactive-color;
    width: $size;
    height: $size;
    border-radius: 50%;
    position: relative;
    border: ($size / 4.5) solid $inactive-color;

    &.is-active {
      background: $active-color;
    }
  }
</style>
+44 −21
Original line number Diff line number Diff line
@@ -3,14 +3,19 @@
    <slot name="header" :items="items" :count="count" :isLoading="isLoading" :hasNext="hasNext" :hasPrev="hasPrev"/>

    <div class="paginator-items" ref="items" v-bind="$attrs"
         :style="isLoading && currentItemsHeight ? { height: `${currentItemsHeight}px` } : null">
         :style="isLoading && currentItemsHeight ? { height: `${currentItemsHeight}px` } : null"
         @touchstart.passive="$emit('touchnav-start', $event)"
         @touchmove.passive="$emit('touchnav-move', $event)"
         @touchend.passive="$emit('touchnav-end', $event)">
      <slot :items="items" :count="count" :isLoading="isLoading" :hasNext="hasNext" :hasPrev="hasPrev"></slot>
      <app-spinner-area v-if="!noLoader && isLoading"/>
      <app-spinner-area v-if="!noLoader && isLoading && !useTouch"/>
      <PaginatorTouchNav ref="touchNav" v-if="useTouch"/>
      <slot name="empty-items" v-if="emptyMessage">
        <div class="content" v-if="isEmpty  && !isLoading">
          <p>{{ emptyMessage }}</p>
        </div>
      </slot>
      <PageIndicator/>
    </div>

    <slot name="footer" :items="items" :count="count" :isLoading="isLoading" :hasNext="hasNext" :hasPrev="hasPrev"/>
@@ -21,10 +26,15 @@
  import { pullRefreshController } from '../../util/dom'
  import { createQueryString } from '../../util'
  import { decodeHash, manipulateHash } from '../../util/encoder'
  import PaginatorTouchNav from './PaginatorTouchNav'
  import PageIndicator from './PageIndicator'

  const { matchMedia } = window

  const getQueryString = url => url ? new URL(url).search.substring(1) : null

  export default {
    components: { PageIndicator, PaginatorTouchNav },
    props: {
      collection: {
        type: Object,
@@ -67,6 +77,20 @@
      },
      noLoader: Boolean
    },
    data () {
      return {
        items: [],
        count: null,
        nextUrl: null,
        prevUrl: null,
        isLoading: false,
        currentItemsHeight: null,
        abortController: null,
        page: 1,
        currentRequest: null,
        useTouch: matchMedia('(pointer: coarse)').matches
      }
    },
    computed: {
      hasNext () {
        if (this.nextUrl === null) return false
@@ -86,22 +110,13 @@
      },
      isEmpty () {
        return this.count === 0
      }
      },
    data () {
      return {
        items: [],
        count: null,
        nextUrl: null,
        prevUrl: null,
        isLoading: false,
        currentItemsHeight: null,
        abortController: null,
        page: 1
      numberOfPages () {
        return Math.min(this.pageLimit || Number.POSITIVE_INFINITY, Math.ceil(this.count / this.pageSize))
      }
    },
    methods: {
      updateFrom (query, forceReplace = false) {
      updateFrom (query, { forceReplace = false, wait = Promise.resolve() }) {
        const abortController = new AbortController()
        if (this.abortController) {
          this.abortController.abort()
@@ -111,7 +126,11 @@
          ? this.$refs.items.getBoundingClientRect().height
          : this.minHeight || null
        this.isLoading = true
        return this.collection.list(query, { signal: abortController.signal })
        this.currentRequest = this.collection.list(query, { signal: abortController.signal })
          .then(async data => {
            await wait
            return data
          })
          .then(data => {
            this.items = forceReplace || this.behavior === 'replace'
              ? data.results
@@ -129,7 +148,9 @@
          .finally(() => {
            this.isLoading = false
            this.abortController = null
            this.currentRequest = null
          })
        return this.currentRequest
      },
      savePageInUrl () {
        if (this.id) {
@@ -145,22 +166,22 @@
        this.items = []
        this.count = this.nextUrl = this.prevUrl = null
        this.page = typeof page === 'number' ? Math.max(1, page) : 1
        return this.updateFrom(this.currentQueryString, true)
        return this.updateFrom(this.currentQueryString, { forceReplace: true })
      },
      refresh () {
        this.collection.clearCache()
        return this.reset()
      },
      prev () {
      prev (options) {
        if (this.hasPrev) {
          this.page -= 1
          return this.updateFrom(this.currentQueryString)
          return this.updateFrom(this.currentQueryString, options)
        }
      },
      next () {
      next (options) {
        if (this.hasNext) {
          this.page += 1
          return this.updateFrom(this.currentQueryString)
          return this.updateFrom(this.currentQueryString, options)
        }
      }
    },
@@ -189,5 +210,7 @@
<style>
  .paginator-items {
    position: relative;
    overflow: hidden;
    overscroll-behavior-x: contain;
  }
</style>
+1 −1
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@
    <template v-if="paginator.behavior === 'append'">
      <app-button @click="paginator.next" v-if="paginator.hasNext">{{ $t('paginator_more') }}</app-button>
    </template>
    <template v-else>
    <template v-else-if="!paginator.useTouch">
      <app-button @click="paginator.prev" :disabled="!paginator.hasPrev" class="mr-3"
                  :aria-label="$t('paginator_backward_label')" :size="isSmall ? 'small' : null"
                  :style="{ visibility: paginator.hasPrev ? null : 'hidden' }">
+161 −0
Original line number Diff line number Diff line
<template>
  <div class="paginator-touch-loader" :style="style" @animationend="onAnimationEnd">
    <app-spinner-area v-if="paginator.isLoading"/>
  </div>
</template>

<script>
  import { deferred } from '../../util'

  const ANIMATION_TIME = 0.35

  export default {
    data () {
      return {
        start: null,
        offset: null,
        snap: null,
        isSnapping: null
      }
    },
    computed: {
      elWidth () {
        const width = this.$el.getBoundingClientRect().width
        return this.windowWidth ? width : width
      },
      style () {
        const { offset, snap, isSnapping } = this
        const { hasPrev, hasNext } = this.paginator

        // if we have no offset nobody feels touchy
        if (offset === null) { return null }

        // if the swipe direction is forward, but there is no next page we don’t do anything
        // same goes for backward and previous pages
        const isForward = offset > 0
        if ((isForward && !hasNext) || (!isForward && !hasPrev)) { return null }

        const [left, right] = isForward ? ['100%', 'auto'] : ['auto', '100%']
        const opacity = Math.min(1, 0.35 + Math.abs(offset) / this.elWidth)
        const animation = isSnapping ? 'touch-loader-snap' : 'touch-loader-end'

        return [
          { left, right },
          snap
            ? {
              animation: `${animation} ${ANIMATION_TIME}s ease-out forwards`,
              '--opacity-start': opacity,
              '--opacity-end': isForward ? offset < snap : offset > snap ? 0 : 1,
              '--snap-start': `${-offset}px`,
              '--snap-end': `${snap}px`
            }
            : {
              transition: 'transform 0.05s, opacity .05s',
              transform: `translateX(${-offset}px)`,
              opacity
            }
        ]
      }
    },
    methods: {
      setStart (event) {
        if (!this.paginator.currentRequest) {
          this.resetTouchNav()
          this.start = event.touches[0].clientX
        }
      },
      setOffset (event) {
        if (this.start) {
          const offset = this.start - event.touches[0].clientX
          // Protection against over-scroll.
          // We want the overlay to stay in place over the paginator.
          this.offset = offset < 0
            ? Math.max(-this.elWidth, offset)
            : Math.min(this.elWidth, offset)
        }
      },
      triggerNav () {
        const { elWidth, offset } = this
        if (offset !== null) {
          const isForward = offset > 0
          const snap = isForward ? -elWidth : elWidth
          this.isSnapping = deferred()
          this.isSnapping.then(() => { this.isSnapping = null })
          // safe-guard against missing animationend events
          setTimeout(() => { this.isSnapping && this.isSnapping.resolve() }, ANIMATION_TIME * 1.05 * 1000)
          if (Math.abs(offset) > elWidth / 3) {
            this.snap = snap
            this.paginator[isForward ? 'next' : 'prev']({ wait: this.isSnapping })
          } else {
            this.snap = -snap
          }
        }
      },
      async onAnimationEnd (event) {
        if (event.animationName === 'touch-loader-snap') {
          this.isSnapping.resolve()
          await (this.paginator.currentRequest || Promise.resolve())
        } else {
          this.resetTouchNav()
        }
      },
      resetTouchNav () {
        this.offset = null
        this.start = null
        this.snap = null
        this.isSnapping = null
      }
    },
    inject: ['paginator'],
    mounted () {
      this.paginator.$on('touchnav-start', this.setStart)
      this.paginator.$on('touchnav-move', this.setOffset)
      this.paginator.$on('touchnav-end', this.triggerNav)
    },
    beforeDestroy () {
      this.paginator.$off('touchnav-start', this.setStart)
      this.paginator.$off('touchnav-move', this.setOffset)
      this.paginator.$off('touchnav-end', this.triggerNav)
    }
  }
</script>

<style lang="scss">
  @import '../../styles/variables';

  .paginator-touch-loader {
    position: absolute;
    background: $color-base;
    top: 0;
    bottom: 0;
    width: 100%;
    opacity: 0;
    pointer-events: none;
  }

  @keyframes touch-loader-snap {
    from {
      transform: translateX(var(--snap-start));
      opacity: var(--opacity-start)
    }
    100% {
      transform: translateX(var(--snap-end));
      opacity: var(--opacity-end)
    }
  }

  @keyframes touch-loader-end {
    from {
      transform: translateX(var(--snap-end));
      opacity: var(--opacity-end)
    }
    25% {
      transform: translateX(var(--snap-end));
      opacity: var(--opacity-end)
    }
    to {
      transform: translateX(var(--snap-end));
      opacity: 0
    }
  }
</style>
+9 −0
Original line number Diff line number Diff line
@@ -4,6 +4,15 @@ export const renderSimpleTemplateString = (str, ctx) => {
  return str.replace(/\{([a-z_]+)\}/gi, (match, key) => ctx[key] || '')
}

export const delay = (time, data) => new Promise(resolve => { setTimeout(() => { resolve(data) }, time) })

export const deferred = () => {
  let resolver
  const promise = new Promise(resolve => { resolver = resolve })
  promise.resolve = value => resolver(value)
  return promise
}

export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))

export const createQueryString = queryArgs => new URLSearchParams(queryArgs).toString()