diff --git a/pages/pagination/permutations.page.tsx b/pages/pagination/permutations.page.tsx index 6a8db3b507..16bdb3ecdb 100644 --- a/pages/pagination/permutations.page.tsx +++ b/pages/pagination/permutations.page.tsx @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; import Pagination, { PaginationProps } from '~components/pagination'; import createPermutations from '../utils/permutations'; @@ -26,16 +28,17 @@ const permutations = createPermutations([ pagesCount: [15], openEnd: [true, false], ariaLabels: [paginationLabels], + jumpToPage: [undefined, { loading: false }, { loading: true }], }, ]); export default function PaginationPermutations() { return ( - <> +

Pagination permutations

} /> - +
); } diff --git a/pages/table/jump-to-page-closed.page.tsx b/pages/table/jump-to-page-closed.page.tsx new file mode 100644 index 0000000000..e05ac153d6 --- /dev/null +++ b/pages/table/jump-to-page-closed.page.tsx @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { CollectionPreferences } from '~components'; +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import Pagination from '~components/pagination'; +import Table from '~components/table'; + +import { generateItems, Instance } from './generate-data'; + +const allItems = generateItems(100); +const PAGE_SIZE = 10; + +function JumpToPageClosedContent() { + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + const totalPages = Math.ceil(allItems.length / PAGE_SIZE); + const startIndex = (currentPageIndex - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + const currentItems = allItems.slice(startIndex, endIndex); + + return ( + Jump to Page - Closed Pagination (100 items, 10 pages)} + columnDefinitions={[ + { header: 'ID', cell: (item: Instance) => item.id }, + { header: 'State', cell: (item: Instance) => item.state }, + { header: 'Type', cell: (item: Instance) => item.type }, + { header: 'DNS Name', cell: (item: Instance) => item.dnsName || '-' }, + ]} + preferences={ + + } + items={currentItems} + pagination={ + setCurrentPageIndex(detail.currentPageIndex)} + jumpToPage={{}} + /> + } + /> + ); +} + +export default function JumpToPageClosedExample() { + return ( + + + + ); +} diff --git a/pages/table/jump-to-page-open-end.page.tsx b/pages/table/jump-to-page-open-end.page.tsx new file mode 100644 index 0000000000..0798a10d55 --- /dev/null +++ b/pages/table/jump-to-page-open-end.page.tsx @@ -0,0 +1,149 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import Pagination, { PaginationProps } from '~components/pagination'; +import Table from '~components/table'; + +import { generateItems, Instance } from './generate-data'; + +const PAGE_SIZE = 10; +const TOTAL_ITEMS = 100; // Simulated server-side total + +function JumpToPageOpenEndContent() { + const [currentPageIndex, setCurrentPageIndex] = useState(1); + const [loadedPages, setLoadedPages] = useState>({ 1: generateItems(10) }); + const [jumpToPageIsLoading, setJumpToPageIsLoading] = useState(false); + const [maxKnownPage, setMaxKnownPage] = useState(1); + const [openEnd, setOpenEnd] = useState(true); + const jumpToPageRef = useRef(null); + + const currentItems = loadedPages[currentPageIndex] || []; + + const loadPage = (pageIndex: number) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + const totalPages = Math.ceil(TOTAL_ITEMS / PAGE_SIZE); + if (pageIndex > totalPages) { + reject({ + message: `Page ${pageIndex} does not exist. Maximum page is ${totalPages}.`, + maxPage: totalPages, + }); + } else { + const startIndex = (pageIndex - 1) * PAGE_SIZE; + resolve(generateItems(10).map((item, i) => ({ ...item, id: `${startIndex + i + 1}` }))); + } + }, 500); + }); + }; + + return ( +
+

Jump to Page - Open End Pagination (100 items total, lazy loaded)

+

+ Current: Page {currentPageIndex}, Max Known: {maxKnownPage}, Mode: {openEnd ? 'Open-End' : 'Closed'} +

