feat: add pagination component

This commit is contained in:
Hardik Pithva 2020-08-21 14:19:33 +02:00 committed by Thomas Allmer
parent 0750431ceb
commit a88f36041e
24 changed files with 717 additions and 5 deletions

View file

@ -67,6 +67,7 @@ The accessibility column indicates whether the functionality is accessible in it
| [tooltip](https://lion-web-components.netlify.app/?path=/docs/overlays-tooltip--main) | [![tooltip](https://img.shields.io/npm/v/@lion/tooltip.svg)](https://www.npmjs.com/package/@lion/tooltip) | Tooltip element | [#175][i175] |
| **-- [Navigation System](https://lion-web-components.netlify.app/?path=/docs/navigation-intro--page) --** | | Components which are used to guide users | |
| [accordion](https://lion-web-components.netlify.app/?path=/docs/navigation-accordion--main) | [![accordion](https://img.shields.io/npm/v/@lion/accordion.svg)](https://www.npmjs.com/package/@lion/accordion) | Accordion | ✔️ |
| [pagination](https://lion-web-components.netlify.app/?path=/docs/navigation-pagination--main) | [![pagination](https://img.shields.io/npm/v/@lion/pagination.svg)](https://www.npmjs.com/package/@lion/pagination) | Pagination | ✔️ |
| [steps](https://lion-web-components.netlify.app/?path=/docs/navigation-steps--main) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System | n/a |
| [tabs](https://lion-web-components.netlify.app/?path=/docs/navigation-tabs--main) | [![tBS](https://img.shields.io/npm/v/@lion/tabs.svg)](https://www.npmjs.com/package/@lion/tabs) | Move between a small number of equally important views | n/a |
| **-- [localize System](https://lion-web-components.netlify.app/?path=/docs/localize-intro--page) --** | | Localize text, numbers, dates and a way to store/fetch these data. | |
@ -77,7 +78,7 @@ The accessibility column indicates whether the functionality is accessible in it
| [core](https://lion-web-components.netlify.app/?path=/docs/others-system-core--page) | [![core](https://img.shields.io/npm/v/@lion/core.svg)](https://www.npmjs.com/package/@lion/core) | Core System (exports LitElement, lit-html) | n/a |
| [ajax](https://lion-web-components.netlify.app/?path=/docs/others-ajax--performing-get-requests) | [![ajax](https://img.shields.io/npm/v/@lion/ajax.svg)](https://www.npmjs.com/package/@lion/ajax) | Fetching data via ajax request | n/a |
| [calendar](https://lion-web-components.netlify.app/?path=/docs/others-calendar--main) | [![calendar](https://img.shields.io/npm/v/@lion/calendar.svg)](https://www.npmjs.com/package/@lion/calendar) | Standalone calendar | [#195][i195], [#194][i194] |
| [collapsible](https://lion-web-components.netlify.app/?path=/docs/others-collapsible--main) | [![collapsible](https://img.shields.io/npm/v/@lion/collapsible.svg)](https://www.npmjs.com/package/@lion/collapsible) | Combination of a button and a chunk of extra content | |
| [collapsible](https://lion-web-components.netlify.app/?path=/docs/others-collapsible--main) | [![collapsible](https://img.shields.io/npm/v/@lion/collapsible.svg)](https://www.npmjs.com/package/@lion/collapsible) | Combination of a button and a chunk of extra content | ✔️ |
| **-- [Helpers](https://lion-web-components.netlify.app/?path=/docs/helpers-intro--page) --** | [![helpers](https://img.shields.io/npm/v/@lion/helpers.svg)](https://www.npmjs.com/package/@lion/helpers) | Helpers to make your and your life easier | |
| [sb-action-logger](https://lion-web-components.netlify.app/?path=/docs/helpers-storybook-action-logger--main) | | Storybook action logger |

View file

@ -8,10 +8,12 @@ Navigational elements are used to guide users within your page.
## Packages
| Package | Version | Description |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [steps](?path=/docs/navigation-steps--default-story) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System |
| [tabs](?path=/docs/navigation-tabs--default-story) | [![tBS](https://img.shields.io/npm/v/@lion/tabs.svg)](https://www.npmjs.com/package/@lion/tabs) | Move between a small number of equally important views |
| Package | Version | Description |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| [accordion](?path=/docs/accordion-steps--default-story) | [![accordion](https://img.shields.io/npm/v/@lion/accordion.svg)](https://www.npmjs.com/package/@lion/accordion) | A component to toggle the display of sections of content |
| [pagination](?path=/docs/pagination-steps--default-story) | [![pagination](https://img.shields.io/npm/v/@lion/pagination.svg)](https://www.npmjs.com/package/@lion/pagination) | A component that handles pagination |
| [steps](?path=/docs/navigation-steps--default-story) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System |
| [tabs](?path=/docs/navigation-tabs--default-story) | [![tBS](https://img.shields.io/npm/v/@lion/tabs.svg)](https://www.npmjs.com/package/@lion/tabs) | Move between a small number of equally important views |
```js script
export default {

View file

@ -0,0 +1 @@
# Change Log

View file

@ -0,0 +1,145 @@
# Pagination
`lion-pagination` component that handles pagination.
```js script
import { html } from 'lit-html';
import './lion-pagination.js';
export default {
title: 'Navigation/Pagination',
};
```
```js preview-story
export const main = () => html` <lion-pagination count="20" current="10"></lion-pagination> `;
```
## Features
- You can pass the total number of pages in the `count` parameter, and the current page in the `current` parameter. If `current` is not defined it will default to the value 1.
- On a click or parameter change of `current` it will fire an event back called `current-changed`.
## How to use
### Installation
```bash
npm i --save @lion/pagination
```
```js
import { LionPagination } from '@lion/pagination';
// or
import '@lion/pagination/lion-pagination.js';
```
### Usage
```html
<lion-pagination count="20" current="10"></lion-pagination>
```
### Examples
### Without current defined
```js preview-story
export const withoutCurrentPage = () => {
return html` <lion-pagination count="20"></lion-pagination> `;
};
```
### Ensure a count value
Be sure to set a count value or you will get an "empty" pagination.
```js preview-story
export const ensureCount = () => {
return html` <lion-pagination></lion-pagination> `;
};
```
#### Methods
There are the following methods available to control the pagination.
- `next()`: move forward in pagination
- `previous()`: goes back to pagination
- `first()`: to the first page
- `last()`: to the last page
- `goto(pageNumber)`: to the specific page
```js preview-story
export const methods = () => {
setTimeout(() => {
document.getElementById('pagination-method-demo').innerText = document.getElementById(
'pagination-method',
).current;
});
return html`
<p>The current page is: <span id="pagination-method-demo"></span></p>
<lion-pagination
id="pagination-method"
count="100"
current="75"
@current-changed=${e => {
const paginationState = document.getElementById('pagination-method-demo');
paginationState.innerText = e.target.current;
}}
></lion-pagination>
<section style="margin-top:16px">
<button @click=${() => document.getElementById('pagination-method').previous()}>
Previous
</button>
<button @click=${() => document.getElementById('pagination-method').next()}>
Next
</button>
<br />
<br />
<button @click=${() => document.getElementById('pagination-method').first()}>
First
</button>
<button @click=${() => document.getElementById('pagination-method').last()}>
Last
</button>
<br />
<br />
<button @click=${() => document.getElementById('pagination-method').goto(55)}>
Go to 55
</button>
</section>
`;
};
```
#### Event
`lion-pagination` fires an event on button click to notify the component's current state. It is useful for analytics purposes or to perform some actions on page change.
- `@current-changed`: triggers when the current page is changed
```js preview-story
export const event = () => {
setTimeout(() => {
document.getElementById('pagination-event-demo-text').innerText = document.getElementById(
'pagination-event-demo',
).current;
});
return html`
<p>The current page is: <span id="pagination-event-demo-text"></span></p>
<lion-pagination
id="pagination-event-demo"
count="10"
current="5"
@current-changed=${e => {
const paginationState = document.getElementById('pagination-event-demo-text');
paginationState.innerText = e.target.current;
}}
></lion-pagination>
`;
};
```

View file

@ -0,0 +1 @@
export { LionPagination } from './src/LionPagination.js';

View file

@ -0,0 +1,3 @@
import { LionPagination } from './src/LionPagination.js';
customElements.define('lion-pagination', LionPagination);

View file

@ -0,0 +1,46 @@
{
"name": "@lion/pagination",
"version": "0.0.0",
"description": "A component that handles pagination.",
"license": "MIT",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/pagination"
},
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"docs",
"src",
"test",
"test-helpers",
"translations",
"types"
],
"scripts": {
"prepublishOnly": "../../scripts/npm-prepublish.js",
"start": "cd ../../ && yarn dev-server --open packages/pagination/README.md",
"test": "cd ../../ && yarn test:browser --grep \"packages/pagination/test/**/*.test.js\"",
"test:watch": "cd ../../ && yarn test:browser:watch --grep \"packages/pagination/test/**/*.test.js\""
},
"sideEffects": [
"lion-pagination.js"
],
"dependencies": {
"@lion/core": "0.10.0",
"@lion/localize": "0.14.2"
},
"keywords": [
"lion",
"pagination",
"web-components"
],
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,296 @@
import { LitElement, html, css } from '@lion/core';
import { LocalizeMixin } from '@lion/localize';
/**
* `LionPagination` is a class for custom Pagination element (`<lion-pagination>` web component).
*
* @customElement lion-pagination
* @extends LitElement
*/
export class LionPagination extends LocalizeMixin(LitElement) {
static get styles() {
return css`
:host {
cursor: default;
}
ul {
list-style: none;
padding: 0;
text-align: center;
}
li {
display: inline-block;
}
button[aria-current='true'] {
font-weight: bold;
}
`;
}
static get localizeNamespaces() {
return [
{
'lion-pagination': locale => {
switch (locale) {
case 'bg-BG':
return import('../translations/bg.js');
case 'cs-CZ':
return import('../translations/cs.js');
case 'de-AT':
case 'de-DE':
return import('../translations/de.js');
case 'en-AU':
case 'en-GB':
case 'en-PH':
case 'en-US':
return import('../translations/en.js');
case 'es-ES':
return import('../translations/es.js');
case 'fr-FR':
case 'fr-BE':
return import('../translations/fr.js');
case 'hu-HU':
return import('../translations/hu.js');
case 'it-IT':
return import('../translations/it.js');
case 'nl-BE':
case 'nl-NL':
return import('../translations/nl.js');
case 'pl-PL':
return import('../translations/pl.js');
case 'ro-RO':
return import('../translations/ro.js');
case 'ru-RU':
return import('../translations/ru.js');
case 'sk-SK':
return import('../translations/sk.js');
case 'uk-UA':
return import('../translations/uk.js');
case 'zh-CN':
return import('../translations/zh.js');
default:
return import('../translations/en.js');
}
},
},
...super.localizeNamespaces,
];
}
static get properties() {
return {
current: {
type: Number,
reflect: true,
},
count: {
type: Number,
reflect: true,
},
};
}
set current(value) {
if (value !== this.current) {
const oldValue = this.current;
this.__current = value;
this.dispatchEvent(new Event('current-changed'));
this.requestUpdate('current', oldValue);
}
}
get current() {
return this.__current;
}
constructor() {
super();
this.__visiblePages = 5;
this.current = 1;
this.count = 0;
}
/**
* Go next in pagination
* @public
*/
next() {
if (this.current < this.count) {
this.__fire(this.current + 1);
}
}
/**
* Go to first page
* @public
*/
first() {
if (this.count >= 1) {
this.__fire(1);
}
}
/**
* Go to the last page
* @public
*/
last() {
if (this.count >= 1) {
this.__fire(this.count);
}
}
/**
* Go to the specific page
* @public
*/
goto(pageNumber) {
if (pageNumber >= 1 && pageNumber <= this.count) {
this.__fire(pageNumber);
}
}
/**
* Go back in pagination
* @public
*/
previous() {
if (this.current !== 1) {
this.__fire(this.current - 1);
}
}
/**
* Set desired page in the pagination and fire the current changed event.
* @param {Number} page page number to be set
* @private
*/
__fire(page) {
if (page !== this.current) {
this.current = page;
}
}
/**
* Calculate nav list based on current page selection.
* @returns {Array}
* @private
*/
__calculateNavList() {
const start = 1;
const finish = this.count;
// If there are more pages then we want to display we have to redo the list each time
// Else we can just return the same list every time.
if (this.count > this.__visiblePages) {
// Calculate left side of current page and right side
const pos3 = this.current - 1;
const pos4 = this.current;
const pos5 = this.current + 1;
// if pos 3 is lower than 4 we have a predefined list of elements
if (pos4 <= 4) {
const list = Array(this.__visiblePages)
.fill()
.map((_, idx) => start + idx);
list.push('...');
list.push(this.count);
return list;
}
// if we are close to the end of the list with the current page then we have again a predefined list
if (finish - pos4 <= 3) {
const list = [];
list.push(1);
list.push('...');
const listRemaining = Array(this.__visiblePages)
.fill()
.map((_, idx) => this.count - this.__visiblePages + 1 + idx);
return list.concat(listRemaining);
}
return [start, '...', pos3, pos4, pos5, '...', finish];
}
return Array(finish - start + 1)
.fill()
.map((_, idx) => start + idx);
}
/**
* Get previous or next button template.
* This method can be overridden to apply customized template in wrapper.
* @param {String} label namespace label i.e. next or previous
* @returns {TemplateResult} icon template
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_prevNextIconTemplate(label) {
return label === 'next' ? html` &gt; ` : html` &lt; `;
}
/**
* Get next or previous button template.
* This method can be overridden to apply customized template in wrapper.
* @param {String} label namespace label i.e. next or previous
* @param {Number} pageNumber page number to be set
* @param {String} namespace namespace prefix for translations
* @returns {TemplateResult} nav item template
* @protected
*/
_prevNextButtonTemplate(label, pageNumber, namespace = 'lion') {
return html`
<li>
<button
aria-label=${this.msgLit(`${namespace}-pagination:${label}`)}
@click=${() => this.__fire(pageNumber)}
>
${this._prevNextIconTemplate(label)}
</button>
</li>
`;
}
/**
* Get disabled button template.
* This method can be overridden to apply customized template in wrapper.
* @param {String} label namespace label i.e. next or previous
* @returns {TemplateResult} nav item template
* @protected
*/
_disabledButtonTemplate(label) {
return html`
<li>
<button disabled>${this._prevNextIconTemplate(label)}</button>
</li>
`;
}
render() {
return html`
<nav role="navigation" aria-label="${this.msgLit('lion-pagination:label')}">
<ul>
${this.current > 1
? this._prevNextButtonTemplate('previous', this.current - 1)
: this._disabledButtonTemplate('previous')}
${this.__calculateNavList().map(page =>
page === '...'
? html` <li><span>${page}</span></li> `
: html`
<li>
<button
aria-label="${this.msgLit('lion-pagination:page', { page })}"
aria-current=${page === this.current}
@click=${() => this.__fire(page)}
>
${page}
</button>
</li>
`,
)}
${this.current < this.count
? this._prevNextButtonTemplate('next', this.current + 1)
: this._disabledButtonTemplate('next')}
</ul>
</nav>
`;
}
}

View file

@ -0,0 +1,127 @@
import { html, fixture, expect } from '@open-wc/testing';
import sinon from 'sinon';
import '../lion-pagination.js';
describe('Pagination', () => {
it('has states for count and current', async () => {
const el = await fixture(html` <lion-pagination count="4"></lion-pagination> `);
expect(el.getAttribute('count')).to.equal('4');
expect(el.getAttribute('current')).to.equal('1');
el.count = 8;
el.current = 2;
await el.updateComplete;
expect(el.getAttribute('count')).to.equal('8');
expect(el.getAttribute('current')).to.equal('2');
});
it('disables the previous button if on first page', async () => {
const el = await fixture(html` <lion-pagination count="4"></lion-pagination> `);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
expect(buttons[0]).to.has.attribute('disabled');
});
it('disables the next button if on last page', async () => {
const el = await fixture(html` <lion-pagination count="4" current="4"></lion-pagination> `);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
expect(buttons[buttons.length - 1]).to.has.attribute('disabled');
});
describe('User interaction', () => {
it('can go to previous page with previous button', async () => {
const el = await fixture(html` <lion-pagination count="6" current="2"></lion-pagination> `);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
buttons[0].click();
expect(el.current).to.equal(1);
});
it('can go to next page with next button', async () => {
const el = await fixture(html` <lion-pagination count="6" current="2"></lion-pagination> `);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
buttons[buttons.length - 1].click();
expect(el.current).to.equal(3);
});
it('goes to the page when clicking on its button', async () => {
const el = await fixture(html` <lion-pagination count="6" current="2"></lion-pagination> `);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
buttons[5].click();
expect(el.current).to.equal(5);
});
it('fires current-changed event when interacting with the pagination', async () => {
const changeSpy = sinon.spy();
const el = await fixture(html`
<lion-pagination count="6" current="2" @current-changed=${changeSpy}></lion-pagination>
`);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
const previous = buttons[0];
const next = buttons[buttons.length - 1];
const page5 = buttons[5];
previous.click();
expect(changeSpy).to.have.callCount(1);
next.click();
expect(changeSpy).to.have.callCount(2);
page5.click();
expect(changeSpy).to.have.callCount(3);
el.current = 3;
expect(changeSpy).to.have.callCount(4);
});
it('does NOT fire current-changed event when clicking on a current page number', async () => {
const changeSpy = sinon.spy();
const el = await fixture(html`
<lion-pagination count="6" current="2" @current-changed=${changeSpy}></lion-pagination>
`);
const page2 = el.shadowRoot.querySelector("button[aria-current='true']");
page2.click();
expect(changeSpy).to.not.be.called;
expect(el.current).to.equal(2);
});
it('should goto next and previous page using `next()` and `previous()`', async () => {
const el = await fixture(html` <lion-pagination count="6" current="2"></lion-pagination> `);
el.next();
expect(el.current).to.equal(3);
el.previous();
expect(el.current).to.equal(2);
});
it('should goto first and last page using `first()` and `last()`', async () => {
const el = await fixture(html` <lion-pagination count="5" current="2"></lion-pagination> `);
expect(el.current).to.equal(2);
el.first();
expect(el.current).to.equal(1);
el.last();
expect(el.current).to.equal(5);
});
it('should goto 7 page using `goto()`', async () => {
const el = await fixture(html` <lion-pagination count="10" current="2"></lion-pagination> `);
expect(el.current).to.equal(2);
el.goto(7);
expect(el.current).to.equal(7);
});
});
describe('Accessibility', () => {
it('sets aria-current to the current page', async () => {
const el = await fixture(html` <lion-pagination count="3"></lion-pagination> `);
const buttons = Array.from(el.shadowRoot.querySelectorAll('button'));
// button[0] is the previous button
expect(buttons[1].getAttribute('aria-current')).to.equal('true');
expect(buttons[2].getAttribute('aria-current')).to.equal('false');
expect(buttons[3].getAttribute('aria-current')).to.equal('false');
el.current = 2;
await el.updateComplete;
expect(buttons[1].getAttribute('aria-current')).to.equal('false');
expect(buttons[2].getAttribute('aria-current')).to.equal('true');
expect(buttons[3].getAttribute('aria-current')).to.equal('false');
});
});
});

View file

@ -0,0 +1,6 @@
export default {
label: 'Навигация по страниците',
previous: 'Предишна страница',
next: 'Следваща страница',
page: 'Страница {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Navigace stránky',
previous: 'Předchozí strana',
next: 'Další strana',
page: 'Strana {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Seitennavigation',
previous: 'Vorherige Seite',
next: 'Nächste Seite',
page: 'Seite {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Page navigation',
previous: 'Previous page',
next: 'Next page',
page: 'Page {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Navegación de la página',
previous: 'Página anterior',
next: 'Página siguiente',
page: 'Página {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Navigation de page',
previous: 'Page précédente',
next: 'Page suivante',
page: 'Page {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Oldalnavigáció',
previous: 'Előző oldal',
next: 'Következő oldal',
page: '{page}. oldal',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Navigazione pagina',
previous: 'Pagina Precedente',
next: 'Pagina successiva',
page: 'Pagina {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Pagina navigatie',
previous: 'Vorige pagina',
next: 'Volgende pagina',
page: 'Pagina {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Nawigacja strony',
previous: 'Poprzednia strona',
next: 'Następna strona',
page: 'Strona {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Navigare în pagină',
previous: 'Pagină anterioară',
next: 'Pagină următoare',
page: 'Pagina {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Навигация по страницам',
previous: 'Предыдущая страница',
next: 'Следующая страница',
page: 'Страница {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Navigácia stranami',
previous: 'Predchádzajúca strana',
next: 'Ďalšia strana',
page: 'Strana {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: 'Навігація по сторінках',
previous: 'Попередня сторінка',
next: 'Наступна сторінка',
page: 'Сторінка {page}',
};

View file

@ -0,0 +1,6 @@
export default {
label: '页面导航',
previous: '上一页',
next: '下一页',
page: '页 {page}',
};