diff --git a/angular/demo/src/app/samples/pagination/hash.route.ts b/angular/demo/src/app/samples/pagination/hash.route.ts new file mode 100644 index 0000000000..6a7b6ad3b0 --- /dev/null +++ b/angular/demo/src/app/samples/pagination/hash.route.ts @@ -0,0 +1,31 @@ +import {AgnosUIAngularModule, toAngularSignal} from '@agnos-ui/angular'; +import {hash$} from '@agnos-ui/common/utils'; +import {Component, computed} from '@angular/core'; +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + template: ` +

A pagination with hrefs provided for each pagination element:

+

+ Page hash: {{ '#' + hash() }} +

+ + `, +}) +export default class HashPaginationComponent { + hash = toAngularSignal(hash$); + + pageNumber = computed(() => +(this.hash().split('#')[1] ?? 4)); + + pageLink = (currentPage: number) => `#${this.hash().split('#')[0]}#${currentPage}`; + + pageChange = (currentPage: number) => (location.hash = `#${this.hash().split('#')[0]}#${currentPage}`); +} diff --git a/angular/lib/src/components/pagination/pagination.component.ts b/angular/lib/src/components/pagination/pagination.component.ts index ac3281c8bc..654041b2e6 100644 --- a/angular/lib/src/components/pagination/pagination.component.ts +++ b/angular/lib/src/components/pagination/pagination.component.ts @@ -121,8 +121,8 @@ export class PaginationPagesDirective { @@ -165,8 +165,8 @@ const defaultConfig: Partial = { @@ -181,8 +181,8 @@ const defaultConfig: Partial = { @@ -198,8 +198,8 @@ const defaultConfig: Partial = { @@ -214,8 +214,8 @@ const defaultConfig: Partial = { @@ -290,6 +290,14 @@ export class PaginationComponent extends BaseWidgetDirective i */ @Input('auAriaLastLabel') ariaLastLabel: string | undefined; + /** + * Factory function providing the href for a "Page" page anchor, + * based on the current page number + * @param pageNumber - The index to use in the link + * + */ + @Input('auPageLink') pageLink: ((pageNumber: number) => string) | undefined; + readonly _widget = callWidgetFactory({ factory: createPagination, widgetName: 'pagination', diff --git a/core/src/components/pagination/pagination.spec.ts b/core/src/components/pagination/pagination.spec.ts index b5d185eb3b..6220478265 100644 --- a/core/src/components/pagination/pagination.spec.ts +++ b/core/src/components/pagination/pagination.spec.ts @@ -3,10 +3,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest'; import type {PaginationState, PaginationWidget} from './pagination'; import {createPagination, getPaginationDefaultConfig} from './pagination'; import {ngBootstrapPagination} from './bootstrap'; +import {assign} from '../../../../common/utils'; describe(`Pagination`, () => { let pagination: PaginationWidget; let state: PaginationState; + let expectedState: PaginationState; let consoleErrorSpy: MockInstance, ReturnType>; @@ -20,6 +22,7 @@ describe(`Pagination`, () => { const unsubscribe = pagination.state$.subscribe((newState) => { state = newState; }); + expectedState = state; return () => { unsubscribe(); expect(consoleErrorSpy).not.toHaveBeenCalled(); @@ -34,22 +37,36 @@ describe(`Pagination`, () => { }; test(`should have sensible state`, () => { - // TODO we don't test ariaPageLabel here... - expect(state).toMatchObject({ + expect(state).toStrictEqual({ pageCount: 1, // total number of page page: 1, // current page pages: [1], // list of the visible pages previousDisabled: true, + ariaLabel: 'Page navigation', + className: '', nextDisabled: true, disabled: false, directionLinks: true, boundaryLinks: false, + slotEllipsis: '…', + slotFirst: '«', + slotLast: '»', + slotNext: '›', + slotNumberLabel: state.slotNumberLabel, + slotPages: undefined, + slotPrevious: '‹', size: null, activeLabel: '(current)', ariaFirstLabel: 'Action link for first page', ariaLastLabel: 'Action link for last page', ariaNextLabel: 'Action link for next page', ariaPreviousLabel: 'Action link for previous page', + directionsHrefs: { + next: '#', + previous: '#', + }, + pagesHrefs: ['#'], + pagesLabel: ['Page 1 of 1'], }); }); @@ -64,24 +81,93 @@ describe(`Pagination`, () => { test('should warn using invalid size value', () => { pagination.patch({size: 'invalidSize' as 'sm'}); - expect(state.size).toStrictEqual(null); + expect(state).toStrictEqual(assign(expectedState, {size: null})); expectLogInvalidValue(); pagination.patch({size: 'sm'}); - expect(state.size).toStrictEqual('sm'); + expect(state).toStrictEqual(assign(expectedState, {size: 'sm'})); }); test('actions should update the state', () => { pagination.patch({collectionSize: 200}); + const pagesLabel = Array.from({length: 20}, (_, index) => `Page ${index + 1} of 20`); + const pages = Array.from({length: 20}, (_, index) => index + 1); + const pagesHrefs = Array.from({length: 20}, (_, __) => `#`); + pagination.actions.first(); - expect(state).toMatchObject({page: 1, pageCount: 20}); + expect(state).toStrictEqual(assign(expectedState, {page: 1, pageCount: 20, pagesLabel, nextDisabled: false, pages, pagesHrefs})); + pagination.actions.next(); - expect(state).toMatchObject({page: 2, pageCount: 20}); + expect(state).toStrictEqual(assign(expectedState, {page: 2, previousDisabled: false})); + pagination.actions.select(5); - expect(state).toMatchObject({page: 5, pageCount: 20}); + expect(state).toStrictEqual(assign(expectedState, {page: 5})); + pagination.actions.last(); - expect(state).toMatchObject({page: 20, pageCount: 20}); + expect(state).toStrictEqual(assign(expectedState, {page: 20, nextDisabled: true})); + pagination.actions.previous(); - expect(state).toMatchObject({page: 19, pageCount: 20}); + expect(state).toStrictEqual(assign(expectedState, {page: 19, nextDisabled: false})); + }); + + test('should prepare pages hrefs', () => { + pagination.patch({page: 3, collectionSize: 50, pageSize: 10, pageLink: (p) => `${p}/5`}); + const pagesLabel = Array.from({length: 5}, (_, index) => `Page ${index + 1} of 5`); + const pages = Array.from({length: 5}, (_, index) => index + 1); + const pagesHrefs = Array.from({length: 5}, (_, index) => `${index + 1}/5`); + expectedState = { + ...expectedState, + page: 3, + pageCount: 5, + pagesLabel, + nextDisabled: false, + pages, + pagesHrefs, + previousDisabled: false, + directionsHrefs: { + next: '4/5', + previous: '2/5', + }, + }; + expect(state).toStrictEqual(expectedState); + + pagination.actions.next(); + expectedState = assign(expectedState, { + page: 4, + directionsHrefs: { + next: '5/5', + previous: '3/5', + }, + }); + expect(state).toStrictEqual(expectedState); + + pagination.actions.next(); + expectedState = assign(expectedState, { + page: 5, + nextDisabled: true, + directionsHrefs: { + next: '5/5', + previous: '4/5', + }, + }); + expect(state).toStrictEqual(expectedState); + + pagination.actions.first(); + expectedState = assign(expectedState, { + page: 1, + nextDisabled: false, + previousDisabled: true, + directionsHrefs: { + next: '2/5', + previous: '1/5', + }, + }); + expect(state).toStrictEqual(expectedState); + }); + + test('should prepare pages hrefs when 1 page', () => { + pagination.patch({page: 1, collectionSize: 20, pageSize: 20, pageLink: (p) => `${p}/1`}); + const pagesHrefs = ['1/1']; + expect(state).toStrictEqual(assign(expectedState, {pagesHrefs, directionsHrefs: {previous: '1/1', next: '1/1'}})); }); test('should return api isEllipisis', () => { diff --git a/core/src/components/pagination/pagination.ts b/core/src/components/pagination/pagination.ts index e7370a53b8..d3dd6da15f 100644 --- a/core/src/components/pagination/pagination.ts +++ b/core/src/components/pagination/pagination.ts @@ -232,6 +232,28 @@ export interface PaginationProps extends PaginationCommonPropsAndState { * ``` */ ariaPageLabel: (processPage: number, pageCount: number) => string; + + /** + * Factory function providing the href for a "Page" page anchor, + * based on the current page number + * @param pageNumber - The index to use in the link + * @defaultValue + * ```ts + * (_pageNumber) => '#' + * ``` + */ + pageLink: (pageNumber: number) => string; +} + +export interface DirectionsHrefs { + /** + * The href for the 'Previous' navigation link + */ + previous: string; + /** + * The href for the 'Next' direction link + */ + next: string; } export interface PaginationState extends PaginationCommonPropsAndState { @@ -255,6 +277,12 @@ export interface PaginationState extends PaginationCommonPropsAndState { * The label for each "Page" page link. */ pagesLabel: string[]; + + /** The hrefs for each "Page" page link */ + pagesHrefs: string[]; + + /** The hrefs for the direction links */ + directionsHrefs: DirectionsHrefs; } export interface PaginationActions { @@ -262,23 +290,23 @@ export interface PaginationActions { * To "go" to a specific page * @param page - The page number to select */ - select(page: number): void; + select(page: number, event?: MouseEvent): void; /** * To "go" to the first page */ - first(): void; + first(event?: MouseEvent): void; /** * To "go" to the previous page */ - previous(): void; + previous(event?: MouseEvent): void; /** * To "go" to the next page */ - next(): void; + next(event?: MouseEvent): void; /** * To "go" to the last page */ - last(): void; + last(event?: MouseEvent): void; } export interface PaginationApi { @@ -292,6 +320,8 @@ export interface PaginationApi { export type PaginationWidget = Widget; +const PAGE_LINK_DEFAULT = '#'; + const defaultConfig: PaginationProps = { page: 1, collectionSize: 0, @@ -324,6 +354,7 @@ const defaultConfig: PaginationProps = { slotLast: '»', slotPages: undefined, slotNumberLabel: ({displayedPage}) => `${displayedPage}`, + pageLink: (_page: number) => PAGE_LINK_DEFAULT, }; /** @@ -352,6 +383,7 @@ const configValidator: ConfigValidator = { ariaNextLabel: typeString, ariaLastLabel: typeString, className: typeString, + pageLink: typeFunction, }; /** @@ -370,6 +402,7 @@ export function createPagination(config?: PropsConfig): Paginat onPageChange$, pagesFactory$, ariaPageLabel$, + pageLink$, ...stateProps }, patch, @@ -399,6 +432,43 @@ export function createPagination(config?: PropsConfig): Paginat return pages$().map((page) => ariaPageLabel(page, pageCount)); }); + const pagesHrefs$ = computed(() => { + const pageLinkFactory = pageLink$(); + const pageCount = pageCount$(); + return Array.from({length: pageCount}, (_, index) => pageLinkFactory(index + 1)); + }); + + const directionsHrefs$ = computed(() => { + const pagesHrefs = pagesHrefs$(); + const pageIndex = page$() - 1; + return { + previous: pagesHrefs.at(pageIndex > 0 ? pageIndex - 1 : 0)!, + next: pagesHrefs.at(pageIndex + 1) ?? pagesHrefs.at(-1)!, + }; + }); + + /** + * Stop event propagation when href is the default value; + * Update page number when navigation is in the same tab and stop the event propagation; + * For navigations outside current browser tab let the default behavior, without updating the page number; + * @param pageNumber current page number + * @param event UI event triggered when page changed + * @param pageNavigationHandler change handler callback for navigation elements + */ + function handleNavigation(pageNumber: number, event?: MouseEvent, pageNavigationHandler?: (pN: number) => number) { + if (pagesHrefs$()[pageNumber - 1] === PAGE_LINK_DEFAULT) { + event?.preventDefault(); + } + if (!event || !(event.ctrlKey || event.metaKey)) { + event?.preventDefault(); + if (pageNavigationHandler) { + page$.update(pageNavigationHandler); + } else { + page$.set(pageNumber); + } + } + } + return { ...stateStores({ pageCount$, @@ -407,6 +477,8 @@ export function createPagination(config?: PropsConfig): Paginat nextDisabled$, previousDisabled$, pagesLabel$, + pagesHrefs$, + directionsHrefs$, ...stateProps, }), patch, @@ -415,37 +487,42 @@ export function createPagination(config?: PropsConfig): Paginat * Set the current page pageNumber (starting from 1) * @param pageNumber - Current page number to set. * Value is normalized between 1 and the number of page + * @param event UI event that triggered the select */ - select(pageNumber: number) { - page$.set(pageNumber); + select(pageNumber: number, event?: MouseEvent) { + handleNavigation(pageNumber, event); }, /** * Select the first page + * @param event Event triggering the action */ - first() { - page$.set(1); + first(event?: MouseEvent) { + handleNavigation(1, event); }, /** * Select the previous page + * @param event Event triggering the action */ - previous() { - page$.update((page) => page - 1); + previous(event?: MouseEvent) { + handleNavigation(page$() - 1, event, (page) => page - 1); }, /** * Select the next page + * @param event Event triggering the action */ - next() { - page$.update((page) => page + 1); + next(event?: MouseEvent) { + handleNavigation(page$() + 1, event, (page) => page + 1); }, /** * Select the last page + * @param event Event triggering the action */ - last() { - page$.set(pageCount$()); + last(event?: MouseEvent) { + handleNavigation(pageCount$(), event); }, }, api: { diff --git a/core/src/services/hash.ts b/core/src/services/hash.ts new file mode 100644 index 0000000000..a4a551525b --- /dev/null +++ b/core/src/services/hash.ts @@ -0,0 +1,15 @@ +import {readable} from '@amadeus-it-group/tansu'; + +/** Store exposing the location.hash string */ +export const hash$ = readable('', { + onUse({set}) { + function handleHashChange() { + const hash = location.hash; + set(hash ? hash.substring(1) : ''); + } + handleHashChange(); + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, +}); diff --git a/demo/src/routes/docs/[framework]/components/pagination/examples/+page.svelte b/demo/src/routes/docs/[framework]/components/pagination/examples/+page.svelte index ac14bd17f9..4652e50f57 100644 --- a/demo/src/routes/docs/[framework]/components/pagination/examples/+page.svelte +++ b/demo/src/routes/docs/[framework]/components/pagination/examples/+page.svelte @@ -1,6 +1,7 @@ @@ -13,3 +14,13 @@

The pagination widget is fully customizable, from the page numbers to the arrows.

+ +
+

+ The example shows the pagination widget with hrefs provided for each page element.
+ The hrefs customization is based on pageLink input. This receives a custom function which computes the href using the received page + number and the location hash. On the pageChange output event, the location hash is updated with the href of the current page element. + This strategy permits navigations in current browser tab, and opening a page in new tabs using the customized href. +

+ +
diff --git a/e2e/pagination/pagination.e2e-spec.ts b/e2e/pagination/pagination.e2e-spec.ts index 91e30c31aa..32247eb1b4 100644 --- a/e2e/pagination/pagination.e2e-spec.ts +++ b/e2e/pagination/pagination.e2e-spec.ts @@ -1,112 +1,97 @@ import {expect, test} from '../fixture'; +import type {PaginationPOState} from '@agnos-ui/page-objects'; import {PaginationPO} from '@agnos-ui/page-objects'; import {PaginationDemoPO} from '../demo-po/pagination.po'; -interface returnState { - rootClasses: string[]; - disabled: string | null; - pages: string[]; - isFirstDisabled?: boolean; - isPreviousDisabled?: boolean; - isNextDisabled?: boolean; - isLastDisabled?: boolean; -} - -async function paginationState(paginationPO: PaginationPO) { - return paginationPO.locatorRoot.evaluate((rootNode: HTMLElement) => { - const returnState: returnState = {rootClasses: [], disabled: null, pages: []}; - const pagesElements = [...rootNode.querySelectorAll('.au-page')] as HTMLLinkElement[]; - const pages = []; - for (const element of pagesElements) { - pages.push((element.textContent || '').trim()); - } - const pagesDisabledElements = [...rootNode.querySelectorAll('a.au-page[aria-disabled]')] as HTMLLinkElement[]; - returnState['pages'] = pages; - returnState['rootClasses'] = rootNode.className.trim().split(' '); - returnState['disabled'] = pagesDisabledElements.length === pagesElements.length ? 'true' : null; - if (rootNode.querySelector('a.au-previous[aria-disabled]')) { - returnState['isPreviousDisabled'] = true; - } else if (rootNode.querySelector('a.au-previous')) { - returnState['isPreviousDisabled'] = false; - } - if (rootNode.querySelector('a.au-next[aria-disabled]')) { - returnState['isNextDisabled'] = true; - } else if (rootNode.querySelector('a.au-next')) { - returnState['isNextDisabled'] = false; - } - if (rootNode.querySelector('a.au-first[aria-disabled]')) { - returnState['isFirstDisabled'] = true; - } else if (rootNode.querySelector('a.au-first')) { - returnState['isFirstDisabled'] = false; - } - if (rootNode.querySelector('a.au-last[aria-disabled]')) { - returnState['isLastDisabled'] = true; - } else if (rootNode.querySelector('a.au-last')) { - returnState['isLastDisabled'] = false; - } - return returnState; - }); -} - -type PromiseValue = T extends Promise ? U : never; -type State = PromiseValue>; test.describe.parallel(`Pagination tests`, () => { - const initState: State = { + const initState: PaginationPOState = { rootClasses: ['au-pagination', 'pagination'], disabled: null, isPreviousDisabled: false, isNextDisabled: false, pages: ['1', '2', '3', '4(current)', '5', '6'], + hrefs: ['#', '#', '#', '#', '#', '#'], + hrefsNavigation: {previous: '#', next: '#'}, }; - const initCustomState: State = { + const initCustomState: PaginationPOState = { rootClasses: ['au-pagination', 'pagination'], disabled: null, isPreviousDisabled: false, isNextDisabled: false, pages: ['A', 'B', 'C', 'D(current)', 'E', 'F'], + hrefs: ['#', '#', '#', '#', '#', '#'], + hrefsNavigation: {previous: '#', next: '#'}, }; - const disabledInitState: State = { + const disabledInitState: PaginationPOState = { rootClasses: ['au-pagination', 'pagination'], disabled: 'true', isPreviousDisabled: true, isNextDisabled: true, pages: ['1(current)', '2', '3', '4', '5', '6'], + hrefs: ['#', '#', '#', '#', '#', '#'], + hrefsNavigation: {previous: '#', next: '#'}, }; test(`Default features`, async ({page}) => { const paginationDemoPO = new PaginationDemoPO(page); const paginationPO = new PaginationPO(page, 0); await page.goto('#/pagination/default'); await paginationPO.locatorRoot.waitFor(); - await expect.poll(() => paginationState(paginationPO)).toEqual(initState); + await expect.poll(() => paginationPO.state()).toEqual(initState); await expect.poll(() => paginationDemoPO.defaultPaginationDemoState()).toEqual({page: 4}); await expect(paginationPO.locatorPreviousButton).not.toBeDisabled(); await expect(paginationPO.locatorNextButton).not.toBeDisabled(); const disabledPaginationPO = new PaginationPO(page, 3); - await expect.poll(() => paginationState(disabledPaginationPO)).toEqual(disabledInitState); + await expect.poll(() => disabledPaginationPO.state()).toEqual(disabledInitState); const paginationWithBoundariesPO = new PaginationPO(page, 2); await paginationWithBoundariesPO.locatorFirstButton.click(); await expect.poll(() => paginationDemoPO.defaultPaginationDemoState()).toEqual({page: 1}); await expect - .poll(() => paginationState(paginationWithBoundariesPO)) + .poll(() => paginationWithBoundariesPO.state()) .toEqual({ ...initState, isFirstDisabled: true, isLastDisabled: false, isPreviousDisabled: true, + hrefsNavigation: {first: '#', previous: '#', next: '#', last: '#'}, pages: ['1(current)', '2', '3', '4', '5', '6'], }); await paginationWithBoundariesPO.locatorLastButton.click(); await expect.poll(() => paginationDemoPO.defaultPaginationDemoState()).toEqual({page: 6}); await expect - .poll(() => paginationState(paginationWithBoundariesPO)) + .poll(() => paginationWithBoundariesPO.state()) .toEqual({ ...initState, isFirstDisabled: false, isLastDisabled: true, isNextDisabled: true, isPreviousDisabled: false, + hrefsNavigation: {first: '#', previous: '#', next: '#', last: '#'}, pages: ['1', '2', '3', '4', '5', '6(current)'], }); + await paginationWithBoundariesPO.locatorPreviousButton.click(); + await expect + .poll(() => paginationWithBoundariesPO.state()) + .toEqual({ + ...initState, + isFirstDisabled: false, + isLastDisabled: false, + isNextDisabled: false, + isPreviousDisabled: false, + hrefsNavigation: {first: '#', previous: '#', next: '#', last: '#'}, + pages: ['1', '2', '3', '4', '5(current)', '6'], + }); + await paginationWithBoundariesPO.locatorNthPage(2).click(); + await expect + .poll(() => paginationWithBoundariesPO.state()) + .toEqual({ + ...initState, + isFirstDisabled: false, + isLastDisabled: false, + isNextDisabled: false, + isPreviousDisabled: false, + hrefsNavigation: {first: '#', previous: '#', next: '#', last: '#'}, + pages: ['1', '2(current)', '3', '4', '5', '6'], + }); }); // TODO add test with the custom template, className... @@ -116,9 +101,71 @@ test.describe.parallel(`Pagination tests`, () => { const paginationPO2 = new PaginationPO(page, 1); await page.goto('#/pagination/custom'); await paginationPO1.locatorRoot.waitFor(); - await expect.poll(() => paginationState(paginationPO1)).toEqual(expectedState); + await expect.poll(() => paginationPO1.state()).toEqual(expectedState); await paginationPO2.locatorNextButton.click(); expectedState.pages = ['A', 'B', 'C', 'D', 'E(current)', 'F']; - await expect.poll(() => paginationState(paginationPO1)).toEqual(expectedState); + await expect.poll(() => paginationPO1.state()).toEqual(expectedState); + }); + + test(`Hrefs custom for pages`, async ({page}) => { + const initialStateWithCustomHrefs: PaginationPOState = { + rootClasses: ['au-pagination', 'pagination'], + disabled: null, + isPreviousDisabled: false, + isNextDisabled: false, + pages: ['1', '2', '3', '4(current)', '5', '6'], + isFirstDisabled: false, + isLastDisabled: false, + hrefsNavigation: { + first: '#/pagination/hash#1', + previous: '#/pagination/hash#3', + next: '#/pagination/hash#5', + last: '#/pagination/hash#6', + }, + hrefs: [ + '#/pagination/hash#1', + '#/pagination/hash#2', + '#/pagination/hash#3', + '#/pagination/hash#4', + '#/pagination/hash#5', + '#/pagination/hash#6', + ], + }; + const paginationPO = new PaginationPO(page, 0); + await page.goto('#/pagination/hash'); + await paginationPO.locatorRoot.waitFor(); + await expect.poll(() => paginationPO.state()).toEqual(initialStateWithCustomHrefs); + await paginationPO.locatorNextButton.click(); + const expectedStateAfterPageClick = structuredClone({ + ...initialStateWithCustomHrefs, + pages: ['1', '2', '3', '4', '5(current)', '6'], + hrefsNavigation: { + first: '#/pagination/hash#1', + previous: '#/pagination/hash#4', + next: '#/pagination/hash#6', + last: '#/pagination/hash#6', + }, + }); + await expect.poll(() => paginationPO.state()).toEqual(expectedStateAfterPageClick); + await paginationPO.locatorFirstButton.click(); + const expectedStateAfterFirstButtonClicked = structuredClone({ + ...initialStateWithCustomHrefs, + pages: ['1(current)', '2', '3', '4', '5', '6'], + hrefsNavigation: { + first: '#/pagination/hash#1', + previous: '#/pagination/hash#1', + next: '#/pagination/hash#2', + last: '#/pagination/hash#6', + }, + isFirstDisabled: true, + isPreviousDisabled: true, + }); + await expect.poll(() => paginationPO.state()).toEqual(expectedStateAfterFirstButtonClicked); + await page.goBack(); + await expect.poll(() => paginationPO.state()).toEqual(expectedStateAfterPageClick); + await page.goBack(); + await expect.poll(() => paginationPO.state()).toEqual(initialStateWithCustomHrefs); + await page.goForward(); + await expect.poll(() => paginationPO.state()).toEqual(expectedStateAfterPageClick); }); }); diff --git a/e2e/samplesMarkup.e2e-spec.ts-snapshots/pagination-hash.html b/e2e/samplesMarkup.e2e-spec.ts-snapshots/pagination-hash.html new file mode 100644 index 0000000000..2d5f7e4c77 --- /dev/null +++ b/e2e/samplesMarkup.e2e-spec.ts-snapshots/pagination-hash.html @@ -0,0 +1,165 @@ + +
+
+

+ "A pagination with hrefs provided for each pagination element:" +

+

+ "Page hash:" + + "#/pagination/hash" + +

+
+
+
+ \ No newline at end of file diff --git a/page-objects/lib/pagination.po.ts b/page-objects/lib/pagination.po.ts index 48938e848f..378a89bb11 100644 --- a/page-objects/lib/pagination.po.ts +++ b/page-objects/lib/pagination.po.ts @@ -1,6 +1,27 @@ import type {Locator} from '@playwright/test'; import {BasePO} from '@agnos-ui/base-po'; +/** Pagination navigation buttons hrefs */ +export interface HrefsNavigation { + first?: string; + previous?: string; + next?: string; + last?: string; +} + +/** Pagination page object */ +export interface PaginationPOState { + rootClasses: string[]; + disabled: string | null; + pages: string[]; + hrefs: string[]; + hrefsNavigation?: HrefsNavigation; + isFirstDisabled?: boolean; + isPreviousDisabled?: boolean; + isNextDisabled?: boolean; + isLastDisabled?: boolean; +} + export const paginationSelectors = { rootComponent: '.au-pagination', activePage: '.active', @@ -82,4 +103,55 @@ export class PaginationPO extends BasePO { get locatorEllipses(): Locator { return this.locatorRoot.locator(this.selectors.ellipses); } + + async state() { + return this.locatorRoot.evaluate((rootNode: HTMLElement) => { + const returnState: PaginationPOState = {rootClasses: [], disabled: null, pages: [], hrefs: []}; + const pagesElements = [...rootNode.querySelectorAll('.au-page')] as HTMLLinkElement[]; + const pages = []; + const hrefs = []; + const hrefsNavigation: HrefsNavigation = {}; + const getHref = (elem: Element | null) => elem?.getAttribute('href'); + const firstElem = rootNode.querySelector('a.au-first'); + const previousElem = rootNode.querySelector('a.au-previous'); + const nextElem = rootNode.querySelector('a.au-next'); + const lastElem = rootNode.querySelector('a.au-last'); + firstElem && (hrefsNavigation.first = getHref(firstElem)!); + previousElem && (hrefsNavigation.previous = getHref(previousElem)!); + nextElem && (hrefsNavigation.next = getHref(nextElem)!); + lastElem && (hrefsNavigation.last = getHref(lastElem)!); + + for (const element of pagesElements) { + hrefs.push(element.getAttribute('href') || ''); + pages.push((element.textContent || '').trim()); + } + const pagesDisabledElements = [...rootNode.querySelectorAll('a.au-page[aria-disabled]')] as HTMLLinkElement[]; + returnState['pages'] = pages; + returnState['hrefs'] = hrefs; + returnState['hrefsNavigation'] = hrefsNavigation; + returnState['rootClasses'] = rootNode.className.trim().split(' '); + returnState['disabled'] = pagesDisabledElements.length === pagesElements.length ? 'true' : null; + if (rootNode.querySelector('a.au-previous[aria-disabled]')) { + returnState['isPreviousDisabled'] = true; + } else if (previousElem) { + returnState['isPreviousDisabled'] = false; + } + if (rootNode.querySelector('a.au-next[aria-disabled]')) { + returnState['isNextDisabled'] = true; + } else if (nextElem) { + returnState['isNextDisabled'] = false; + } + if (rootNode.querySelector('a.au-first[aria-disabled]')) { + returnState['isFirstDisabled'] = true; + } else if (firstElem) { + returnState['isFirstDisabled'] = false; + } + if (rootNode.querySelector('a.au-last[aria-disabled]')) { + returnState['isLastDisabled'] = true; + } else if (lastElem) { + returnState['isLastDisabled'] = false; + } + return returnState; + }); + } } diff --git a/react/demo/src/app/samples/pagination/Hash.route.tsx b/react/demo/src/app/samples/pagination/Hash.route.tsx new file mode 100644 index 0000000000..72bf635112 --- /dev/null +++ b/react/demo/src/app/samples/pagination/Hash.route.tsx @@ -0,0 +1,33 @@ +import {hash$} from '@agnos-ui/common/utils'; +import {useObservable} from '@agnos-ui/react/utils/stores'; +import {Pagination} from '@agnos-ui/react/components/pagination'; +import {WidgetsDefaultConfig} from '@agnos-ui/react/config'; +import {useCallback} from 'react'; + +const PaginationHrefs = () => { + const hash = useObservable(hash$); + const pageNumber = +(hash.split('#')[1] ?? 4); + + const pageChange = useCallback((currentPage: number) => (location.hash = `#${hash.split('#')[0]}#${currentPage}`), []); + + const pageLink = useCallback((currentPage: number) => `#${hash.split('#')[0]}#${currentPage}`, []); + + return ( + <> + +

A pagination with hrefs provided for each pagination element:

+

+ Page hash: {'#' + hash} +

+ +
+ + ); +}; +export default PaginationHrefs; diff --git a/react/lib/src/components/pagination/pageItem.tsx b/react/lib/src/components/pagination/pageItem.tsx index 8bb88718af..1f3798c557 100644 --- a/react/lib/src/components/pagination/pageItem.tsx +++ b/react/lib/src/components/pagination/pageItem.tsx @@ -6,18 +6,19 @@ export interface PageItemProps extends React.HTMLAttributes { active?: boolean; ariaLabel?: string; activeLabel?: string; + href?: string; } // className and children are issue of React.HTMLAttributes export const PageItem = React.forwardRef( - ({disabled, active, ariaLabel, activeLabel, className, children, ...props}: PageItemProps, ref) => { + ({disabled, active, ariaLabel, activeLabel, className, children, href, ...props}: PageItemProps, ref) => { return (
  • diff --git a/react/lib/src/components/pagination/pagination.tsx b/react/lib/src/components/pagination/pagination.tsx index 2989ded3b8..f28d982e93 100644 --- a/react/lib/src/components/pagination/pagination.tsx +++ b/react/lib/src/components/pagination/pagination.tsx @@ -26,10 +26,8 @@ export function DefaultPages(slotContext: PaginationContext) { className={'au-page'} disabled={state.disabled} active={state.pages[i] === state.page} - onClick={(e) => { - widget.actions.select(state.pages[i]); - e.preventDefault(); - }} + onClick={(e) => widget.actions.select(state.pages[i], e.nativeEvent)} + href={state.pagesHrefs[i]} ariaLabel={state.pagesLabel[i]} activeLabel={state.activeLabel} > @@ -58,10 +56,8 @@ export function Pagination(props: Partial) { key={'first'} className={'au-first'} ariaLabel={state.ariaFirstLabel} - onClick={(e) => { - widget.actions.first(); - e.preventDefault(); - }} + href={state.pagesHrefs[0]} + onClick={(e) => widget.actions.first(e.nativeEvent)} disabled={state.previousDisabled} > @@ -74,10 +70,8 @@ export function Pagination(props: Partial) { key={'prev'} className={'au-previous'} ariaLabel={state.ariaPreviousLabel} - onClick={(e) => { - widget.actions.previous(); - e.preventDefault(); - }} + href={state.directionsHrefs.previous} + onClick={(e) => widget.actions.previous(e.nativeEvent)} disabled={state.previousDisabled} > @@ -90,10 +84,8 @@ export function Pagination(props: Partial) { key={'next'} className={'au-next'} ariaLabel={state.ariaNextLabel} - onClick={(e) => { - widget.actions.next(); - e.preventDefault(); - }} + href={state.directionsHrefs.next} + onClick={(e) => widget.actions.next(e.nativeEvent)} disabled={state.nextDisabled} > @@ -106,10 +98,8 @@ export function Pagination(props: Partial) { key={'last'} className={'au-last'} ariaLabel={state.ariaLastLabel} - onClick={(e) => { - widget.actions.last(); - e.preventDefault(); - }} + href={state.pagesHrefs.at(-1)} + onClick={(e) => widget.actions.last(e.nativeEvent)} disabled={state.nextDisabled} > diff --git a/svelte/demo/src/app/samples/pagination/Hash.route.svelte b/svelte/demo/src/app/samples/pagination/Hash.route.svelte new file mode 100644 index 0000000000..06de9aed23 --- /dev/null +++ b/svelte/demo/src/app/samples/pagination/Hash.route.svelte @@ -0,0 +1,18 @@ + + +

    A pagination with hrefs provided for each pagination element:

    +

    + Page hash: {'#' + $hash$} +

    + diff --git a/svelte/demo/src/common/AppCommon.svelte b/svelte/demo/src/common/AppCommon.svelte index dcaa365982..3129a42da2 100644 --- a/svelte/demo/src/common/AppCommon.svelte +++ b/svelte/demo/src/common/AppCommon.svelte @@ -1,5 +1,5 @@ {#if $component$} diff --git a/svelte/lib/src/components/pagination/Pagination.svelte b/svelte/lib/src/components/pagination/Pagination.svelte index a9f849568d..d6a3ed4d3d 100644 --- a/svelte/lib/src/components/pagination/Pagination.svelte +++ b/svelte/lib/src/components/pagination/Pagination.svelte @@ -56,6 +56,8 @@ slotNext$, slotLast$, slotPages$, + pagesHrefs$, + directionsHrefs$, }, state$, actions: {first, previous, next, last}, @@ -72,12 +74,11 @@