+ + } + columnDefinitions={[ + { header: 'ID', cell: (item: Instance) => item.id }, + { header: 'State', cell: (item: Instance) => item.state }, + { header: 'Type', cell: (item: Instance) => item.type }, + { header: 'DNS Name', cell: (item: Instance) => item.dnsName || '-' }, + ]} + items={currentItems} + pagination={ + { + const requestedPage = detail.currentPageIndex; + // If page already loaded, just navigate + if (loadedPages[requestedPage]) { + setCurrentPageIndex(requestedPage); + return; + } + // Otherwise, load the page + setJumpToPageIsLoading(true); + loadPage(requestedPage) + .then(items => { + setLoadedPages(prev => ({ ...prev, [requestedPage]: items })); + setCurrentPageIndex(requestedPage); + setMaxKnownPage(Math.max(maxKnownPage, requestedPage)); + setJumpToPageIsLoading(false); + }) + .catch((error: { message: string; maxPage?: number }) => { + const newMaxPage = error.maxPage || maxKnownPage; + setMaxKnownPage(newMaxPage); + setOpenEnd(false); + jumpToPageRef.current?.setError(true); + // Load all pages from current to max + const pagesToLoad = []; + for (let i = 1; i <= newMaxPage; i++) { + if (!loadedPages[i]) { + pagesToLoad.push(loadPage(i).then(items => ({ page: i, items }))); + } + } + + Promise.all(pagesToLoad).then(results => { + setLoadedPages(prev => { + const updated = { ...prev }; + results.forEach(({ page, items }) => { + updated[page] = items; + }); + return updated; + }); + setCurrentPageIndex(newMaxPage); + setJumpToPageIsLoading(false); + }); + }); + }} + onNextPageClick={({ detail }) => { + // If page already loaded, just navigate + if (loadedPages[detail.requestedPageIndex]) { + setCurrentPageIndex(detail.requestedPageIndex); + return; + } + // Load the next page + setJumpToPageIsLoading(true); + loadPage(detail.requestedPageIndex) + .then(items => { + setLoadedPages(prev => ({ ...prev, [detail.requestedPageIndex]: items })); + setCurrentPageIndex(detail.requestedPageIndex); + setMaxKnownPage(Math.max(maxKnownPage, detail.requestedPageIndex)); + setJumpToPageIsLoading(false); + }) + .catch((error: { message: string; maxPage?: number }) => { + // Discovered the end - switch to closed pagination and stay on current page + if (error.maxPage) { + setMaxKnownPage(error.maxPage); + setOpenEnd(false); + } + // Reset to current page (undo the navigation that already happened) + setCurrentPageIndex(currentPageIndex); + jumpToPageRef.current?.setError(true); + setJumpToPageIsLoading(false); + }); + }} + jumpToPage={{ + loading: jumpToPageIsLoading, + }} + /> + } + /> + ); +} + +export default function JumpToPageOpenEndExample() { + return ( + + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 2127661123..c14df90c8a 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Components definition for alert matches the snapshot: alert 1`] = ` { @@ -18137,7 +18137,19 @@ exports[`Components definition for pagination matches the snapshot: pagination 1 "name": "onPreviousPageClick", }, ], - "functions": [], + "functions": [ + { + "description": "Set error state for jump to page. Component will auto-clear when user types or navigates.", + "name": "setError", + "parameters": [ + { + "name": "hasError", + "type": "boolean", + }, + ], + "returnType": "void", + }, + ], "name": "Pagination", "properties": [ { @@ -18161,6 +18173,11 @@ Example: "inlineType": { "name": "PaginationProps.Labels", "properties": [ + { + "name": "jumpToPageButton", + "optional": true, + "type": "string", + }, { "name": "nextPageLabel", "optional": true, @@ -18212,6 +18229,44 @@ from changing page before items are loaded.", "optional": true, "type": "boolean", }, + { + "inlineType": { + "name": "PaginationProps.I18nStrings", + "properties": [ + { + "name": "jumpToPageError", + "optional": true, + "type": "string", + }, + { + "name": "jumpToPageLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "PaginationProps.I18nStrings", + }, + { + "description": "Jump to page configuration", + "inlineType": { + "name": "PaginationProps.JumpToPageProps", + "properties": [ + { + "name": "loading", + "optional": true, + "type": "boolean", + }, + ], + "type": "object", + }, + "name": "jumpToPage", + "optional": true, + "type": "PaginationProps.JumpToPageProps", + }, { "description": "Sets the pagination variant. It can be either default (when setting it to \`false\`) or open ended (when setting it to \`true\`). Default pagination navigates you through the items list. The open-end variant enables you @@ -22243,6 +22298,11 @@ The function will be called when a user clicks on the trigger button.", "inlineType": { "name": "PaginationProps.Labels", "properties": [ + { + "name": "jumpToPageButton", + "optional": true, + "type": "string", + }, { "name": "nextPageLabel", "optional": true, @@ -34334,6 +34394,33 @@ Returns the current value of the input.", ], }, }, + { + "description": "Returns the jump to page submit button.", + "name": "findJumpToPageButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "description": "Returns the jump to page input field.", + "name": "findJumpToPageInput", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "InputWrapper", + }, + }, + { + "description": "Returns the error popover for jump to page.", + "name": "findJumpToPagePopover", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "PopoverWrapper", + }, + }, { "name": "findNextPageButton", "parameters": [], @@ -34407,6 +34494,88 @@ Returns the current value of the input.", ], "name": "PaginationWrapper", }, + { + "methods": [ + { + "name": "findContent", + "parameters": [ + { + "defaultValue": "{ renderWithPortal: false }", + "description": "* renderWithPortal (boolean) - Flag to find the content when the popover is rendered with a portal", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ renderWithPortal: boolean; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findDismissButton", + "parameters": [ + { + "defaultValue": "{ renderWithPortal: false }", + "description": "* renderWithPortal (boolean) - Flag to find the dismiss button when the popover is rendered with a portal", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ renderWithPortal: boolean; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "name": "findHeader", + "parameters": [ + { + "defaultValue": "{ renderWithPortal: false }", + "description": "* renderWithPortal (boolean) - Flag to find the header when the popover is rendered with a portal", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ renderWithPortal: boolean; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findTrigger", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + ], + "name": "PopoverWrapper", + }, { "methods": [ { @@ -38630,88 +38799,6 @@ Returns null if the panel layout is not resizable.", ], "name": "PieChartWrapper", }, - { - "methods": [ - { - "name": "findContent", - "parameters": [ - { - "defaultValue": "{ renderWithPortal: false }", - "description": "* renderWithPortal (boolean) - Flag to find the content when the popover is rendered with a portal", - "flags": { - "isOptional": false, - }, - "name": "options", - "typeName": "{ renderWithPortal: boolean; }", - }, - ], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "name": "findDismissButton", - "parameters": [ - { - "defaultValue": "{ renderWithPortal: false }", - "description": "* renderWithPortal (boolean) - Flag to find the dismiss button when the popover is rendered with a portal", - "flags": { - "isOptional": false, - }, - "name": "options", - "typeName": "{ renderWithPortal: boolean; }", - }, - ], - "returnType": { - "isNullable": true, - "name": "ButtonWrapper", - }, - }, - { - "name": "findHeader", - "parameters": [ - { - "defaultValue": "{ renderWithPortal: false }", - "description": "* renderWithPortal (boolean) - Flag to find the header when the popover is rendered with a portal", - "flags": { - "isOptional": false, - }, - "name": "options", - "typeName": "{ renderWithPortal: boolean; }", - }, - ], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "name": "findTrigger", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - ], - "name": "PopoverWrapper", - }, { "methods": [ { @@ -44846,6 +44933,33 @@ is '2-indexed', that is, the first section in a card has an index of \`2\`.", "name": "ElementWrapper", }, }, + { + "description": "Returns the jump to page submit button.", + "name": "findJumpToPageButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "description": "Returns the jump to page input field.", + "name": "findJumpToPageInput", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "InputWrapper", + }, + }, + { + "description": "Returns the error popover for jump to page.", + "name": "findJumpToPagePopover", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "PopoverWrapper", + }, + }, { "name": "findNextPageButton", "parameters": [], @@ -44896,6 +45010,79 @@ is '2-indexed', that is, the first section in a card has an index of \`2\`.", ], "name": "PaginationWrapper", }, + { + "methods": [ + { + "name": "findContent", + "parameters": [ + { + "defaultValue": "{ + renderWithPortal: false + }", + "description": "* renderWithPortal (boolean) - Flag to find the content when the popover is rendered with a portal", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ renderWithPortal: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findDismissButton", + "parameters": [ + { + "defaultValue": "{ + renderWithPortal: false + }", + "description": "* renderWithPortal (boolean) - Flag to find the dismiss button when the popover is rendered with a portal", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ renderWithPortal: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "ButtonWrapper", + }, + }, + { + "name": "findHeader", + "parameters": [ + { + "defaultValue": "{ + renderWithPortal: false + }", + "description": "* renderWithPortal (boolean) - Flag to find the header when the popover is rendered with a portal", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ renderWithPortal: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findTrigger", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + ], + "name": "PopoverWrapper", + }, { "methods": [ { @@ -47851,79 +48038,6 @@ Returns null if the panel layout is not resizable.", ], "name": "PieChartWrapper", }, - { - "methods": [ - { - "name": "findContent", - "parameters": [ - { - "defaultValue": "{ - renderWithPortal: false - }", - "description": "* renderWithPortal (boolean) - Flag to find the content when the popover is rendered with a portal", - "flags": { - "isOptional": false, - }, - "name": "options", - "typeName": "{ renderWithPortal: boolean; }", - }, - ], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "name": "findDismissButton", - "parameters": [ - { - "defaultValue": "{ - renderWithPortal: false - }", - "description": "* renderWithPortal (boolean) - Flag to find the dismiss button when the popover is rendered with a portal", - "flags": { - "isOptional": false, - }, - "name": "options", - "typeName": "{ renderWithPortal: boolean; }", - }, - ], - "returnType": { - "isNullable": false, - "name": "ButtonWrapper", - }, - }, - { - "name": "findHeader", - "parameters": [ - { - "defaultValue": "{ - renderWithPortal: false - }", - "description": "* renderWithPortal (boolean) - Flag to find the header when the popover is rendered with a portal", - "flags": { - "isOptional": false, - }, - "name": "options", - "typeName": "{ renderWithPortal: boolean; }", - }, - ], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "name": "findTrigger", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - ], - "name": "PopoverWrapper", - }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index d1620c2d1d..f63a021ccc 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -462,6 +462,8 @@ exports[`test-utils selectors 1`] = ` "pagination": [ "awsui_button-current_fvjdu", "awsui_button_fvjdu", + "awsui_jump-to-page-input_fvjdu", + "awsui_jump-to-page_fvjdu", "awsui_page-number_fvjdu", "awsui_root_fvjdu", ], diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index a3a0f8caf2..28bb167d3e 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -196,6 +196,8 @@ export interface I18nFormatArgTypes { "i18nStrings.endMonthLabel": never; "i18nStrings.endDateLabel": never; "i18nStrings.endTimeLabel": never; + "i18nStrings.datePlaceholder": never; + "i18nStrings.timePlaceholder": never; "i18nStrings.dateTimeConstraintText": never; "i18nStrings.dateConstraintText": never; "i18nStrings.slashedDateTimeConstraintText": never; @@ -223,12 +225,15 @@ export interface I18nFormatArgTypes { "i18nStrings.loadingText": never; } "error-boundary": { - "i18nStrings.headerText"?: never; - "i18nStrings.descriptionText"?: { - hasFeedback: boolean; - Feedback: (chunks: React.ReactNode[]) => React.ReactNode; - }; - "i18nStrings.refreshActionText"?: never; + "i18nStrings.headerText": never; + "i18nStrings.descriptionText": { + "hasFeedback": string; + } + "i18nStrings.refreshActionText": never; + } + "features-notification-drawer": { + "i18nStrings.title": never; + "i18nStrings.viewAll": never; } "file-token-group": { "i18nStrings.limitShowFewer": never; @@ -315,6 +320,9 @@ export interface I18nFormatArgTypes { "pageNumber": string | number; } "ariaLabels.previousPageLabel": never; + "ariaLabels.jumpToPageButtonLabel": never; + "i18nStrings.jumpToPageInputLabel": never; + "i18nStrings.jumpToPageError": never; } "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": never; diff --git a/src/i18n/messages/all.ar.json b/src/i18n/messages/all.ar.json index 008e420b13..6e6ba3d443 100644 --- a/src/i18n/messages/all.ar.json +++ b/src/i18n/messages/all.ar.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "الصفحة التالية", "ariaLabels.pageLabel": "صفحة رقم {pageNumber} من إجمالي عدد الصفحات", - "ariaLabels.previousPageLabel": "الصفحة السابقة" + "ariaLabels.previousPageLabel": "الصفحة السابقة", + "ariaLabels.jumpToPageButtonLabel": "الانتقال إلى الصفحة", + "i18nStrings.jumpToPageInputLabel": "صفحة", + "i18nStrings.jumpToPageError": "الصفحة خارج نطاق الوصول. جارٍ عرض آخر صفحة متاحة." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "مقبض تغيير حجم اللوحة", diff --git a/src/i18n/messages/all.de.json b/src/i18n/messages/all.de.json index f7a9cef13a..b23c65fb69 100644 --- a/src/i18n/messages/all.de.json +++ b/src/i18n/messages/all.de.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Nächste Seite", "ariaLabels.pageLabel": "Seite {pageNumber} aller Seiten", - "ariaLabels.previousPageLabel": "Vorherige Seite" + "ariaLabels.previousPageLabel": "Vorherige Seite", + "ariaLabels.jumpToPageButtonLabel": "Zur Seite springen", + "i18nStrings.jumpToPageInputLabel": "Seite", + "i18nStrings.jumpToPageError": "Seite ist außerhalb des Bereichs. Zeigt die letzte verfügbare Seite an." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Größe des Panels ändern", diff --git a/src/i18n/messages/all.en-GB.json b/src/i18n/messages/all.en-GB.json index 1ad2c1996e..6339a55961 100644 --- a/src/i18n/messages/all.en-GB.json +++ b/src/i18n/messages/all.en-GB.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Next page", "ariaLabels.pageLabel": "Page {pageNumber} of all pages", - "ariaLabels.previousPageLabel": "Previous page" + "ariaLabels.previousPageLabel": "Previous page", + "ariaLabels.jumpToPageButtonLabel": "Jump to page", + "i18nStrings.jumpToPageInputLabel": "Page", + "i18nStrings.jumpToPageError": "Page out of range. Showing the last available page." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Panel resize handle", diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index b8f988720f..9585251aa1 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -158,6 +158,8 @@ "i18nStrings.endMonthLabel": "End month", "i18nStrings.endDateLabel": "End date", "i18nStrings.endTimeLabel": "End time", + "i18nStrings.datePlaceholder": "YYYY-MM-DD", + "i18nStrings.timePlaceholder": "hh:mm:ss", "i18nStrings.dateTimeConstraintText": "For date, use YYYY/MM/DD. For time, use 24 hr format.", "i18nStrings.dateConstraintText": "For date, use YYYY/MM/DD.", "i18nStrings.slashedDateTimeConstraintText": "For date, use YYYY/MM/DD. For time, use 24 hr format.", @@ -180,6 +182,10 @@ "i18nStrings.descriptionText": "{hasFeedback, select, true {Refresh to try again. We are tracking this issue, but you can share more information here.} other {Refresh to try again.}}", "i18nStrings.refreshActionText": "Refresh page" }, + "features-notification-drawer": { + "i18nStrings.title": "Latest feature releases", + "i18nStrings.viewAll": "View all feature releases" + }, "file-token-group": { "i18nStrings.limitShowFewer": "Show fewer", "i18nStrings.limitShowMore": "Show more", @@ -240,7 +246,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Next page", "ariaLabels.pageLabel": "Page {pageNumber} of all pages", - "ariaLabels.previousPageLabel": "Previous page" + "ariaLabels.previousPageLabel": "Previous page", + "ariaLabels.jumpToPageButtonLabel": "Jump to page", + "i18nStrings.jumpToPageInputLabel": "Page", + "i18nStrings.jumpToPageError": "Page out of range. Showing last available page." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Panel resize handle", @@ -467,4 +476,4 @@ "i18nStrings.nextButtonLoadingAnnouncement": "Loading next step", "i18nStrings.submitButtonLoadingAnnouncement": "Submitting form" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/all.es.json b/src/i18n/messages/all.es.json index 90c8f35248..9e5e5ba3c9 100644 --- a/src/i18n/messages/all.es.json +++ b/src/i18n/messages/all.es.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Página siguiente", "ariaLabels.pageLabel": "Página {pageNumber} de todas las páginas", - "ariaLabels.previousPageLabel": "Página anterior" + "ariaLabels.previousPageLabel": "Página anterior", + "ariaLabels.jumpToPageButtonLabel": "Ir a la página", + "i18nStrings.jumpToPageInputLabel": "Página", + "i18nStrings.jumpToPageError": "Página fuera de rango. Se muestra la última página disponible." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Controlador de cambio del tamaño del panel", diff --git a/src/i18n/messages/all.fr.json b/src/i18n/messages/all.fr.json index 6a41faff14..cc467d6942 100644 --- a/src/i18n/messages/all.fr.json +++ b/src/i18n/messages/all.fr.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Page suivante", "ariaLabels.pageLabel": "Page {pageNumber} de toutes les pages", - "ariaLabels.previousPageLabel": "Page précédente" + "ariaLabels.previousPageLabel": "Page précédente", + "ariaLabels.jumpToPageButtonLabel": "Aller à la page", + "i18nStrings.jumpToPageInputLabel": "Page", + "i18nStrings.jumpToPageError": "Page hors de portée. Affichage de la dernière page disponible." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Poignée de redimensionnement du panneau", diff --git a/src/i18n/messages/all.id.json b/src/i18n/messages/all.id.json index c34b7988d1..04300d28a4 100644 --- a/src/i18n/messages/all.id.json +++ b/src/i18n/messages/all.id.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Halaman berikutnya", "ariaLabels.pageLabel": "Halaman {pageNumber} dari semua halaman", - "ariaLabels.previousPageLabel": "Halaman sebelumnya" + "ariaLabels.previousPageLabel": "Halaman sebelumnya", + "ariaLabels.jumpToPageButtonLabel": "Langsung ke halaman", + "i18nStrings.jumpToPageInputLabel": "Halaman", + "i18nStrings.jumpToPageError": "Halaman di luar rentang. Menampilkan halaman terakhir yang tersedia." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Handel pengubahan ukuran panel", diff --git a/src/i18n/messages/all.it.json b/src/i18n/messages/all.it.json index a6515a5e71..59a260d0bf 100644 --- a/src/i18n/messages/all.it.json +++ b/src/i18n/messages/all.it.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Pagina successiva", "ariaLabels.pageLabel": "Pagina {pageNumber} di tutte le pagine", - "ariaLabels.previousPageLabel": "Pagina precedente" + "ariaLabels.previousPageLabel": "Pagina precedente", + "ariaLabels.jumpToPageButtonLabel": "Vai alla pagina", + "i18nStrings.jumpToPageInputLabel": "Pagina", + "i18nStrings.jumpToPageError": "Pagina fuori intervallo. Mostra l'ultima pagina disponibile." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Maniglia di ridimensionamento del pannello", diff --git a/src/i18n/messages/all.ja.json b/src/i18n/messages/all.ja.json index 65d9f9c68d..5b3a9a84a2 100644 --- a/src/i18n/messages/all.ja.json +++ b/src/i18n/messages/all.ja.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "次のページ", "ariaLabels.pageLabel": "全ページ中 {pageNumber} ページ", - "ariaLabels.previousPageLabel": "前のページ" + "ariaLabels.previousPageLabel": "前のページ", + "ariaLabels.jumpToPageButtonLabel": "ページに移動", + "i18nStrings.jumpToPageInputLabel": "ページ", + "i18nStrings.jumpToPageError": "ページが範囲外です。最後に利用可能なページを表示しています。" }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "パネルのサイズ変更ハンドル", diff --git a/src/i18n/messages/all.ko.json b/src/i18n/messages/all.ko.json index 4e125fa177..3843034e7e 100644 --- a/src/i18n/messages/all.ko.json +++ b/src/i18n/messages/all.ko.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "다음 페이지", "ariaLabels.pageLabel": "전체 페이지 중 {pageNumber}페이지", - "ariaLabels.previousPageLabel": "이전 페이지" + "ariaLabels.previousPageLabel": "이전 페이지", + "ariaLabels.jumpToPageButtonLabel": "페이지로 이동", + "i18nStrings.jumpToPageInputLabel": "페이지", + "i18nStrings.jumpToPageError": "페이지가 범위를 벗어났습니다. 마지막으로 사용 가능한 페이지를 표시합니다." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "패널 크기 조정 핸들", diff --git a/src/i18n/messages/all.pt-BR.json b/src/i18n/messages/all.pt-BR.json index 050ae67cb7..f873eb481b 100644 --- a/src/i18n/messages/all.pt-BR.json +++ b/src/i18n/messages/all.pt-BR.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Próxima página", "ariaLabels.pageLabel": "Página {pageNumber} de todas as páginas", - "ariaLabels.previousPageLabel": "Página anterior" + "ariaLabels.previousPageLabel": "Página anterior", + "ariaLabels.jumpToPageButtonLabel": "Ir para a página", + "i18nStrings.jumpToPageInputLabel": "Página", + "i18nStrings.jumpToPageError": "Página fora do alcance. Mostrando a última página disponível." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Alça de redimensionamento do painel", diff --git a/src/i18n/messages/all.tr.json b/src/i18n/messages/all.tr.json index 7fb8a438bc..8d7cf0dc8b 100644 --- a/src/i18n/messages/all.tr.json +++ b/src/i18n/messages/all.tr.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "Sonraki sayfa", "ariaLabels.pageLabel": "Sayfa {pageNumber}/tüm sayfalar", - "ariaLabels.previousPageLabel": "Önceki sayfa" + "ariaLabels.previousPageLabel": "Önceki sayfa", + "ariaLabels.jumpToPageButtonLabel": "Sayfaya atla", + "i18nStrings.jumpToPageInputLabel": "Sayfa", + "i18nStrings.jumpToPageError": "Aralık dışı sayfa. Mevcut son sayfa gösteriliyor." }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "Panel yeniden boyutlandırma tutamacı", diff --git a/src/i18n/messages/all.zh-CN.json b/src/i18n/messages/all.zh-CN.json index a1a50aec6a..13c8b83a19 100644 --- a/src/i18n/messages/all.zh-CN.json +++ b/src/i18n/messages/all.zh-CN.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "下一页", "ariaLabels.pageLabel": "所有页面中的第 {pageNumber} 页", - "ariaLabels.previousPageLabel": "上一页" + "ariaLabels.previousPageLabel": "上一页", + "ariaLabels.jumpToPageButtonLabel": "跳转到页面", + "i18nStrings.jumpToPageInputLabel": "页面", + "i18nStrings.jumpToPageError": "页面超出范围。显示最后一个可用页面。" }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "面板大小调整手柄", diff --git a/src/i18n/messages/all.zh-TW.json b/src/i18n/messages/all.zh-TW.json index a3e229d170..9e8de5b797 100644 --- a/src/i18n/messages/all.zh-TW.json +++ b/src/i18n/messages/all.zh-TW.json @@ -240,7 +240,10 @@ "pagination": { "ariaLabels.nextPageLabel": "下一頁", "ariaLabels.pageLabel": "所有頁面中的第 {pageNumber} 頁", - "ariaLabels.previousPageLabel": "上一頁" + "ariaLabels.previousPageLabel": "上一頁", + "ariaLabels.jumpToPageButtonLabel": "跳到頁面", + "i18nStrings.jumpToPageInputLabel": "頁面", + "i18nStrings.jumpToPageError": "頁面超出範圍。顯示最後一個可用頁面。" }, "panel-resize-handle": { "i18nStrings.resizeHandleAriaLabel": "面板調整大小控點", diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 7c798d294e..26a90bbe60 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -52,6 +52,7 @@ export interface InternalInputProps __inheritFormFieldProps?: boolean; __injectAnalyticsComponentMetadata?: boolean; __skipNativeAttributesWarnings?: SkipWarnings; + __inlineLabelText?: string; } function InternalInput( @@ -93,6 +94,7 @@ function InternalInput( __inheritFormFieldProps, __injectAnalyticsComponentMetadata, __skipNativeAttributesWarnings, + __inlineLabelText, style, ...rest }: InternalInputProps, @@ -196,6 +198,18 @@ function InternalInput( }, }; + const mainInput = ( + + ); + return (
)} - + {__inlineLabelText ? ( +
+ +
{mainInput}
+
+ ) : ( + mainInput + )} {__rightIcon && ( @@ -301,3 +301,209 @@ describe('open-end pagination', () => { ); }); }); + +describe('jump to page', () => { + test('should render jump to page input and button when jumpToPage is provided', () => { + const { wrapper } = renderPagination(); + + expect(wrapper.findJumpToPageInput()).toBeTruthy(); + expect(wrapper.findJumpToPageButton()).toBeTruthy(); + }); + + test('should not render jump to page when jumpToPage is not provided', () => { + const { wrapper } = renderPagination(); + + expect(wrapper.findJumpToPageInput()).toBeNull(); + expect(wrapper.findJumpToPageButton()).toBeNull(); + }); + + test('should show loading state on jump to page button', () => { + const { wrapper } = renderPagination( + + ); + + expect(wrapper.findJumpToPageButton()!.findLoadingIndicator()).toBeTruthy(); + }); + + test('should disable jump to page button when input is empty', () => { + const { wrapper } = renderPagination(); + + wrapper.findJumpToPageInput()!.setInputValue(''); + expect(wrapper.findJumpToPageButton()!.getElement()).toBeDisabled(); + }); + + test('should disable jump to page button when input equals current page', () => { + const { wrapper } = renderPagination(); + + expect(wrapper.findJumpToPageButton()!.getElement()).toBeDisabled(); + }); + + test('should set min attribute to 1 on input', () => { + const { wrapper } = renderPagination(); + + expect(wrapper.findJumpToPageInput()!.findNativeInput().getElement()).toHaveAttribute('min', '1'); + }); + + test('should set max attribute to pagesCount in closed mode', () => { + const { wrapper } = renderPagination(); + + expect(wrapper.findJumpToPageInput()!.findNativeInput().getElement()).toHaveAttribute('max', '10'); + }); + + test('should not set max attribute in open-end mode', () => { + const { wrapper } = renderPagination( + + ); + + expect(wrapper.findJumpToPageInput()!.findNativeInput().getElement()).not.toHaveAttribute('max'); + }); + + describe('closed mode validation', () => { + test('should navigate to valid page in range', () => { + const onChange = jest.fn(); + const { wrapper } = renderPagination( + + ); + + wrapper.findJumpToPageInput()!.setInputValue('5'); + wrapper.findJumpToPageButton()!.click(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { currentPageIndex: 5 }, + }) + ); + }); + + test('should navigate to first page when input is less than 1', () => { + const onChange = jest.fn(); + const { wrapper } = renderPagination( + + ); + + wrapper.findJumpToPageInput()!.setInputValue('0'); + wrapper.findJumpToPageButton()!.click(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { currentPageIndex: 1 }, + }) + ); + expect(wrapper.findJumpToPageInput()!.findNativeInput().getElement()).toHaveValue(1); + }); + + test('should show error and navigate to last page when input exceeds pagesCount', () => { + const onChange = jest.fn(); + const { wrapper } = renderPagination( + + ); + + wrapper.findJumpToPageInput()!.setInputValue('15'); + wrapper.findJumpToPageButton()!.click(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { currentPageIndex: 10 }, + }) + ); + expect(wrapper.findJumpToPageInput()!.findNativeInput().getElement()).toHaveValue(10); + }); + }); + + describe('open-end mode', () => { + test('should navigate to any page without validation', () => { + const onChange = jest.fn(); + const { wrapper } = renderPagination( + + ); + + wrapper.findJumpToPageInput()!.setInputValue('100'); + wrapper.findJumpToPageButton()!.click(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { currentPageIndex: 100 }, + }) + ); + }); + }); + + describe('error handling via ref', () => { + test('should show error popover when setError is called', () => { + const ref = React.createRef(); + const { wrapper, rerender } = renderPagination( + + ); + + ref.current?.setError(true); + rerender(); + + // Error popover should be visible + expect(wrapper.findJumpToPagePopover()).not.toBeNull(); + }); + + test('should clear error when user types in input', () => { + const ref = React.createRef(); + const { wrapper, rerender } = renderPagination( + + ); + + ref.current?.setError(true); + rerender(); + + wrapper.findJumpToPageInput()!.setInputValue('5'); + + // Error should be cleared - popover should not be visible + expect(wrapper.findJumpToPagePopover()).toBeNull(); + }); + + test('should clear error when user navigates successfully', () => { + const ref = React.createRef(); + const onChange = jest.fn(); + const { wrapper, rerender } = renderPagination( + + ); + + ref.current?.setError(true); + rerender(); + + wrapper.findPageNumberByIndex(3)!.click(); + + expect(onChange).toHaveBeenCalled(); + // Error should be cleared + expect(wrapper.findJumpToPagePopover()).toBeNull(); + }); + }); + + describe('keyboard navigation', () => { + test('should submit on Enter key', () => { + const onChange = jest.fn(); + const { wrapper } = renderPagination( + + ); + + const input = wrapper.findJumpToPageInput()!.findNativeInput().getElement(); + wrapper.findJumpToPageInput()!.setInputValue('7'); + fireEvent.keyDown(input, { keyCode: 13, key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { currentPageIndex: 7 }, + }) + ); + }); + + test('should not submit on Enter when input equals current page', () => { + const onChange = jest.fn(); + const { wrapper } = renderPagination( + + ); + + const input = wrapper.findJumpToPageInput()!.findNativeInput().getElement(); + wrapper.findJumpToPageInput()!.setInputValue('5'); + fireEvent.keyDown(input, { keyCode: 13, key: 'Enter' }); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pagination/index.tsx b/src/pagination/index.tsx index 4cd09564db..2ef99ab558 100644 --- a/src/pagination/index.tsx +++ b/src/pagination/index.tsx @@ -13,12 +13,13 @@ import InternalPagination from './internal'; export { PaginationProps }; -export default function Pagination(props: PaginationProps) { +const Pagination = React.forwardRef((props, ref) => { const baseComponentProps = useBaseComponent('Pagination', { props: { openEnd: props.openEnd } }); return ( ); -} +}); applyDisplayName(Pagination, 'Pagination'); + +export default Pagination; diff --git a/src/pagination/interfaces.ts b/src/pagination/interfaces.ts index 4b736a2061..17aae7db40 100644 --- a/src/pagination/interfaces.ts +++ b/src/pagination/interfaces.ts @@ -48,6 +48,7 @@ export interface PaginationProps { * @i18n */ ariaLabels?: PaginationProps.Labels; + i18nStrings?: PaginationProps.I18nStrings; /** * Called when a user interaction causes a pagination change. The event `detail` contains the new `currentPageIndex`. @@ -68,6 +69,10 @@ export interface PaginationProps { * * `requestedPageIndex` (integer) - The index of the requested page. */ onNextPageClick?: NonCancelableEventHandler; + /** + * Jump to page configuration + */ + jumpToPage?: PaginationProps.JumpToPageProps; } export namespace PaginationProps { @@ -76,6 +81,16 @@ export namespace PaginationProps { paginationLabel?: string; previousPageLabel?: string; pageLabel?: (pageNumber: number) => string; + jumpToPageButton?: string; + } + + export interface I18nStrings { + jumpToPageError?: string; + jumpToPageLabel?: string; + } + + export interface ChangeDetail { + currentPageIndex: number; } export interface PageClickDetail { @@ -83,7 +98,17 @@ export namespace PaginationProps { requestedPageIndex: number; } - export interface ChangeDetail { - currentPageIndex: number; + export interface JumpToPageProps { + /** + * User controlled loading state when jump to page callback is executing + */ + loading?: boolean; + } + + export interface Ref { + /** + * Set error state for jump to page. Component will auto-clear when user types or navigates. + */ + setError: (hasError: boolean) => void; } } diff --git a/src/pagination/internal.tsx b/src/pagination/internal.tsx index ec6ecad9b3..c3591ced87 100644 --- a/src/pagination/internal.tsx +++ b/src/pagination/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useState } from 'react'; import clsx from 'clsx'; import { @@ -8,12 +8,17 @@ import { getAnalyticsMetadataAttribute, } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; +import InternalButton from '../button/internal'; import { useInternalI18n } from '../i18n/context'; import InternalIcon from '../icon/internal'; +import { BaseChangeDetail } from '../input/interfaces'; +import InternalInput from '../input/internal'; import { getBaseProps } from '../internal/base-component'; import { useTableComponentsContext } from '../internal/context/table-component-context'; -import { fireNonCancelableEvent } from '../internal/events'; +import { fireNonCancelableEvent, NonCancelableCustomEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import InternalPopover from '../popover/internal'; +import InternalSpaceBetween from '../space-between/internal'; import { GeneratedAnalyticsMetadataPaginationClick } from './analytics-metadata/interfaces'; import { PaginationProps } from './interfaces'; import { getPaginationState, range } from './utils'; @@ -24,9 +29,15 @@ const defaultAriaLabels: Required = { nextPageLabel: '', paginationLabel: '', previousPageLabel: '', + jumpToPageButton: '', pageLabel: pageNumber => `${pageNumber}`, }; +const defaultI18nStrings: Required = { + jumpToPageLabel: '', + jumpToPageError: '', +}; + interface PageButtonProps { className?: string; ariaLabel: string; @@ -100,126 +111,251 @@ function PageNumber({ pageIndex, ...rest }: PageButtonProps) { } type InternalPaginationProps = PaginationProps & InternalBaseComponentProps; -export default function InternalPagination({ - openEnd, - currentPageIndex, - ariaLabels, - pagesCount, - disabled, - onChange, - onNextPageClick, - onPreviousPageClick, - __internalRootRef, - ...rest -}: InternalPaginationProps) { - const baseProps = getBaseProps(rest); - const { leftDots, leftIndex, rightIndex, rightDots } = getPaginationState(currentPageIndex, pagesCount, openEnd); - - const i18n = useInternalI18n('pagination'); - - const paginationLabel = ariaLabels?.paginationLabel; - const nextPageLabel = i18n('ariaLabels.nextPageLabel', ariaLabels?.nextPageLabel) ?? defaultAriaLabels.nextPageLabel; - const previousPageLabel = - i18n('ariaLabels.previousPageLabel', ariaLabels?.previousPageLabel) ?? defaultAriaLabels.previousPageLabel; - const pageNumberLabelFn = - i18n('ariaLabels.pageLabel', ariaLabels?.pageLabel, format => pageNumber => format({ pageNumber })) ?? - defaultAriaLabels.pageLabel; - - function handlePrevPageClick(requestedPageIndex: number) { - handlePageClick(requestedPageIndex); - fireNonCancelableEvent(onPreviousPageClick, { - requestedPageAvailable: true, - requestedPageIndex: requestedPageIndex, - }); - } - function handleNextPageClick(requestedPageIndex: number) { - handlePageClick(requestedPageIndex); - fireNonCancelableEvent(onNextPageClick, { - requestedPageAvailable: currentPageIndex < pagesCount, - requestedPageIndex: requestedPageIndex, - }); - } +const InternalPagination = React.forwardRef( + ( + { + openEnd, + currentPageIndex, + ariaLabels, + i18nStrings, + pagesCount, + disabled, + onChange, + onNextPageClick, + onPreviousPageClick, + __internalRootRef, + jumpToPage, + ...rest + }: InternalPaginationProps, + ref: React.Ref + ) => { + const baseProps = getBaseProps(rest); + const { leftDots, leftIndex, rightIndex, rightDots } = getPaginationState(currentPageIndex, pagesCount, openEnd); + const [jumpToPageValue, setJumpToPageValue] = useState(currentPageIndex?.toString()); + const prevLoadingRef = React.useRef(jumpToPage?.loading); + const [popoverVisible, setPopoverVisible] = useState(false); + const [hasError, setHasError] = useState(false); - function handlePageClick(requestedPageIndex: number) { - fireNonCancelableEvent(onChange, { currentPageIndex: requestedPageIndex }); - } + const i18n = useInternalI18n('pagination'); - const previousButtonDisabled = disabled || currentPageIndex === 1; - const nextButtonDisabled = disabled || (!openEnd && (pagesCount === 0 || currentPageIndex === pagesCount)); - const tableComponentContext = useTableComponentsContext(); - if (tableComponentContext?.paginationRef?.current) { - tableComponentContext.paginationRef.current.currentPageIndex = currentPageIndex; - tableComponentContext.paginationRef.current.totalPageCount = pagesCount; - tableComponentContext.paginationRef.current.openEnd = openEnd; - } - return ( -
    - - - - ({ + setError: (error: boolean) => setHasError(error), + })); + + // Sync input with currentPageIndex after loading completes + React.useEffect(() => { + if (prevLoadingRef.current && !jumpToPage?.loading) { + setJumpToPageValue(String(currentPageIndex)); + } + prevLoadingRef.current = jumpToPage?.loading; + }, [jumpToPage?.loading, currentPageIndex]); + + const paginationLabel = ariaLabels?.paginationLabel; + const nextPageLabel = i18n('ariaLabels.nextPageLabel', ariaLabels?.nextPageLabel); + const previousPageLabel = i18n('ariaLabels.previousPageLabel', ariaLabels?.previousPageLabel); + const pageNumberLabelFn = + i18n('ariaLabels.pageLabel', ariaLabels?.pageLabel, format => pageNumber => format({ pageNumber })) ?? + defaultAriaLabels.pageLabel; + const jumpToPageLabel = + i18n('i18nStrings.jumpToPageInputLabel', i18nStrings?.jumpToPageLabel) ?? defaultI18nStrings.jumpToPageLabel; + const jumpToPageButtonLabel = + i18n('ariaLabels.jumpToPageButtonLabel', ariaLabels?.jumpToPageButton) ?? defaultAriaLabels.jumpToPageButton; + const jumpToPageError = + i18n('i18nStrings.jumpToPageError', i18nStrings?.jumpToPageError) ?? defaultI18nStrings.jumpToPageError; + + function handlePrevPageClick(requestedPageIndex: number) { + handlePageClick(requestedPageIndex); + fireNonCancelableEvent(onPreviousPageClick, { + requestedPageAvailable: true, + requestedPageIndex: requestedPageIndex, + }); + } + + function handleNextPageClick(requestedPageIndex: number) { + handlePageClick(requestedPageIndex); + fireNonCancelableEvent(onNextPageClick, { + requestedPageAvailable: currentPageIndex < pagesCount, + requestedPageIndex: requestedPageIndex, + }); + } + + function handlePageClick(requestedPageIndex: number, errorState?: boolean) { + setJumpToPageValue(String(requestedPageIndex)); + setHasError(!!errorState); // Clear error on successful navigation + fireNonCancelableEvent(onChange, { currentPageIndex: requestedPageIndex }); + } + + function handleJumpToPageClick(requestedPageIndex: number) { + if (requestedPageIndex < 1) { + handlePageClick(1); + return; + } + + if (openEnd) { + // Open-end: always navigate, parent will handle async loading + handlePageClick(requestedPageIndex); + } else { + // Closed-end: validate range + if (requestedPageIndex >= 1 && requestedPageIndex <= pagesCount) { + handlePageClick(requestedPageIndex); + } else { + // Out of range - set error and navigate to last page + handlePageClick(pagesCount, true); + } + } + } + + // Auto-clear error when user types in the input + const handleInputChange = (e: NonCancelableCustomEvent) => { + setJumpToPageValue(e.detail.value); + if (hasError) { + setHasError(false); + } + }; + + // Show popover when error appears + React.useEffect(() => { + if (hasError) { + // For open-end, wait until loading completes + if (openEnd && jumpToPage?.loading) { + return; + } + setPopoverVisible(true); + } else { + setPopoverVisible(false); + } + }, [hasError, jumpToPage?.loading, openEnd]); + + const previousButtonDisabled = disabled || currentPageIndex === 1; + const nextButtonDisabled = disabled || (!openEnd && (pagesCount === 0 || currentPageIndex === pagesCount)); + const tableComponentContext = useTableComponentsContext(); + if (tableComponentContext?.paginationRef?.current) { + tableComponentContext.paginationRef.current.currentPageIndex = currentPageIndex; + tableComponentContext.paginationRef.current.totalPageCount = pagesCount; + tableComponentContext.paginationRef.current.openEnd = openEnd; + } + + const jumpToPageButton = ( + handleJumpToPageClick(Number(jumpToPageValue))} + disabled={!jumpToPageValue || Number(jumpToPageValue) === currentPageIndex} /> - {leftDots &&
  • ...
  • } - {range(leftIndex, rightIndex).map(pageIndex => ( - - ))} - {rightDots &&
  • ...
  • } - {!openEnd && pagesCount > 1 && ( + ); + + return ( +
      + + + - )} - - - -
    - ); -} + {leftDots &&
  • ...
  • } + {range(leftIndex, rightIndex).map(pageIndex => ( + + ))} + {rightDots &&
  • ...
  • } + {!openEnd && pagesCount > 1 && ( + + )} + + + + {jumpToPage && ( +
    + +
    + { + if (e.detail.keyCode === 13 && jumpToPageValue && Number(jumpToPageValue) !== currentPageIndex) { + handleJumpToPageClick(Number(jumpToPageValue)); + } + }} + /> +
    + {hasError ? ( + setPopoverVisible(detail.visible)} + > + {jumpToPageButton} + + ) : ( + jumpToPageButton + )} +
    +
    + )} +
