|
1 | 1 | 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 | +}; |
2 | 15 |
|
3 | 16 | /**
|
| 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 | + * |
4 | 19 | * @element uui-pagination
|
| 20 | + * @fires change - When clicked on the page button fires change event |
| 21 | + * |
5 | 22 | */
|
6 | 23 | export class UUIPaginationElement extends LitElement {
|
7 | 24 | static styles = [
|
8 | 25 | 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; |
11 | 45 | }
|
12 | 46 | `,
|
13 | 47 | ];
|
14 | 48 |
|
| 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 | + |
15 | 324 | 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 | + `; |
17 | 332 | }
|
18 | 333 | }
|
0 commit comments