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 a88b80e189..04ae252b00 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -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, @@ -34438,6 +34498,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": [], @@ -34511,6 +34598,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": [ { @@ -38734,88 +38903,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": [ { @@ -44950,6 +45037,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": [], @@ -45000,6 +45114,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": [ { @@ -47955,79 +48142,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/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']);