+ ); + } +); + +export default InternalPagination; diff --git a/src/pagination/styles.scss b/src/pagination/styles.scss index 31095a9be2..576159e3df 100644 --- a/src/pagination/styles.scss +++ b/src/pagination/styles.scss @@ -13,6 +13,7 @@ flex-direction: row; flex-wrap: wrap; box-sizing: border-box; + align-items: center; //reset base styles for ul padding-inline-start: 0; margin-block: 0; @@ -78,6 +79,20 @@ } } +.jump-to-page { + border-inline-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + margin-inline-start: awsui.$space-xs; + padding-inline-start: awsui.$space-xs; + padding-inline-start: 15px; + + &-input { + max-inline-size: 87px; + margin-block-start: -0.6em; + overflow: visible; + } +} + .dots { color: awsui.$color-text-interactive-default; } diff --git a/src/popover/internal.tsx b/src/popover/internal.tsx index 0c866d995e..f290579848 100644 --- a/src/popover/internal.tsx +++ b/src/popover/internal.tsx @@ -29,6 +29,8 @@ export interface InternalPopoverProps extends Omit; } export default React.forwardRef(InternalPopover); @@ -53,6 +55,8 @@ function InternalPopover( __onOpen, __internalRootRef, __closeAnalyticsAction, + visible: controlledVisible, + onVisibleChange, ...restProps }: InternalPopoverProps, @@ -65,7 +69,20 @@ function InternalPopover( const i18n = useInternalI18n('popover'); const dismissAriaLabel = i18n('dismissAriaLabel', restProps.dismissAriaLabel); - const [visible, setVisible] = useState(false); + const [internalVisible, setInternalVisible] = useState(false); + const isControlled = controlledVisible !== undefined; + const visible = isControlled ? controlledVisible : internalVisible; + + const updateVisible = useCallback( + (newVisible: boolean) => { + if (isControlled) { + fireNonCancelableEvent(onVisibleChange, { visible: newVisible }); + } else { + setInternalVisible(newVisible); + } + }, + [isControlled, onVisibleChange] + ); const focusTrigger = useCallback(() => { if (['text', 'text-inline'].includes(triggerType)) { @@ -77,13 +94,13 @@ function InternalPopover( const onTriggerClick = useCallback(() => { fireNonCancelableEvent(__onOpen); - setVisible(true); - }, [__onOpen]); + updateVisible(true); + }, [__onOpen, updateVisible]); const onDismiss = useCallback(() => { - setVisible(false); + updateVisible(false); focusTrigger(); - }, [focusTrigger]); + }, [focusTrigger, updateVisible]); const onTriggerKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -93,21 +110,25 @@ function InternalPopover( event.stopPropagation(); } if (isTabKey || isEscapeKey) { - setVisible(false); + updateVisible(false); } }, - [visible] + [visible, updateVisible] ); - useImperativeHandle(ref, () => ({ - dismiss: () => { - setVisible(false); - }, - focus: () => { - setVisible(false); - focusTrigger(); - }, - })); + useImperativeHandle( + ref, + () => ({ + dismiss: () => { + updateVisible(false); + }, + focus: () => { + updateVisible(false); + focusTrigger(); + }, + }), + [updateVisible, focusTrigger] + ); const clickFrameId = useRef(null); useEffect(() => { @@ -119,7 +140,7 @@ function InternalPopover( const onDocumentClick = () => { // Dismiss popover unless there was a click inside within the last animation frame. if (clickFrameId.current === null) { - setVisible(false); + updateVisible(false); } }; @@ -128,7 +149,7 @@ function InternalPopover( return () => { document.removeEventListener('mousedown', onDocumentClick); }; - }, []); + }, [updateVisible]); const popoverClasses = usePortalModeClasses(triggerRef, { resetVisualContext: true }); diff --git a/src/select/parts/styles.scss b/src/select/parts/styles.scss index 805b3344bd..8257df5bb1 100644 --- a/src/select/parts/styles.scss +++ b/src/select/parts/styles.scss @@ -5,6 +5,7 @@ @use '../../internal/styles' as styles; @use '../../internal/styles/tokens' as awsui; +@use '../../internal/styles/forms/mixins' as forms; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; .placeholder { @@ -12,7 +13,6 @@ } $checkbox-size: awsui.$size-control; -$inlineLabel-border-radius: 2px; .item { display: flex; @@ -100,35 +100,17 @@ $inlineLabel-border-radius: 2px; } .inline-label-trigger-wrapper { - margin-block-start: -7px; + @include forms.inline-label-trigger-wrapper; } .inline-label-wrapper { - margin-block-start: calc(awsui.$space-scaled-xs * -1); + @include forms.inline-label-wrapper; } .inline-label { - background: linear-gradient(to bottom, awsui.$color-background-layout-main, awsui.$color-background-input-default); - border-start-start-radius: $inlineLabel-border-radius; - border-start-end-radius: $inlineLabel-border-radius; - border-end-start-radius: $inlineLabel-border-radius; - border-end-end-radius: $inlineLabel-border-radius; - box-sizing: border-box; - display: inline-block; - color: awsui.$color-text-form-label; - font-weight: awsui.$font-display-label-weight; - font-size: awsui.$font-size-body-s; - line-height: 14px; - letter-spacing: awsui.$letter-spacing-body-s; - position: relative; - inset-inline-start: calc(awsui.$border-width-field + awsui.$space-field-horizontal - awsui.$space-scaled-xxs); - margin-block-start: awsui.$space-scaled-xs; - padding-block-end: 2px; - padding-inline: awsui.$space-scaled-xxs; - max-inline-size: calc(100% - (2 * awsui.$space-field-horizontal)); - z-index: 1; + @include forms.inline-label; &-disabled { - background: linear-gradient(to bottom, awsui.$color-background-layout-main, awsui.$color-background-input-disabled); + @include forms.inline-label-disabled; } } diff --git a/src/test-utils/dom/pagination/index.ts b/src/test-utils/dom/pagination/index.ts index cacf371836..58416472ae 100644 --- a/src/test-utils/dom/pagination/index.ts +++ b/src/test-utils/dom/pagination/index.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { ComponentWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import ButtonWrapper from '../button'; +import InputWrapper from '../input'; +import PopoverWrapper from '../popover'; + import styles from '../../../pagination/styles.selectors.js'; export default class PaginationWrapper extends ComponentWrapper { @@ -34,6 +38,28 @@ export default class PaginationWrapper extends ComponentWrapper { return this.find(`li:last-child .${styles.button}`)!; } + /** + * Returns the jump to page input field. + */ + findJumpToPageInput(): InputWrapper | null { + return this.findComponent(`.${styles['jump-to-page-input']}`, InputWrapper); + } + + /** + * Returns the jump to page submit button. + */ + findJumpToPageButton(): ButtonWrapper | null { + const jumpToPageContainer = this.findByClassName(styles['jump-to-page']); + return jumpToPageContainer ? jumpToPageContainer.findComponent('button', ButtonWrapper) : null; + } + + /** + * Returns the error popover for jump to page. + */ + findJumpToPagePopover(): PopoverWrapper | null { + return this.findComponent(`.${PopoverWrapper.rootSelector}`, PopoverWrapper); + } + @usesDom isDisabled(): boolean { return this.element.classList.contains(styles['root-disabled']);