From f808d38065da1d2ad6bdab5a66808b70794d7ec0 Mon Sep 17 00:00:00 2001 From: Cansu Aksu Date: Tue, 11 Aug 2026 10:56:02 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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/).