Loading src/components/generic/PageIndicator.vue 0 → 100644 +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> src/components/generic/Paginator.vue +44 −21 Original line number Diff line number Diff line Loading @@ -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"/> Loading @@ -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, Loading Loading @@ -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 Loading @@ -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() Loading @@ -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 Loading @@ -129,7 +148,9 @@ .finally(() => { this.isLoading = false this.abortController = null this.currentRequest = null }) return this.currentRequest }, savePageInUrl () { if (this.id) { Loading @@ -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) } } }, Loading Loading @@ -189,5 +210,7 @@ <style> .paginator-items { position: relative; overflow: hidden; overscroll-behavior-x: contain; } </style> src/components/generic/PaginatorNav.vue +1 −1 Original line number Diff line number Diff line Loading @@ -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' }"> Loading src/components/generic/PaginatorTouchNav.vue 0 → 100644 +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> src/util/index.js +9 −0 Original line number Diff line number Diff line Loading @@ -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() Loading Loading
src/components/generic/PageIndicator.vue 0 → 100644 +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>
src/components/generic/Paginator.vue +44 −21 Original line number Diff line number Diff line Loading @@ -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"/> Loading @@ -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, Loading Loading @@ -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 Loading @@ -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() Loading @@ -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 Loading @@ -129,7 +148,9 @@ .finally(() => { this.isLoading = false this.abortController = null this.currentRequest = null }) return this.currentRequest }, savePageInUrl () { if (this.id) { Loading @@ -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) } } }, Loading Loading @@ -189,5 +210,7 @@ <style> .paginator-items { position: relative; overflow: hidden; overscroll-behavior-x: contain; } </style>
src/components/generic/PaginatorNav.vue +1 −1 Original line number Diff line number Diff line Loading @@ -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' }"> Loading
src/components/generic/PaginatorTouchNav.vue 0 → 100644 +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>
src/util/index.js +9 −0 Original line number Diff line number Diff line Loading @@ -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() Loading