From ab579b48054db68aaebc9e39ffb948f19b063990 Mon Sep 17 00:00:00 2001 From: Michelle Nguyen Date: Mon, 5 Jan 2026 15:18:05 -0800 Subject: [PATCH 01/16] feat: implement pagination - jump to page --- .../test-utils-selectors.test.tsx.snap | 2 + src/pagination/__tests__/pagination.test.tsx | 210 +++++++++++++++++- src/pagination/index.tsx | 7 +- src/pagination/interfaces.ts | 29 ++- src/pagination/internal.tsx | 140 +++++++++++- src/pagination/styles.scss | 15 ++ src/test-utils/dom/pagination/index.ts | 26 +++ 7 files changed, 420 insertions(+), 9 deletions(-) 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/pagination/__tests__/pagination.test.tsx b/src/pagination/__tests__/pagination.test.tsx index b32e6e13c3..5ae1bc1546 100644 --- a/src/pagination/__tests__/pagination.test.tsx +++ b/src/pagination/__tests__/pagination.test.tsx @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import * as React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; -import Pagination from '../../../lib/components/pagination'; +import Pagination, { PaginationProps } from '../../../lib/components/pagination'; import createWrapper, { PaginationWrapper } from '../../../lib/components/test-utils/dom'; const getItemsContent = (wrapper: PaginationWrapper) => @@ -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.findPopover()).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.findPopover()).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.findPopover()).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..cdb47f3741 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..6bfedcd423 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 JumpToPageRef { + /** + * 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..29ec6d1b46 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,14 @@ const defaultAriaLabels: Required = { nextPageLabel: '', paginationLabel: '', previousPageLabel: '', + jumpToPageButton: '', pageLabel: pageNumber => `${pageNumber}`, }; +const defaultI18nStrings: Required = { + jumpToPageLabel: 'Page', + jumpToPageError: 'Page out of range. Showing last available page.', +}; interface PageButtonProps { className?: string; ariaLabel: string; @@ -99,24 +109,47 @@ function PageNumber({ pageIndex, ...rest }: PageButtonProps) { ); } -type InternalPaginationProps = PaginationProps & InternalBaseComponentProps; +type InternalPaginationProps = PaginationProps & + InternalBaseComponentProps & { + jumpToPageRef?: React.Ref; + }; export default function InternalPagination({ openEnd, currentPageIndex, ariaLabels, + i18nStrings, pagesCount, disabled, onChange, onNextPageClick, onPreviousPageClick, __internalRootRef, + jumpToPage, + jumpToPageRef, ...rest }: InternalPaginationProps) { 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); const i18n = useInternalI18n('pagination'); + // Expose setError function via ref + React.useImperativeHandle(jumpToPageRef, () => ({ + 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) ?? defaultAriaLabels.nextPageLabel; const previousPageLabel = @@ -124,6 +157,9 @@ export default function InternalPagination({ const pageNumberLabelFn = i18n('ariaLabels.pageLabel', ariaLabels?.pageLabel, format => pageNumber => format({ pageNumber })) ?? defaultAriaLabels.pageLabel; + const jumpToPageLabel = i18nStrings?.jumpToPageLabel ?? defaultI18nStrings.jumpToPageLabel; + const jumpToPageButtonLabel = ariaLabels?.jumpToPageButton ?? defaultAriaLabels.jumpToPageButton; + const jumpToPageError = i18nStrings?.jumpToPageError ?? defaultI18nStrings.jumpToPageError; function handlePrevPageClick(requestedPageIndex: number) { handlePageClick(requestedPageIndex); @@ -142,9 +178,57 @@ export default function InternalPagination({ } function handlePageClick(requestedPageIndex: number) { + setJumpToPageValue(String(requestedPageIndex)); + setHasError(false); // Clear error on successful navigation fireNonCancelableEvent(onChange, { currentPageIndex: requestedPageIndex }); } + function handleJumpToPageClick(requestedPageIndex: number) { + if (requestedPageIndex < 1) { + // Out of range lower bound - navigate to first page + setJumpToPageValue('1'); + setHasError(false); + fireNonCancelableEvent(onChange, { currentPageIndex: 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 + setHasError(true); + setJumpToPageValue(String(pagesCount)); + fireNonCancelableEvent(onChange, { currentPageIndex: pagesCount }); + } + } + } + + // 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(); @@ -153,6 +237,20 @@ export default function InternalPagination({ tableComponentContext.paginationRef.current.totalPageCount = pagesCount; tableComponentContext.paginationRef.current.openEnd = openEnd; } + + const renderJumpToPageButton = () => { + return ( + handleJumpToPageClick(Number(jumpToPageValue))} + disabled={!jumpToPageValue || Number(jumpToPageValue) === currentPageIndex} + /> + ); + }; + return (
    + {jumpToPage && ( +
    + +
    + { + if (e.detail.keyCode === 13 && jumpToPageValue && Number(jumpToPageValue) !== currentPageIndex) { + handleJumpToPageClick(Number(jumpToPageValue)); + } + }} + /> +
    + {hasError ? ( + setPopoverVisible(detail.visible)} + > + {renderJumpToPageButton()} + + ) : ( + renderJumpToPageButton() + )} +
    +
    + )}
); } 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/test-utils/dom/pagination/index.ts b/src/test-utils/dom/pagination/index.ts index cacf371836..00d7e119dc 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. + */ + findPopover(): PopoverWrapper | null { + return this.findComponent(`.${PopoverWrapper.rootSelector}`, PopoverWrapper); + } + @usesDom isDisabled(): boolean { return this.element.classList.contains(styles['root-disabled']); From 09ecdf65decc3c00a5083a718ecbfbf2c9bb1bbb Mon Sep 17 00:00:00 2001 From: Michelle Nguyen Date: Mon, 5 Jan 2026 15:35:29 -0800 Subject: [PATCH 02/16] feat: add input inline label and popover controlled visibility --- pages/table/jump-to-page-closed.page.tsx | 99 +++++++++++++++ pages/table/jump-to-page-open-end.page.tsx | 141 +++++++++++++++++++++ src/input/internal.tsx | 33 +++-- src/input/styles.scss | 32 +++++ src/popover/internal.tsx | 53 +++++--- 5 files changed, 333 insertions(+), 25 deletions(-) create mode 100644 pages/table/jump-to-page-closed.page.tsx create mode 100644 pages/table/jump-to-page-open-end.page.tsx 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..50542d7faa --- /dev/null +++ b/pages/table/jump-to-page-closed.page.tsx @@ -0,0 +1,99 @@ +// 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 Pagination from '~components/pagination'; +import Table from '~components/table'; + +import { generateItems, Instance } from './generate-data'; + +const allItems = generateItems(100); +const PAGE_SIZE = 10; + +export default function JumpToPageClosedExample() { + 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={{}} + /> + } + /> + ); +} 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..be7e91f2c4 --- /dev/null +++ b/pages/table/jump-to-page-open-end.page.tsx @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Pagination 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 + +export default function JumpToPageOpenEndExample() { + const [currentPageIndex, setCurrentPageIndex] = useState(1); + const [loadedPages, setLoadedPages] = useState>({ 1: generateItems(10) }); + const [jumpToPageError, setJumpToPageError] = useState(false); + const [jumpToPageIsLoading, setJumpToPageIsLoading] = useState(false); + const [maxKnownPage, setMaxKnownPage] = useState(1); + const [openEnd, setOpenEnd] = useState(true); + + 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); + setJumpToPageError(false); + return; + } + // Otherwise, load the page + setJumpToPageIsLoading(true); + loadPage(requestedPage) + .then(items => { + setLoadedPages(prev => ({ ...prev, [requestedPage]: items })); + setCurrentPageIndex(requestedPage); + setMaxKnownPage(Math.max(maxKnownPage, requestedPage)); + setJumpToPageError(false); + setJumpToPageIsLoading(false); + }) + .catch((error: { message: string; maxPage?: number }) => { + const newMaxPage = error.maxPage || maxKnownPage; + setMaxKnownPage(newMaxPage); + setOpenEnd(false); + setJumpToPageError(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); + setJumpToPageError(true); + setJumpToPageIsLoading(false); + }); + }} + jumpToPage={{ + isLoading: jumpToPageIsLoading, + hasError: jumpToPageError, + }} + /> + } + /> + ); +} diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 7c798d294e..1cf90ff9dd 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 renderMainInput = () => ( + + ); + return (
)} - + {__inlineLabelText ? ( +
+ +
{renderMainInput()}
+
+ ) : ( + renderMainInput() + )} {__rightIcon && ( ; } 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(() => { From 7109f940354ee29991ebbb45cffbe234856d8b83 Mon Sep 17 00:00:00 2001 From: Michelle Nguyen Date: Mon, 5 Jan 2026 16:21:15 -0800 Subject: [PATCH 03/16] fix: update demo pages to use ref-based error handling --- pages/pagination/permutations.page.tsx | 1 + pages/table/jump-to-page-open-end.page.tsx | 16 +++++++--------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pages/pagination/permutations.page.tsx b/pages/pagination/permutations.page.tsx index 6a8db3b507..7a9b711c10 100644 --- a/pages/pagination/permutations.page.tsx +++ b/pages/pagination/permutations.page.tsx @@ -26,6 +26,7 @@ const permutations = createPermutations([ pagesCount: [15], openEnd: [true, false], ariaLabels: [paginationLabels], + jumpToPage: [undefined, { loading: false }, { loading: true }], }, ]); diff --git a/pages/table/jump-to-page-open-end.page.tsx b/pages/table/jump-to-page-open-end.page.tsx index be7e91f2c4..9d86ecae14 100644 --- a/pages/table/jump-to-page-open-end.page.tsx +++ b/pages/table/jump-to-page-open-end.page.tsx @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; -import Pagination from '~components/pagination'; +import Pagination, { PaginationProps } from '~components/pagination'; import Table from '~components/table'; import { generateItems, Instance } from './generate-data'; @@ -13,10 +13,10 @@ const TOTAL_ITEMS = 100; // Simulated server-side total export default function JumpToPageOpenEndExample() { const [currentPageIndex, setCurrentPageIndex] = useState(1); const [loadedPages, setLoadedPages] = useState>({ 1: generateItems(10) }); - const [jumpToPageError, setJumpToPageError] = useState(false); const [jumpToPageIsLoading, setJumpToPageIsLoading] = useState(false); const [maxKnownPage, setMaxKnownPage] = useState(1); const [openEnd, setOpenEnd] = useState(true); + const jumpToPageRef = useRef(null); const currentItems = loadedPages[currentPageIndex] || []; @@ -56,6 +56,7 @@ export default function JumpToPageOpenEndExample() { items={currentItems} pagination={ ({ ...prev, [requestedPage]: items })); setCurrentPageIndex(requestedPage); setMaxKnownPage(Math.max(maxKnownPage, requestedPage)); - setJumpToPageError(false); setJumpToPageIsLoading(false); }) .catch((error: { message: string; maxPage?: number }) => { const newMaxPage = error.maxPage || maxKnownPage; setMaxKnownPage(newMaxPage); setOpenEnd(false); - setJumpToPageError(true); + jumpToPageRef.current?.setError(true); // Load all pages from current to max const pagesToLoad = []; for (let i = 1; i <= newMaxPage; i++) { @@ -126,13 +125,12 @@ export default function JumpToPageOpenEndExample() { } // Reset to current page (undo the navigation that already happened) setCurrentPageIndex(currentPageIndex); - setJumpToPageError(true); + jumpToPageRef.current?.setError(true); setJumpToPageIsLoading(false); }); }} jumpToPage={{ - isLoading: jumpToPageIsLoading, - hasError: jumpToPageError, + loading: jumpToPageIsLoading, }} /> } From b22f6c56b2b1390b1280fddd42154efc5ff18a06 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Tue, 6 Jan 2026 16:27:23 +0100 Subject: [PATCH 04/16] fix: Fixes drag handle UAP buttons to never render outside viewport (#4155) --- ...drag-handle-uap-buttons-placement.page.tsx | 98 +++++++ .../__tests__/resizable-box.test.tsx | 6 +- .../__tests__/drag-handle-wrapper.test.tsx | 242 +++++++++++++++++- .../drag-handle-wrapper/direction-button.tsx | 15 +- .../components/drag-handle-wrapper/index.tsx | 79 +++--- .../drag-handle-wrapper/styles.scss | 28 ++ 6 files changed, 424 insertions(+), 44 deletions(-) create mode 100644 pages/drag-handle/drag-handle-uap-buttons-placement.page.tsx diff --git a/pages/drag-handle/drag-handle-uap-buttons-placement.page.tsx b/pages/drag-handle/drag-handle-uap-buttons-placement.page.tsx new file mode 100644 index 0000000000..5bda20a396 --- /dev/null +++ b/pages/drag-handle/drag-handle-uap-buttons-placement.page.tsx @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext } from 'react'; + +import { Box, Checkbox, SpaceBetween } from '~components'; +import DragHandle, { DragHandleProps } from '~components/internal/components/drag-handle'; +import { colorBackgroundStatusInfo } from '~design-tokens'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +type PageContext = React.Context< + AppContextType<{ + hideStart: boolean; + hideEnd: boolean; + hideTop: boolean; + hideBottom: boolean; + }> +>; + +export default function TestBoardItemButton() { + const { + urlParams: { hideStart = false, hideEnd = false, hideTop = false, hideBottom = false }, + setUrlParams, + } = useContext(AppContext as PageContext); + const dragHandleProps = (id: string): DragHandleProps & { 'data-testid': string } => ({ + variant: 'drag-indicator', + ariaLabel: id, + 'data-testid': id, + directions: { + 'inline-start': hideStart ? undefined : 'active', + 'inline-end': hideEnd ? undefined : 'active', + 'block-start': hideTop ? undefined : 'active', + 'block-end': hideBottom ? undefined : 'active', + }, + }); + return ( + + UAP buttons placement + + } + i18n={{}} + screenshotArea={{ disableAnimations: true }} + settings={ + + setUrlParams({ hideStart: detail.checked })}> + Hide start + + setUrlParams({ hideEnd: detail.checked })}> + Hide end + + setUrlParams({ hideTop: detail.checked })}> + Hide top + + setUrlParams({ hideBottom: detail.checked })}> + Hide bottom + + + } + > +
+ + + + + + + + + + + + +
+
+ ); +} + +function DragHandleWrapper({ inset, ...props }: DragHandleProps & { inset: string[] }) { + return ( +
+
+ +
+
+ ); +} diff --git a/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx b/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx index 612883c412..eb07da8a3e 100644 --- a/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx +++ b/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx @@ -7,7 +7,7 @@ import { ResizableBox, ResizeBoxProps } from '../../../../lib/components/code-ed import { PointerEventMock } from '../../../../lib/components/internal/utils/pointer-events-mock'; import dragHandleStyles from '../../../../lib/components/internal/components/drag-handle/styles.css.js'; -import dragHandleWrapperStyles from '../../../../lib/components/internal/components/drag-handle-wrapper/styles.css.js'; +import dragHandleTestStyles from '../../../../lib/components/internal/components/drag-handle-wrapper/test-classes/styles.css.js'; import styles from '../../../../lib/components/code-editor/resizable-box/styles.selectors.js'; const defaultProps: ResizeBoxProps = { @@ -26,9 +26,7 @@ function findHandle() { } function findDirectionButton(direction: 'block-start' | 'block-end') { - return document.querySelector( - `.${dragHandleWrapperStyles[`direction-button-wrapper-${direction}`]} .${dragHandleWrapperStyles['direction-button']}` - )!; + return document.querySelector(`.${dragHandleTestStyles[`direction-button-${direction}`]}`)!; } beforeAll(() => { diff --git a/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx b/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx index c16568a691..aa1062e24f 100644 --- a/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx +++ b/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx @@ -4,6 +4,8 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; +import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal'; + import DragHandleWrapper from '../../../../../lib/components/internal/components/drag-handle-wrapper'; import { Direction, @@ -12,12 +14,36 @@ import { import { PointerEventMock } from '../../../../../lib/components/internal/utils/pointer-events-mock'; import styles from '../../../../../lib/components/internal/components/drag-handle-wrapper/styles.css.js'; +import testUtilStyles from '../../../../../lib/components/internal/components/drag-handle-wrapper/test-classes/styles.css.js'; import tooltipStyles from '../../../../../lib/components/internal/components/tooltip/styles.css.js'; +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + getLogicalBoundingClientRect: jest.fn(), +})); + +const viewport = { width: 800, height: 600 }; +const size = { inlineSize: 50, blockSize: 50 }; +const position = (inlineStart: number, blockStart: number) => ({ + ...size, + insetInlineStart: inlineStart, + insetInlineEnd: inlineStart + size.inlineSize, + insetBlockStart: blockStart, + insetBlockEnd: blockStart + size.blockSize, +}); + beforeAll(() => { (window as any).PointerEvent ??= PointerEventMock; }); +// We mock window and rect dimensions as if the handle was rendered far from the screen edges to +// prevent forced position rendering. +beforeEach(() => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, viewport.height / 2)); + Object.defineProperty(window, 'innerWidth', { value: viewport.width, writable: true }); + Object.defineProperty(window, 'innerHeight', { value: viewport.height, writable: true }); +}); + afterEach(() => { delete document.body.dataset.awsuiFocusVisible; jest.restoreAllMocks(); @@ -25,15 +51,34 @@ afterEach(() => { function getDirectionButton(direction: Direction) { return document.querySelector( - `.${styles[`direction-button-wrapper-${direction}`]} .${styles['direction-button']}` + `.${styles[`direction-button-wrapper-${direction}`]} .${testUtilStyles[`direction-button-${direction}`]}` + ); +} + +function getRandomDirectionButton() { + const dir = (['block-start', 'block-end', 'inline-start', 'inline-end'] as const)[Math.floor(Math.random() * 4)]; + return document.querySelector( + `.${styles[`direction-button-wrapper-${dir}`]} .${testUtilStyles[`direction-button-${dir}`]}` + ); +} + +function getAnyForcedDirectionButton() { + return document.querySelector( + `.${styles['direction-button-wrapper-forced']} .${testUtilStyles['direction-button']}` + ); +} + +function getForcedDirectionButton(direction: Direction, forcedPosition: 'top' | 'bottom', forcedIndex: 0 | 1 | 2 | 3) { + return document.querySelector( + `.${styles['direction-button-wrapper-forced']}.${styles[`direction-button-wrapper-forced-${forcedPosition}-${forcedIndex}`]} .${testUtilStyles[`direction-button-${direction}`]}` ); } // Direction buttons get hidden via transition which doesn't end in JSDOM, so we use -// the "exiting", "exited" or "hidden" classname instead to verify it's hidden. +// the "exiting", "exit" or "hidden" classname instead to verify it's hidden. const DIRECTION_BUTTON_HIDDEN_CLASSES = [ styles['direction-button-wrapper-motion-exiting'], - styles['direction-button-wrapper-motion-exited'], + styles['direction-button-wrapper-motion-exit'], styles['direction-button-wrapper-hidden'], ]; @@ -598,7 +643,7 @@ test("doesn't call onDirectionClick when disabled direction button is pressed", expect(onDirectionClick).not.toHaveBeenCalled(); }); -describe('initialinitialShowButtons property', () => { +describe('initialShowButtons property', () => { test('shows direction buttons initially when initialShowButtons=true', () => { renderDragHandle({ directions: { 'block-start': 'active', 'block-end': 'active' }, @@ -703,3 +748,192 @@ describe('initialinitialShowButtons property', () => { }); }); }); + +describe('forced position behavior', () => { + const allDirections: Partial = { + directions: { 'block-start': 'active', 'block-end': 'active', 'inline-start': 'active', 'inline-end': 'active' }, + initialShowButtons: true, + }; + + test('shows UAP buttons normally when rendered with enough distance from the viewport edges', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, viewport.height / 2)); + + renderDragHandle(allDirections); + + expect(getDirectionButton('block-start')).not.toBe(null); + expect(getDirectionButton('block-end')).not.toBe(null); + expect(getDirectionButton('inline-start')).not.toBe(null); + expect(getDirectionButton('inline-end')).not.toBe(null); + expect(getAnyForcedDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column under the handle when rendered in the top', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, 25)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'bottom', 3)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'bottom', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'bottom', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'bottom', 0)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column under the handle when rendered in the top-left', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(25, 25)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'bottom', 3)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'bottom', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'bottom', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'bottom', 0)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column under the handle when rendered in the top-right', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width - 75, 25)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'bottom', 3)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'bottom', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'bottom', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'bottom', 0)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column above the handle when rendered on the left', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(25, viewport.height / 2)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'top', 0)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'top', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'top', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'top', 3)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column above the handle when rendered on the right', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width - 75, viewport.height / 2)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'top', 0)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'top', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'top', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'top', 3)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column above the handle when rendered in the bottom', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, viewport.height - 75)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'top', 0)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'top', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'top', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'top', 3)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays partial UAP buttons in one column under the handle when rendered in the top', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, 25)); + + renderDragHandle({ directions: { 'inline-start': 'active', 'block-start': 'active' }, initialShowButtons: true }); + + expect(getForcedDirectionButton('block-start', 'bottom', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'bottom', 0)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays partial UAP buttons in one column above the handle when rendered in the bottom', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, viewport.height - 75)); + + renderDragHandle({ directions: { 'inline-end': 'active', 'block-end': 'active' }, initialShowButtons: true }); + + expect(getForcedDirectionButton('block-end', 'top', 0)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'top', 1)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column above the handle when there is enough space', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(25, 100)); + + renderDragHandle({ directions: { 'inline-start': 'active', 'block-end': 'active' }, initialShowButtons: true }); + + expect(getForcedDirectionButton('block-end', 'top', 0)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'top', 1)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('displays UAP buttons in one column under the handle when there is not enough space above', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(25, 100)); + + renderDragHandle(allDirections); + + expect(getForcedDirectionButton('block-end', 'bottom', 3)).not.toBe(null); + expect(getForcedDirectionButton('block-start', 'bottom', 2)).not.toBe(null); + expect(getForcedDirectionButton('inline-end', 'bottom', 1)).not.toBe(null); + expect(getForcedDirectionButton('inline-start', 'bottom', 0)).not.toBe(null); + expect(getRandomDirectionButton()).toBe(null); + }); + + test('does not force column position when there is no conflict on the left', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(25, viewport.height / 2)); + + renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active', 'inline-end': 'active' }, + initialShowButtons: true, + }); + + expect(getAnyForcedDirectionButton()).toBe(null); + expect(getDirectionButton('block-start')).not.toBe(null); + expect(getDirectionButton('block-end')).not.toBe(null); + expect(getDirectionButton('inline-end')).not.toBe(null); + }); + + test('does not force column position when there is no conflict on the right', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width - 75, viewport.height / 2)); + + renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active', 'inline-start': 'active' }, + initialShowButtons: true, + }); + + expect(getAnyForcedDirectionButton()).toBe(null); + expect(getDirectionButton('block-start')).not.toBe(null); + expect(getDirectionButton('block-end')).not.toBe(null); + expect(getDirectionButton('inline-start')).not.toBe(null); + }); + + test('does not force column position when there is no conflict on the top', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, 25)); + + renderDragHandle({ + directions: { 'block-end': 'active', 'inline-start': 'active', 'inline-end': 'active' }, + initialShowButtons: true, + }); + + expect(getAnyForcedDirectionButton()).toBe(null); + expect(getDirectionButton('block-end')).not.toBe(null); + expect(getDirectionButton('inline-start')).not.toBe(null); + expect(getDirectionButton('inline-end')).not.toBe(null); + }); + + test('does not force column position when there is no conflict on the bottom', () => { + jest.mocked(getLogicalBoundingClientRect).mockReturnValue(position(viewport.width / 2, viewport.height - 75)); + + renderDragHandle({ + directions: { 'block-start': 'active', 'inline-start': 'active', 'inline-end': 'active' }, + initialShowButtons: true, + }); + + expect(getAnyForcedDirectionButton()).toBe(null); + expect(getDirectionButton('block-start')).not.toBe(null); + expect(getDirectionButton('inline-start')).not.toBe(null); + expect(getDirectionButton('inline-end')).not.toBe(null); + }); +}); diff --git a/src/internal/components/drag-handle-wrapper/direction-button.tsx b/src/internal/components/drag-handle-wrapper/direction-button.tsx index 609eb8091a..211f64dc6f 100644 --- a/src/internal/components/drag-handle-wrapper/direction-button.tsx +++ b/src/internal/components/drag-handle-wrapper/direction-button.tsx @@ -27,9 +27,18 @@ interface DirectionButtonProps { state: DirectionState; onClick: React.MouseEventHandler; show: boolean; + forcedPosition: null | 'top' | 'bottom'; + forcedIndex: number; } -export default function DirectionButton({ direction, state, show, onClick }: DirectionButtonProps) { +export default function DirectionButton({ + direction, + state, + show, + onClick, + forcedPosition, + forcedIndex, +}: DirectionButtonProps) { return ( {(transitionState, ref) => ( @@ -40,7 +49,9 @@ export default function DirectionButton({ direction, state, show, onClick }: Dir ref={ref} className={clsx( styles['direction-button-wrapper'], - styles[`direction-button-wrapper-${direction}`], + !forcedPosition && styles[`direction-button-wrapper-${direction}`], + forcedPosition && styles['direction-button-wrapper-forced'], + forcedPosition && styles[`direction-button-wrapper-forced-${forcedPosition}-${forcedIndex}`], transitionState === 'exited' && styles['direction-button-wrapper-hidden'], styles[`direction-button-wrapper-motion-${transitionState}`] )} diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx index e12c80c96c..943fce8316 100644 --- a/src/internal/components/drag-handle-wrapper/index.tsx +++ b/src/internal/components/drag-handle-wrapper/index.tsx @@ -1,19 +1,26 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { nodeContains } from '@cloudscape-design/component-toolkit/dom'; +import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal'; import Tooltip from '../tooltip'; import DirectionButton from './direction-button'; -import { DragHandleWrapperProps } from './interfaces'; +import { Direction, DragHandleWrapperProps } from './interfaces'; import PortalOverlay from './portal-overlay'; import styles from './styles.css.js'; import testUtilsStyles from './test-classes/styles.css.js'; +// The UAP buttons are forced to top/bottom position if the handle is close to the screen edge. +const FORCED_POSITION_PROXIMITY_PX = 50; +// Approximate UAP button size with margins to decide forced direction. +const UAP_BUTTON_SIZE_PX = 40; +const DIRECTIONS_ORDER: Direction[] = ['block-end', 'block-start', 'inline-end', 'inline-start']; + export default function DragHandleWrapper({ directions, tooltipText, @@ -157,7 +164,7 @@ export default function DragHandleWrapper({ setUncontrolledShowButtons(false); } else if (triggerMode === 'keyboard-activate' && (event.key === 'Enter' || event.key === ' ')) { // toggle buttons when Enter or space is pressed in 'keyboard-activate' triggerMode - setUncontrolledShowButtons(prevshowButtons => !prevshowButtons); + setUncontrolledShowButtons(prevShowButtons => !prevShowButtons); } else if ( event.key !== 'Alt' && event.key !== 'Control' && @@ -173,6 +180,28 @@ export default function DragHandleWrapper({ const showButtons = triggerMode === 'controlled' ? controlledShowButtons : uncontrolledShowButtons; + const [forcedPosition, setForcedPosition] = useState(null); + const directionsOrder = forcedPosition === 'bottom' ? [...DIRECTIONS_ORDER].reverse() : DIRECTIONS_ORDER; + const visibleDirections = directionsOrder.filter(dir => directions[dir]); + + useLayoutEffect(() => { + if (showButtons && dragHandleRef.current) { + const rect = getLogicalBoundingClientRect(dragHandleRef.current); + const conflicts = { + 'block-start': rect.insetBlockStart < FORCED_POSITION_PROXIMITY_PX, + 'block-end': window.innerHeight - rect.insetBlockEnd < FORCED_POSITION_PROXIMITY_PX, + 'inline-start': rect.insetInlineStart < FORCED_POSITION_PROXIMITY_PX, + 'inline-end': window.innerWidth - rect.insetInlineEnd < FORCED_POSITION_PROXIMITY_PX, + }; + if (visibleDirections.some(direction => conflicts[direction])) { + const hasEnoughSpaceAbove = rect.insetBlockStart > visibleDirections.length * UAP_BUTTON_SIZE_PX; + setForcedPosition(hasEnoughSpaceAbove ? 'top' : 'bottom'); + } else { + setForcedPosition(null); + } + } + }, [showButtons, visibleDirections]); + return ( <> {/* Wrapper for focus detection. The buttons are shown when any element inside this wrapper is @@ -209,37 +238,19 @@ export default function DragHandleWrapper({
- {directions['block-start'] && ( - onDirectionClick?.('block-start')} - /> - )} - {directions['block-end'] && ( - onDirectionClick?.('block-end')} - /> - )} - {directions['inline-start'] && ( - onDirectionClick?.('inline-start')} - /> - )} - {directions['inline-end'] && ( - onDirectionClick?.('inline-end')} - /> + {visibleDirections.map( + (direction, index) => + directions[direction] && ( + onDirectionClick?.(direction)} + forcedPosition={forcedPosition} + forcedIndex={index} + /> + ) )} diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss index d845c0dba6..b903138437 100644 --- a/src/internal/components/drag-handle-wrapper/styles.scss +++ b/src/internal/components/drag-handle-wrapper/styles.scss @@ -73,6 +73,34 @@ $direction-button-offset: awsui.$space-static-xxs; inset-block-start: calc(50% - $direction-button-wrapper-size / 2); } +.direction-button-wrapper-forced { + inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); +} +.direction-button-wrapper-forced-top-0 { + inset-block-start: calc(-1 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-top-1 { + inset-block-start: calc(-2 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-top-2 { + inset-block-start: calc(-3 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-top-3 { + inset-block-start: calc(-4 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-bottom-0 { + inset-block-start: calc(1 * $direction-button-wrapper-size - 50%); +} +.direction-button-wrapper-forced-bottom-1 { + inset-block-start: calc(2 * $direction-button-wrapper-size - 50%); +} +.direction-button-wrapper-forced-bottom-2 { + inset-block-start: calc(3 * $direction-button-wrapper-size - 50%); +} +.direction-button-wrapper-forced-bottom-3 { + inset-block-start: calc(4 * $direction-button-wrapper-size - 50%); +} + .direction-button { position: absolute; border-width: 0; From 94ac26cc0c24e28fefbc3847903aa36501294ac1 Mon Sep 17 00:00:00 2001 From: Amanuel Abiy Date: Tue, 6 Jan 2026 16:53:04 +0100 Subject: [PATCH 05/16] chore: switch build-tools to transition branch (#4138) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 952123f6f0..bd7240abfb 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@babel/core": "^7.23.7", "@babel/plugin-syntax-typescript": "^7.23.3", "@cloudscape-design/browser-test-tools": "^3.0.0", - "@cloudscape-design/build-tools": "^3.0.0", + "@cloudscape-design/build-tools": "github:cloudscape-design/build-tools#transition-main-do-not-edit", "@cloudscape-design/documenter": "^1.0.0", "@cloudscape-design/global-styles": "^1.0.0", "@cloudscape-design/jest-preset": "^2.0.0", From 6dbd763c24705f008e9046bb622bbcc49fcd9b73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:21:23 +0100 Subject: [PATCH 06/16] chore: Bump qs and express (#4145) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Andrei Zhaleznichenka --- package-lock.json | 46 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19b7fbdb38..5c92152aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9772,38 +9772,40 @@ "peer": true }, "node_modules/express": { - "version": "4.21.2", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -9834,6 +9836,22 @@ "dev": true, "license": "MIT" }, + "node_modules/express/node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/extend": { "version": "3.0.2", "dev": true, From 3950362f235bd82471e36f39044678ff9e441a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Sch=C3=B6ll?= Date: Wed, 7 Jan 2026 10:33:36 +0100 Subject: [PATCH 07/16] fix: wrap scrollToIndex in requestAnimationFrame (#4134) Co-authored-by: Andrei Zhaleznichenka --- src/select/parts/virtual-list.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/select/parts/virtual-list.tsx b/src/select/parts/virtual-list.tsx index b59589ac7d..93b611ef9d 100644 --- a/src/select/parts/virtual-list.tsx +++ b/src/select/parts/virtual-list.tsx @@ -74,7 +74,10 @@ const VirtualListOpen = forwardRef( menuEl: menuRefObject?.current, }); } else { - scrollToIndex(index); + // Fix for AWSUI-61506. Defer scroll to next frame to ensure + // virtual items are measured after re-render. When called from + // parent's useEffect, measurements may not be ready yet. + requestAnimationFrame(() => scrollToIndex(index)); } } previousHighlightedIndex.current = index; From 1bfe630dae5c2764c577737455b2d3bf15abcd7e Mon Sep 17 00:00:00 2001 From: Amanuel Abiy Date: Wed, 7 Jan 2026 16:54:01 +0100 Subject: [PATCH 08/16] chore: update build-tools dependency to use main branch (#4162) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd7240abfb..bca070986c 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@babel/core": "^7.23.7", "@babel/plugin-syntax-typescript": "^7.23.3", "@cloudscape-design/browser-test-tools": "^3.0.0", - "@cloudscape-design/build-tools": "github:cloudscape-design/build-tools#transition-main-do-not-edit", + "@cloudscape-design/build-tools": "github:cloudscape-design/build-tools#main", "@cloudscape-design/documenter": "^1.0.0", "@cloudscape-design/global-styles": "^1.0.0", "@cloudscape-design/jest-preset": "^2.0.0", From 90a4fea2aefa36d555d722498fd6750b0f7c600c Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Wed, 7 Jan 2026 17:35:01 +0100 Subject: [PATCH 09/16] fix: Fixes UAP buttons forced padding (#4171) --- src/internal/components/drag-handle-wrapper/styles.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss index b903138437..0461aa84d4 100644 --- a/src/internal/components/drag-handle-wrapper/styles.scss +++ b/src/internal/components/drag-handle-wrapper/styles.scss @@ -89,16 +89,16 @@ $direction-button-offset: awsui.$space-static-xxs; inset-block-start: calc(-4 * $direction-button-wrapper-size); } .direction-button-wrapper-forced-bottom-0 { - inset-block-start: calc(1 * $direction-button-wrapper-size - 50%); + inset-block-start: calc(1 * $direction-button-wrapper-size); } .direction-button-wrapper-forced-bottom-1 { - inset-block-start: calc(2 * $direction-button-wrapper-size - 50%); + inset-block-start: calc(2 * $direction-button-wrapper-size); } .direction-button-wrapper-forced-bottom-2 { - inset-block-start: calc(3 * $direction-button-wrapper-size - 50%); + inset-block-start: calc(3 * $direction-button-wrapper-size); } .direction-button-wrapper-forced-bottom-3 { - inset-block-start: calc(4 * $direction-button-wrapper-size - 50%); + inset-block-start: calc(4 * $direction-button-wrapper-size); } .direction-button { From def84b96f9a0566de757742376f79726b3427533 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Thu, 8 Jan 2026 12:30:02 +0100 Subject: [PATCH 10/16] fix: Fixes treeview offset for better items alignment (#4163) --- .../__snapshots__/themes.test.ts.snap | 16 ++--- .../__snapshots__/design-tokens.test.ts.snap | 66 +++++++++---------- src/tree-view/tree-item/styles.scss | 3 + src/tree-view/vertical-connector/styles.scss | 4 +- style-dictionary/visual-refresh/spacing.ts | 2 +- 5 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/__integ__/__snapshots__/themes.test.ts.snap b/src/__integ__/__snapshots__/themes.test.ts.snap index 346f6290a7..259280f69a 100644 --- a/src/__integ__/__snapshots__/themes.test.ts.snap +++ b/src/__integ__/__snapshots__/themes.test.ts.snap @@ -748,7 +748,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] = "space-tabs-content-top": "12px", "space-tabs-focus-outline-gutter": "0px", "space-tile-gutter": "16px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -1506,7 +1506,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = ` "space-tabs-content-top": "16px", "space-tabs-focus-outline-gutter": "0px", "space-tile-gutter": "24px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -2264,7 +2264,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = ` "space-tabs-content-top": "16px", "space-tabs-focus-outline-gutter": "0px", "space-tile-gutter": "24px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -3022,7 +3022,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion" "space-tabs-content-top": "16px", "space-tabs-focus-outline-gutter": "0px", "space-tile-gutter": "24px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -3780,7 +3780,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh" "space-tabs-content-top": "12px", "space-tabs-focus-outline-gutter": "-8px", "space-tile-gutter": "24px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -4538,7 +4538,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "space-tabs-content-top": "8px", "space-tabs-focus-outline-gutter": "-8px", "space-tile-gutter": "16px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -5296,7 +5296,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "space-tabs-content-top": "12px", "space-tabs-focus-outline-gutter": "-8px", "space-tile-gutter": "24px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", @@ -6054,7 +6054,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "space-tabs-content-top": "12px", "space-tabs-focus-outline-gutter": "-8px", "space-tile-gutter": "24px", - "space-tree-view-indentation": "20px", + "space-tree-view-indentation": "24px", "space-xl": "24px", "space-xs": "8px", "space-xxl": "32px", diff --git a/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap index 8fe3a4f509..838bd21c26 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design tokens artifacts Design tokens JSON for classic matches the snapshot: classic 1`] = ` { @@ -2677,8 +2677,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -5357,8 +5357,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -8037,8 +8037,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -10717,8 +10717,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -13397,8 +13397,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -16077,8 +16077,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -18757,8 +18757,8 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -21442,8 +21442,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -24122,8 +24122,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -26802,8 +26802,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -29482,8 +29482,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -32162,8 +32162,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -34842,8 +34842,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -37522,8 +37522,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -40202,8 +40202,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, @@ -42882,8 +42882,8 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "space-tree-view-indentation": { "$description": "The indentation of tree view items.", "$value": { - "comfortable": "20px", - "compact": "20px", + "comfortable": "24px", + "compact": "24px", }, }, }, diff --git a/src/tree-view/tree-item/styles.scss b/src/tree-view/tree-item/styles.scss index cfa16af6be..7d2286c7db 100644 --- a/src/tree-view/tree-item/styles.scss +++ b/src/tree-view/tree-item/styles.scss @@ -43,6 +43,9 @@ $item-toggle-column-width: 28px; justify-self: center; position: relative; inset-block-start: 2px; + // Compensate for toggle size difference compared to the normal icon size. + // This way, in trees with icons the toggles and icons are perfectly aligned. + inset-inline-start: -2px; } } diff --git a/src/tree-view/vertical-connector/styles.scss b/src/tree-view/vertical-connector/styles.scss index 934fa2449d..4e9a2bf52d 100644 --- a/src/tree-view/vertical-connector/styles.scss +++ b/src/tree-view/vertical-connector/styles.scss @@ -18,13 +18,13 @@ $connector-line-width: awsui.$border-divider-list-width; grid-column: 1; block-size: 100%; position: relative; - inset-inline-start: calc(($item-toggle-column-width - $connector-line-width) / 2); + inset-inline-start: calc(($item-toggle-column-width - $connector-line-width) / 2 - 2px); } &.absolute { position: absolute; inset: 0; inset-block-end: awsui.$space-scaled-xs; - inset-inline-start: calc(($item-toggle-column-width - $connector-line-width) / 2); + inset-inline-start: calc(($item-toggle-column-width - $connector-line-width) / 2 - 2px); } } diff --git a/style-dictionary/visual-refresh/spacing.ts b/style-dictionary/visual-refresh/spacing.ts index ec116eda71..18e97743f0 100644 --- a/style-dictionary/visual-refresh/spacing.ts +++ b/style-dictionary/visual-refresh/spacing.ts @@ -63,7 +63,7 @@ const tokens: StyleDictionary.SpacingDictionary = { spaceTableHeaderToolsBottom: '0px', spaceTableHeaderToolsFullPageBottom: '4px', spaceTableHorizontal: '{spaceContainerHorizontal}', - spaceTreeViewIndentation: '{spaceL}', + spaceTreeViewIndentation: '{spaceXl}', spaceTileGutter: { comfortable: '{spaceXl}', compact: '{spaceM}' }, spaceScaled2xNone: '{spaceNone}', From a1881da22d9ae6f49df69ccdea6f3804c1dc764e Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 8 Jan 2026 08:45:13 -0500 Subject: [PATCH 11/16] feat: add post-copy event handlers to CopyToClipboard (#4040) (#4118) --- .../__snapshots__/documenter.test.ts.snap | 39 +++- .../__tests__/copy-to-clipboard.test.tsx | 186 ++++++++++++++++++ src/copy-to-clipboard/interfaces.ts | 23 +++ src/copy-to-clipboard/internal.tsx | 6 + 4 files changed, 253 insertions(+), 1 deletion(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ff2491504c..2127661123 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -9757,7 +9757,44 @@ Note that the secondary header will not have a high-contrast treatement, even if exports[`Components definition for copy-to-clipboard matches the snapshot: copy-to-clipboard 1`] = ` { "dashCaseName": "copy-to-clipboard", - "events": [], + "events": [ + { + "cancelable": false, + "description": "Called when the copy operation fails. +The event \`detail\` contains the text that failed to copy.", + "detailInlineType": { + "name": "CopyToClipboardProps.CopyFailureDetail", + "properties": [ + { + "name": "text", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "CopyToClipboardProps.CopyFailureDetail", + "name": "onCopyFailure", + }, + { + "cancelable": false, + "description": "Called when the text is successfully copied to the clipboard. +The event \`detail\` contains the text that was copied.", + "detailInlineType": { + "name": "CopyToClipboardProps.CopySuccessDetail", + "properties": [ + { + "name": "text", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "CopyToClipboardProps.CopySuccessDetail", + "name": "onCopySuccess", + }, + ], "functions": [], "name": "CopyToClipboard", "properties": [ diff --git a/src/copy-to-clipboard/__tests__/copy-to-clipboard.test.tsx b/src/copy-to-clipboard/__tests__/copy-to-clipboard.test.tsx index e0a4e1f647..fc39d94636 100644 --- a/src/copy-to-clipboard/__tests__/copy-to-clipboard.test.tsx +++ b/src/copy-to-clipboard/__tests__/copy-to-clipboard.test.tsx @@ -283,4 +283,190 @@ describe('CopyToClipboard', () => { await waitFor(() => expect(wrapper.findStatusText()!.getElement().textContent).toBe('Copied to clipboard')); }); }); + + describe('onCopySuccess callback', () => { + test.each(['simple text', 'special chars: @#$%^&*()', 'unicode: 你好世界 🎉', 'multiline\ntext\nhere'])( + 'passes correct text to callback for various string types - %s', + async textToCopy => { + Object.assign(global.navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + + const onCopySuccess = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopySuccess).toHaveBeenCalledWith(expect.objectContaining({ detail: { text: textToCopy } })); + }); + } + ); + + test('invokes callback on successful copy', async () => { + const onCopySuccess = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopySuccess).toHaveBeenCalledTimes(1); + }); + }); + + test('callback receives correct text in detail object', async () => { + const onCopySuccess = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopySuccess).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { text: 'Text to copy' }, + }) + ); + }); + }); + + test('does not invoke callback on copy failure', async () => { + const onCopySuccess = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => + expect(wrapper.findStatusText()!.getElement().textContent).toBe('Failed to copy to clipboard') + ); + expect(onCopySuccess).not.toHaveBeenCalled(); + }); + + test('does not invoke callback when prop is undefined', async () => { + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + // Should not throw when callback is undefined + wrapper.findCopyButton().click(); + await waitFor(() => expect(wrapper.findStatusText()!.getElement().textContent).toBe('Copied to clipboard')); + }); + + test('does not invoke callback when component is disabled', async () => { + const onCopySuccess = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + // Wait a bit to ensure no async callback is triggered + await new Promise(resolve => setTimeout(resolve, 100)); + expect(onCopySuccess).not.toHaveBeenCalled(); + }); + }); + + describe('onCopyFailure callback', () => { + test.each(['simple text', 'special chars: @#$%^&*()', 'unicode: 你好世界 🎉', 'multiline\ntext\nhere'])( + 'passes correct text to callback for various string types - %s', + async textToCopy => { + Object.assign(global.navigator, { + clipboard: { writeText: jest.fn().mockRejectedValue(new Error('Copy failed')) }, + }); + + const onCopyFailure = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopyFailure).toHaveBeenCalledWith(expect.objectContaining({ detail: { text: textToCopy } })); + }); + } + ); + + test('invokes callback on copy failure', async () => { + const onCopyFailure = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopyFailure).toHaveBeenCalledTimes(1); + }); + }); + + test('invokes callback when Clipboard API is unavailable', async () => { + Object.assign(global.navigator, { clipboard: undefined }); + + const onCopyFailure = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopyFailure).toHaveBeenCalledTimes(1); + }); + }); + + test('callback receives correct text in detail object', async () => { + const onCopyFailure = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => { + expect(onCopyFailure).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { text: 'Text to copy with error' }, + }) + ); + }); + }); + + test('does not invoke callback on successful copy', async () => { + const onCopyFailure = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + await waitFor(() => expect(wrapper.findStatusText()!.getElement().textContent).toBe('Copied to clipboard')); + expect(onCopyFailure).not.toHaveBeenCalled(); + }); + + test('does not invoke callback when prop is undefined', async () => { + const { container } = render(); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + // Should not throw when callback is undefined + wrapper.findCopyButton().click(); + await waitFor(() => + expect(wrapper.findStatusText()!.getElement().textContent).toBe('Failed to copy to clipboard') + ); + }); + + test('does not invoke callback when component is disabled', async () => { + const onCopyFailure = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findCopyToClipboard()!; + + wrapper.findCopyButton().click(); + // Wait a bit to ensure no async callback is triggered + await new Promise(resolve => setTimeout(resolve, 100)); + expect(onCopyFailure).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/copy-to-clipboard/interfaces.ts b/src/copy-to-clipboard/interfaces.ts index 65d5ee3ca9..e341b7c195 100644 --- a/src/copy-to-clipboard/interfaces.ts +++ b/src/copy-to-clipboard/interfaces.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { BaseComponentProps } from '../internal/base-component'; +import { NonCancelableEventHandler } from '../internal/events'; export interface CopyToClipboardProps extends BaseComponentProps { /** Determines the general styling of the copy button as follows: @@ -64,8 +65,30 @@ export interface CopyToClipboardProps extends BaseComponentProps { * Applicable for all variants except inline. */ disabledReason?: string; + + /** + * Called when the text is successfully copied to the clipboard. + * The event `detail` contains the text that was copied. + */ + onCopySuccess?: NonCancelableEventHandler; + + /** + * Called when the copy operation fails. + * The event `detail` contains the text that failed to copy. + */ + onCopyFailure?: NonCancelableEventHandler; } export namespace CopyToClipboardProps { export type Variant = 'button' | 'icon' | 'inline'; + + export interface CopySuccessDetail { + /** The text that was copied to the clipboard */ + text: string; + } + + export interface CopyFailureDetail { + /** The text that failed to copy to the clipboard */ + text: string; + } } diff --git a/src/copy-to-clipboard/internal.tsx b/src/copy-to-clipboard/internal.tsx index 17dd126da9..097f68eb8c 100644 --- a/src/copy-to-clipboard/internal.tsx +++ b/src/copy-to-clipboard/internal.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import InternalButton from '../button/internal'; import { getBaseProps } from '../internal/base-component'; +import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import InternalPopover from '../popover/internal'; import InternalStatusIndicator from '../status-indicator/internal'; @@ -26,6 +27,8 @@ export default function InternalCopyToClipboard({ popoverRenderWithPortal, disabled, disabledReason, + onCopySuccess, + onCopyFailure, __internalRootRef, ...restProps }: InternalCopyToClipboardProps) { @@ -54,6 +57,7 @@ export default function InternalCopyToClipboard({ // The clipboard API is not available in insecure contexts. setStatus('error'); setStatusText(copyErrorText); + fireNonCancelableEvent(onCopyFailure, { text: textToCopy }); return; } @@ -62,10 +66,12 @@ export default function InternalCopyToClipboard({ .then(() => { setStatus('success'); setStatusText(copySuccessText); + fireNonCancelableEvent(onCopySuccess, { text: textToCopy }); }) .catch(() => { setStatus('error'); setStatusText(copyErrorText); + fireNonCancelableEvent(onCopyFailure, { text: textToCopy }); }); }; From 99cac325cdd0c5c3111caa16bba95fac466b7a71 Mon Sep 17 00:00:00 2001 From: Nathnael_D <109980176+NathanZlion@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:46:41 +0300 Subject: [PATCH 12/16] fix: Fix sticky state listener logic to listen size change (#4174) --- .../__tests__/use-sticky-footer.test.tsx | 90 ++++++++----------- src/drawer/use-sticky-footer.ts | 14 +-- 2 files changed, 40 insertions(+), 64 deletions(-) diff --git a/src/drawer/__tests__/use-sticky-footer.test.tsx b/src/drawer/__tests__/use-sticky-footer.test.tsx index 0577c9cb3a..2f78c84eec 100644 --- a/src/drawer/__tests__/use-sticky-footer.test.tsx +++ b/src/drawer/__tests__/use-sticky-footer.test.tsx @@ -1,11 +1,19 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { fireEvent } from '@testing-library/react'; +import { act } from '@testing-library/react'; + +import { ContainerQueryEntry } from '@cloudscape-design/component-toolkit'; +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; import { MINIMUM_SCROLLABLE_SPACE, useStickyFooter } from '../../../lib/components/drawer/use-sticky-footer'; import { renderHook } from '../../__tests__/render-hook'; +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + useResizeObserver: jest.fn(), +})); + function createElementWithHeight(tag: string, height = 0) { const element = document.createElement(tag); jest.spyOn(element, 'getBoundingClientRect').mockImplementation(() => ({ height }) as DOMRect); @@ -41,14 +49,21 @@ describe('useStickyFooter', () => { }); test('returns isSticky false when there is not enough scrollable space', () => { + const callbacks: ((entry: ContainerQueryEntry) => void)[] = []; + jest.mocked(useResizeObserver).mockImplementation((_target, cb) => { + callbacks.push(cb); + }); + footerElement = createElementWithHeight('div', 300); const drawerRef = { current: drawerElement }; const footerRef = { current: footerElement }; - const { result, rerender } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); + const { result } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); - rerender({}); + act(() => { + callbacks.forEach(cb => cb({} as ContainerQueryEntry)); + }); // Drawer: 400px, Footer: 300px → Scrollable: 100px (< 148px) expect(result.current.isSticky).toBe(false); @@ -85,40 +100,24 @@ describe('useStickyFooter', () => { expect(result.current.isSticky).toBe(true); }); - test('registers and removes resize event listener', () => { - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + test('updates sticky state when parent element resizes', () => { + const callbacks: ((entry: ContainerQueryEntry) => void)[] = []; + jest.mocked(useResizeObserver).mockImplementation((_target, cb) => { + callbacks.push(cb); + }); const drawerRef = { current: drawerElement }; const footerRef = { current: footerElement }; - const { unmount } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); - - expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); - }); - - test('updates sticky state on resize', () => { - const drawerRef = { current: drawerElement }; - const footerRef = { current: footerElement }; - - const { result, rerender } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); - - rerender({}); - + const { result } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); expect(result.current.isSticky).toBe(true); - // Change parent height to reduce scrollable space jest.spyOn(parentElement, 'getBoundingClientRect').mockImplementation(() => ({ height: 200 }) as DOMRect); - fireEvent.resize(window); - - rerender({}); + act(() => { + callbacks.forEach(cb => cb({} as ContainerQueryEntry)); + }); - // Effective: 200px, Footer: 100px → Scrollable: 100px (< 148px) expect(result.current.isSticky).toBe(false); }); @@ -138,15 +137,22 @@ describe('useStickyFooter', () => { }); test('handles one pixel below threshold', () => { + const callbacks: ((entry: ContainerQueryEntry) => void)[] = []; + jest.mocked(useResizeObserver).mockImplementation((_target, cb) => { + callbacks.push(cb); + }); + const footerHeight = 400 - MINIMUM_SCROLLABLE_SPACE + 1; footerElement = createElementWithHeight('div', footerHeight); const drawerRef = { current: drawerElement }; const footerRef = { current: footerElement }; - const { result, rerender } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); + const { result } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); - rerender({}); + act(() => { + callbacks.forEach(cb => cb({} as ContainerQueryEntry)); + }); // Drawer: 400px, Footer: 253px → Scrollable: 147px (< 148px) expect(result.current.isSticky).toBe(false); @@ -186,30 +192,6 @@ describe('useStickyFooter', () => { expect(result.current.isSticky).toBe(true); }); - test('updates state when properties change', () => { - const drawerRef = { current: drawerElement }; - const footerRef = { current: footerElement }; - - const { result, rerender } = renderHook(() => useStickyFooter({ drawerRef, footerRef })); - - rerender({}); - expect(result.current.isSticky).toBe(true); - - jest.spyOn(footerElement, 'getBoundingClientRect').mockImplementation(() => ({ height: 300 }) as DOMRect); - - fireEvent.resize(window); - - rerender({}); - expect(result.current.isSticky).toBe(false); - - jest.spyOn(footerElement, 'getBoundingClientRect').mockImplementation(() => ({ height: 50 }) as DOMRect); - - fireEvent.resize(window); - - rerender({}); - expect(result.current.isSticky).toBe(true); - }); - test('element heights fallback to actual values when parent is larger', () => { parentElement = createElementWithHeight('div', 600); drawerElement = createElementWithHeight('div', 400); diff --git a/src/drawer/use-sticky-footer.ts b/src/drawer/use-sticky-footer.ts index 8adde1bcb4..eee014ccf6 100644 --- a/src/drawer/use-sticky-footer.ts +++ b/src/drawer/use-sticky-footer.ts @@ -1,7 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { RefObject, useCallback, useLayoutEffect, useState } from 'react'; +import { RefObject, useCallback, useState } from 'react'; + +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; import { throttle } from '../internal/utils/throttle'; @@ -41,15 +43,7 @@ export function useStickyFooter({ STICKY_STATE_CHECK_THROTTLE_DELAY ); - useLayoutEffect(() => { - window.addEventListener('resize', checkStickyState); - checkStickyState(); - - return () => { - window.removeEventListener('resize', checkStickyState); - checkStickyState.cancel(); - }; - }, [checkStickyState]); + useResizeObserver(() => drawerRef.current?.parentElement ?? null, checkStickyState); return { isSticky }; } From c44ab5e82c2746e0d4006c4de87e48e6e9e51b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Sch=C3=B6ll?= Date: Thu, 8 Jan 2026 16:47:19 +0100 Subject: [PATCH 13/16] chore: modify snapshot update instructions (#4176) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 876d7a93cd..01f9daa99f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -175,12 +175,12 @@ Alternatively, you can set the flag inline with the command: NODE_OPTIONS='--experimental-vm-modules' npx jest -c jest.integ.config.js src/input/__integ__/input.test.ts ``` -#### Updating snapshots +#### Updating all snapshots When component APIs change, you may need to update test snapshots. Use the `-u` flag to update snapshots: ``` -npx jest -u snapshot -c jest.unit.config.js +npx jest -u snapshot -c jest.unit.config.js src/ ``` ### Run visual regression tests From 55b4ef031ce457fc05c881ff5ffe1fc610246801 Mon Sep 17 00:00:00 2001 From: Nathnael_D <109980176+NathanZlion@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:51:03 +0300 Subject: [PATCH 14/16] fix: Restore focus to tutorial panel on dismiss (#4167) --- .../__tests__/tutorial-panel.test.tsx | 36 +++++++++++++++++++ .../components/tutorial-list/index.tsx | 9 ++++- src/tutorial-panel/index.tsx | 30 ++++++++++++++-- src/tutorial-panel/styles.scss | 8 +++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/tutorial-panel/__tests__/tutorial-panel.test.tsx b/src/tutorial-panel/__tests__/tutorial-panel.test.tsx index 23d77af8e7..e3ab981672 100644 --- a/src/tutorial-panel/__tests__/tutorial-panel.test.tsx +++ b/src/tutorial-panel/__tests__/tutorial-panel.test.tsx @@ -293,5 +293,41 @@ describe('URL sanitization', () => { 'LEARN_MORE_ABOUT_TUTORIA' ); }); + + test('focus returns to panel when exiting tutorial', () => { + const mockFocus = jest.fn(); + const tutorials = getTutorials(); + const originalFocus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = mockFocus; + + const { container, context, rerender } = renderTutorialPanelWithContext( + {}, + { + currentTutorial: tutorials[0], + } + ); + + const wrapper = createWrapper(container).findTutorialPanel()!; + wrapper.findDismissButton()!.click(); + expect(context.onExitTutorial).toHaveBeenCalledTimes(1); + rerender( + + {}} + tutorials={tutorials} + /> + + ); + + const wrapperAfterExit = createWrapper(container).findTutorialPanel()!; + const panelElement = wrapperAfterExit.getElement(); + + expect(mockFocus).toHaveBeenCalledTimes(1); + expect(mockFocus.mock.instances[0]).toBe(panelElement); + + HTMLElement.prototype.focus = originalFocus; + }); }); }); diff --git a/src/tutorial-panel/components/tutorial-list/index.tsx b/src/tutorial-panel/components/tutorial-list/index.tsx index 2a773993fb..0bb5cd80a6 100644 --- a/src/tutorial-panel/components/tutorial-list/index.tsx +++ b/src/tutorial-panel/components/tutorial-list/index.tsx @@ -28,6 +28,7 @@ interface TutorialListProps { onStartTutorial: HotspotContext['onStartTutorial']; i18nStrings: TutorialPanelProps['i18nStrings']; downloadUrl: TutorialPanelProps['downloadUrl']; + headingId?: string; } export default function TutorialList({ @@ -36,6 +37,7 @@ export default function TutorialList({ loading = false, onStartTutorial, downloadUrl, + headingId, }: TutorialListProps) { checkSafeUrl('TutorialPanel', downloadUrl); @@ -45,7 +47,12 @@ export default function TutorialList({ <> - + {i18nStrings.tutorialListTitle} diff --git a/src/tutorial-panel/index.tsx b/src/tutorial-panel/index.tsx index 9b8b50409a..b366f7ee38 100644 --- a/src/tutorial-panel/index.tsx +++ b/src/tutorial-panel/index.tsx @@ -1,11 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 'use client'; -import React, { useContext } from 'react'; +import React, { useContext, useRef } from 'react'; import clsx from 'clsx'; +import { useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + import { hotspotContext } from '../annotation-context/context'; import { getBaseProps } from '../internal/base-component'; +import { NonCancelableCustomEvent } from '../internal/events'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import TutorialDetailView from './components/tutorial-detail-view'; @@ -25,18 +28,38 @@ export default function TutorialPanel({ ...restProps }: TutorialPanelProps) { const { __internalRootRef } = useBaseComponent('TutorialPanel'); + const panelRef = useRef(null); + const headingId = useUniqueId(); const baseProps = getBaseProps(restProps); const context = useContext(hotspotContext); + function handleExitTutorial(event: NonCancelableCustomEvent) { + context.onExitTutorial(event); + panelRef.current?.focus(); + } + + const mergedRef = useMergeRefs(panelRef, __internalRootRef); + return ( <> -
+
{context.currentTutorial ? ( @@ -47,6 +70,7 @@ export default function TutorialPanel({ loading={loading} onStartTutorial={context.onStartTutorial} downloadUrl={downloadUrl} + headingId={headingId} /> )}
diff --git a/src/tutorial-panel/styles.scss b/src/tutorial-panel/styles.scss index c03d16c09f..cbee84aba9 100644 --- a/src/tutorial-panel/styles.scss +++ b/src/tutorial-panel/styles.scss @@ -5,10 +5,18 @@ @use '../internal/styles/tokens' as awsui; @use '../internal/styles' as styles; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; .tutorial-panel { @include styles.styles-reset; padding-block-start: 0; padding-block-end: awsui.$space-m; padding-inline: awsui.$space-l; + + &:focus { + outline: none; + } + @include focus-visible.when-visible { + @include styles.focus-highlight(0px); + } } From e04be933e6966b853cb28cf93a971b98d1d6f660 Mon Sep 17 00:00:00 2001 From: Joan Perals Date: Fri, 9 Jan 2026 17:04:55 +0100 Subject: [PATCH 15/16] chore: Add extra dev page to test popover trigger focus ring (#4175) --- pages/popover/common.ts | 10 ++++++++ pages/popover/focus-ring.page.tsx | 42 +++++++++++++++++++++++++++++++ pages/popover/text-wrap.page.tsx | 7 ++---- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 pages/popover/common.ts create mode 100644 pages/popover/focus-ring.page.tsx diff --git a/pages/popover/common.ts b/pages/popover/common.ts new file mode 100644 index 0000000000..c73a0cbf67 --- /dev/null +++ b/pages/popover/common.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const shortText = 'Hello!'; + +export const longTriggerTextWithSpaces = + 'Really long popover content with a lot of text that will probably overflow the popover trigger'; + +export const longTriggerTextWithoutSpaces = + 'Reallylongpopovercontentwithalotoftextbutnospacesthatwillprobablyoverflowthepopovertrigger'; diff --git a/pages/popover/focus-ring.page.tsx b/pages/popover/focus-ring.page.tsx new file mode 100644 index 0000000000..75caf6aeee --- /dev/null +++ b/pages/popover/focus-ring.page.tsx @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Popover, { PopoverProps } from '~components/popover'; + +import FocusTarget from '../common/focus-target'; +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; +import ScreenshotArea from '../utils/screenshot-area'; +import { longTriggerTextWithoutSpaces, longTriggerTextWithSpaces, shortText } from './common'; + +const triggerPermutations = createPermutations([ + { + children: [shortText, longTriggerTextWithSpaces, longTriggerTextWithoutSpaces], + wrapTriggerText: [true, false], + triggerType: ['text', 'text-inline'], + }, +]); + +export default function () { + return ( +
+

Popover trigger focus ring

+ + + ( +
+ +
+ )} + /> +
+
+ ); +} diff --git a/pages/popover/text-wrap.page.tsx b/pages/popover/text-wrap.page.tsx index a374f9682c..a4a712185c 100644 --- a/pages/popover/text-wrap.page.tsx +++ b/pages/popover/text-wrap.page.tsx @@ -9,6 +9,7 @@ import FocusTarget from '../common/focus-target'; import createPermutations from '../utils/permutations'; import PermutationsView from '../utils/permutations-view'; import ScreenshotArea from '../utils/screenshot-area'; +import { longTriggerTextWithoutSpaces, longTriggerTextWithSpaces, shortText } from './common'; const noop = () => {}; @@ -21,11 +22,7 @@ const sizeMap: Record = { const triggerPermutations = createPermutations([ { size: ['small', 'medium', 'large'], - children: [ - 'Hello!', - 'Really long popover content with a lot of text that will probably overflow the popover trigger', - 'Reallylongpopovercontentwithalotoftextbutnospacesthatwillprobablyoverflowthepopovertrigger', - ], + children: [shortText, longTriggerTextWithSpaces, longTriggerTextWithoutSpaces], wrapTriggerText: [true, false], triggerType: ['text', 'text-inline'], }, From 7fd9e6f46bb4dddc18e66c693b75920a0536fdd3 Mon Sep 17 00:00:00 2001 From: Michelle Nguyen Date: Fri, 9 Jan 2026 16:20:27 -0800 Subject: [PATCH 16/16] feat: add pagination - jump to page, update i18n, snapshots --- pages/pagination/permutations.page.tsx | 6 +- pages/table/jump-to-page-closed.page.tsx | 12 +- pages/table/jump-to-page-open-end.page.tsx | 14 +- .../__snapshots__/documenter.test.ts.snap | 428 ++++++++++------- src/i18n/messages-types.ts | 20 +- src/i18n/messages/all.ar.json | 5 +- src/i18n/messages/all.de.json | 5 +- src/i18n/messages/all.en-GB.json | 5 +- src/i18n/messages/all.en.json | 13 +- src/i18n/messages/all.es.json | 5 +- src/i18n/messages/all.fr.json | 5 +- src/i18n/messages/all.id.json | 5 +- src/i18n/messages/all.it.json | 5 +- src/i18n/messages/all.ja.json | 5 +- src/i18n/messages/all.ko.json | 5 +- src/i18n/messages/all.pt-BR.json | 5 +- src/i18n/messages/all.tr.json | 5 +- src/i18n/messages/all.zh-CN.json | 5 +- src/i18n/messages/all.zh-TW.json | 5 +- src/input/internal.tsx | 6 +- src/input/styles.scss | 27 +- src/internal/styles/forms/mixins.scss | 36 ++ src/pagination/__tests__/pagination.test.tsx | 12 +- src/pagination/index.tsx | 4 +- src/pagination/interfaces.ts | 2 +- src/pagination/internal.tsx | 434 +++++++++--------- src/popover/internal.tsx | 4 +- src/select/parts/styles.scss | 28 +- src/test-utils/dom/pagination/index.ts | 2 +- 29 files changed, 653 insertions(+), 460 deletions(-) diff --git a/pages/pagination/permutations.page.tsx b/pages/pagination/permutations.page.tsx index 7a9b711c10..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'; @@ -32,11 +34,11 @@ const permutations = createPermutations([ 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 index 50542d7faa..e05ac153d6 100644 --- a/pages/table/jump-to-page-closed.page.tsx +++ b/pages/table/jump-to-page-closed.page.tsx @@ -3,6 +3,8 @@ 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'; @@ -11,7 +13,7 @@ import { generateItems, Instance } from './generate-data'; const allItems = generateItems(100); const PAGE_SIZE = 10; -export default function JumpToPageClosedExample() { +function JumpToPageClosedContent() { const [currentPageIndex, setCurrentPageIndex] = useState(1); const totalPages = Math.ceil(allItems.length / PAGE_SIZE); @@ -97,3 +99,11 @@ export default function JumpToPageClosedExample() { /> ); } + +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 index 9d86ecae14..0798a10d55 100644 --- a/pages/table/jump-to-page-open-end.page.tsx +++ b/pages/table/jump-to-page-open-end.page.tsx @@ -2,6 +2,8 @@ // 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'; @@ -10,13 +12,13 @@ import { generateItems, Instance } from './generate-data'; const PAGE_SIZE = 10; const TOTAL_ITEMS = 100; // Simulated server-side total -export default function JumpToPageOpenEndExample() { +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 jumpToPageRef = useRef(null); const currentItems = loadedPages[currentPageIndex] || []; @@ -137,3 +139,11 @@ export default function JumpToPageOpenEndExample() { /> ); } + +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/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 1cf90ff9dd..26a90bbe60 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -198,7 +198,7 @@ function InternalInput( }, }; - const renderMainInput = () => ( + const mainInput = ( {__inlineLabelText} -
{renderMainInput()}
+
{mainInput}
) : ( - renderMainInput() + mainInput )} {__rightIcon && ( { describe('error handling via ref', () => { test('should show error popover when setError is called', () => { - const ref = React.createRef(); + const ref = React.createRef(); const { wrapper, rerender } = renderPagination( ); @@ -439,11 +439,11 @@ describe('jump to page', () => { rerender(); // Error popover should be visible - expect(wrapper.findPopover()).not.toBeNull(); + expect(wrapper.findJumpToPagePopover()).not.toBeNull(); }); test('should clear error when user types in input', () => { - const ref = React.createRef(); + const ref = React.createRef(); const { wrapper, rerender } = renderPagination( ); @@ -454,11 +454,11 @@ describe('jump to page', () => { wrapper.findJumpToPageInput()!.setInputValue('5'); // Error should be cleared - popover should not be visible - expect(wrapper.findPopover()).toBeNull(); + expect(wrapper.findJumpToPagePopover()).toBeNull(); }); test('should clear error when user navigates successfully', () => { - const ref = React.createRef(); + const ref = React.createRef(); const onChange = jest.fn(); const { wrapper, rerender } = renderPagination( @@ -471,7 +471,7 @@ describe('jump to page', () => { expect(onChange).toHaveBeenCalled(); // Error should be cleared - expect(wrapper.findPopover()).toBeNull(); + expect(wrapper.findJumpToPagePopover()).toBeNull(); }); }); diff --git a/src/pagination/index.tsx b/src/pagination/index.tsx index cdb47f3741..2ef99ab558 100644 --- a/src/pagination/index.tsx +++ b/src/pagination/index.tsx @@ -13,13 +13,13 @@ import InternalPagination from './internal'; export { PaginationProps }; -const Pagination = React.forwardRef((props, ref) => { +const Pagination = React.forwardRef((props, ref) => { const baseComponentProps = useBaseComponent('Pagination', { props: { openEnd: props.openEnd } }); return ( = { }; const defaultI18nStrings: Required = { - jumpToPageLabel: 'Page', - jumpToPageError: 'Page out of range. Showing last available page.', + jumpToPageLabel: '', + jumpToPageError: '', }; + interface PageButtonProps { className?: string; ariaLabel: string; @@ -109,137 +110,134 @@ function PageNumber({ pageIndex, ...rest }: PageButtonProps) { ); } -type InternalPaginationProps = PaginationProps & - InternalBaseComponentProps & { - jumpToPageRef?: React.Ref; - }; -export default function InternalPagination({ - openEnd, - currentPageIndex, - ariaLabels, - i18nStrings, - pagesCount, - disabled, - onChange, - onNextPageClick, - onPreviousPageClick, - __internalRootRef, - jumpToPage, - jumpToPageRef, - ...rest -}: InternalPaginationProps) { - 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); +type InternalPaginationProps = PaginationProps & InternalBaseComponentProps; - const i18n = useInternalI18n('pagination'); +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); - // Expose setError function via ref - React.useImperativeHandle(jumpToPageRef, () => ({ - setError: (error: boolean) => setHasError(error), - })); + const i18n = useInternalI18n('pagination'); - // Sync input with currentPageIndex after loading completes - React.useEffect(() => { - if (prevLoadingRef.current && !jumpToPage?.loading) { - setJumpToPageValue(String(currentPageIndex)); - } - prevLoadingRef.current = jumpToPage?.loading; - }, [jumpToPage?.loading, currentPageIndex]); + // Expose setError function via ref + React.useImperativeHandle(ref, () => ({ + setError: (error: boolean) => setHasError(error), + })); - 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; - const jumpToPageLabel = i18nStrings?.jumpToPageLabel ?? defaultI18nStrings.jumpToPageLabel; - const jumpToPageButtonLabel = ariaLabels?.jumpToPageButton ?? defaultAriaLabels.jumpToPageButton; - const jumpToPageError = i18nStrings?.jumpToPageError ?? defaultI18nStrings.jumpToPageError; + // Sync input with currentPageIndex after loading completes + React.useEffect(() => { + if (prevLoadingRef.current && !jumpToPage?.loading) { + setJumpToPageValue(String(currentPageIndex)); + } + prevLoadingRef.current = jumpToPage?.loading; + }, [jumpToPage?.loading, currentPageIndex]); - function handlePrevPageClick(requestedPageIndex: number) { - handlePageClick(requestedPageIndex); - fireNonCancelableEvent(onPreviousPageClick, { - requestedPageAvailable: true, - requestedPageIndex: requestedPageIndex, - }); - } + 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 handleNextPageClick(requestedPageIndex: number) { - handlePageClick(requestedPageIndex); - fireNonCancelableEvent(onNextPageClick, { - requestedPageAvailable: currentPageIndex < pagesCount, - requestedPageIndex: requestedPageIndex, - }); - } + function handlePrevPageClick(requestedPageIndex: number) { + handlePageClick(requestedPageIndex); + fireNonCancelableEvent(onPreviousPageClick, { + requestedPageAvailable: true, + requestedPageIndex: requestedPageIndex, + }); + } - function handlePageClick(requestedPageIndex: number) { - setJumpToPageValue(String(requestedPageIndex)); - setHasError(false); // Clear error on successful navigation - fireNonCancelableEvent(onChange, { currentPageIndex: requestedPageIndex }); - } + function handleNextPageClick(requestedPageIndex: number) { + handlePageClick(requestedPageIndex); + fireNonCancelableEvent(onNextPageClick, { + requestedPageAvailable: currentPageIndex < pagesCount, + requestedPageIndex: requestedPageIndex, + }); + } - function handleJumpToPageClick(requestedPageIndex: number) { - if (requestedPageIndex < 1) { - // Out of range lower bound - navigate to first page - setJumpToPageValue('1'); - setHasError(false); - fireNonCancelableEvent(onChange, { currentPageIndex: 1 }); - return; + function handlePageClick(requestedPageIndex: number, errorState?: boolean) { + setJumpToPageValue(String(requestedPageIndex)); + setHasError(!!errorState); // Clear error on successful navigation + fireNonCancelableEvent(onChange, { currentPageIndex: requestedPageIndex }); } - if (openEnd) { - // Open-end: always navigate, parent will handle async loading - handlePageClick(requestedPageIndex); - } else { - // Closed-end: validate range - if (requestedPageIndex >= 1 && requestedPageIndex <= pagesCount) { + function handleJumpToPageClick(requestedPageIndex: number) { + if (requestedPageIndex < 1) { + handlePageClick(1); + return; + } + + if (openEnd) { + // Open-end: always navigate, parent will handle async loading handlePageClick(requestedPageIndex); } else { - // Out of range - set error and navigate to last page - setHasError(true); - setJumpToPageValue(String(pagesCount)); - fireNonCancelableEvent(onChange, { currentPageIndex: pagesCount }); + // 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); - } - }; + // 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; + // 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); } - setPopoverVisible(true); - } else { - setPopoverVisible(false); - } - }, [hasError, jumpToPage?.loading, openEnd]); + }, [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 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 renderJumpToPageButton = () => { - return ( + const jumpToPageButton = ( ); - }; - 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)} - > - {renderJumpToPageButton()} - - ) : ( - renderJumpToPageButton() - )} -
    -
    - )} -
- ); -} + {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/popover/internal.tsx b/src/popover/internal.tsx index 64d8f9e8d9..f290579848 100644 --- a/src/popover/internal.tsx +++ b/src/popover/internal.tsx @@ -140,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); } }; @@ -149,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 00d7e119dc..58416472ae 100644 --- a/src/test-utils/dom/pagination/index.ts +++ b/src/test-utils/dom/pagination/index.ts @@ -56,7 +56,7 @@ export default class PaginationWrapper extends ComponentWrapper { /** * Returns the error popover for jump to page. */ - findPopover(): PopoverWrapper | null { + findJumpToPagePopover(): PopoverWrapper | null { return this.findComponent(`.${PopoverWrapper.rootSelector}`, PopoverWrapper); }