From f808d38065da1d2ad6bdab5a66808b70794d7ec0 Mon Sep 17 00:00:00 2001 From: Cansu Aksu Date: Tue, 11 Aug 2026 10:56:02 +0200 Subject: [PATCH 1/5] initial commit --- pages/button-dropdown/filtering.page.tsx | 143 +++++++++- .../__snapshots__/documenter.test.ts.snap | 266 +++++++++++++++++- .../button-dropdown-async-loading.test.tsx | 138 +++++++++ src/button-dropdown/index.tsx | 16 ++ src/button-dropdown/interfaces.ts | 36 ++- src/button-dropdown/internal.tsx | 47 +++- .../utils/use-button-dropdown.ts | 20 +- src/button-dropdown/utils/use-load-items.ts | 49 ++++ src/i18n/messages-types.ts | 4 + src/i18n/messages/all.en.json | 4 + src/test-utils/dom/button-dropdown/index.ts | 15 + 11 files changed, 712 insertions(+), 26 deletions(-) create mode 100644 src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx create mode 100644 src/button-dropdown/utils/use-load-items.ts diff --git a/pages/button-dropdown/filtering.page.tsx b/pages/button-dropdown/filtering.page.tsx index 94c9a5b4ab..0a120a6e31 100644 --- a/pages/button-dropdown/filtering.page.tsx +++ b/pages/button-dropdown/filtering.page.tsx @@ -1,12 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { Checkbox } from '~components'; import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import SpaceBetween from '~components/space-between'; +import AppContext, { AppContextType } from '../app/app-context'; import { SimplePage } from '../app/templates'; +import { useOptionsLoader } from '../common/options-loader'; import styles from './styles.scss'; @@ -155,13 +157,69 @@ const withCheckboxItems: ButtonDropdownProps['items'] = [ { itemType: 'checkbox', id: 'verbose-logs', text: 'Verbose logging', checked: true }, ]; +// Flat action list used to demonstrate manual (app-controlled) filtering. +const manualSourceItems: ButtonDropdownProps.Item[] = [ + { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, + { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, + { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, + { id: 'undo', text: 'Undo', labelTag: 'Ctrl+Z' }, + { id: 'redo', text: 'Redo', labelTag: 'Ctrl+Y' }, + { id: 'select-all', text: 'Select all', labelTag: 'Ctrl+A' }, + { id: 'find', text: 'Find and replace', secondaryText: 'Search within document', labelTag: 'Ctrl+H' }, + { id: 'preferences', text: 'Preferences', secondaryText: 'Configure editor settings' }, +]; + +// Larger list used to demonstrate asynchronous, paginated loading. +const asyncSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, index) => ({ + id: `action-${index + 1}`, + text: `Action ${index + 1}`, + secondaryText: index % 3 === 0 ? `Description for action ${index + 1}` : undefined, +})); + +type PageContext = React.Context< + AppContextType<{ + fakeResponses?: boolean; + }> +>; + export default function ButtonDropdownFilteringPage() { const [expandToViewport, setExpandToViewport] = useState(false); + const { + urlParams: { fakeResponses = true }, + } = useContext(AppContext as PageContext); + const [checkboxItems, setCheckboxItems] = useState(withCheckboxItems); const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; const onItemClick = (event: CustomEvent) => console.log(event.detail); + // Manual filtering: the default (client-side) filtering is disabled and the app decides + // which items to display based on the filtering text provided by `onLoadItems`. + const [manualItems, setManualItems] = useState(manualSourceItems); + + // Async loading: items are fetched (and paginated) through the shared options loader. + const { + items: asyncItems, + status, + filteringText, + fetchItems, + } = useOptionsLoader({ pageSize: 10 }); + + const showAsyncFilteredText = (matchesCount: number, totalCount: number) => { + if (status === 'pending') { + return `${matchesCount}+ results`; + } + if (status === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + }; + + // Error use case: the initial request fails deterministically, and clicking the recovery + // button (which fires `onLoadItems`) simulates a successful retry. + const [errorStatus, setErrorStatus] = useState('error'); + const [errorItems, setErrorItems] = useState([]); + return ( @@ -313,6 +371,89 @@ export default function ButtonDropdownFilteringPage() { onItemClick={onItemClick} /> + +
+

Manual filtering

+ No actions match your search. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + const normalized = filteringText.toLowerCase(); + setManualItems(manualSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized))); + }} + > + Actions (manual) + +
+ +
+

Async loading (paginated)

+ { + const normalized = filteringText.toLowerCase(); + const filtered = asyncSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); + fetchItems({ firstPage, filteringText, sourceItems: fakeResponses ? filtered : undefined }); + }} + > + Async actions + +
+ +
+

Error state with recovery

