Skip to content

Commit 533ed51

Browse files
authored
#105 UIPagination: add component (#111)
* feat(pagination): completed pagination component * chore: updated version * chore(dev-deps): updated dependencies - @types/requestidlecallback - @typescript-eslint/eslint-plugin - @typescript-eslint/parser - @vitejs/plugin-vue - autoprefixer - lint-staged - sass - ts-node - vite * test(pagination): added tests * style: spacing Add spacing between methods * test(pagination): updated snapshots * chore: added todo * style(pagination): added no selecting to the `...` characters * style(pagination): set min-width for page buttons
1 parent 0bf7240 commit 533ed51

File tree

12 files changed

+749
-235
lines changed

12 files changed

+749
-235
lines changed

.commitlintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ module.exports = {
3131
'table',
3232
'textarea',
3333
'toggle',
34+
'pagination',
3435
'transitions',
3536
'tooltip',
3637
'outer-html',

package-lock.json

Lines changed: 232 additions & 232 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@karnama/vueish",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"files": [
55
"dist",
66
"types"

src/components/input/UIInput.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('UIInput', () => {
1212

1313
expect(wrapper.element).toMatchSnapshot();
1414
});
15+
1516
it('should handle model-binding correctly', async () => {
1617
const wrapper = mount(UIInput, {
1718
props: {

src/components/pagination/Demo.vue

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<template>
2+
<div class="mb-6 flex flex-col text-color">
3+
<UICheckbox v-model="disabled"
4+
name="disabled"
5+
label="Disabled"
6+
class="mb-2" />
7+
<label>
8+
Page count ({{ length }})
9+
<input v-model="length"
10+
type="range"
11+
min="1"
12+
max="100"
13+
name="page-count">
14+
</label>
15+
16+
<label>
17+
Visible Buttons ({{ visible }})
18+
<input v-model="visible"
19+
type="range"
20+
min="1"
21+
:max="length"
22+
step="2"
23+
name="visible-buttons">
24+
</label>
25+
</div>
26+
27+
<UIPagination v-model="page"
28+
:length="length"
29+
:disabled="disabled"
30+
:visible-count="visible" />
31+
</template>
32+
33+
<script lang="ts">
34+
import { defineComponent, ref } from 'vue';
35+
36+
export default defineComponent({
37+
name: 'Pagination',
38+
39+
setup() {
40+
const disabled = ref(false);
41+
const page = ref(5);
42+
const length = ref(10);
43+
const visible = ref(3);
44+
45+
return {
46+
disabled,
47+
page,
48+
length,
49+
visible
50+
};
51+
}
52+
});
53+
</script>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { mount } from '@vue/test-utils';
2+
import UIPagination from './UIPagination.vue';
3+
import { ref } from 'vue';
4+
5+
const commonProps = {
6+
modelValue: 5,
7+
length: 10,
8+
visibleCount: 3
9+
} as const;
10+
11+
describe('UIPagination', () => {
12+
it('should render correctly', () => {
13+
const wrapper = mount(UIPagination, {
14+
props: commonProps
15+
});
16+
17+
expect(wrapper.element).toMatchSnapshot();
18+
});
19+
20+
it('should display the correct number of buttons', async () => {
21+
const wrapper = mount(UIPagination, {
22+
props: commonProps
23+
});
24+
25+
expect(wrapper.findAll('button')).toHaveLength(
26+
// previous and next buttons
27+
2
28+
// the buttons
29+
+ commonProps.visibleCount
30+
// the wrapping end buttons
31+
+ 2
32+
);
33+
34+
await wrapper.setProps({ visibleCount: 5 });
35+
expect(wrapper.findAll('button')).toHaveLength(
36+
// previous and next buttons
37+
2
38+
// the buttons
39+
+ 5
40+
// the wrapping end buttons
41+
+ 2
42+
);
43+
});
44+
45+
it('should navigate to the previous page on previous button', async () => {
46+
const wrapper = mount(UIPagination, {
47+
props: commonProps
48+
});
49+
50+
const previousPageBtn = wrapper.find('#previous-page-button');
51+
await previousPageBtn.trigger('click');
52+
53+
expect(wrapper.lastEventValue()).toStrictEqual([4]);
54+
await previousPageBtn.trigger('click');
55+
await previousPageBtn.trigger('click');
56+
await previousPageBtn.trigger('click');
57+
expect(wrapper.lastEventValue()).toStrictEqual([1]);
58+
59+
await previousPageBtn.trigger('click');
60+
// it is now disabled so:
61+
expect(wrapper.lastEventValue()).toStrictEqual([1]);
62+
expect(wrapper.emitted('update:modelValue')).toHaveLength(4);
63+
});
64+
65+
it('should navigate to the next page on next button', async () => {
66+
const wrapper = mount(UIPagination, {
67+
props: commonProps
68+
});
69+
70+
const nextPageBtn = wrapper.find('#next-page-button');
71+
await nextPageBtn.trigger('click');
72+
73+
expect(wrapper.lastEventValue()).toStrictEqual([6]);
74+
await nextPageBtn.trigger('click');
75+
await nextPageBtn.trigger('click');
76+
await nextPageBtn.trigger('click');
77+
await nextPageBtn.trigger('click');
78+
expect(wrapper.lastEventValue()).toStrictEqual([10]);
79+
80+
await nextPageBtn.trigger('click');
81+
// it is now disabled so:
82+
expect(wrapper.lastEventValue()).toStrictEqual([10]);
83+
expect(wrapper.emitted('update:modelValue')).toHaveLength(5);
84+
});
85+
86+
it('should handle model-binding correctly', async () => {
87+
const wrapper = mount({
88+
components: { UIPagination },
89+
template: '<UIPagination v-model="value" length="10" visible-count="3" />' +
90+
'<div id="decrement" @click="value--" />',
91+
setup: () => {
92+
return {
93+
value: ref(5)
94+
};
95+
}
96+
});
97+
98+
expect(wrapper.find('#page-5-button').attributes()['title']).toBe('Current Page, Page 5');
99+
await wrapper.find('#next-page-button').trigger('click');
100+
expect(wrapper.find('#page-5-button').attributes()['title']).toBe('Go to page 5');
101+
expect(wrapper.find('#page-6-button').attributes()['title']).toBe('Current Page, Page 6');
102+
await wrapper.find('#decrement').trigger('click');
103+
expect(wrapper.find('#page-5-button').attributes()['title']).toBe('Current Page, Page 5');
104+
expect(wrapper.find('#page-6-button').attributes()['title']).toBe('Go to page 6');
105+
});
106+
107+
it('should disable buttons if prop given', () => {
108+
const wrapper = mount(UIPagination, {
109+
props: { ...commonProps, disabled: true }
110+
});
111+
112+
expect(wrapper.findAll('button[disabled]')).toHaveLength(
113+
// previous and next buttons
114+
2
115+
// the buttons
116+
+ commonProps.visibleCount
117+
// the wrapping end buttons
118+
+ 2
119+
);
120+
});
121+
});
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<template>
2+
<div :key="page"
3+
class="flex flex-wrap justify-between items-center max-w-max space-x-2"
4+
role="navigation"
5+
aria-label="Pagination navigation">
6+
<UIButton id="previous-page-button"
7+
:disabled="disabled || !hasPrevious"
8+
aria-label="Previous Page"
9+
class="transform rotate-90 !p-1"
10+
@click="page === 1 ? undefined : setPage(page - 1)"
11+
v-html="chevronIcon" />
12+
<UIButton id="page-1-button"
13+
:aria-current="isCurrent = page === 1"
14+
:category="isCurrent ? 'primary' : 'default'"
15+
:disabled="disabled"
16+
:outline="!isCurrent"
17+
class="page-button"
18+
:aria-label="isCurrent ? 'Current Page, Page 1' : 'Go to page 1'"
19+
:title="isCurrent ? 'Current Page, Page 1' : 'Go to page 1'"
20+
@click="setPage(1)">
21+
1
22+
</UIButton>
23+
<div v-if="pages[0] !== 2" class="select-none">
24+
...
25+
</div>
26+
<UIButton v-for="pageNum in pages"
27+
:id="`page-${pageNum}-button`"
28+
:key="pageNum"
29+
:aria-current="isCurrent = pageNum === page"
30+
:category="isCurrent ? 'primary' : 'default'"
31+
:outline="!isCurrent"
32+
class="page-button"
33+
:aria-label="isCurrent ? 'Current Page, Page ' + pageNum : 'Go to page ' + pageNum"
34+
:title="isCurrent ? 'Current Page, Page ' + pageNum : 'Go to page ' + pageNum"
35+
:disabled="disabled"
36+
@click="setPage(pageNum)">
37+
{{ pageNum }}
38+
</UIButton>
39+
<div v-if="startPagesFrom + Number(visibleCount) < Number(length)" class="select-none">
40+
...
41+
</div>
42+
<UIButton v-if="Number(length) > 1"
43+
:id="`page-${length}-button`"
44+
:aria-current="isCurrent = page === Number(length)"
45+
:category="isCurrent ? 'primary' : 'default'"
46+
:disabled="disabled"
47+
class="page-button"
48+
:outline="!isCurrent"
49+
:aria-label="isCurrent ? 'Current Page, Page ' + length : 'Go to page ' + length"
50+
:title="isCurrent ? 'Current Page, Page ' + length : 'Go to page ' + length"
51+
@click="setPage(Number(length))">
52+
{{ length }}
53+
</UIButton>
54+
<UIButton id="next-page-button"
55+
:disabled="disabled || !hasNext"
56+
aria-label="Next Page"
57+
class="transform rotate-270 !p-1"
58+
@click="page === Number(length) ? undefined : setPage(page + 1)"
59+
v-html="chevronIcon" />
60+
</div>
61+
</template>
62+
63+
<script lang="ts">
64+
import { computed, defineComponent } from 'vue';
65+
import { useVModel, disabled } from '@composables/input';
66+
import { getIcon } from '@/helpers';
67+
import UIButton from '@components/button/UIButton.vue';
68+
69+
export default defineComponent({
70+
name: 'UIPagination',
71+
72+
components: { UIButton },
73+
74+
props: {
75+
/**
76+
* The current page.
77+
*/
78+
modelValue: {
79+
type: Number,
80+
default: 1,
81+
validator: (val: number) => Number.isInteger(val) && val >= 0
82+
},
83+
84+
/**
85+
* The number of pages.
86+
*/
87+
length: {
88+
type: [Number, String],
89+
required: true,
90+
validator: (val: number | string) => {
91+
val = Number(val);
92+
return !isNaN(val) && Number.isInteger(val) && val > 0;
93+
}
94+
},
95+
96+
/**
97+
* The total number of page buttons to show.
98+
*/
99+
visibleCount: {
100+
type: [Number, String],
101+
default: 3,
102+
validator: (val: number | string) => {
103+
val = Number(val);
104+
const isValid = !isNaN(val) && Number.isInteger(val) && val >= 0;
105+
106+
if (isValid && val % 2 !== 1) {
107+
throw new Error('visibleCount expected to be an odd number.');
108+
}
109+
110+
return isValid;
111+
}
112+
},
113+
114+
disabled
115+
},
116+
117+
emits: ['update:modelValue'],
118+
119+
setup(props) {
120+
const chevronIcon = getIcon('chevron');
121+
122+
const page = useVModel<number>(props);
123+
const hasPrevious = computed(() => page.value > 1);
124+
const hasNext = computed(() => page.value < Number(props.length));
125+
const startPagesFrom = computed(() => {
126+
const sideLength = Math.floor(Number(props.visibleCount) / 2);
127+
128+
if (page.value <= sideLength + 1) {
129+
return 2;
130+
}
131+
132+
if (page.value >= Number(props.length) - sideLength) {
133+
return Number(props.length) - Number(props.visibleCount);
134+
}
135+
136+
if (Number(props.length) === Number(props.visibleCount)) {
137+
return page.value - sideLength - 1;
138+
}
139+
return page.value - sideLength;
140+
});
141+
const pages = computed(() => {
142+
return Array.from(
143+
{ length: Number(props.visibleCount) },
144+
(_, index) => index + startPagesFrom.value
145+
)
146+
.filter(pageNum => pageNum > 1 && pageNum < Number(props.length));
147+
});
148+
149+
const setPage = (pageNum: number) => {
150+
if (pageNum === page.value) return;
151+
152+
page.value = pageNum;
153+
};
154+
155+
return {
156+
page,
157+
hasPrevious,
158+
startPagesFrom,
159+
setPage,
160+
hasNext,
161+
chevronIcon,
162+
pages
163+
};
164+
}
165+
});
166+
</script>
167+
168+
<style>
169+
.page-button {
170+
min-width: 40px;
171+
padding: 0 5px !important;
172+
height: 38px;
173+
}
174+
</style>

0 commit comments

Comments
 (0)