Skip to content

Commit 7478b49

Browse files
committed
add pagination element
1 parent 946d48e commit 7478b49

File tree

3 files changed

+356
-7
lines changed

3 files changed

+356
-7
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { UUIEvent } from '@umbraco-ui/uui-base/lib/events';
2+
import { UUIPaginationElement } from './uui-pagination.element';
3+
4+
export class UUIPaginationEvent extends UUIEvent<{}, UUIPaginationElement> {
5+
public static readonly CHANGE = 'change';
6+
}
Lines changed: 318 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,333 @@
11
import { LitElement, html, css } from 'lit';
2+
import { property, query, queryAll, state } from 'lit/decorators.js';
3+
import { UUIButtonElement } from '@umbraco-ui/uui-button/lib/uui-button.element';
4+
import { UUIPaginationEvent } from './UUIPaginationEvent';
5+
6+
const PAGE_BUTTON_MAX_WIDTH = 45;
7+
8+
const valueLimit = (val: number, min: number, max: number) => {
9+
return Math.min(Math.max(val, min), max);
10+
};
11+
12+
const generateArrayOfNumbers = (start: number, stop: number) => {
13+
return Array.from({ length: stop - start + 1 }, (_, i) => start + i);
14+
};
215

316
/**
17+
* Umbraco UI pagination component. By implementing a resizeObserver it changes the number of visible buttons to fit the width of the container it sits in. Based on uui-button and uui-button-group.
18+
*
419
* @element uui-pagination
20+
* @fires change - When clicked on the page button fires change event
21+
*
522
*/
623
export class UUIPaginationElement extends LitElement {
724
static styles = [
825
css`
9-
:host {
10-
/* Styles goes here */
26+
uui-button {
27+
min-width: 36px;
28+
max-width: 72px;
29+
}
30+
31+
.nav-button {
32+
min-width: 72px;
33+
}
34+
35+
uui-button-group {
36+
width: 100%;
37+
}
38+
39+
.dots-button {
40+
pointer-events: none;
41+
}
42+
43+
.active-button {
44+
pointer-events: none;
1145
}
1246
`,
1347
];
1448

49+
connectedCallback() {
50+
super.connectedCallback();
51+
this.setAttribute('role', 'navigation');
52+
this.visiblePages = this._generateVisiblePages(this.current);
53+
}
54+
55+
disconnectedCallback() {
56+
this.observer.disconnect();
57+
}
58+
59+
private observer = new ResizeObserver(() => {
60+
this.calculateRange();
61+
});
62+
63+
firstUpdated() {
64+
this.observer.observe(this.buttonGroup);
65+
66+
this.updateLabel();
67+
// Wait for first rendering complete:
68+
window.requestAnimationFrame(() => {
69+
this.calculateRange();
70+
});
71+
}
72+
73+
willUpdate(changedProperties: Map<string | number | symbol, unknown>) {
74+
if (changedProperties.has('current') || changedProperties.has('label')) {
75+
this.updateLabel();
76+
}
77+
}
78+
79+
updateLabel() {
80+
this.ariaLabel = `${this.label || 'Pagination navigation'}. Current page: ${
81+
this.current
82+
}.`;
83+
}
84+
85+
private _containerWidth = 0;
86+
87+
private calculateRange = () => {
88+
this._containerWidth = this.offsetWidth;
89+
90+
// get all the buttons with .nav-button class and sum up their widths
91+
const navButtonsWidth = Array.from(this.navButtons).reduce(
92+
(totalWidth, button) => {
93+
return totalWidth + button.getBoundingClientRect().width;
94+
},
95+
0
96+
);
97+
98+
// subtract width of navbuttons from the pagination container
99+
const rangeBaseWidth = this._containerWidth - navButtonsWidth;
100+
101+
// divide remaining width by max-width of page button (when it has 3 digits), then divide by 2 to get the range.
102+
// Range is number of buttons visible on either "side" of current pag button. So, if range === 5 we shall see 11 buttons in total - 5 before the current page and 5 after. This is why we divide by 2.
103+
const range = rangeBaseWidth / PAGE_BUTTON_MAX_WIDTH / 2;
104+
this.range = Math.floor(range);
105+
};
106+
107+
private _generateVisiblePages(current: number) {
108+
const start =
109+
current < this._range
110+
? 1
111+
: current < this.total - this._range
112+
? current - this._range
113+
: this.total - this._range * 2;
114+
115+
const stop =
116+
current <= this._range
117+
? this._range * 2 + 1
118+
: current < this.total - this._range
119+
? current + this._range
120+
: this.total;
121+
122+
const pages = generateArrayOfNumbers(
123+
valueLimit(start, 1, this.total),
124+
valueLimit(stop, 1, this.total)
125+
);
126+
127+
return pages;
128+
}
129+
130+
@queryAll('uui-button.nav-button')
131+
navButtons!: Array<UUIButtonElement>;
132+
133+
@query('#group')
134+
buttonGroup!: any;
135+
136+
/**
137+
* This property is used to generate a proper `aria-label`. It will be announced by screen reader as: "<<this.label>>. Current page: <<this.current>>"
138+
* @type {string}
139+
* @attr
140+
*/
141+
@property()
142+
label = '';
143+
144+
// TODO: Handle localization
145+
/**
146+
* With this property you can overwrite aria-label.
147+
* @type {string}
148+
* @attr
149+
*/
150+
@property({ reflect: true, attribute: 'aria-label' })
151+
ariaLabel = '';
152+
153+
/**
154+
* With this property You can set how many buttons the pagination should have. Mind that the number of visible buttons will change with the width of the container.
155+
* @type {number}
156+
* @attr
157+
*/
158+
@property({ type: Number })
159+
total = 1;
160+
161+
protected _range = 0;
162+
@state()
163+
get range() {
164+
return this._range;
165+
}
166+
167+
set range(newValue: number) {
168+
const oldValue = this._range;
169+
this._range = newValue <= 0 ? 1 : newValue;
170+
this.visiblePages = this._generateVisiblePages(this.current);
171+
this.requestUpdate('range', oldValue);
172+
}
173+
174+
@state()
175+
visiblePages: number[] = [];
176+
177+
protected _current = 1;
178+
/**
179+
* This property says which page is currently shown.
180+
* @type {number}
181+
* @attr
182+
*/
183+
@property({ type: Number })
184+
get current() {
185+
return this._current;
186+
}
187+
188+
set current(newValue: number) {
189+
const oldValue = this._current;
190+
this._current = valueLimit(newValue, 1, this.total);
191+
this.visiblePages = this._generateVisiblePages(this._current);
192+
this.requestUpdate('current', oldValue);
193+
}
194+
195+
/**
196+
* This method will change the page to a next one.
197+
*/
198+
goToNextPage() {
199+
this.current++;
200+
this.dispatchEvent(new UUIPaginationEvent(UUIPaginationEvent.CHANGE));
201+
}
202+
203+
/**
204+
* This method will change the page to a previous one.
205+
*/
206+
goToPreviousPage() {
207+
this.current--;
208+
this.dispatchEvent(new UUIPaginationEvent(UUIPaginationEvent.CHANGE));
209+
}
210+
211+
/**
212+
* This method will change the page to the one passed as an argument to this method.
213+
*/
214+
goToPage(page: number) {
215+
this.current = page;
216+
this.dispatchEvent(new UUIPaginationEvent(UUIPaginationEvent.CHANGE));
217+
}
218+
219+
/** When having limited display of page-buttons and clicking a page-button that changes the current range, the focus stays on the position of the clicked button which is not anymore representing the number clicked, therefore we move focus to the button that represents the current page. */
220+
protected setFocusActivePageButton() {
221+
requestAnimationFrame(() => {
222+
// for none range changing clicks we need to ensure a rendering before querying.
223+
const activeButtonElement =
224+
this.renderRoot.querySelector<HTMLElement>('.active-button');
225+
if (activeButtonElement) {
226+
activeButtonElement.focus();
227+
}
228+
});
229+
}
230+
231+
protected firstButtonTemplate() {
232+
return html`<uui-button
233+
compact
234+
look="outline"
235+
class="nav-button"
236+
role="listitem"
237+
aria-label="Go to first page"
238+
.disabled=${1 === this._current}
239+
@click=${() => this.goToPage(1)}>
240+
First
241+
</uui-button>`;
242+
}
243+
244+
protected previousButtonTemplate() {
245+
return html`<uui-button
246+
compact
247+
look="outline"
248+
class="nav-button"
249+
role="listitem"
250+
aria-label="Go to previous page"
251+
.disabled=${this.current === 1}
252+
@click=${this.goToPreviousPage}>
253+
Previous
254+
</uui-button>`;
255+
}
256+
257+
protected nextButtonTemplate() {
258+
return html`<uui-button
259+
compact
260+
look="outline"
261+
role="listitem"
262+
class="nav-button"
263+
aria-label="Go to next page"
264+
.disabled=${this.current === this.total}
265+
@click=${this.goToNextPage}>
266+
Next
267+
</uui-button>`;
268+
}
269+
270+
protected lastButtonTemplate() {
271+
return html`
272+
<uui-button
273+
compact
274+
look="outline"
275+
role="listitem"
276+
class="nav-button"
277+
aria-label="Go to last page"
278+
?disabled=${this.total === this._current}
279+
@click=${() => this.goToPage(this.total)}>
280+
Last
281+
</uui-button>
282+
`;
283+
}
284+
285+
protected dotsTemplate() {
286+
return html`<uui-button
287+
compact
288+
look="outline"
289+
tabindex="-1"
290+
class="dots-button"
291+
>...</uui-button
292+
> `;
293+
}
294+
295+
protected pageTemplate(page: number) {
296+
return html`<uui-button
297+
compact
298+
look=${page === this._current ? 'primary' : 'outline'}
299+
role="listitem"
300+
aria-label="Go to page ${page}"
301+
class=${page === this._current ? 'active-button' : ''}
302+
tabindex=${page === this._current ? '-1' : ''}
303+
@click=${() => {
304+
if (page === this._current) return;
305+
this.goToPage(page);
306+
this.setFocusActivePageButton();
307+
}}
308+
>${page}</uui-button
309+
>`;
310+
}
311+
312+
protected navigationLeftTemplate() {
313+
return html` ${this.firstButtonTemplate()} ${this.previousButtonTemplate()}
314+
${this.visiblePages.includes(1) ? '' : this.dotsTemplate()}`;
315+
}
316+
317+
protected navigationRightTemplate() {
318+
return html`${this.visiblePages.includes(this.total)
319+
? ''
320+
: this.dotsTemplate()}
321+
${this.nextButtonTemplate()} ${this.lastButtonTemplate()}`;
322+
}
323+
15324
render() {
16-
return html` Markup goes here `;
325+
// prettier-ignore
326+
return html`<uui-button-group role="list" id="group">
327+
${this.navigationLeftTemplate()}${this.visiblePages.map(
328+
page =>
329+
this.pageTemplate(page)
330+
)}${this.navigationRightTemplate()}</uui-button-group>
331+
`;
17332
}
18333
}
Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,46 @@
1-
import { Story } from '@storybook/web-components';
21
import { html } from 'lit-html';
2+
import { Story } from '@storybook/web-components';
3+
34
import '@umbraco-ui/uui-pagination/lib/index';
45