+ { + if (samePage) { + // Triggered by the recovery button: simulate a successful retry. + setErrorStatus('loading'); + setTimeout(() => { + setErrorItems(manualSourceItems); + setErrorStatus('finished'); + }, 1000); + } else { + // Initial load (or a new filtering request) fails. + setErrorItems([]); + setErrorStatus('error'); + } + }} + > + Actions (error) + +
); diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b6144f59a3..1dac1c3c81 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6274,6 +6274,32 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se "detailType": "ButtonDropdownProps.ItemClickDetails", "name": "onItemFollow", }, + { + "cancelable": false, + "detailInlineType": { + "name": "ButtonDropdownProps.LoadItemsDetail", + "properties": [ + { + "name": "filteringText", + "optional": false, + "type": "string", + }, + { + "name": "firstPage", + "optional": false, + "type": "boolean", + }, + { + "name": "samePage", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "ButtonDropdownProps.LoadItemsDetail", + "name": "onLoadItems", + }, ], "functions": [ { @@ -6329,6 +6355,19 @@ If provided, the disabled button becomes focusable.", "optional": true, "type": "string", }, + { + "description": "Provides a text alternative for the error icon in the error message.", + "i18nTag": true, + "name": "errorIconAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the text to display when a data fetching error occurs. Make sure that you provide \`recoveryText\`.", + "name": "errorText", + "optional": true, + "type": "string", + }, { "defaultValue": "false", "description": "Controls expandability of the item groups.", @@ -6395,21 +6434,35 @@ because fixed positioning results in a slight, visible lag when scrolling comple "defaultValue": "'none'", "description": "Enables filtering of the dropdown items. -When set to \`auto\`, a search input is rendered inside the dropdown and the items are filtered as the user -types. Items are matched client-side using a case-insensitive substring match against their \`text\`, -\`secondaryText\`, and \`labelTag\`.", +* \`auto\` - A search input is rendered inside the dropdown and the items are automatically filtered as the user types. +* \`manual\` - You will set up \`onLoadItems\` event listeners and filter items on your side or request +them from server. + +If you set this property to \`auto\`, the component will filter the provided \`items\` based on the value of the filtering input field. +The filtering text is matched against the item's \`text\`, \`secondaryText\`, and \`labelTag\`. + +If you set this property to \`manual\`, the default filtering mechanism is disabled and all provided \`items\` are +displayed in the dropdown list. In that case make sure that you use the \`onLoadItems\` events in order +to set the \`items\` property to the items that are relevant for the user, given the filtering input value.", "inlineType": { "name": "ButtonDropdownProps.FilteringType", "type": "union", "values": [ "auto", "none", + "manual", ], }, "name": "filteringType", "optional": true, "type": "string", }, + { + "description": "Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end.", + "name": "finishedText", + "optional": true, + "type": "string", + }, { "description": "Sets the button width to be 100% of the parent container width. Button content is centered.", "name": "fullWidth", @@ -6660,6 +6713,12 @@ An item which belongs to nested group has the following properties: \`id\`, \`te "optional": false, "type": "ReadonlyArray", }, + { + "description": "Specifies the text to display inside the dropdown when items are loading.", + "name": "itemsLoadingText", + "optional": true, + "type": "string", + }, { "defaultValue": "false", "description": "Renders the button as being in a loading state. It takes precedence over the \`disabled\` if both are set to \`true\`. @@ -7036,6 +7095,14 @@ We do not support using this attribute to apply custom styling.", ], "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", }, + { + "description": "Specifies the text for the recovery button. The text is displayed next to the error text. +Use the \`onLoadItems\` event to perform a recovery action (for example, retrying the request).", + "i18nTag": true, + "name": "recoveryText", + "optional": true, + "type": "string", + }, { "description": "Specifies a render function to render custom options in the dropdown menu. @@ -7093,13 +7160,28 @@ When returning \`null\`, the default styling will be applied.", "optional": true, "type": "ButtonDropdownProps.ItemRenderer", }, + { + "description": "Specifies the current status of loading more options. +* \`pending\` - Indicates that no request in progress, but more options may be loaded. +* \`loading\` - Indicates that data fetching is in progress. +* \`finished\` - Indicates that pagination has finished and no more requests are expected. +* \`error\` - Indicates that an error occurred during fetch. You should use \`recoveryText\` to enable the user to recover.", + "inlineType": { + "name": "DropdownStatusProps.StatusType", + "type": "union", + "values": [ + "error", + "finished", + "loading", + "pending", + ], + }, + "name": "statusType", + "optional": true, + "type": "string", + }, { "defaultValue": "'normal'", - "description": "Determines the general styling of the button dropdown. -* \`primary\` for primary buttons -* \`normal\` for secondary buttons -* \`icon\` for icon buttons -* \`inline-icon\` for icon buttons with no outer padding", "inlineType": { "name": "ButtonDropdownProps.Variant", "type": "union", @@ -7122,6 +7204,12 @@ When returning \`null\`, the default styling will be applied.", "isDefault": true, "name": "children", }, + { + "description": "Displayed when there are no options to display. +This is only shown when \`statusType\` is set to \`finished\` or not set at all.", + "isDefault": false, + "name": "empty", + }, { "description": "Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/). Applies to the \`icon\` and \`inline-icon\` variants only. @@ -35270,6 +35358,20 @@ Use this method to assert the panel position.", ], }, }, + { + "description": "Finds the error recovery button when item loading fails.", + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -35456,6 +35558,20 @@ Supported options: ], }, }, + { + "description": "Finds the status displayed at the footer of the dropdown.", + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTriggerButton", "parameters": [], @@ -44852,6 +44968,23 @@ Supported options: ], }, }, + { + "description": "Finds the error recovery button when item loading fails.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -45146,6 +45279,23 @@ Supported options: ], }, }, + { + "description": "Finds the status displayed at the footer of the dropdown.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "ButtonDropdownWrapper.findTriggerButton", @@ -46501,6 +46651,23 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", ], }, }, + { + "description": "Finds the error recovery button when item loading fails.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -46714,6 +46881,23 @@ Supported options: ], }, }, + { + "description": "Finds the status displayed at the footer of the dropdown.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTitle", "parameters": [], @@ -47933,6 +48117,15 @@ Use this method to assert the panel position.", "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button when item loading fails.", + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -48070,6 +48263,15 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the status displayed at the footer of the dropdown.", + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findTriggerButton", "parameters": [], @@ -54738,6 +54940,18 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button when item loading fails.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -54965,6 +55179,18 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the status displayed at the footer of the dropdown.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "inheritedFrom": { "name": "ButtonDropdownWrapper.findTriggerButton", @@ -55919,6 +56145,18 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button when item loading fails.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -56080,6 +56318,18 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the status displayed at the footer of the dropdown.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findTitle", "parameters": [], diff --git a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx new file mode 100644 index 0000000000..53b468cb77 --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -0,0 +1,138 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: jest.fn(), +})); + +const items: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Cut' }, + { id: 'i2', text: 'Copy' }, + { id: 'i3', text: 'Paste' }, +]; + +function renderDropdown(props: Partial = {}) { + const result = render( + + Actions + + ); + const wrapper = createWrapper(result.container).findButtonDropdown()!; + return { ...result, wrapper }; +} + +beforeEach(() => { + jest.mocked(warnOnce).mockClear(); +}); + +describe('ButtonDropdown async loading', () => { + test('fires onLoadItems with the initial (empty) filtering text when the dropdown opens', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: true, samePage: false }); + }); + + test('fires onLoadItems after a delay when the filtering input changes', async () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + onLoadItems.mockClear(); + + wrapper.findFilteringInput()!.setInputValue('test'); + expect(wrapper.findFilteringInput()!.findNativeInput().getElement()).toHaveValue('test'); + + await waitFor(() => + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'test', firstPage: true, samePage: false }) + ); + }); + + test('fires onLoadItems to retry a failed request when the recovery button is clicked', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + statusType: 'error', + errorText: 'Error fetching items', + recoveryText: 'Retry', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + onLoadItems.mockClear(); + + const recoveryButton = wrapper.findErrorRecoveryButton()!; + expect(recoveryButton).not.toBeNull(); + recoveryButton.click(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: false, samePage: true }); + }); + + test('warns if recoveryText is provided without onLoadItems', () => { + renderDropdown({ statusType: 'error', errorText: 'Error', recoveryText: 'Retry' }); + expect(warnOnce).toHaveBeenCalledWith( + 'ButtonDropdown', + '`onLoadItems` must be provided for `recoveryText` to be displayed.' + ); + }); + + test('does not apply client-side filtering when filteringType is "manual"', () => { + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + // All provided items remain visible regardless of the filtering value in manual mode. + wrapper.findFilteringInput()!.setInputValue('zzz'); + expect(wrapper.findItems()).toHaveLength(items.length); + }); +}); + +describe('ButtonDropdown status display', () => { + test.each([ + ['loading', true], + ['error', true], + ['finished', false], + ])('displays %s status text as %s footer', (statusType, isSticky) => { + const statusText = + statusType === 'loading' + ? { itemsLoadingText: 'Test loading text' } + : { [`${statusType}Text`]: `Test ${statusType} text` }; + const expectedText = statusType === 'loading' ? 'Test loading text' : `Test ${statusType} text`; + + const { wrapper } = renderDropdown({ + statusType: statusType as ButtonDropdownProps['statusType'], + onLoadItems: () => {}, + ...statusText, + }); + wrapper.openDropdown(); + + const statusIndicator = wrapper.findStatusIndicator(); + expect(statusIndicator).not.toBeNull(); + expect(statusIndicator!.getElement()).toHaveTextContent(expectedText); + // isSticky is currently unused in the assertion beyond documenting intent. + void isSticky; + }); + + test('displays the empty state when there are no items', () => { + const { wrapper } = renderDropdown({ + items: [], + empty: 'No items available', + }); + wrapper.openDropdown(); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('No items available'); + }); +}); diff --git a/src/button-dropdown/index.tsx b/src/button-dropdown/index.tsx index b9183506ad..cc336f0e30 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -47,6 +47,14 @@ const ButtonDropdown = React.forwardRef( filteringClearAriaLabel, filteringResultsText, noMatch, + onLoadItems, + statusType, + empty, + itemsLoadingText, + finishedText, + errorText, + recoveryText, + errorIconAriaLabel, i18nStrings, ...props }: ButtonDropdownProps, @@ -101,6 +109,14 @@ const ButtonDropdown = React.forwardRef( filteringClearAriaLabel={filteringClearAriaLabel} filteringResultsText={filteringResultsText} noMatch={noMatch} + onLoadItems={onLoadItems} + statusType={statusType} + empty={empty} + itemsLoadingText={itemsLoadingText} + finishedText={finishedText} + errorText={errorText} + recoveryText={recoveryText} + errorIconAriaLabel={errorIconAriaLabel} i18nStrings={i18nStrings} {...getAnalyticsMetadataAttribute({ component: analyticsComponentMetadata, diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index 1ca0a03ac6..b50a56a6c8 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -3,16 +3,20 @@ import React, { ReactNode } from 'react'; import { ButtonProps } from '../button/interfaces'; -import { ExpandToViewport } from '../dropdown/interfaces'; +import { ExpandToViewport, OptionsLoadItemsDetail } from '../dropdown/interfaces'; import { IconProps } from '../icon/interfaces'; import { BaseComponentProps } from '../types/base-component'; -import { BaseNavigationDetail, CancelableEventHandler } from '../types/events'; +import { DropdownStatusProps } from '../types/dropdown-status'; +import { BaseNavigationDetail, CancelableEventHandler, NonCancelableEventHandler } from '../types/events'; /** * @awsuiSystem core */ import { NativeAttributes } from '../types/native-attributes'; -export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewport { +export interface ButtonDropdownProps + extends BaseComponentProps, + ExpandToViewport, + Omit { /** * Array of objects with a number of supported types. * @@ -130,6 +134,10 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * * `icon` for icon buttons * * `inline-icon` for icon buttons with no outer padding */ + /** + * Specifies the text to display inside the dropdown when items are loading. + **/ + itemsLoadingText?: string; variant?: ButtonDropdownProps.Variant; /** * Specifies the name of the icon used in the button dropdown trigger, used with the [icon component](/components/icon/). @@ -176,6 +184,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an `href` set. */ onItemFollow?: CancelableEventHandler; + onLoadItems?: NonCancelableEventHandler; /** * A standalone action that is shown prior to the dropdown trigger. * Use it with "primary" and "normal" variant only. @@ -198,9 +207,17 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor /** * Enables filtering of the dropdown items. * - * When set to `auto`, a search input is rendered inside the dropdown and the items are filtered as the user - * types. Items are matched client-side using a case-insensitive substring match against their `text`, - * `secondaryText`, and `labelTag`. + * * `auto` - A search input is rendered inside the dropdown and the items are automatically filtered as the user types. + * * `manual` - You will set up `onLoadItems` event listeners and filter items on your side or request + * them from server. + * + * If you set this property to `auto`, the component will filter the provided `items` based on the value of the filtering input field. + * The filtering text is matched against the item's `text`, `secondaryText`, and `labelTag`. + * + * If you set this property to `manual`, the default filtering mechanism is disabled and all provided `items` are + * displayed in the dropdown list. In that case make sure that you use the `onLoadItems` events in order + * to set the `items` property to the items that are relevant for the user, given the filtering input value. + * */ filteringType?: ButtonDropdownProps.FilteringType; @@ -269,7 +286,12 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor export namespace ButtonDropdownProps { export type Variant = 'normal' | 'primary' | 'icon' | 'inline-icon'; export type ItemType = 'action' | 'group'; - export type FilteringType = 'auto' | 'none'; + export type FilteringType = 'auto' | 'manual' | 'none'; + + /* eslint-disable-next-line @typescript-eslint/no-empty-object-type -- + * Required to create a distinct named type for the documenter. + **/ + export interface LoadItemsDetail extends OptionsLoadItemsDetail {} export interface I18nStrings { filteringItemAriaDescription?: string; diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 6f62cc522d..a0b47ce872 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -10,6 +10,7 @@ import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton, InternalButtonProps } from '../button/internal'; import Dropdown from '../dropdown/internal'; +import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; @@ -32,6 +33,7 @@ import { InternalButtonDropdownProps, InternalItem } from './internal-interfaces import ItemsList from './items-list'; import { countLeafItems } from './utils/filter-items'; import { useButtonDropdown } from './utils/use-button-dropdown'; +import { useLoadItems } from './utils/use-load-items'; import { isLinkItem } from './utils/utils.js'; import analyticsSelectors from './analytics-metadata/styles.css.js'; @@ -75,7 +77,13 @@ const InternalButtonDropdown = React.forwardRef( filteringAriaLabel, filteringClearAriaLabel, filteringResultsText, + onLoadItems, noMatch, + empty, + itemsLoadingText, + finishedText, + errorText, + statusType = 'finished', i18nStrings, compactTrigger, ariaDescribedby, @@ -86,7 +94,7 @@ const InternalButtonDropdown = React.forwardRef( const isInRestrictedView = useMobile(); const dropdownId = useUniqueId('dropdown'); const menuId = useUniqueId('button-dropdown-menu'); - const hasFiltering = filteringType === 'auto'; + const hasFiltering = filteringType === 'auto' || filteringType === 'manual'; for (const item of items) { if (isLinkItem(item)) { checkSafeUrl('ButtonDropdown', item.href); @@ -109,6 +117,19 @@ const InternalButtonDropdown = React.forwardRef( const hasMainAction = mainAction && (variant === 'primary' || variant === 'normal'); const isVisualRefresh = useVisualRefresh(); const isOneTheme = isThemeActive(Theme.OneTheme); + const i18n = useInternalI18n('button-dropdown'); + const errorIconAriaLabel = i18n('errorIconAriaLabel', props.errorIconAriaLabel); + const recoveryText = i18n('recoveryText', props.recoveryText); + + if (props.recoveryText && !onLoadItems) { + warnOnce('ButtonDropdown', '`onLoadItems` must be provided for `recoveryText` to be displayed.'); + } + + const { fireLoadItems, handleLoadMore, handleRecoveryClick } = useLoadItems({ + onLoadItems, + items, + statusType, + }); const { isOpen, @@ -139,7 +160,8 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport, hasExpandableGroups: expandableGroups, isInRestrictedView, - hasFiltering, + filteringType, + fireLoadItems, }); const filterRef = useRef(null); @@ -380,6 +402,7 @@ const InternalButtonDropdown = React.forwardRef( const headerId = useUniqueId('awsui-button-dropdown__header'); const footerId = useUniqueId('awsui-button-dropdown__footer'); + const isEmpty = !items || items.length === 0; const isNoMatch = hasFiltering && !!filteringValue && filteredItems.length === 0; const isFiltered = hasFiltering && !!filteringValue && filteredItems.length > 0; @@ -388,10 +411,19 @@ const InternalButtonDropdown = React.forwardRef( const filteredText = isFiltered ? filteringResultsText?.(matchesCount, totalCount) : undefined; const dropdownStatus = useDropdownStatus({ - statusType: 'finished', + statusType, + empty, + loadingText: itemsLoadingText, + finishedText, + errorText, + recoveryText, + isEmpty, isNoMatch, noMatch, filteringResultsText: filteredText, + errorIconAriaLabel, + onRecoveryClick: handleRecoveryClick, + hasRecoveryCallback: !!onLoadItems, }); // Only create a filteringDescription element if filtering is actually enabled, @@ -410,6 +442,7 @@ const InternalButtonDropdown = React.forwardRef( ref={filterRef} value={filteringValue} onChange={event => setFilteringValue(event.detail.value)} + __onDelayedInput={event => fireLoadItems(event.detail.value)} placeholder={filteringPlaceholder} ariaLabel={filteringAriaLabel} clearAriaLabel={filteringClearAriaLabel} @@ -464,7 +497,7 @@ const InternalButtonDropdown = React.forwardRef( ariaRole={hasFiltering ? 'dialog' : undefined} ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ - dropdownStatus.content ? ( + dropdownStatus.content && dropdownStatus.isSticky ? ( ) : null } @@ -502,7 +535,8 @@ const InternalButtonDropdown = React.forwardRef( ariaLabel={ariaLabel} ariaLabelledby={hasHeader ? headerId : shouldLabelWithTrigger ? triggerId : undefined} ariaDescribedby={dropdownStatus.content ? footerId : undefined} - statusType="finished" + statusType={statusType} + onLoadMore={handleLoadMore} > + {dropdownStatus.content && !dropdownStatus.isSticky ? ( + + ) : null} {filteringDescriptionEl} } diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index 8eebdbca5f..5248a41ca4 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -17,7 +17,8 @@ interface UseButtonDropdownOptions extends ButtonDropdownSettings { onItemFollow?: CancelableEventHandler; onReturnFocus: () => void; expandToViewport?: boolean; - hasFiltering: boolean; + filteringType?: ButtonDropdownProps.FilteringType; + fireLoadItems?: (filteringText: string) => void; } interface UseButtonDropdownApi extends HighlightProps { @@ -45,13 +46,15 @@ export function useButtonDropdown({ hasExpandableGroups, isInRestrictedView = false, expandToViewport = false, - hasFiltering, + filteringType, + fireLoadItems, }: UseButtonDropdownOptions): UseButtonDropdownApi { const [filteringValue, setFilteringValue] = useState(''); + const hasFiltering = filteringType === 'auto' || filteringType === 'manual'; const filteredItems = useMemo( - () => (hasFiltering && filteringValue ? filterItems(items, filteringValue) : items), - [hasFiltering, filteringValue, items] + () => (filteringType === 'auto' && filteringValue ? filterItems(items, filteringValue) : items), + [filteringType, filteringValue, items] ); const showExpandableGroups = hasExpandableGroups && !filteringValue; @@ -83,7 +86,14 @@ export function useButtonDropdown({ } }, [filteringValue, reset]); - const { isOpen, closeDropdown: closeDropdownState, ...openStateProps } = useOpenState({ onClose: reset }); + const { + isOpen, + closeDropdown: closeDropdownState, + ...openStateProps + } = useOpenState({ + onOpen: () => fireLoadItems?.(''), + onClose: reset, + }); const closeDropdown = () => { setFilteringValue(''); diff --git a/src/button-dropdown/utils/use-load-items.ts b/src/button-dropdown/utils/use-load-items.ts new file mode 100644 index 0000000000..29977e9253 --- /dev/null +++ b/src/button-dropdown/utils/use-load-items.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useRef } from 'react'; + +import { fireNonCancelableEvent } from '../../internal/events'; +import { DropdownStatusProps } from '../../types/dropdown-status'; +import { ButtonDropdownProps } from '../interfaces'; + +interface UseLoadItemsProps { + onLoadItems: ButtonDropdownProps['onLoadItems']; + items: ButtonDropdownProps.Items; + statusType: DropdownStatusProps.StatusType; +} + +export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsProps) => { + const prevFilteringText = useRef(undefined); + + const fireLoadItems = (filteringText: string) => { + if (prevFilteringText.current === filteringText) { + return; + } + prevFilteringText.current = filteringText; + fireNonCancelableEvent(onLoadItems, { filteringText, firstPage: true, samePage: false }); + }; + + const handleLoadMore = () => { + const firstPage = items.length === 0; + if (statusType === 'pending') { + fireNonCancelableEvent(onLoadItems, { + firstPage, + samePage: false, + filteringText: prevFilteringText.current || '', + }); + } + }; + + const handleRecoveryClick = () => + fireNonCancelableEvent(onLoadItems, { + firstPage: false, + samePage: true, + filteringText: prevFilteringText.current || '', + }); + + return { + fireLoadItems, + handleLoadMore, + handleRecoveryClick, + }; +}; diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 88bda3f312..4f34b657fa 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -74,6 +74,10 @@ export interface I18nFormatArgTypes { button: { 'i18nStrings.externalIconAriaLabel': never; }; + 'button-dropdown': { + errorIconAriaLabel: never; + recoveryText: never; + }; calendar: { nextMonthAriaLabel: never; previousMonthAriaLabel: never; diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index 9e3c9797ec..8535862703 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -58,6 +58,10 @@ "button": { "i18nStrings.externalIconAriaLabel": "Opens in a new tab" }, + "button-dropdown": { + "errorIconAriaLabel": "Error", + "recoveryText": "Retry" + }, "calendar": { "nextMonthAriaLabel": "Next month", "previousMonthAriaLabel": "Previous month", diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 04f247f2a8..31d96e6a1d 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -13,6 +13,7 @@ import styles from '../../../button-dropdown/styles.selectors.js'; import dropdownStyles from '../../../dropdown/styles.selectors.js'; import inputStyles from '../../../input/styles.selectors.js'; import footerStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; +import dropdownStatusStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; function getItemSelector({ disabled }: { disabled?: boolean }): string { let selector = `.${itemStyles['item-element']}`; @@ -122,6 +123,20 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { return createWrapper().find(`[data-testid="button-dropdown-disabled-reason"]`); } + /** + * Finds the error recovery button when item loading fails. + */ + findErrorRecoveryButton(): ElementWrapper | null { + return this.findOpenDropdown()?.findByClassName(footerStyles.recovery) ?? null; + } + + /** + * Finds the status displayed at the footer of the dropdown. + */ + findStatusIndicator(): ElementWrapper | null { + return this.findOpenDropdown()?.findByClassName(dropdownStatusStyles.root) ?? null; + } + /** * Finds the filtering input rendered inside the open dropdown when filtering is enabled. * Returns null if there is no open dropdown or filtering is not enabled. From 2de9547aa141c5a0475e53380724186207a28af8 Mon Sep 17 00:00:00 2001 From: Cansu Aksu Date: Tue, 11 Aug 2026 11:27:10 +0200 Subject: [PATCH 2/5] move manual filtering examples to a separate page --- pages/button-dropdown/filtering.page.tsx | 143 +-------------- .../button-dropdown/manual-filtering.page.tsx | 173 ++++++++++++++++++ 2 files changed, 174 insertions(+), 142 deletions(-) create mode 100644 pages/button-dropdown/manual-filtering.page.tsx diff --git a/pages/button-dropdown/filtering.page.tsx b/pages/button-dropdown/filtering.page.tsx index 0a120a6e31..94c9a5b4ab 100644 --- a/pages/button-dropdown/filtering.page.tsx +++ b/pages/button-dropdown/filtering.page.tsx @@ -1,14 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { Checkbox } from '~components'; import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import SpaceBetween from '~components/space-between'; -import AppContext, { AppContextType } from '../app/app-context'; import { SimplePage } from '../app/templates'; -import { useOptionsLoader } from '../common/options-loader'; import styles from './styles.scss'; @@ -157,69 +155,13 @@ const withCheckboxItems: ButtonDropdownProps['items'] = [ { itemType: 'checkbox', id: 'verbose-logs', text: 'Verbose logging', checked: true }, ]; -// Flat action list used to demonstrate manual (app-controlled) filtering. -const manualSourceItems: ButtonDropdownProps.Item[] = [ - { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, - { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, - { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, - { id: 'undo', text: 'Undo', labelTag: 'Ctrl+Z' }, - { id: 'redo', text: 'Redo', labelTag: 'Ctrl+Y' }, - { id: 'select-all', text: 'Select all', labelTag: 'Ctrl+A' }, - { id: 'find', text: 'Find and replace', secondaryText: 'Search within document', labelTag: 'Ctrl+H' }, - { id: 'preferences', text: 'Preferences', secondaryText: 'Configure editor settings' }, -]; - -// Larger list used to demonstrate asynchronous, paginated loading. -const asyncSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, index) => ({ - id: `action-${index + 1}`, - text: `Action ${index + 1}`, - secondaryText: index % 3 === 0 ? `Description for action ${index + 1}` : undefined, -})); - -type PageContext = React.Context< - AppContextType<{ - fakeResponses?: boolean; - }> ->; - export default function ButtonDropdownFilteringPage() { const [expandToViewport, setExpandToViewport] = useState(false); - const { - urlParams: { fakeResponses = true }, - } = useContext(AppContext as PageContext); - const [checkboxItems, setCheckboxItems] = useState(withCheckboxItems); const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; const onItemClick = (event: CustomEvent) => console.log(event.detail); - // Manual filtering: the default (client-side) filtering is disabled and the app decides - // which items to display based on the filtering text provided by `onLoadItems`. - const [manualItems, setManualItems] = useState(manualSourceItems); - - // Async loading: items are fetched (and paginated) through the shared options loader. - const { - items: asyncItems, - status, - filteringText, - fetchItems, - } = useOptionsLoader({ pageSize: 10 }); - - const showAsyncFilteredText = (matchesCount: number, totalCount: number) => { - if (status === 'pending') { - return `${matchesCount}+ results`; - } - if (status === 'finished') { - return `${matchesCount} out of ${totalCount} results`; - } - return ''; - }; - - // Error use case: the initial request fails deterministically, and clicking the recovery - // button (which fires `onLoadItems`) simulates a successful retry. - const [errorStatus, setErrorStatus] = useState('error'); - const [errorItems, setErrorItems] = useState([]); - return ( @@ -371,89 +313,6 @@ export default function ButtonDropdownFilteringPage() { onItemClick={onItemClick} /> - -
-

Manual filtering

- No actions match your search. Try a different keyword.} - expandToViewport={expandToViewport} - filteringResultsText={filteringResultsText} - onItemClick={onItemClick} - onLoadItems={({ detail: { filteringText } }) => { - const normalized = filteringText.toLowerCase(); - setManualItems(manualSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized))); - }} - > - Actions (manual) - -
- -
-

Async loading (paginated)

- { - const normalized = filteringText.toLowerCase(); - const filtered = asyncSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); - fetchItems({ firstPage, filteringText, sourceItems: fakeResponses ? filtered : undefined }); - }} - > - Async actions - -
- -
-

Error state with recovery

- { - if (samePage) { - // Triggered by the recovery button: simulate a successful retry. - setErrorStatus('loading'); - setTimeout(() => { - setErrorItems(manualSourceItems); - setErrorStatus('finished'); - }, 1000); - } else { - // Initial load (or a new filtering request) fails. - setErrorItems([]); - setErrorStatus('error'); - } - }} - > - Actions (error) - -
); diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx new file mode 100644 index 0000000000..62e490e14b --- /dev/null +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -0,0 +1,173 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { Checkbox } from '~components'; +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +import { useOptionsLoader } from '../common/options-loader'; + +import styles from './styles.scss'; + +// Flat action list used to demonstrate manual (app-controlled) filtering. +const manualSourceItems: ButtonDropdownProps.Item[] = [ + { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, + { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, + { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, + { id: 'undo', text: 'Undo', labelTag: 'Ctrl+Z' }, + { id: 'redo', text: 'Redo', labelTag: 'Ctrl+Y' }, + { id: 'select-all', text: 'Select all', labelTag: 'Ctrl+A' }, + { id: 'find', text: 'Find and replace', secondaryText: 'Search within document', labelTag: 'Ctrl+H' }, + { id: 'preferences', text: 'Preferences', secondaryText: 'Configure editor settings' }, +]; + +// Larger list used to demonstrate asynchronous, paginated loading. +const asyncSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, index) => ({ + id: `action-${index + 1}`, + text: `Action ${index + 1}`, + secondaryText: index % 3 === 0 ? `Description for action ${index + 1}` : undefined, +})); + +type PageContext = React.Context< + AppContextType<{ + fakeResponses?: boolean; + }> +>; + +export default function ButtonDropdownManualFilteringPage() { + const [expandToViewport, setExpandToViewport] = useState(false); + + const { + urlParams: { fakeResponses = true }, + } = useContext(AppContext as PageContext); + + const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; + const onItemClick = (event: CustomEvent) => console.log(event.detail); + + // Manual filtering: the default (client-side) filtering is disabled and the app decides + // which items to display based on the filtering text provided by `onLoadItems`. + const [manualItems, setManualItems] = useState(manualSourceItems); + + // Async loading: items are fetched (and paginated) through the shared options loader. + const { + items: asyncItems, + status, + filteringText, + fetchItems, + } = useOptionsLoader({ pageSize: 10 }); + + const showAsyncFilteredText = (matchesCount: number, totalCount: number) => { + if (status === 'pending') { + return `${matchesCount}+ results`; + } + if (status === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + }; + + // Error use case: the initial request fails deterministically, and clicking the recovery + // button (which fires `onLoadItems`) simulates a successful retry. + const [errorStatus, setErrorStatus] = useState('error'); + const [errorItems, setErrorItems] = useState([]); + + return ( + + + setExpandToViewport(event.detail.checked)} + data-testid="expand-to-viewport" + > + Expand to viewport + + +
+

Manual filtering

+ No actions match your search. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + const normalized = filteringText.toLowerCase(); + setManualItems(manualSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized))); + }} + > + Actions (manual) + +
+ +
+

Async loading (paginated)

+ { + const normalized = filteringText.toLowerCase(); + const filtered = asyncSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); + fetchItems({ firstPage, filteringText, sourceItems: fakeResponses ? filtered : undefined }); + }} + > + Async actions + +
+ +
+

Error state with recovery

+ { + if (samePage) { + // Triggered by the recovery button: simulate a successful retry. + setErrorStatus('loading'); + setTimeout(() => { + setErrorItems(manualSourceItems); + setErrorStatus('finished'); + }, 1000); + } else { + // Initial load (or a new filtering request) fails. + setErrorItems([]); + setErrorStatus('error'); + } + }} + > + Actions (error) + +
+
+
+ ); +} From 1efa2fbaafd58fa300f1e434b753a011efe6f772 Mon Sep 17 00:00:00 2001 From: Cansu Aksu Date: Tue, 11 Aug 2026 13:53:12 +0200 Subject: [PATCH 3/5] fix dev page and interface --- pages/button-dropdown/manual-filtering.page.tsx | 15 ++------------- .../__snapshots__/documenter.test.ts.snap | 5 +++++ src/button-dropdown/interfaces.ts | 8 ++++---- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx index 62e490e14b..3b187d3a52 100644 --- a/pages/button-dropdown/manual-filtering.page.tsx +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { Checkbox } from '~components'; import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import SpaceBetween from '~components/space-between'; -import AppContext, { AppContextType } from '../app/app-context'; import { SimplePage } from '../app/templates'; import { useOptionsLoader } from '../common/options-loader'; @@ -31,19 +30,9 @@ const asyncSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, secondaryText: index % 3 === 0 ? `Description for action ${index + 1}` : undefined, })); -type PageContext = React.Context< - AppContextType<{ - fakeResponses?: boolean; - }> ->; - export default function ButtonDropdownManualFilteringPage() { const [expandToViewport, setExpandToViewport] = useState(false); - const { - urlParams: { fakeResponses = true }, - } = useContext(AppContext as PageContext); - const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; const onItemClick = (event: CustomEvent) => console.log(event.detail); @@ -126,7 +115,7 @@ export default function ButtonDropdownManualFilteringPage() { onLoadItems={({ detail: { firstPage, filteringText } }) => { const normalized = filteringText.toLowerCase(); const filtered = asyncSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); - fetchItems({ firstPage, filteringText, sourceItems: fakeResponses ? filtered : undefined }); + fetchItems({ firstPage, filteringText, sourceItems: filtered }); }} > Async actions diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index c1bf2fabdf..f4202361fc 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -7182,6 +7182,11 @@ When returning \`null\`, the default styling will be applied.", }, { "defaultValue": "'normal'", + "description": "Determines the general styling of the button dropdown. +* \`primary\` for primary buttons +* \`normal\` for secondary buttons +* \`icon\` for icon buttons +* \`inline-icon\` for icon buttons with no outer padding", "inlineType": { "name": "ButtonDropdownProps.Variant", "type": "union", diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index b50a56a6c8..bbe056cd92 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -128,16 +128,16 @@ export interface ButtonDropdownProps * Specifies the text that screen reader announces when the button dropdown is in a loading state. */ loadingText?: string; + /** + * Specifies the text to display inside the dropdown when items are loading. + **/ + itemsLoadingText?: string; /** Determines the general styling of the button dropdown. * * `primary` for primary buttons * * `normal` for secondary buttons * * `icon` for icon buttons * * `inline-icon` for icon buttons with no outer padding */ - /** - * Specifies the text to display inside the dropdown when items are loading. - **/ - itemsLoadingText?: string; variant?: ButtonDropdownProps.Variant; /** * Specifies the name of the icon used in the button dropdown trigger, used with the [icon component](/components/icon/). From f6601da53aec7e3ea05a3028b0e7d3742bd37294 Mon Sep 17 00:00:00 2001 From: Cansu Aksu Date: Mon, 17 Aug 2026 15:42:41 +0200 Subject: [PATCH 4/5] update api and test utils --- pages/button-dropdown/async-loading.page.tsx | 210 +++++++ .../button-dropdown/manual-filtering.page.tsx | 144 ++--- .../__snapshots__/documenter.test.ts.snap | 561 +++++++++++++++--- .../button-dropdown-async-loading.test.tsx | 31 +- .../button-dropdown-filtering.test.tsx | 16 + .../expandable-category-element.tsx | 48 +- src/button-dropdown/index.tsx | 18 +- src/button-dropdown/interfaces.ts | 113 +++- src/button-dropdown/internal-interfaces.ts | 6 + src/button-dropdown/internal.tsx | 28 +- src/button-dropdown/items-list.tsx | 6 + src/button-dropdown/utils/use-load-items.ts | 15 +- src/test-utils/dom/button-dropdown/index.ts | 24 +- 13 files changed, 1001 insertions(+), 219 deletions(-) create mode 100644 pages/button-dropdown/async-loading.page.tsx diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx new file mode 100644 index 0000000000..a39a3d5972 --- /dev/null +++ b/pages/button-dropdown/async-loading.page.tsx @@ -0,0 +1,210 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Checkbox } from '~components'; +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; +import { useOptionsLoader } from '../common/options-loader'; + +import styles from './styles.scss'; + +// Source data for the flat async-loading example (25 items, paginated). +const flatSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, i) => ({ + id: `action-${i + 1}`, + text: `Action ${i + 1}`, + secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, +})); + +// Groups for the expandable async-loading example. +// group-files: loads successfully after 600ms. +// group-edit: starts loading then fails after 800ms (error + retry button visible; retry also fails). +// group-view: stays loading indefinitely to show a persistent loading spinner. +const groupSourceItems: Record = { + 'group-files': Array.from({ length: 8 }, (_, i) => ({ id: `file-${i + 1}`, text: `File action ${i + 1}` })), +}; + +function fetchGroupItems(groupId: string): Promise { + if (groupId === 'group-files') { + return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), 600)); + } + if (groupId === 'group-edit') { + // Always rejects — simulates a persistent server error. + return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), 800)); + } + // group-view: never resolves — shows a permanent loading spinner. + return new Promise(() => {}); +} + +export default function ButtonDropdownAsyncLoadingPage() { + const [expandToViewport, setExpandToViewport] = useState(false); + const onItemClick = (event: CustomEvent) => console.log(event.detail); + + // --- Flat async loading --- + const { + items: flatItems, + status: flatStatus, + filteringText: flatFilteringText, + fetchItems, + } = useOptionsLoader({ pageSize: 10 }); + + const flatFilteringResultsText = (matchesCount: number, totalCount: number) => { + if (flatStatus === 'pending') { + return `${matchesCount}+ results`; + } + if (flatStatus === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + }; + + // --- Expandable groups async loading --- + // Track loaded items and status per group id. + const [groupItems, setGroupItems] = useState>({}); + const [groupStatuses, setGroupStatuses] = useState>({}); + + const expandableItems: ButtonDropdownProps.Items = [ + { id: 'group-files', text: 'File (loads successfully)', items: groupItems['group-files'] ?? [] }, + { id: 'group-edit', text: 'Edit (always errors)', items: groupItems['group-edit'] ?? [] }, + { id: 'group-view', text: 'View (loading forever)', items: groupItems['group-view'] ?? [] }, + ]; + + // Error state example for the flat async loading. + const [errorStatus, setErrorStatus] = useState('error'); + const [errorItems, setErrorItems] = useState([]); + const manualSourceItems: ButtonDropdownProps.Item[] = flatSourceItems.slice(0, 8); + + return ( + + + setExpandToViewport(event.detail.checked)} + data-testid="expand-to-viewport" + > + Expand to viewport + + +
+

Async loading (flat items, paginated)

+

+ Items are loaded on open and on scroll using useOptionsLoader. Supports filtering with a fake + server-side search. +

+ 'Loading actions', + errorText: () => 'Error fetching actions.', + recoveryText: 'Retry', + finishedText: () => (flatFilteringText ? `End of "${flatFilteringText}" results` : 'End of all results'), + empty: () => 'No actions found', + }} + expandToViewport={expandToViewport} + filteringResultsText={flatFilteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { firstPage, filteringText } }) => { + const normalized = filteringText.toLowerCase(); + const filtered = flatSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); + fetchItems({ firstPage, filteringText, sourceItems: filtered }); + }} + > + Async actions + +
+ +
+

Async loading per expandable group

+

+ Each group's items are fetched independently when the group is expanded. The per-group status is shown + inside the group's sub-dropdown via getExpandableItemsAsyncLoadingState. +

+ `Loading ${groupId ?? 'items'}…`, + errorText: (groupId?: string) => `Failed to load ${groupId ?? 'items'}.`, + recoveryText: 'Retry', + empty: (groupId?: string) => `No items in ${groupId ?? 'group'}.`, + }} + getExpandableItemsAsyncLoadingState={({ item }) => { + const id = item.id; + return id ? (groupStatuses[id] ?? null) : null; + }} + expandToViewport={expandToViewport} + onItemClick={onItemClick} + onLoadItems={({ detail: { expandedGroupId, samePage } }) => { + if (!expandedGroupId) { + return; + } + // Both initial expand and retry go through the same fetch path. + // For a retry (samePage=true) we keep the existing items visible while reloading. + if (!samePage) { + setGroupItems(prev => ({ ...prev, [expandedGroupId]: [] })); + } + setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'loading' })); + fetchGroupItems(expandedGroupId) + .then(items => { + setGroupItems(prev => ({ ...prev, [expandedGroupId]: items })); + setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'finished' })); + }) + .catch(() => { + setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'error' })); + }); + }} + > + Instance actions + +
+ +
+

Error state with recovery

+

+ The initial load fails deterministically. Clicking Retry simulates a successful recovery after a 1 second + delay. +

+ 'Loading actions', + errorText: () => 'Error fetching actions.', + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + empty: () => 'No actions found', + }} + expandToViewport={expandToViewport} + onItemClick={onItemClick} + onLoadItems={({ detail: { samePage } }) => { + if (samePage) { + setErrorStatus('loading'); + setTimeout(() => { + setErrorItems(manualSourceItems); + setErrorStatus('finished'); + }, 1000); + } else { + setErrorItems([]); + setErrorStatus('error'); + } + }} + > + Actions (error) + +
+
+
+ ); +} diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx index 3b187d3a52..7268bc4fcc 100644 --- a/pages/button-dropdown/manual-filtering.page.tsx +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -7,12 +7,10 @@ import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown import SpaceBetween from '~components/space-between'; import { SimplePage } from '../app/templates'; -import { useOptionsLoader } from '../common/options-loader'; import styles from './styles.scss'; -// Flat action list used to demonstrate manual (app-controlled) filtering. -const manualSourceItems: ButtonDropdownProps.Item[] = [ +const sourceItems: ButtonDropdownProps.Item[] = [ { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, @@ -23,48 +21,32 @@ const manualSourceItems: ButtonDropdownProps.Item[] = [ { id: 'preferences', text: 'Preferences', secondaryText: 'Configure editor settings' }, ]; -// Larger list used to demonstrate asynchronous, paginated loading. -const asyncSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, index) => ({ - id: `action-${index + 1}`, - text: `Action ${index + 1}`, - secondaryText: index % 3 === 0 ? `Description for action ${index + 1}` : undefined, -})); +function filterLocally(filteringText: string): ButtonDropdownProps.Items { + const normalized = filteringText.toLowerCase(); + return sourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); +} + +// Simulates a 400ms server round-trip. +function fetchFromServer(filteringText: string): Promise { + const normalized = filteringText.toLowerCase(); + const results = sourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); + return new Promise(resolve => setTimeout(() => resolve(results), 400)); +} export default function ButtonDropdownManualFilteringPage() { const [expandToViewport, setExpandToViewport] = useState(false); - - const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; const onItemClick = (event: CustomEvent) => console.log(event.detail); + const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; - // Manual filtering: the default (client-side) filtering is disabled and the app decides - // which items to display based on the filtering text provided by `onLoadItems`. - const [manualItems, setManualItems] = useState(manualSourceItems); - - // Async loading: items are fetched (and paginated) through the shared options loader. - const { - items: asyncItems, - status, - filteringText, - fetchItems, - } = useOptionsLoader({ pageSize: 10 }); - - const showAsyncFilteredText = (matchesCount: number, totalCount: number) => { - if (status === 'pending') { - return `${matchesCount}+ results`; - } - if (status === 'finished') { - return `${matchesCount} out of ${totalCount} results`; - } - return ''; - }; + // Client-side manual filtering: the app filters synchronously in onLoadItems. + const [clientItems, setClientItems] = useState(sourceItems); - // Error use case: the initial request fails deterministically, and clicking the recovery - // button (which fires `onLoadItems`) simulates a successful retry. - const [errorStatus, setErrorStatus] = useState('error'); - const [errorItems, setErrorItems] = useState([]); + // Server-side manual filtering: onLoadItems triggers a fake async request. + const [serverItems, setServerItems] = useState(sourceItems); + const [serverStatus, setServerStatus] = useState('finished'); return ( - +
-

Manual filtering

+

Client-side manual filtering

+

+ The app filters the items synchronously inside onLoadItems. No status indicators are needed. +

No actions match your search. Try a different keyword.} + noMatch={No actions match. Try a different keyword.} expandToViewport={expandToViewport} filteringResultsText={filteringResultsText} onItemClick={onItemClick} onLoadItems={({ detail: { filteringText } }) => { - const normalized = filteringText.toLowerCase(); - setManualItems(manualSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized))); + setClientItems(filterLocally(filteringText)); }} > - Actions (manual) + Actions
-

Async loading (paginated)

+

Server-side manual filtering (fake)

+

+ The app calls a fake async API on every filtering change. A loading spinner appears while the request is in + flight. +

{ - const normalized = filteringText.toLowerCase(); - const filtered = asyncSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); - fetchItems({ firstPage, filteringText, sourceItems: filtered }); + asyncLoadingProps={{ + statusType: serverStatus, + loadingText: () => 'Searching…', + empty: () => 'No actions found', }} - > - Async actions - -
- -
-

Error state with recovery

- No actions match. Try a different keyword.} expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} onItemClick={onItemClick} - onLoadItems={({ detail: { samePage } }) => { - if (samePage) { - // Triggered by the recovery button: simulate a successful retry. - setErrorStatus('loading'); - setTimeout(() => { - setErrorItems(manualSourceItems); - setErrorStatus('finished'); - }, 1000); - } else { - // Initial load (or a new filtering request) fails. - setErrorItems([]); - setErrorStatus('error'); - } + onLoadItems={({ detail: { filteringText } }) => { + setServerStatus('loading'); + setServerItems([]); + fetchFromServer(filteringText).then(results => { + setServerItems(results); + setServerStatus('finished'); + }); }} > - Actions (error) + Actions
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index f4202361fc..b863787579 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6276,9 +6276,28 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se }, { "cancelable": false, + "description": "Use this event to implement the asynchronous behavior for the component. + +The event is called in the following situations: +* The user scrolls to the end of the list of options, if \`statusType\` is set to \`pending\`. +* The user clicks on the recovery button in the error state. +* The user types inside the input field. +* The user focuses the input field. +* The user expands an expandable group item. + +The detail object contains the following properties: +* \`filteringText\` - The value that you need to use to fetch options. +* \`firstPage\` - Indicates that you should fetch the first page of options that match the \`filteringText\`. +* \`samePage\` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). +* \`expandedGroupId\` - The ID of the expanded group that you need to load the items of.", "detailInlineType": { "name": "ButtonDropdownProps.LoadItemsDetail", "properties": [ + { + "name": "expandedGroupId", + "optional": true, + "type": "string", + }, { "name": "filteringText", "optional": false, @@ -6334,6 +6353,118 @@ Use this to provide an accessible name for buttons that don't have visible text. "optional": true, "type": "string", }, + { + "description": "Contains all the properties for async loading. Make sure to listen to \`onLoadItems\`. +* \`empty\` - (Optional) Displayed when there are no options to display. This is only shown when \`statusType\` is set to \`finished\` or not set at all. +* \`loadingText\` - (Optional) Specifies the text to display when in the loading state. +* \`finishedText\` - (Optional) Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. +* \`errorText\` - (Optional) Specifies the text to display when a data fetching error occurs. Make sure that you provide \`recoveryText\`. +* \`recoveryText\` (i18n) - (Optional) Specifies the text for the recovery button. The text is displayed next to the error text. Use the \`onLoadItems\` event to perform a recovery action (for example, retrying the request). +* \`errorIconAriaLabel\` (i18n) - (Optional) Provides a text alternative for the error icon in the error message. +* \`statusType\` - (Optional) Specifies the current status of loading more options. +* * \`pending\` - Indicates that no request in progress, but more options may be loaded. +* * \`loading\` - Indicates that data fetching is in progress. +* * \`finished\` - Indicates that pagination has finished and no more requests are expected. +* * \`error\` - Indicates that an error occurred during fetch. You should use \`recoveryText\` to enable the user to recover.", + "inlineType": { + "name": "ButtonDropdownProps.AsyncLoadingProps", + "properties": [ + { + "inlineType": { + "name": "(expandedGroupId?: string | undefined) => React.ReactNode", + "parameters": [ + { + "name": "expandedGroupId", + "type": "string", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "empty", + "optional": true, + "type": "((expandedGroupId?: string | undefined) => React.ReactNode)", + }, + { + "name": "errorIconAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(expandedGroupId?: string | undefined) => string", + "parameters": [ + { + "name": "expandedGroupId", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "errorText", + "optional": true, + "type": "((expandedGroupId?: string | undefined) => string)", + }, + { + "inlineType": { + "name": "(expandedGroupId?: string | undefined) => string", + "parameters": [ + { + "name": "expandedGroupId", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "finishedText", + "optional": true, + "type": "((expandedGroupId?: string | undefined) => string)", + }, + { + "inlineType": { + "name": "(expandedGroupId?: string | undefined) => string", + "parameters": [ + { + "name": "expandedGroupId", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "loadingText", + "optional": true, + "type": "((expandedGroupId?: string | undefined) => string)", + }, + { + "name": "recoveryText", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "ButtonDropdownProps.AsyncLoadingStatusType", + "type": "union", + "values": [ + "error", + "finished", + "loading", + "pending", + ], + }, + "name": "statusType", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "asyncLoadingProps", + "optional": true, + "type": "ButtonDropdownProps.AsyncLoadingProps", + }, { "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", "description": "Adds the specified classes to the root element of the component.", @@ -6355,22 +6486,11 @@ If provided, the disabled button becomes focusable.", "optional": true, "type": "string", }, - { - "description": "Provides a text alternative for the error icon in the error message.", - "i18nTag": true, - "name": "errorIconAriaLabel", - "optional": true, - "type": "string", - }, - { - "description": "Specifies the text to display when a data fetching error occurs. Make sure that you provide \`recoveryText\`.", - "name": "errorText", - "optional": true, - "type": "string", - }, { "defaultValue": "false", - "description": "Controls expandability of the item groups.", + "description": "Controls expandability of the item groups. +If async loading, make sure to define expandable groups' statuses in \`expandableItemsAsyncLoadingStates\`. +If manual filtering and there are filtered items, make sure to disable this to ensure filtered items are easily discoverable.", "name": "expandableGroups", "optional": true, "type": "boolean", @@ -6443,7 +6563,8 @@ The filtering text is matched against the item's \`text\`, \`secondaryText\`, an If you set this property to \`manual\`, the default filtering mechanism is disabled and all provided \`items\` are displayed in the dropdown list. In that case make sure that you use the \`onLoadItems\` events in order -to set the \`items\` property to the items that are relevant for the user, given the filtering input value.", +to set the \`items\` property to the items that are relevant for the user, given the filtering input value. +When there are filtered items, disable \`expandableGroups\` to ensure filtered items are easily discoverable.", "inlineType": { "name": "ButtonDropdownProps.FilteringType", "type": "union", @@ -6457,18 +6578,38 @@ to set the \`items\` property to the items that are relevant for the user, given "optional": true, "type": "string", }, - { - "description": "Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end.", - "name": "finishedText", - "optional": true, - "type": "string", - }, { "description": "Sets the button width to be 100% of the parent container width. Button content is centered.", "name": "fullWidth", "optional": true, "type": "boolean", }, + { + "description": "Specifies the async loading status of individual expandable items. +Use only if you load nested items asynchronously upon expanding an item. + +Return values are: +* \`pending\` - Indicates that no request in progress, but more options may be loaded. +* \`loading\` - Indicates that data fetching is in progress. +* \`finished\` - Indicates that pagination has finished and no more requests are expected. +* \`error\` - Indicates that an error occurred during fetch. You should use \`recoveryText\` to enable the user to recover. + +If null or undefined, the status will be treated as \`finished\`.", + "inlineType": { + "name": "(options: { item: ButtonDropdownProps.ItemOrGroup; }) => ButtonDropdownProps.AsyncLoadingStatusType | null", + "parameters": [ + { + "name": "options", + "type": "{ item: ButtonDropdownProps.ItemOrGroup; }", + }, + ], + "returnType": "ButtonDropdownProps.AsyncLoadingStatusType | null", + "type": "function", + }, + "name": "getExpandableItemsAsyncLoadingState", + "optional": true, + "type": "((options: { item: ButtonDropdownProps.ItemOrGroup; }) => ButtonDropdownProps.AsyncLoadingStatusType | null | undefined)", + }, { "description": "An object containing all the necessary localized strings required by the component.", "inlineType": { @@ -7095,14 +7236,6 @@ We do not support using this attribute to apply custom styling.", ], "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", }, - { - "description": "Specifies the text for the recovery button. The text is displayed next to the error text. -Use the \`onLoadItems\` event to perform a recovery action (for example, retrying the request).", - "i18nTag": true, - "name": "recoveryText", - "optional": true, - "type": "string", - }, { "description": "Specifies a render function to render custom options in the dropdown menu. @@ -7160,26 +7293,6 @@ When returning \`null\`, the default styling will be applied.", "optional": true, "type": "ButtonDropdownProps.ItemRenderer", }, - { - "description": "Specifies the current status of loading more options. -* \`pending\` - Indicates that no request in progress, but more options may be loaded. -* \`loading\` - Indicates that data fetching is in progress. -* \`finished\` - Indicates that pagination has finished and no more requests are expected. -* \`error\` - Indicates that an error occurred during fetch. You should use \`recoveryText\` to enable the user to recover.", - "inlineType": { - "name": "DropdownStatusProps.StatusType", - "type": "union", - "values": [ - "error", - "finished", - "loading", - "pending", - ], - }, - "name": "statusType", - "optional": true, - "type": "string", - }, { "defaultValue": "'normal'", "description": "Determines the general styling of the button dropdown. @@ -7209,12 +7322,6 @@ When returning \`null\`, the default styling will be applied.", "isDefault": true, "name": "children", }, - { - "description": "Displayed when there are no options to display. -This is only shown when \`statusType\` is set to \`finished\` or not set at all.", - "isDefault": false, - "name": "empty", - }, { "description": "Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/). Applies to the \`icon\` and \`inline-icon\` variants only. @@ -7224,7 +7331,7 @@ If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.", "name": "iconSvg", }, { - "description": "Displayed when filtering is enabled and there are no matches for the filtering input.", + "description": "Displayed for \`filteringType="auto"\` when there are no matches for the filtering input.", "isDefault": false, "name": "noMatch", }, @@ -35554,7 +35661,8 @@ Use this method to assert the panel position.", }, }, { - "description": "Finds the error recovery button when item loading fails.", + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", "name": "findErrorRecoveryButton", "parameters": [], "returnType": { @@ -35591,6 +35699,58 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -35754,7 +35914,8 @@ Supported options: }, }, { - "description": "Finds the status displayed at the footer of the dropdown.", + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", "name": "findStatusIndicator", "parameters": [], "returnType": { @@ -45274,7 +45435,8 @@ Supported options: }, }, { - "description": "Finds the error recovery button when item loading fails.", + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findErrorRecoveryButton", }, @@ -45317,6 +45479,64 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -45585,7 +45805,8 @@ Supported options: }, }, { - "description": "Finds the status displayed at the footer of the dropdown.", + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findStatusIndicator", }, @@ -46957,7 +47178,8 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", }, }, { - "description": "Finds the error recovery button when item loading fails.", + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findErrorRecoveryButton", }, @@ -47000,6 +47222,64 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -47187,7 +47467,8 @@ Supported options: }, }, { - "description": "Finds the status displayed at the footer of the dropdown.", + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findStatusIndicator", }, @@ -48423,7 +48704,8 @@ Use this method to assert the panel position.", }, }, { - "description": "Finds the error recovery button when item loading fails.", + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", "name": "findErrorRecoveryButton", "parameters": [], "returnType": { @@ -48450,6 +48732,48 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -48569,7 +48893,8 @@ Supported options: }, }, { - "description": "Finds the status displayed at the footer of the dropdown.", + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", "name": "findStatusIndicator", "parameters": [], "returnType": { @@ -55339,7 +55664,8 @@ Supported options: }, }, { - "description": "Finds the error recovery button when item loading fails.", + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findErrorRecoveryButton", }, @@ -55372,6 +55698,54 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -55578,7 +55952,8 @@ Supported options: }, }, { - "description": "Finds the status displayed at the footer of the dropdown.", + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findStatusIndicator", }, @@ -56544,7 +56919,8 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", }, }, { - "description": "Finds the error recovery button when item loading fails.", + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findErrorRecoveryButton", }, @@ -56577,6 +56953,54 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -56717,7 +57141,8 @@ Supported options: }, }, { - "description": "Finds the status displayed at the footer of the dropdown.", + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", "inheritedFrom": { "name": "ButtonDropdownWrapper.findStatusIndicator", }, diff --git a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx index 53b468cb77..e67d5be099 100644 --- a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -65,9 +65,11 @@ describe('ButtonDropdown async loading', () => { const onLoadItems = jest.fn(); const { wrapper } = renderDropdown({ filteringType: 'manual', - statusType: 'error', - errorText: 'Error fetching items', - recoveryText: 'Retry', + asyncLoadingProps: { + statusType: 'error', + errorText: () => 'Error fetching items', + recoveryText: 'Retry', + }, onLoadItems: event => onLoadItems(event.detail), }); wrapper.openDropdown(); @@ -80,7 +82,13 @@ describe('ButtonDropdown async loading', () => { }); test('warns if recoveryText is provided without onLoadItems', () => { - renderDropdown({ statusType: 'error', errorText: 'Error', recoveryText: 'Retry' }); + renderDropdown({ + asyncLoadingProps: { + statusType: 'error', + errorText: () => 'Error', + recoveryText: 'Retry', + }, + }); expect(warnOnce).toHaveBeenCalledWith( 'ButtonDropdown', '`onLoadItems` must be provided for `recoveryText` to be displayed.' @@ -107,28 +115,31 @@ describe('ButtonDropdown status display', () => { ])('displays %s status text as %s footer', (statusType, isSticky) => { const statusText = statusType === 'loading' - ? { itemsLoadingText: 'Test loading text' } - : { [`${statusType}Text`]: `Test ${statusType} text` }; + ? { loadingText: () => 'Test loading text' } + : { [`${statusType}Text`]: () => `Test ${statusType} text` }; const expectedText = statusType === 'loading' ? 'Test loading text' : `Test ${statusType} text`; const { wrapper } = renderDropdown({ - statusType: statusType as ButtonDropdownProps['statusType'], + asyncLoadingProps: { + statusType: statusType as ButtonDropdownProps.AsyncLoadingStatusType, + ...statusText, + }, onLoadItems: () => {}, - ...statusText, }); wrapper.openDropdown(); const statusIndicator = wrapper.findStatusIndicator(); expect(statusIndicator).not.toBeNull(); expect(statusIndicator!.getElement()).toHaveTextContent(expectedText); - // isSticky is currently unused in the assertion beyond documenting intent. void isSticky; }); test('displays the empty state when there are no items', () => { const { wrapper } = renderDropdown({ items: [], - empty: 'No items available', + asyncLoadingProps: { + empty: () => 'No items available', + }, }); wrapper.openDropdown(); const status = wrapper.findStatusIndicator(); diff --git a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx index 9bb5e30fde..c18cbce22a 100644 --- a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx @@ -465,6 +465,22 @@ describe('Button dropdown filtering', () => { }); describe('filtered expandable groups', () => { + test('keeps expandable groups expandable while filtering in manual mode', () => { + // In manual mode the parent controls the returned items (they are not flattened + // client-side), so the provided expandable group structure must be preserved even + // while the filtering input has a value. + const { wrapper } = renderDropdown({ + filteringType: 'manual', + items: expandableItems, + expandableGroups: true, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Start'); + + expect(wrapper.findExpandableCategoryById('states')).not.toBeNull(); + }); + test('renders matching nested items and collapses expandable groups', () => { const { wrapper } = renderDropdown({ filteringType: 'auto', diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index 11e87689cd..aba8019124 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -3,11 +3,15 @@ import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; -import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import Dropdown from '../../dropdown/internal'; +import { useInternalI18n } from '../../i18n/context'; import InternalIcon from '../../icon/internal'; +import DropdownFooter from '../../internal/components/dropdown-footer'; +import { useDropdownStatus } from '../../internal/components/dropdown-status'; +import { fireNonCancelableEvent } from '../../internal/events'; import useHiddenDescription from '../../internal/hooks/use-hidden-description'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { @@ -42,12 +46,44 @@ const ExpandableCategoryElement = ({ filteringEnabled, menuId, filteringDescriptionId, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onLoadItems, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); const isKeyboardHighlighted = isKeyboardHighlight(item); const triggerRef = React.useRef(null); const ref = useRef(null); + const footerId = useUniqueId('awsui-button-dropdown__group-footer'); + + // Per-group async loading status derived from the callback prop. + const groupId = item.id; + const groupStatusType = groupId ? (getExpandableItemsAsyncLoadingState?.({ item }) ?? undefined) : undefined; + + const i18n = useInternalI18n('button-dropdown'); + const recoveryText = i18n('recoveryText', asyncLoadingProps?.recoveryText); + const errorIconAriaLabel = i18n('errorIconAriaLabel', asyncLoadingProps?.errorIconAriaLabel); + + const groupDropdownStatus = useDropdownStatus({ + statusType: groupStatusType, + empty: asyncLoadingProps?.empty?.(groupId), + loadingText: asyncLoadingProps?.loadingText?.(groupId), + finishedText: asyncLoadingProps?.finishedText?.(groupId), + errorText: asyncLoadingProps?.errorText?.(groupId), + recoveryText, + errorIconAriaLabel, + isEmpty: !item.items || item.items.length === 0, + isNoMatch: false, + hasRecoveryCallback: !!onLoadItems, + onRecoveryClick: () => + fireNonCancelableEvent(onLoadItems, { + filteringText: '', + firstPage: false, + samePage: true, + expandedGroupId: groupId, + }), + }); useEffect(() => { if (triggerRef.current && highlighted && !expanded && !filteringEnabled) { @@ -58,6 +94,15 @@ const ExpandableCategoryElement = ({ const onClick: React.MouseEventHandler = event => { if (!disabled) { event.preventDefault(); + // Fire onLoadItems when expanding (not collapsing) a group. + if (!expanded && groupId && onLoadItems) { + fireNonCancelableEvent(onLoadItems, { + filteringText: '', + firstPage: true, + samePage: false, + expandedGroupId: groupId, + }); + } onGroupToggle(item, event); if (!filteringEnabled) { triggerRef.current?.focus(); @@ -192,6 +237,7 @@ const ExpandableCategoryElement = ({ menuId={menuId} filteringDescriptionId={filteringDescriptionId} /> + {groupDropdownStatus.content && } ) : undefined } diff --git a/src/button-dropdown/index.tsx b/src/button-dropdown/index.tsx index cc336f0e30..b5a04708bf 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -48,13 +48,8 @@ const ButtonDropdown = React.forwardRef( filteringResultsText, noMatch, onLoadItems, - statusType, - empty, - itemsLoadingText, - finishedText, - errorText, - recoveryText, - errorIconAriaLabel, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, i18nStrings, ...props }: ButtonDropdownProps, @@ -110,13 +105,8 @@ const ButtonDropdown = React.forwardRef( filteringResultsText={filteringResultsText} noMatch={noMatch} onLoadItems={onLoadItems} - statusType={statusType} - empty={empty} - itemsLoadingText={itemsLoadingText} - finishedText={finishedText} - errorText={errorText} - recoveryText={recoveryText} - errorIconAriaLabel={errorIconAriaLabel} + asyncLoadingProps={asyncLoadingProps} + getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} i18nStrings={i18nStrings} {...getAnalyticsMetadataAttribute({ component: analyticsComponentMetadata, diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index bbe056cd92..6847112dd8 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -3,20 +3,16 @@ import React, { ReactNode } from 'react'; import { ButtonProps } from '../button/interfaces'; -import { ExpandToViewport, OptionsLoadItemsDetail } from '../dropdown/interfaces'; +import { ExpandToViewport } from '../dropdown/interfaces'; import { IconProps } from '../icon/interfaces'; import { BaseComponentProps } from '../types/base-component'; -import { DropdownStatusProps } from '../types/dropdown-status'; import { BaseNavigationDetail, CancelableEventHandler, NonCancelableEventHandler } from '../types/events'; /** * @awsuiSystem core */ import { NativeAttributes } from '../types/native-attributes'; -export interface ButtonDropdownProps - extends BaseComponentProps, - ExpandToViewport, - Omit { +export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewport { /** * Array of objects with a number of supported types. * @@ -132,6 +128,21 @@ export interface ButtonDropdownProps * Specifies the text to display inside the dropdown when items are loading. **/ itemsLoadingText?: string; + /** + * Specifies the async loading status of individual expandable items. + * Use only if you load nested items asynchronously upon expanding an item. + * + * Return values are: + * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * `loading` - Indicates that data fetching is in progress. + * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + * + * If null or undefined, the status will be treated as `finished`. + */ + getExpandableItemsAsyncLoadingState?: (options: { + item: ButtonDropdownProps.ItemGroup; + }) => ButtonDropdownProps.AsyncLoadingStatusType | null | undefined; /** Determines the general styling of the button dropdown. * * `primary` for primary buttons * * `normal` for secondary buttons @@ -163,6 +174,8 @@ export interface ButtonDropdownProps iconSvg?: React.ReactNode; /** * Controls expandability of the item groups. + * If async loading, make sure to define expandable groups' statuses in `expandableItemsAsyncLoadingStates`. + * If manual filtering and there are filtered items, make sure to disable this to ensure filtered items are easily discoverable. */ expandableGroups?: boolean; /** @@ -184,7 +197,6 @@ export interface ButtonDropdownProps * modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an `href` set. */ onItemFollow?: CancelableEventHandler; - onLoadItems?: NonCancelableEventHandler; /** * A standalone action that is shown prior to the dropdown trigger. * Use it with "primary" and "normal" variant only. @@ -217,6 +229,7 @@ export interface ButtonDropdownProps * If you set this property to `manual`, the default filtering mechanism is disabled and all provided `items` are * displayed in the dropdown list. In that case make sure that you use the `onLoadItems` events in order * to set the `items` property to the items that are relevant for the user, given the filtering input value. + * When there are filtered items, disable `expandableGroups` to ensure filtered items are easily discoverable. * */ filteringType?: ButtonDropdownProps.FilteringType; @@ -243,7 +256,7 @@ export interface ButtonDropdownProps filteringResultsText?: (matchesCount: number, totalCount: number) => string; /** - * Displayed when filtering is enabled and there are no matches for the filtering input. + * Displayed for `filteringType="auto"` when there are no matches for the filtering input. */ noMatch?: React.ReactNode; @@ -252,6 +265,40 @@ export interface ButtonDropdownProps */ i18nStrings?: ButtonDropdownProps.I18nStrings; + /** + * Contains all the properties for async loading. Make sure to listen to `onLoadItems`. + * * `empty` - (Optional) Displayed when there are no options to display. This is only shown when `statusType` is set to `finished` or not set at all. + * * `loadingText` - (Optional) Specifies the text to display when in the loading state. + * * `finishedText` - (Optional) Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. + * * `errorText` - (Optional) Specifies the text to display when a data fetching error occurs. Make sure that you provide `recoveryText`. + * * `recoveryText` (i18n) - (Optional) Specifies the text for the recovery button. The text is displayed next to the error text. Use the `onLoadItems` event to perform a recovery action (for example, retrying the request). + * * `errorIconAriaLabel` (i18n) - (Optional) Provides a text alternative for the error icon in the error message. + * * `statusType` - (Optional) Specifies the current status of loading more options. + * * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * * `loading` - Indicates that data fetching is in progress. + * * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + */ + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + + /** + * Use this event to implement the asynchronous behavior for the component. + * + * The event is called in the following situations: + * * The user scrolls to the end of the list of options, if `statusType` is set to `pending`. + * * The user clicks on the recovery button in the error state. + * * The user types inside the input field. + * * The user focuses the input field. + * * The user expands an expandable group item. + * + * The detail object contains the following properties: + * * `filteringText` - The value that you need to use to fetch options. + * * `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. + * * `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). + * * `expandedGroupId` - The ID of the expanded group that you need to load the items of. + **/ + onLoadItems?: NonCancelableEventHandler; + /** * Attributes to add to the native `button` element. * Some attributes will be automatically combined with internal attribute values: @@ -287,11 +334,53 @@ export namespace ButtonDropdownProps { export type Variant = 'normal' | 'primary' | 'icon' | 'inline-icon'; export type ItemType = 'action' | 'group'; export type FilteringType = 'auto' | 'manual' | 'none'; + export type AsyncLoadingStatusType = 'pending' | 'loading' | 'finished' | 'error'; - /* eslint-disable-next-line @typescript-eslint/no-empty-object-type -- - * Required to create a distinct named type for the documenter. - **/ - export interface LoadItemsDetail extends OptionsLoadItemsDetail {} + export interface LoadItemsDetail { + expandedGroupId?: string; + filteringText: string; + firstPage: boolean; + samePage: boolean; + } + + export interface AsyncLoadingProps { + /** + * Displayed when there are no options to display. + * This is only shown when `statusType` is set to `finished` or not set at all. + */ + empty?: (expandedGroupId?: string) => ReactNode; + /** + * Specifies the text to display when in the loading state. + **/ + loadingText?: (expandedGroupId?: string) => string; + /** + * Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. + **/ + finishedText?: (expandedGroupId?: string) => string; + /** + * Specifies the text to display when a data fetching error occurs. Make sure that you provide `recoveryText`. + **/ + errorText?: (expandedGroupId?: string) => string; + /** + * Specifies the text for the recovery button. The text is displayed next to the error text. + * Use the `onLoadItems` event to perform a recovery action (for example, retrying the request). + * @i18n + **/ + recoveryText?: string; + /** + * Provides a text alternative for the error icon in the error message. + * @i18n + */ + errorIconAriaLabel?: string; + /** + * Specifies the current status of loading more options. + * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * `loading` - Indicates that data fetching is in progress. + * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + **/ + statusType?: ButtonDropdownProps.AsyncLoadingStatusType; + } export interface I18nStrings { filteringItemAriaDescription?: string; diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 65dd6ba282..798a0cffaf 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -29,6 +29,9 @@ export interface CategoryProps extends HighlightProps { filteringEnabled?: boolean; menuId?: string; filteringDescriptionId?: string; + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; + onLoadItems?: ButtonDropdownProps['onLoadItems']; } export interface ItemListProps extends HighlightProps { @@ -50,6 +53,9 @@ export interface ItemListProps extends HighlightProps { filteringEnabled?: boolean; menuId?: string; filteringDescriptionId?: string; + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; + onLoadItems?: ButtonDropdownProps['onLoadItems']; } export interface ItemProps { diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index a0b47ce872..a6acf4374f 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -79,11 +79,8 @@ const InternalButtonDropdown = React.forwardRef( filteringResultsText, onLoadItems, noMatch, - empty, - itemsLoadingText, - finishedText, - errorText, - statusType = 'finished', + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, i18nStrings, compactTrigger, ariaDescribedby, @@ -118,13 +115,15 @@ const InternalButtonDropdown = React.forwardRef( const isVisualRefresh = useVisualRefresh(); const isOneTheme = isThemeActive(Theme.OneTheme); const i18n = useInternalI18n('button-dropdown'); - const errorIconAriaLabel = i18n('errorIconAriaLabel', props.errorIconAriaLabel); - const recoveryText = i18n('recoveryText', props.recoveryText); + const errorIconAriaLabel = i18n('errorIconAriaLabel', asyncLoadingProps?.errorIconAriaLabel); + const recoveryText = i18n('recoveryText', asyncLoadingProps?.recoveryText); - if (props.recoveryText && !onLoadItems) { + if (asyncLoadingProps?.recoveryText && !onLoadItems) { warnOnce('ButtonDropdown', '`onLoadItems` must be provided for `recoveryText` to be displayed.'); } + const statusType = asyncLoadingProps?.statusType ?? 'finished'; + const { fireLoadItems, handleLoadMore, handleRecoveryClick } = useLoadItems({ onLoadItems, items, @@ -412,17 +411,17 @@ const InternalButtonDropdown = React.forwardRef( const dropdownStatus = useDropdownStatus({ statusType, - empty, - loadingText: itemsLoadingText, - finishedText, - errorText, + empty: asyncLoadingProps?.empty?.(), + loadingText: asyncLoadingProps?.loadingText?.(), + finishedText: asyncLoadingProps?.finishedText?.(), + errorText: asyncLoadingProps?.errorText?.(), recoveryText, isEmpty, isNoMatch, noMatch, filteringResultsText: filteredText, errorIconAriaLabel, - onRecoveryClick: handleRecoveryClick, + onRecoveryClick: () => handleRecoveryClick(), hasRecoveryCallback: !!onLoadItems, }); @@ -559,6 +558,9 @@ const InternalButtonDropdown = React.forwardRef( filteringEnabled={hasFiltering} menuId={hasFiltering ? menuId : undefined} filteringDescriptionId={filteringItemDescription ? filteringDescriptionId : undefined} + asyncLoadingProps={asyncLoadingProps} + getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} + onLoadItems={onLoadItems} /> {dropdownStatus.content && !dropdownStatus.isSticky ? ( diff --git a/src/button-dropdown/items-list.tsx b/src/button-dropdown/items-list.tsx index a4b0df7578..cce4aa47f1 100644 --- a/src/button-dropdown/items-list.tsx +++ b/src/button-dropdown/items-list.tsx @@ -34,6 +34,9 @@ export default function ItemsList({ filteringEnabled, menuId, filteringDescriptionId, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onLoadItems, }: ItemListProps) { const isMobile = useMobile(); @@ -112,6 +115,9 @@ export default function ItemsList({ filteringEnabled={filteringEnabled} menuId={menuId} filteringDescriptionId={filteringDescriptionId} + asyncLoadingProps={asyncLoadingProps} + getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} + onLoadItems={onLoadItems} /> ) ) : null; diff --git a/src/button-dropdown/utils/use-load-items.ts b/src/button-dropdown/utils/use-load-items.ts index 29977e9253..9c45ee724e 100644 --- a/src/button-dropdown/utils/use-load-items.ts +++ b/src/button-dropdown/utils/use-load-items.ts @@ -3,13 +3,12 @@ import { useRef } from 'react'; import { fireNonCancelableEvent } from '../../internal/events'; -import { DropdownStatusProps } from '../../types/dropdown-status'; import { ButtonDropdownProps } from '../interfaces'; interface UseLoadItemsProps { onLoadItems: ButtonDropdownProps['onLoadItems']; items: ButtonDropdownProps.Items; - statusType: DropdownStatusProps.StatusType; + statusType: ButtonDropdownProps.AsyncLoadingStatusType | undefined; } export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsProps) => { @@ -34,16 +33,26 @@ export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsPro } }; - const handleRecoveryClick = () => + const handleRecoveryClick = (expandedGroupId?: string) => fireNonCancelableEvent(onLoadItems, { firstPage: false, samePage: true, filteringText: prevFilteringText.current || '', + expandedGroupId, + }); + + const fireGroupLoadItems = (expandedGroupId: string) => + fireNonCancelableEvent(onLoadItems, { + filteringText: prevFilteringText.current || '', + firstPage: true, + samePage: false, + expandedGroupId, }); return { fireLoadItems, handleLoadMore, handleRecoveryClick, + fireGroupLoadItems, }; }; diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 31d96e6a1d..46796f93fb 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -125,16 +125,32 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { /** * Finds the error recovery button when item loading fails. + * Set `expandedGroupDropdown` to true to access the recovery button of an expanded group. + * This utility does not open the dropdown. To find dropdown items, call `openDropdown()` first. */ - findErrorRecoveryButton(): ElementWrapper | null { - return this.findOpenDropdown()?.findByClassName(footerStyles.recovery) ?? null; + findErrorRecoveryButton(options = { expandedGroupDropdown: false }): ElementWrapper | null { + let dropdown = this.findOpenDropdown(); + + if (options.expandedGroupDropdown && dropdown) { + dropdown = dropdown.find(`.${dropdownStyles.dropdown}[data-open=true]`); + } + + return dropdown?.findByClassName(footerStyles.recovery) ?? null; } /** * Finds the status displayed at the footer of the dropdown. + * Set `expandedGroupDropdown` to true to access the status of an expanded group. + * This utility does not open the dropdown. To find dropdown items, call `openDropdown()` first. */ - findStatusIndicator(): ElementWrapper | null { - return this.findOpenDropdown()?.findByClassName(dropdownStatusStyles.root) ?? null; + findStatusIndicator(options = { expandedGroupDropdown: false }): ElementWrapper | null { + let dropdown = this.findOpenDropdown(); + + if (options.expandedGroupDropdown && dropdown) { + dropdown = dropdown.find(`.${dropdownStyles.dropdown}[data-open=true]`); + } + + return dropdown?.findByClassName(dropdownStatusStyles.root) ?? null; } /** From 6da93137805eea72fa868075b5591bb5369d5cf6 Mon Sep 17 00:00:00 2001 From: Cansu Aksu Date: Fri, 21 Aug 2026 15:40:22 +0200 Subject: [PATCH 5/5] add example with all features --- pages/button-dropdown/async-loading.page.tsx | 128 +++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx index a39a3d5972..46b0e49435 100644 --- a/pages/button-dropdown/async-loading.page.tsx +++ b/pages/button-dropdown/async-loading.page.tsx @@ -18,6 +18,22 @@ const flatSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, ( secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, })); +// Source data for the combined example: flat async items plus two expandable groups. +const combinedFlatSource: ButtonDropdownProps.Item[] = Array.from({ length: 20 }, (_, i) => ({ + id: `combined-action-${i + 1}`, + text: `Instance action ${i + 1}`, + secondaryText: i % 4 === 0 ? `Details for instance action ${i + 1}` : undefined, +})); + +const combinedGroupSource: Record = { + 'combined-file': Array.from({ length: 6 }, (_, i) => ({ id: `cf-${i + 1}`, text: `File action ${i + 1}` })), + 'combined-edit': Array.from({ length: 5 }, (_, i) => ({ id: `ce-${i + 1}`, text: `Edit action ${i + 1}` })), +}; + +function fetchCombinedGroupItems(groupId: string): Promise { + return new Promise(resolve => setTimeout(() => resolve(combinedGroupSource[groupId] ?? []), 600)); +} + // Groups for the expandable async-loading example. // group-files: loads successfully after 600ms. // group-edit: starts loading then fails after 800ms (error + retry button visible; retry also fails). @@ -71,6 +87,47 @@ export default function ButtonDropdownAsyncLoadingPage() { { id: 'group-view', text: 'View (loading forever)', items: groupItems['group-view'] ?? [] }, ]; + // --- Combined: filtering + async loading + expandable groups --- + const { + items: combinedItems, + status: combinedStatus, + filteringText: combinedFilteringText, + fetchItems: fetchCombinedItems, + } = useOptionsLoader({ pageSize: 10 }); + + const [combinedGroupItems, setCombinedGroupItems] = useState>({}); + const [combinedGroupStatuses, setCombinedGroupStatuses] = useState< + Record + >({}); + // Track the current filter value directly so group visibility updates immediately on input, + // without waiting for useOptionsLoader's filteringText (which only updates after firstPage resolves). + const [combinedFilter, setCombinedFilter] = useState(''); + + // The top-level items are a mix of flat async results and expandable groups. + // Groups themselves have their children loaded on demand. + // When a filter is active: + // - Groups whose text matches are kept and their already-loaded children are shown inline. + // - Groups with no loaded children yet show a loading/pending status so they can be fetched. + // - Groups whose text doesn't match are hidden. + const combinedGroups: ButtonDropdownProps.ItemGroup[] = [ + { id: 'combined-file', text: 'File', items: combinedGroupItems['combined-file'] ?? [] }, + { id: 'combined-edit', text: 'Edit', items: combinedGroupItems['combined-edit'] ?? [] }, + ]; + const visibleCombinedGroups = combinedFilter + ? combinedGroups.filter(g => (g.text ?? '').toLowerCase().includes(combinedFilter.toLowerCase())) + : combinedGroups; + const combinedTopLevelItems: ButtonDropdownProps.Items = [...visibleCombinedGroups, ...combinedItems]; + + const combinedFilteringResultsText = (matchesCount: number, totalCount: number) => { + if (combinedStatus === 'pending') { + return `${matchesCount}+ results`; + } + if (combinedStatus === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + }; + // Error state example for the flat async loading. const [errorStatus, setErrorStatus] = useState('error'); const [errorItems, setErrorItems] = useState([]); @@ -204,6 +261,77 @@ export default function ButtonDropdownAsyncLoadingPage() { Actions (error) +
+

Async loading with filtering and expandable groups

+

+ The top-level list is loaded and filtered asynchronously. Two expandable groups also load their children on + demand when expanded, independently of the filtering. +

+ + combinedFilteringText ? `End of "${combinedFilteringText}" results` : 'End of all results', + empty: () => 'No actions found', + loadingText: (groupId?: string) => (groupId ? `Loading ${groupId} items…` : 'Loading actions'), + errorText: (groupId?: string) => + groupId ? `Failed to load ${groupId} items.` : 'Error fetching actions.', + }} + getExpandableItemsAsyncLoadingState={({ item }) => { + const id = item.id; + return id ? (combinedGroupStatuses[id] ?? null) : null; + }} + expandToViewport={expandToViewport} + filteringResultsText={combinedFilteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { firstPage, filteringText, expandedGroupId, samePage } }) => { + if (expandedGroupId) { + // Group expansion — load the group's children. + if (!samePage) { + setCombinedGroupItems(prev => ({ ...prev, [expandedGroupId]: [] })); + } + setCombinedGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'loading' })); + fetchCombinedGroupItems(expandedGroupId).then(items => { + setCombinedGroupItems(prev => ({ ...prev, [expandedGroupId]: items })); + setCombinedGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'finished' })); + }); + } else { + // Top-level filtering / pagination / open. + if (firstPage) { + setCombinedFilter(filteringText); + // Clear group items so nothing stale shows while the new filter is in-flight. + setCombinedGroupItems({}); + setCombinedGroupStatuses({}); + // Eagerly load any group whose text matches the filter. + combinedGroups + .filter(g => filteringText && (g.text ?? '').toLowerCase().includes(filteringText.toLowerCase())) + .forEach(g => { + const gid = g.id!; + setCombinedGroupStatuses(prev => ({ ...prev, [gid]: 'loading' })); + fetchCombinedGroupItems(gid).then(items => { + setCombinedGroupItems(prev => ({ ...prev, [gid]: items })); + setCombinedGroupStatuses(prev => ({ ...prev, [gid]: 'finished' })); + }); + }); + } + const normalized = filteringText.toLowerCase(); + const filtered = combinedFlatSource.filter(item => + (item.text ?? '').toLowerCase().includes(normalized) + ); + fetchCombinedItems({ firstPage, filteringText, sourceItems: filtered }); + } + }} + > + Actions + +
);