Skip to content

Commit

Permalink
feat: APPS-1974_Add numbered page buttons to SectionPagination (#647)
Browse files Browse the repository at this point in the history
* feat: pagination part 1

* feat: finish truncation logic

* style: FTVA pagination styles

* chore: documentation

* fix: ts errors

* chore: run linter

* fix: prevent prev/ next button appearing in legacy

* chore: cleanup

* chore: refactor to computed methods

---------

Co-authored-by: Jess Divers <[email protected]>
Co-authored-by: JenDiamond <[email protected]>
  • Loading branch information
3 people authored Nov 8, 2024
1 parent a41c820 commit 9210fce
Show file tree
Hide file tree
Showing 4 changed files with 314 additions and 79 deletions.
236 changes: 158 additions & 78 deletions src/lib-components/SectionPagination.vue
Original file line number Diff line number Diff line change
@@ -1,116 +1,196 @@
<script lang="ts" setup>
import { computed } from 'vue'
// SVGs
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useWindowSize } from '@vueuse/core'
import SvgIconArrowRight from 'ucla-library-design-tokens/assets/svgs/icon-arrow-right.svg'
import { useTheme } from '@/composables/useTheme'
// COMPONENTS
import SmartLink from '@/lib-components/SmartLink.vue'
// PROPS & DATA
const { nextTo, previousTo } = defineProps({
const { nextTo, previousTo, pages, initialCurrentPage } = defineProps({
nextTo: {
type: String,
default: '',
required: false,
},
previousTo: {
type: String,
default: '',
required: false,
},
pages: {
type: Number,
required: false,
},
initialCurrentPage: {
type: Number,
required: false,
},
})
const emit = defineEmits(['changePage']) // let parent component know when page changes
const theme = useTheme()
const maxPages = ref(10) // default # of buttons that will fit in container, gets recalculated onMount & resize
const leftPages = ref([33]) // an array of numbers representing the page buttons that will appear ( we start with a single '33' so we can measure the width of a button to calc maxPages)
const currPage = ref(1) // current page, defaults to 1
const pageButtons: Ref<HTMLElement | null> = ref(null)
// METHODS
function handlePageChange(item: number) {
if (initialCurrentPage && pages) {
if (currPage.value !== item) {
currPage.value = item
generateLeftPages()
emit('changePage', item)
}
}
}
function generateLeftPages() {
if (pages && maxPages) {
let start = 1
// stop at either maxPages or total pages, whichever is lesser
let stop = Math.min(maxPages.value, pages)
// if current page is greater than than maxPages,
// put current page in middle of range of generated page number buttons
if (currPage.value > maxPages.value) {
let newMaxPages = maxPages.value - 4 // subtract 4 for '...' first/last number buttons
start = Math.max(1, currPage.value - Math.floor(newMaxPages / 2))
stop = start + newMaxPages
// if current page is very near the last page,
// we need to remove the truncation button near the end
if (stop > pages) {
newMaxPages = newMaxPages + 1 // add 1 back for missing '...' button
if (currPage.value === pages)
newMaxPages = newMaxPages + 1 // add another 1 back because 'next' button is hidden
stop = pages
start = Math.max(1, stop - newMaxPages)
}
}
// if we're on first page
if (currPage.value === 1) {
// add 1 more button to the end because 'prev' button is hidden, unless thay would exceed total pages
stop = Math.min(stop + 2, pages)
}
leftPages.value = []
for (let i = start; i <= stop; i++)
leftPages.value.push(i)
}
}
function setPaginationMaxPages(width: number) {
// fail gracefully with 10 as a the default
if (!initialCurrentPage || !pages)
return 10
// get width of buttons
const button = document.getElementsByClassName('pButton')[0]
const buttonWidth = Math.ceil(button.getBoundingClientRect().width)
const buttonMargin = getComputedStyle(button).marginRight
const itemWidth = Math.ceil(buttonWidth + (Number.parseInt(buttonMargin) * 2) + 1) // we add 1 to give us a little leeway
const prevButtonWidth = Math.ceil(document.getElementsByClassName('previous')[0].getBoundingClientRect().width + 10)
const nextButtonWidth = Math.ceil(document.getElementsByClassName('next')[0].getBoundingClientRect().width + 10)
// calc # of buttons that can fit
// take width minus the width of: 2 page buttons (last button and '...'), 2 prev/next buttons
const MaxButtons = Math.max(0, Math.floor(+((width - (prevButtonWidth + nextButtonWidth + (itemWidth * 2))) / itemWidth).toFixed(2)))
return MaxButtons
}
// COMPUTED
const classes = computed(() => {
if (previousTo === '')
return ['section-pagination', 'first-page']
return ['section-pagination', theme?.value || '', previousTo === '' ? 'first-page' : '', nextTo === '' ? 'last-page' : '']
})
const parsedPrevTo = computed(() => {
return currPage.value - 1
})
const parsedNextTo = computed(() => {
return currPage.value + 1
})
const isNotFirstPage = computed(() => {
return (initialCurrentPage && pages) && currPage?.value !== 1
})
const isNotLastPage = computed(() => {
return (initialCurrentPage && pages) && currPage?.value !== pages
})
if (nextTo === '')
return ['section-pagination', 'last-page']
else
return ['section-pagination']
onMounted(() => {
// legacy implementation does not require any onMounted logic
if (!initialCurrentPage || !pages)
return
currPage.value = initialCurrentPage
const { width } = useWindowSize()
// wait for next tick to ensure children are rendered and width is correct
nextTick(() => {
// watch for width changes and update # of buttons that will fit
watch([width], () => {
const paginationWidth = pageButtons.value!.clientWidth
maxPages.value = setPaginationMaxPages(paginationWidth) as number
generateLeftPages() // then generate buttons representing pages
}, { immediate: true })
})
})
</script>

<template>
<div :class="classes">
<div ref="pageButtons" :class="classes" role="navigation" aria-label="page list navigation">
<!-- if legacy attribute previousTo is supplied, use that for Prev button instead of handlePageChange -->
<SmartLink v-if="previousTo" :to="previousTo" class="previous">
<SvgIconArrowRight class="previous-svg" />
<div class="underline-hover">
Previous
</div>
</SmartLink>
<SmartLink v-else-if="isNotFirstPage" class="previous" @click="handlePageChange(parsedPrevTo)">
<SvgIconArrowRight class="previous-svg" />
<div class="underline-hover">
Previous
</div>
</SmartLink>
<div v-if="initialCurrentPage && pages" class="pagination-numbers-container">
<div class="pagination-numbers">
<span v-if="currPage > maxPages" class="page-list-first"><button
:class="`pButton${1 === currPage ? ' ' + 'pButton-selected' : ''}`"
@click="handlePageChange(1)"
>{{ 1 }}</button>
</span>
<span v-if="currPage > maxPages" class="page-list-truncate">...</span>
<button
v-for="item in leftPages"
:key="item"
:class="`pButton${item === currPage ? ' ' + 'pButton-selected' : ''}`"
@click="handlePageChange(item)"
>
{{ item }}
</button>
<span v-if="leftPages.length < pages && leftPages.indexOf(pages) === -1" class="page-list-truncate">...</span>
<span v-if="leftPages.length < pages && leftPages.indexOf(pages) === -1" class="page-list-right"><button
:class="`pButton${pages === currPage ? ' ' + 'pButton-selected' : ''}`"
@click="handlePageChange(pages)"
>{{ pages }}</button></span>
</div>
</div>
<!-- if legacy attribute nextTo is supplied, use that for Next button instead of handlePageChange -->
<SmartLink v-if="nextTo" :to="nextTo" class="next">
<div class="underline-hover">
Next
</div>
<SvgIconArrowRight class="next-svg" />
</SmartLink>
<SmartLink v-else-if="isNotLastPage" class="next" @click="handlePageChange(parsedNextTo)">
<div class="underline-hover">
Next
</div>
<SvgIconArrowRight class="next-svg" />
</SmartLink>
</div>
</template>

<style lang="scss" scoped>
.section-pagination {
max-width: 990px;
font-family: var(--font-secondary);
font-size: 18px;
padding-left: 13px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
align-content: center;
align-items: center;
.previous {
display: flex;
align-items: center;
}
.next {
display: flex;
align-items: center;
}
.previous-svg {
transform: scaleX(-1);
margin-right: 5px;
}
.next-svg {
margin-left: 5px;
}
&.first-page {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-end;
align-content: stretch;
align-items: center;
}
&.last-page {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
}
// Hover states
@media #{$has-hover} {
.previous:hover,
.previous:active {
color: var(--color-primary-blue-03);
}
.next:hover,
.next:active {
color: var(--color-primary-blue-03);
}
}
}
@import "@/styles/default/_section-pagination.scss";
@import "@/styles/ftva/_section-pagination.scss";
</style>
36 changes: 35 additions & 1 deletion src/stories/SectionPagination.stories.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { computed } from 'vue'
import SectionPagination from '@/lib-components/SectionPagination'

// Storybook default settings
/**
* A component to provide pagination for a list of items. It can be used in 2 ways:
*
* 1. With a previous and next page string, and no # buttons. In this mode the props determine if the buttons are shown or not
* 2. With a number of pages and a number representing the initial current page. In this mode the component will show the # buttons, and it will only hide the previous and next buttons if the current page is the first or last page
*
* Props:
* - nextTo: A string representing the URL to the next page
* - previousTo: A string representing the URL to the previous page
*
* Props added 2024-10-29:
*
* - pages: A number representing the total number of pages we need to show all content
* - initialCurrentPage: A number representing the page we are starting on
*/
export default {
title: 'SECTION / Pagination',
component: SectionPagination,
Expand All @@ -26,3 +41,22 @@ export function LastPage() {
template: '<section-pagination previousTo="/page/10" />',
}
}

export function WithPagesAndCurrentPage() {
return {
components: { SectionPagination },
template: '<section-pagination :pages="23" :initialCurrentPage="4" />',
}
}

export function FTVA() {
return {
components: { SectionPagination },
provide() {
return {
theme: computed(() => 'ftva'),
}
},
template: '<section-pagination :pages="23" :initialCurrentPage="14" />',
}
}
Loading

1 comment on commit 9210fce

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.