56
export default {
67
id: 'uui-pagination',
7-
title: 'Pagination',
8+
title: 'Buttons/Pagination',
89
component: 'uui-pagination',
910
parameters: {
1011
docs: {
1112
source: {
12-
code: `<uui-pagination></uui-pagination>`,
13+
code: `<uui-pagination total="100"></uui-pagination>`,
1314
},
1415
},
1516
},
1617
};
1718

18-
export const Overview: Story = () => html`<uui-pagination></uui-pagination>`;
19+
export const AAAOverview: Story = () =>
20+
html` <uui-pagination .total=${100}></uui-pagination> `;
21+
AAAOverview.storyName = 'Overview';
22+
23+
export const TwoPages: Story = () =>
24+
html` <uui-pagination .total=${2}></uui-pagination> `;
25+
26+
TwoPages.parameters = {
27+
docs: {
28+
source: {
29+
code: `
30+
<uui-pagination total="2"></uui-pagination>`,
31+
},
32+
},
33+
};
34+
35+
export const OnePage: Story = () => html` <uui-pagination></uui-pagination> `;
36+
37+
export const Resize: Story = () => html`
38+
<h4>
39+
Resize the orange container to see how the number of pagination buttons
40+
changes.
41+
</h4>
42+
<div
43+
style="resize: horizontal; overflow: hidden; padding: 2em; border: 1px solid orange">
44+
<uui-pagination total=${30}></uui-pagination>
45+
</div>
46+
`;

0 commit comments

Comments
 (0)