From 8daeb004ae4c58042093a85cdd0a1422b3debe74 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 3 Sep 2026 10:36:14 +0200 Subject: [PATCH 01/14] chore: Implement dropdown async --- pages/button-dropdown/async-loading.page.tsx | 190 +++++++ .../button-dropdown/manual-filtering.page.tsx | 115 ++++ .../__snapshots__/documenter.test.ts.snap | 510 +++++++++++++++++- .../button-dropdown-async-loading.test.tsx | 262 +++++++++ .../expandable-category-element.tsx | 109 +++- src/button-dropdown/index.tsx | 6 + src/button-dropdown/interfaces.ts | 120 ++++- src/button-dropdown/internal-interfaces.ts | 6 + src/button-dropdown/internal.tsx | 51 +- src/button-dropdown/items-list.tsx | 6 + .../utils/use-button-dropdown.ts | 20 +- src/button-dropdown/utils/use-load-items.ts | 58 ++ src/i18n/messages-types.ts | 2 + src/i18n/messages/all.en.json | 6 +- src/test-utils/dom/button-dropdown/index.ts | 27 + 15 files changed, 1441 insertions(+), 47 deletions(-) create mode 100644 pages/button-dropdown/async-loading.page.tsx create mode 100644 pages/button-dropdown/manual-filtering.page.tsx 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/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx new file mode 100644 index 0000000000..67174556b4 --- /dev/null +++ b/pages/button-dropdown/async-loading.page.tsx @@ -0,0 +1,190 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import Checkbox from '~components/checkbox'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; +import { useOptionsLoader } from '../common/options-loader'; + +// Source data + +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, +})); + +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') { + 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`; + } + return `${matchesCount} out of ${totalCount} results`; + }; + + // Per-group async loading + 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'] ?? [] }, + ] as ButtonDropdownProps.Items; + + // Error state example + const [errorStatus, setErrorStatus] = useState('error'); + const [errorItems, setErrorItems] = useState([]); + const manualSourceItems = flatSourceItems.slice(0, 8); + + return ( + setExpandToViewport(event.detail.checked)} + data-testid="expand-to-viewport" + > + Expand to viewport + + } + > + +
+

Flat async loading (paginated)

+

Items are loaded on open and on scroll. Supports server-side filtering.

+ '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 + +
+ +
+

Per-expandable-group async loading

+

+ Each group loads items independently on expand. File: loads in 600 ms. Edit: always errors (retry to see). + View: loads forever. +

+ `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; + } + 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

+

Initial load fails. Clicking Retry simulates a successful recovery after 1 s.

+ '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 new file mode 100644 index 0000000000..663628d653 --- /dev/null +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import Checkbox from '~components/checkbox'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; + +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' }, + { 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' }, +]; + +function filterLocally(filteringText: string): ButtonDropdownProps.Items { + const normalized = filteringText.toLowerCase(); + return sourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); +} + +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 onItemClick = (event: CustomEvent) => console.log(event.detail); + const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; + + // Client-side manual filtering + const [clientItems, setClientItems] = useState(sourceItems); + + // Server-side manual filtering + const [serverItems, setServerItems] = useState(sourceItems); + const [serverStatus, setServerStatus] = useState('finished'); + + return ( + setExpandToViewport(event.detail.checked)} + data-testid="expand-to-viewport" + > + Expand to viewport + + } + > + +
+

Client-side manual filtering

+

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

+ No actions match. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + setClientItems(filterLocally(filteringText)); + }} + > + Actions + +
+ +
+

Server-side manual filtering

+

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

+ 'Searching...', + empty: () => 'No actions found', + }} + noMatch={No actions match. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + setServerStatus('loading'); + setServerItems([]); + fetchFromServer(filteringText).then(results => { + setServerItems(results); + setServerStatus('finished'); + }); + }} + > + Actions + +
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 8f45830239..48fb6ec75a 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6274,6 +6274,51 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se "detailType": "ButtonDropdownProps.ItemClickDetails", "name": "onItemFollow", }, + { + "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, + "type": "string", + }, + { + "name": "firstPage", + "optional": false, + "type": "boolean", + }, + { + "name": "samePage", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "ButtonDropdownProps.LoadItemsDetail", + "name": "onLoadItems", + }, ], "functions": [ { @@ -6308,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.", @@ -6331,7 +6488,8 @@ If provided, the disabled button becomes focusable.", }, { "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\`.", "name": "expandableGroups", "optional": true, "type": "boolean", @@ -6394,17 +6552,25 @@ because fixed positioning results in a slight, visible lag when scrolling comple }, { "defaultValue": "'none'", - "description": "Enables filtering of the dropdown items. + "description": "Determines how filtering is applied to 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\` - The component will automatically filter options based on user input. +* \`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", @@ -6417,6 +6583,32 @@ types. Items are matched client-side using a case-insensitive substring match ag "optional": true, "type": "boolean", }, + { + "description": "Specifies the async loading status of individual expandable items. +Use only if you load nested items in asynchronously upon expanding a group. + +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.", "i18nTag": true, @@ -7133,7 +7325,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 filtering is enabled and there are no matches for the filtering input.", "isDefault": false, "name": "noMatch", }, @@ -35465,6 +35657,31 @@ Use this method to assert the panel position.", ], }, }, + { + "description": "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.", + "name": "findErrorRecoveryButton", + "parameters": [ + { + "defaultValue": "{ expandedGroupDropdown: false }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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. @@ -35651,6 +35868,31 @@ Supported options: ], }, }, + { + "description": "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.", + "name": "findStatusIndicator", + "parameters": [ + { + "defaultValue": "{ expandedGroupDropdown: false }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTriggerButton", "parameters": [], @@ -45157,6 +45399,34 @@ Supported options: ], }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [ + { + "defaultValue": "{ expandedGroupDropdown: false }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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. @@ -45451,6 +45721,34 @@ Supported options: ], }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [ + { + "defaultValue": "{ expandedGroupDropdown: false }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "ButtonDropdownWrapper.findTriggerButton", @@ -46806,6 +47104,34 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", ], }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [ + { + "defaultValue": "{ expandedGroupDropdown: false }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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. @@ -47019,6 +47345,34 @@ Supported options: ], }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [ + { + "defaultValue": "{ expandedGroupDropdown: false }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTitle", "parameters": [], @@ -48238,6 +48592,28 @@ Use this method to assert the panel position.", "name": "ElementWrapper", }, }, + { + "description": "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.", + "name": "findErrorRecoveryButton", + "parameters": [ + { + "defaultValue": "{ + expandedGroupDropdown: false + }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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. @@ -48375,6 +48751,28 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "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.", + "name": "findStatusIndicator", + "parameters": [ + { + "defaultValue": "{ + expandedGroupDropdown: false + }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findTriggerButton", "parameters": [], @@ -55136,6 +55534,31 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [ + { + "defaultValue": "{ + expandedGroupDropdown: false + }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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. @@ -55363,6 +55786,31 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [ + { + "defaultValue": "{ + expandedGroupDropdown: false + }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "inheritedFrom": { "name": "ButtonDropdownWrapper.findTriggerButton", @@ -56317,6 +56765,31 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", "name": "ElementWrapper", }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [ + { + "defaultValue": "{ + expandedGroupDropdown: false + }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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. @@ -56478,6 +56951,31 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "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.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [ + { + "defaultValue": "{ + expandedGroupDropdown: false + }", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandedGroupDropdown: boolean; }", + }, + ], + "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..45a0d01606 --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -0,0 +1,262 @@ +// 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 empty filteringText 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 with firstPage=true when filteringText changes', async () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + onLoadItems.mockClear(); + wrapper.findFilteringInput()!.setInputValue('test'); + await waitFor(() => + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'test', firstPage: true, samePage: false }) + ); + }); + + test('does not fire onLoadItems again when filteringText has not changed', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + const callCount = onLoadItems.mock.calls.length; + // Simulate a re-render without changing the text — no extra call expected. + wrapper.openDropdown(); + expect(onLoadItems).toHaveBeenCalledTimes(callCount); + }); + + test('fires onLoadItems with samePage=true when the recovery button is clicked', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + asyncLoadingProps: { + 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('does not apply client-side filtering when filteringType is "manual"', () => { + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('zzz'); + // All provided items remain visible — consumer is responsible for filtering. + expect(wrapper.findItems()).toHaveLength(items.length); + }); + + test('applies client-side filtering when filteringType is "auto"', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + expect(wrapper.findItems()).toHaveLength(1); + expect(wrapper.findItems()[0].getElement()).toHaveTextContent('Cut'); + }); + + test('warns if recoveryText is provided without onLoadItems', () => { + renderDropdown({ + asyncLoadingProps: { + statusType: 'error', + errorText: () => 'Error', + recoveryText: 'Retry', + }, + }); + expect(warnOnce).toHaveBeenCalledWith( + 'ButtonDropdown', + '`onLoadItems` must be provided for `recoveryText` to be displayed.' + ); + }); +}); + +describe('ButtonDropdown status display', () => { + test('shows loading status text when statusType is "loading"', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { + statusType: 'loading', + loadingText: () => 'Loading actions', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('Loading actions'); + }); + + test('shows error status text when statusType is "error"', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { + statusType: 'error', + errorText: () => 'Failed to load', + recoveryText: 'Retry', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('Failed to load'); + }); + + test('shows finished text when statusType is "finished" and finishedText provided', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { + statusType: 'finished', + finishedText: () => 'End of results', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('End of results'); + }); + + test('shows empty text when items are empty and statusType is "finished"', () => { + const { wrapper } = renderDropdown({ + items: [], + asyncLoadingProps: { + statusType: 'finished', + empty: () => 'No actions found', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('No actions found'); + }); + + test('shows no status indicator when statusType is "finished" with no special text', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { + statusType: 'finished', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + expect(wrapper.findStatusIndicator()).toBeNull(); + }); +}); + +describe('ButtonDropdown async loading with expandable groups', () => { + const groupItems: ButtonDropdownProps.Items = [ + { id: 'g1', text: 'Group 1', items: [] as ButtonDropdownProps.Items } as ButtonDropdownProps.ItemGroup, + { id: 'g2', text: 'Group 2', items: [{ id: 'g2i1', text: 'Action 1' }] } as ButtonDropdownProps.ItemGroup, + ]; + + test('fires onLoadItems with expandedGroupId when an expandable group is opened', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'pending' : null), + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + onLoadItems.mockClear(); + wrapper.findExpandableCategoryById('g1')!.click(); + expect(onLoadItems).toHaveBeenCalledWith({ + filteringText: '', + firstPage: true, + samePage: false, + expandedGroupId: 'g1', + }); + }); + + test('shows per-group loading status from getExpandableItemsAsyncLoadingState', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'loading' : null), + asyncLoadingProps: { + loadingText: (groupId?: string) => `Loading ${groupId ?? 'items'}`, + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + const groupStatus = wrapper.findStatusIndicator({ expandedGroupDropdown: true }); + expect(groupStatus).not.toBeNull(); + expect(groupStatus!.getElement()).toHaveTextContent('Loading g1'); + }); + + test('shows recovery button inside expanded group when group status is "error"', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { + errorText: (groupId?: string) => `Error loading ${groupId ?? 'items'}`, + recoveryText: 'Retry', + }, + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + const groupRecovery = wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true }); + expect(groupRecovery).not.toBeNull(); + onLoadItems.mockClear(); + groupRecovery!.click(); + expect(onLoadItems).toHaveBeenCalledWith(expect.objectContaining({ samePage: true, expandedGroupId: 'g1' })); + }); +}); diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index 11e87689cd..0b8150a766 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -3,11 +3,16 @@ 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 DropdownStatus 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 +47,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 +95,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(); @@ -163,35 +209,54 @@ const ExpandableCategoryElement = ({ 0 ? ( + + ) : undefined + } content={ - item.items && expanded ? ( + expanded ? (
    - + {groupDropdownStatus.content && !groupDropdownStatus.isSticky ? ( + + ) : null} + {groupDropdownStatus.content && + groupDropdownStatus.isSticky && + (!item.items || item.items.length === 0) ? ( + {groupDropdownStatus.content} + ) : null} + {item.items && item.items.length > 0 ? ( + + ) : null}
) : undefined } diff --git a/src/button-dropdown/index.tsx b/src/button-dropdown/index.tsx index 48ebaeaa44..d13f800f05 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -49,6 +49,9 @@ const ButtonDropdown = React.forwardRef( filteringResultsText, noMatch, i18nStrings, + onLoadItems, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, ...props }: ButtonDropdownProps, ref: React.Ref @@ -114,6 +117,9 @@ const ButtonDropdown = React.forwardRef( i18nStrings?.filteringItemAriaDescription ), }} + onLoadItems={onLoadItems} + asyncLoadingProps={asyncLoadingProps} + getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} {...getAnalyticsMetadataAttribute({ component: analyticsComponentMetadata, })} diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index b88d197ed0..904fe2176c 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -3,10 +3,10 @@ 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 { BaseNavigationDetail, CancelableEventHandler, NonCancelableEventHandler } from '../types/events'; /** * @awsuiSystem core */ @@ -155,6 +155,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor iconSvg?: React.ReactNode; /** * Controls expandability of the item groups. + * If async loading, make sure to define expandable groups' statuses in `expandableItemsAsyncLoadingStates`. */ expandableGroups?: boolean; /** @@ -196,11 +197,69 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor fullWidth?: boolean; /** - * Enables filtering of the dropdown items. + * 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; + + /** + * Specifies the async loading status of individual expandable items. + * Use only if you load nested items in asynchronously upon expanding a group. + * + * 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.ItemOrGroup; + }) => ButtonDropdownProps.AsyncLoadingStatusType | null | undefined; + + /** + * Determines how filtering is applied to the dropdown `items`: + * + * * `auto` - The component will automatically filter options based on user input. + * * `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. * - * 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`. */ filteringType?: ButtonDropdownProps.FilteringType; @@ -227,7 +286,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor 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 filtering is enabled and there are no matches for the filtering input. */ noMatch?: React.ReactNode; @@ -271,7 +330,52 @@ 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 = 'none' | 'auto' | 'manual'; + + 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 type AsyncLoadingStatusType = 'pending' | 'loading' | 'finished' | 'error'; + + export interface LoadItemsDetail extends OptionsLoadItemsDetail { + expandedGroupId?: string; + } 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 6f62cc522d..b74247e676 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'; @@ -76,6 +78,9 @@ const InternalButtonDropdown = React.forwardRef( filteringClearAriaLabel, filteringResultsText, noMatch, + onLoadItems, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, i18nStrings, compactTrigger, ariaDescribedby, @@ -86,7 +91,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); @@ -110,6 +115,24 @@ const InternalButtonDropdown = React.forwardRef( const isVisualRefresh = useVisualRefresh(); const isOneTheme = isThemeActive(Theme.OneTheme); + const i18n = useInternalI18n('button-dropdown'); + const errorIconAriaLabel = i18n('errorIconAriaLabel', asyncLoadingProps?.errorIconAriaLabel); + const recoveryText = i18n('recoveryText', asyncLoadingProps?.recoveryText); + + if (isDevelopment) { + 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, + statusType, + }); + const { isOpen, targetItem, @@ -139,7 +162,8 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport, hasExpandableGroups: expandableGroups, isInRestrictedView, - hasFiltering, + filteringType, + fireLoadItems, }); const filterRef = useRef(null); @@ -387,11 +411,22 @@ const InternalButtonDropdown = React.forwardRef( const matchesCount = useMemo(() => countLeafItems(filteredItems), [filteredItems]); const filteredText = isFiltered ? filteringResultsText?.(matchesCount, totalCount) : undefined; + const isEmpty = !items || items.length === 0; + const dropdownStatus = useDropdownStatus({ - statusType: 'finished', + statusType, + empty: asyncLoadingProps?.empty?.(), + loadingText: asyncLoadingProps?.loadingText?.(), + finishedText: asyncLoadingProps?.finishedText?.(), + errorText: asyncLoadingProps?.errorText?.(), + recoveryText, + errorIconAriaLabel, + isEmpty, isNoMatch, noMatch, filteringResultsText: filteredText, + hasRecoveryCallback: !!onLoadItems, + onRecoveryClick: () => handleRecoveryClick(), }); // Only create a filteringDescription element if filtering is actually enabled, @@ -410,6 +445,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 +500,7 @@ const InternalButtonDropdown = React.forwardRef( ariaRole={hasFiltering ? 'dialog' : undefined} ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ - dropdownStatus.content ? ( + dropdownStatus.content && dropdownStatus.isSticky ? ( ) : null } @@ -503,6 +539,7 @@ const InternalButtonDropdown = React.forwardRef( ariaLabelledby={hasHeader ? headerId : shouldLabelWithTrigger ? triggerId : undefined} ariaDescribedby={dropdownStatus.content ? footerId : undefined} statusType="finished" + onLoadMore={handleLoadMore} > + {dropdownStatus.content && !dropdownStatus.isSticky ? ( + + ) : null} {filteringDescriptionEl} } 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-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..9c45ee724e --- /dev/null +++ b/src/button-dropdown/utils/use-load-items.ts @@ -0,0 +1,58 @@ +// 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 { ButtonDropdownProps } from '../interfaces'; + +interface UseLoadItemsProps { + onLoadItems: ButtonDropdownProps['onLoadItems']; + items: ButtonDropdownProps.Items; + statusType: ButtonDropdownProps.AsyncLoadingStatusType | undefined; +} + +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 = (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/i18n/messages-types.ts b/src/i18n/messages-types.ts index 8a92970206..dfb0fa055d 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -81,6 +81,8 @@ export interface I18nFormatArgTypes { }; noMatch: never; 'i18nStrings.filteringItemAriaDescription': never; + recoveryText: never; + errorIconAriaLabel: never; }; calendar: { nextMonthAriaLabel: never; diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index d04d9d1332..62783ea0c7 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -58,7 +58,9 @@ "button-dropdown": { "filteringResultsText": "{matchesCount} out of {totalCount} items", "noMatch": "No matching actions", - "i18nStrings.filteringItemAriaDescription": "Keep typing to filter results." + "i18nStrings.filteringItemAriaDescription": "Keep typing to filter results.", + "recoveryText": "Retry", + "errorIconAriaLabel": "Error" }, "button": { "i18nStrings.externalIconAriaLabel": "Opens in a new tab" @@ -517,4 +519,4 @@ "i18nStrings.nextButtonLoadingAnnouncement": "Loading next step", "i18nStrings.submitButtonLoadingAnnouncement": "Submitting form" } -} +} \ No newline at end of file diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 04f247f2a8..18df64bd8c 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']}`; @@ -132,6 +133,32 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { return this.findOpenDropdown()?.findComponent(`.${inputStyles['input-container']}`, InputWrapper) ?? null; } + /** + * 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(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(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; + } + /** * Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is * entered, this contains content rendered by filteringResultsText if there are matching items and the `noMatch` From c81867f21d5fa9529bdc62456e804bdddddec8e3 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 3 Sep 2026 11:36:41 +0200 Subject: [PATCH 02/14] fix: remove border on loading/error status when dropdown has no items - Render DropdownStatus directly (no DropdownFooter border) when items are empty and status is sticky (loading/error state) - Apply fix to both main dropdown (internal.tsx) and expandable group sub-dropdown (expandable-category-element.tsx) - Remove dead code: fireGroupLoadItems was exported but never called - Add use-load-items.test.tsx covering handleLoadMore and handleRecoveryClick branches - Improve test coverage for DropdownStatus direct-render path --- .../button-dropdown-async-loading.test.tsx | 18 +++ .../__tests__/use-load-items.test.tsx | 105 ++++++++++++++++++ src/button-dropdown/internal.tsx | 6 +- src/button-dropdown/utils/use-load-items.ts | 9 -- 4 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 src/button-dropdown/__tests__/use-load-items.test.tsx 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 45a0d01606..5b819292fe 100644 --- a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -259,4 +259,22 @@ describe('ButtonDropdown async loading with expandable groups', () => { groupRecovery!.click(); expect(onLoadItems).toHaveBeenCalledWith(expect.objectContaining({ samePage: true, expandedGroupId: 'g1' })); }); + + test('shows loading status inside expanded group without a border when items are empty', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'loading' : null), + asyncLoadingProps: { + loadingText: () => 'Loading group items', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + // g1 has no items yet - status renders via DropdownStatus (no DropdownFooter border) + const groupStatus = wrapper.findStatusIndicator({ expandedGroupDropdown: true }); + expect(groupStatus).not.toBeNull(); + expect(groupStatus!.getElement()).toHaveTextContent('Loading group items'); + }); }); diff --git a/src/button-dropdown/__tests__/use-load-items.test.tsx b/src/button-dropdown/__tests__/use-load-items.test.tsx new file mode 100644 index 0000000000..1affbbccd9 --- /dev/null +++ b/src/button-dropdown/__tests__/use-load-items.test.tsx @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; +import { useLoadItems } from '../../../lib/components/button-dropdown/utils/use-load-items'; + +const items: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Item 1' }, + { id: 'i2', text: 'Item 2' }, +]; + +// Minimal component to expose hook functions for testing +function HookHarness({ + onLoadItems, + hookItems, + statusType, + onRender, +}: { + onLoadItems: ButtonDropdownProps['onLoadItems']; + hookItems: ButtonDropdownProps.Items; + statusType: ButtonDropdownProps.AsyncLoadingStatusType; + onRender: (fns: ReturnType) => void; +}) { + const fns = useLoadItems({ onLoadItems, items: hookItems, statusType }); + onRender(fns); + return null; +} + +function renderHook( + onLoadItems: jest.Mock, + hookItems: ButtonDropdownProps.Items, + statusType: ButtonDropdownProps.AsyncLoadingStatusType +) { + let fns!: ReturnType; + render( + onLoadItems(event.detail)} + hookItems={hookItems} + statusType={statusType} + onRender={f => { + fns = f; + }} + /> + ); + return fns; +} + +describe('useLoadItems', () => { + test('fireLoadItems fires onLoadItems with firstPage=true', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'pending'); + fns.fireLoadItems('test'); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'test', firstPage: true, samePage: false }); + }); + + test('fireLoadItems deduplicates identical filteringText', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'pending'); + fns.fireLoadItems('test'); + fns.fireLoadItems('test'); + expect(onLoadItems).toHaveBeenCalledTimes(1); + }); + + test('handleLoadMore fires when statusType is "pending" and items exist (firstPage=false)', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'pending'); + // prime prevFilteringText + fns.fireLoadItems('query'); + onLoadItems.mockClear(); + fns.handleLoadMore(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'query', firstPage: false, samePage: false }); + }); + + test('handleLoadMore fires with firstPage=true when items are empty', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, [], 'pending'); + fns.handleLoadMore(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: true, samePage: false }); + }); + + test('handleLoadMore does not fire when statusType is not "pending"', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'finished'); + fns.handleLoadMore(); + expect(onLoadItems).not.toHaveBeenCalled(); + }); + + test('handleRecoveryClick fires onLoadItems with samePage=true', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'error'); + fns.fireLoadItems('search'); + onLoadItems.mockClear(); + fns.handleRecoveryClick(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'search', firstPage: false, samePage: true }); + }); + + test('handleRecoveryClick passes expandedGroupId when provided', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'error'); + fns.handleRecoveryClick('group-1'); + expect(onLoadItems).toHaveBeenCalledWith(expect.objectContaining({ expandedGroupId: 'group-1', samePage: true })); + }); +}); diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index b74247e676..d5f9395fc2 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -16,6 +16,7 @@ import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; +import DropdownStatus from '../internal/components/dropdown-status'; import OptionsList from '../internal/components/options-list'; import useHiddenDescription from '../internal/hooks/use-hidden-description'; import { useMobile } from '../internal/hooks/use-mobile'; @@ -500,7 +501,7 @@ const InternalButtonDropdown = React.forwardRef( ariaRole={hasFiltering ? 'dialog' : undefined} ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ - dropdownStatus.content && dropdownStatus.isSticky ? ( + dropdownStatus.content && dropdownStatus.isSticky && !isEmpty ? ( ) : null } @@ -570,6 +571,9 @@ const InternalButtonDropdown = React.forwardRef( {dropdownStatus.content && !dropdownStatus.isSticky ? ( ) : null} + {dropdownStatus.content && dropdownStatus.isSticky && isEmpty ? ( + {isOpen ? dropdownStatus.content : null} + ) : null} {filteringDescriptionEl} } diff --git a/src/button-dropdown/utils/use-load-items.ts b/src/button-dropdown/utils/use-load-items.ts index 9c45ee724e..8d7649d6f0 100644 --- a/src/button-dropdown/utils/use-load-items.ts +++ b/src/button-dropdown/utils/use-load-items.ts @@ -41,18 +41,9 @@ export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsPro expandedGroupId, }); - const fireGroupLoadItems = (expandedGroupId: string) => - fireNonCancelableEvent(onLoadItems, { - filteringText: prevFilteringText.current || '', - firstPage: true, - samePage: false, - expandedGroupId, - }); - return { fireLoadItems, handleLoadMore, handleRecoveryClick, - fireGroupLoadItems, }; }; From ca88f2fd5d023041b19ac706c9846ae66017fc8b Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 3 Sep 2026 14:21:41 +0200 Subject: [PATCH 03/14] chore: redesign async dev pages for bug bashing --- pages/button-dropdown/async-loading.page.tsx | 255 ++++++++++++++---- .../button-dropdown/manual-filtering.page.tsx | 149 +++++++--- 2 files changed, 311 insertions(+), 93 deletions(-) diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx index 67174556b4..5d2880cb94 100644 --- a/pages/button-dropdown/async-loading.page.tsx +++ b/pages/button-dropdown/async-loading.page.tsx @@ -4,19 +4,57 @@ import React, { useState } from 'react'; import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import Checkbox from '~components/checkbox'; +import FormField from '~components/form-field'; +import Select from '~components/select'; import SpaceBetween from '~components/space-between'; import { SimplePage } from '../app/templates'; import { useOptionsLoader } from '../common/options-loader'; -// Source data +// ---- Source data ---- -const flatSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, i) => ({ +const ALL_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({ id: `action-${i + 1}`, text: `Action ${i + 1}`, secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, })); +const GROUP_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 6 }, (_, i) => ({ + id: `sub-${i + 1}`, + text: `Sub-action ${i + 1}`, +})); + +const STATUS_OPTIONS = [ + { value: 'loading', label: 'loading' }, + { value: 'error', label: 'error' }, + { value: 'pending', label: 'pending' }, + { value: 'finished', label: 'finished' }, +]; + +const ITEMS_OPTIONS = [ + { value: 'none', label: 'No items' }, + { value: 'some', label: '6 items' }, + { value: 'all', label: '12 items' }, +]; + +type StatusType = ButtonDropdownProps.AsyncLoadingStatusType; + +function itemsFromPreset(preset: string, source: ButtonDropdownProps.Item[]): ButtonDropdownProps.Items { + if (preset === 'none') { + return []; + } + if (preset === 'some') { + return source.slice(0, Math.floor(source.length / 2)); + } + return source; +} + +const flatSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, i) => ({ + id: `flat-action-${i + 1}`, + text: `Action ${i + 1}`, + secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, +})); + const groupSourceItems: Record = { 'group-files': Array.from({ length: 8 }, (_, i) => ({ id: `file-${i + 1}`, text: `File action ${i + 1}` })), }; @@ -28,77 +66,178 @@ function fetchGroupItems(groupId: string): Promise { if (groupId === 'group-edit') { 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 = (e: CustomEvent) => console.log('clicked', e.detail.id); + + // Interactive controls - flat + const [flatStatus, setFlatStatus] = useState('loading'); + const [flatItemsPreset, setFlatItemsPreset] = useState('none'); - const onItemClick = (event: CustomEvent) => console.log(event.detail); + // Interactive controls - groups + const [groupAStatus, setGroupAStatus] = useState('loading'); + const [groupAPreset, setGroupAPreset] = useState('none'); + const [groupBStatus, setGroupBStatus] = useState('error'); + const [groupBPreset, setGroupBPreset] = useState('none'); - // Flat async loading + // Preconfigured - paginated async const { - items: flatItems, - status: flatStatus, - filteringText: flatFilteringText, + items: paginatedItems, + status: paginatedStatus, + filteringText: paginatedFilteringText, fetchItems, } = useOptionsLoader({ pageSize: 10 }); - const flatFilteringResultsText = (matchesCount: number, totalCount: number) => { - if (flatStatus === 'pending') { - return `${matchesCount}+ results`; - } - return `${matchesCount} out of ${totalCount} results`; - }; - - // Per-group async loading + // Preconfigured - groups 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'] ?? [] }, - ] as ButtonDropdownProps.Items; + const [groupStatuses, setGroupStatuses] = useState>({}); - // Error state example - const [errorStatus, setErrorStatus] = useState('error'); + // Preconfigured - error + recovery + const [errorStatus, setErrorStatus] = useState('error'); const [errorItems, setErrorItems] = useState([]); - const manualSourceItems = flatSourceItems.slice(0, 8); return ( setExpandToViewport(event.detail.checked)} - data-testid="expand-to-viewport" - > + setExpandToViewport(e.detail.checked)}> Expand to viewport } > - + + {/* Interactive: flat */}
-

Flat async loading (paginated)

-

Items are loaded on open and on scroll. Supports server-side filtering.

+

Interactive - flat async

+

Set status and items directly to test any combination without waiting.

+ + + o.value === flatItemsPreset) ?? null} + onChange={e => setFlatItemsPreset(e.detail.selectedOption.value!)} + options={ITEMS_OPTIONS} + /> + + +
'Loading actions...', + errorText: () => 'Failed to load actions.', + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + finishedText: () => 'End of results', + empty: () => 'No actions found', + }} + expandToViewport={expandToViewport} + onItemClick={onItemClick} + onLoadItems={({ detail }) => console.log('onLoadItems', detail)} + > + Actions + +
+ + {/* Interactive: groups */} +
+

Interactive - expandable groups

+

Set status and items per group independently. Expand each group to see its state.

+ + + o.value === groupAPreset) ?? null} + onChange={e => setGroupAPreset(e.detail.selectedOption.value!)} + options={ITEMS_OPTIONS} + /> + + + o.value === groupBPreset) ?? null} + onChange={e => setGroupBPreset(e.detail.selectedOption.value!)} + options={ITEMS_OPTIONS} + /> + + +
+ `Loading ${gid ?? 'items'}...`, + errorText: (gid?: string) => `Failed to load ${gid ?? 'items'}.`, + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + finishedText: (gid?: string) => `End of ${gid ?? 'results'}`, + empty: (gid?: string) => `No items in ${gid ?? 'group'}.`, + }} + getExpandableItemsAsyncLoadingState={({ item }) => { + if (item.id === 'group-a') { + return groupAStatus; + } + if (item.id === 'group-b') { + return groupBStatus; + } + return null; + }} + expandToViewport={expandToViewport} + onItemClick={onItemClick} + onLoadItems={({ detail }) => console.log('onLoadItems', detail)} + > + Instance actions + +
+ + {/* Preconfigured: paginated flat async */} +
+

Preconfigured - flat async (paginated)

+

Items load on open and paginate on scroll. Filter input triggers server-side search.

+ 'Loading actions', + statusType: paginatedStatus, + loadingText: () => 'Loading actions...', errorText: () => 'Error fetching actions.', recoveryText: 'Retry', - finishedText: () => (flatFilteringText ? `End of "${flatFilteringText}" results` : 'End of all results'), + finishedText: () => + paginatedFilteringText ? `End of "${paginatedFilteringText}" results` : 'End of all results', empty: () => 'No actions found', }} expandToViewport={expandToViewport} - filteringResultsText={flatFilteringResultsText} + filteringResultsText={(matchesCount, totalCount) => + paginatedStatus === 'pending' ? `${matchesCount}+ results` : `${matchesCount} of ${totalCount}` + } onItemClick={onItemClick} onLoadItems={({ detail: { firstPage, filteringText } }) => { const normalized = filteringText.toLowerCase(); @@ -110,25 +249,26 @@ export default function ButtonDropdownAsyncLoadingPage() {
+ {/* Preconfigured: per-group async */}
-

Per-expandable-group async loading

-

- Each group loads items independently on expand. File: loads in 600 ms. Edit: always errors (retry to see). - View: loads forever. -

+

Preconfigured - per-group async

+

File loads in 600 ms. Edit always errors. View loads forever.

`Loading ${groupId ?? 'items'}...`, - errorText: (groupId?: string) => `Failed to load ${groupId ?? 'items'}.`, + loadingText: (gid?: string) => `Loading ${gid ?? 'items'}...`, + errorText: (gid?: string) => `Failed to load ${gid ?? 'items'}.`, recoveryText: 'Retry', - empty: (groupId?: string) => `No items in ${groupId ?? 'group'}.`, - }} - getExpandableItemsAsyncLoadingState={({ item }) => { - const id = item.id; - return id ? (groupStatuses[id] ?? null) : null; + empty: (gid?: string) => `No items in ${gid ?? 'group'}.`, }} + getExpandableItemsAsyncLoadingState={({ item }) => (item.id ? (groupStatuses[item.id] ?? null) : null)} expandToViewport={expandToViewport} onItemClick={onItemClick} onLoadItems={({ detail: { expandedGroupId, samePage } }) => { @@ -153,14 +293,15 @@ export default function ButtonDropdownAsyncLoadingPage() {
+ {/* Preconfigured: error + recovery */}
-

Error state with recovery

-

Initial load fails. Clicking Retry simulates a successful recovery after 1 s.

+

Preconfigured - error with recovery

+

Opens in error state. Retry recovers after 1 s.

'Loading actions', + loadingText: () => 'Loading actions...', errorText: () => 'Error fetching actions.', recoveryText: 'Retry', errorIconAriaLabel: 'Error', @@ -172,7 +313,7 @@ export default function ButtonDropdownAsyncLoadingPage() { if (samePage) { setErrorStatus('loading'); setTimeout(() => { - setErrorItems(manualSourceItems); + setErrorItems(flatSourceItems.slice(0, 8)); setErrorStatus('finished'); }, 1000); } else { diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx index 663628d653..dd0e3eb365 100644 --- a/pages/button-dropdown/manual-filtering.page.tsx +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -4,11 +4,13 @@ import React, { useState } from 'react'; import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import Checkbox from '~components/checkbox'; +import FormField from '~components/form-field'; +import RadioGroup from '~components/radio-group'; import SpaceBetween from '~components/space-between'; import { SimplePage } from '../app/templates'; -const sourceItems: ButtonDropdownProps.Item[] = [ +const SOURCE_ITEMS: ButtonDropdownProps.Item[] = [ { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, @@ -19,71 +21,96 @@ const sourceItems: ButtonDropdownProps.Item[] = [ { id: 'preferences', text: 'Preferences', secondaryText: 'Configure editor settings' }, ]; -function filterLocally(filteringText: string): ButtonDropdownProps.Items { - const normalized = filteringText.toLowerCase(); - return sourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); +function filterItems(text: string): ButtonDropdownProps.Items { + const q = text.toLowerCase(); + return SOURCE_ITEMS.filter(i => (i.text ?? '').toLowerCase().includes(q)); } -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)); +function simulateServer(text: string, delayMs: number): Promise { + return new Promise(resolve => setTimeout(() => resolve(filterItems(text)), delayMs)); } export default function ButtonDropdownManualFilteringPage() { const [expandToViewport, setExpandToViewport] = useState(false); + const onItemClick = (e: CustomEvent) => console.log('clicked', e.detail.id); - const onItemClick = (event: CustomEvent) => console.log(event.detail); - const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; + // Interactive: filteringType switcher + const [filteringType, setFilteringType] = useState('manual'); + const [switcherItems, setSwitcherItems] = useState(SOURCE_ITEMS); - // Client-side manual filtering - const [clientItems, setClientItems] = useState(sourceItems); - - // Server-side manual filtering - const [serverItems, setServerItems] = useState(sourceItems); + // Interactive: server delay control + const [serverDelay, setServerDelay] = useState('400'); + const [serverItems, setServerItems] = useState(SOURCE_ITEMS); const [serverStatus, setServerStatus] = useState('finished'); + // Preconfigured: client-side manual filtering + const [clientItems, setClientItems] = useState(SOURCE_ITEMS); + + // Preconfigured: server-side manual filtering (fixed 400 ms) + const [preItems, setPreItems] = useState(SOURCE_ITEMS); + const [preStatus, setPreStatus] = useState('finished'); + return ( setExpandToViewport(event.detail.checked)} - data-testid="expand-to-viewport" - > + setExpandToViewport(e.detail.checked)}> Expand to viewport } > - + + {/* Interactive: filteringType switcher */}
-

Client-side manual filtering

-

- The app filters items synchronously inside onLoadItems. No status indicators needed. -

+

Interactive - filteringType switcher

+

Switch between none, auto, and manual on the same dropdown to compare behavior.

+ + setFilteringType(e.detail.value as ButtonDropdownProps.FilteringType)} + items={[ + { value: 'none', label: 'none' }, + { value: 'auto', label: 'auto (client-side)' }, + { value: 'manual', label: 'manual (consumer-controlled)' }, + ]} + /> + +
No actions match. Try a different keyword.} + noMatch={No actions match.} expandToViewport={expandToViewport} - filteringResultsText={filteringResultsText} + filteringResultsText={(m, t) => `${m} of ${t}`} onItemClick={onItemClick} onLoadItems={({ detail: { filteringText } }) => { - setClientItems(filterLocally(filteringText)); + setSwitcherItems(filterItems(filteringText)); }} > Actions
+ {/* Interactive: server delay control */}
-

Server-side manual filtering

+

Interactive - server delay control

- The app calls a fake async API on every filter change. A loading spinner appears while the request is in - flight. + Set delay to 0 ms to stay in the loading state long enough to inspect it, or use 1500 ms for slow network + testing.

+ + setServerDelay(e.detail.value)} + items={[ + { value: '0', label: '0 ms (instant)' }, + { value: '400', label: '400 ms' }, + { value: '1500', label: '1500 ms (slow)' }, + ]} + /> + +
'Searching...', empty: () => 'No actions found', }} - noMatch={No actions match. Try a different keyword.} + noMatch={No actions match.} expandToViewport={expandToViewport} - filteringResultsText={filteringResultsText} + filteringResultsText={(m, t) => `${m} of ${t}`} onItemClick={onItemClick} onLoadItems={({ detail: { filteringText } }) => { setServerStatus('loading'); setServerItems([]); - fetchFromServer(filteringText).then(results => { + simulateServer(filteringText, parseInt(serverDelay, 10)).then(results => { setServerItems(results); setServerStatus('finished'); }); @@ -109,6 +136,56 @@ export default function ButtonDropdownManualFilteringPage() { Actions
+ + {/* Preconfigured: client-side manual */} +
+

Preconfigured - client-side manual filtering

+

App filters synchronously inside onLoadItems. No status indicators needed.

+ No actions match. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={(m, t) => `${m} of ${t} matches`} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + setClientItems(filterItems(filteringText)); + }} + > + Actions + +
+ + {/* Preconfigured: server-side manual */} +
+

Preconfigured - server-side manual filtering

+

App calls a fake API on every filter change. Loading spinner appears while the request is in flight.

+ 'Searching...', + empty: () => 'No actions found', + }} + noMatch={No actions match. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={(m, t) => `${m} of ${t} matches`} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + setPreStatus('loading'); + setPreItems([]); + simulateServer(filteringText, 400).then(results => { + setPreItems(results); + setPreStatus('finished'); + }); + }} + > + Actions + +
); From 5cf0d345685c308fdbb8f66c62f7a4ded8c0a13d Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 29 Sep 2026 05:58:46 +0200 Subject: [PATCH 04/14] fix: Add bug fixes --- pages/button-dropdown/async-loading.page.tsx | 8 +- .../button-dropdown.async.example.page.tsx | 232 ++++++++++++++++++ .../button-dropdown-async-loading.test.tsx | 223 ++++++++++++++++- .../__tests__/use-load-items.test.tsx | 14 ++ .../expandable-category-element.tsx | 48 ++-- src/button-dropdown/internal.tsx | 66 ++++- src/button-dropdown/status-footer.tsx | 31 +++ .../utils/use-button-dropdown.ts | 28 ++- src/button-dropdown/utils/use-load-items.ts | 4 + 9 files changed, 605 insertions(+), 49 deletions(-) create mode 100644 pages/button-dropdown/button-dropdown.async.example.page.tsx create mode 100644 src/button-dropdown/status-footer.tsx diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx index 5d2880cb94..dba5c0d1aa 100644 --- a/pages/button-dropdown/async-loading.page.tsx +++ b/pages/button-dropdown/async-loading.page.tsx @@ -61,10 +61,10 @@ const groupSourceItems: Record = { function fetchGroupItems(groupId: string): Promise { if (groupId === 'group-files') { - return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), 600)); + return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), 5000)); } if (groupId === 'group-edit') { - return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), 800)); + return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), 5000)); } return new Promise(() => {}); } @@ -89,7 +89,7 @@ export default function ButtonDropdownAsyncLoadingPage() { status: paginatedStatus, filteringText: paginatedFilteringText, fetchItems, - } = useOptionsLoader({ pageSize: 10 }); + } = useOptionsLoader({ pageSize: 10, timeout: 5000 }); // Preconfigured - groups const [groupItems, setGroupItems] = useState>({}); @@ -315,7 +315,7 @@ export default function ButtonDropdownAsyncLoadingPage() { setTimeout(() => { setErrorItems(flatSourceItems.slice(0, 8)); setErrorStatus('finished'); - }, 1000); + }, 5000); } else { setErrorItems([]); setErrorStatus('error'); diff --git a/pages/button-dropdown/button-dropdown.async.example.page.tsx b/pages/button-dropdown/button-dropdown.async.example.page.tsx new file mode 100644 index 0000000000..1b4131e519 --- /dev/null +++ b/pages/button-dropdown/button-dropdown.async.example.page.tsx @@ -0,0 +1,232 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useRef, useState } from 'react'; + +import Box from '~components/box'; +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import Checkbox from '~components/checkbox'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +import { useOptionsLoader } from '../common/options-loader'; + +type StatusType = ButtonDropdownProps.AsyncLoadingStatusType; + +type PageContext = React.Context< + AppContextType<{ + fakeResponses?: boolean; + randomErrors?: boolean; + expandToViewport?: boolean; + }> +>; + +// ---- Fake server data: EC2 instance actions ---- + +const ACTION_NAMES = [ + 'Connect', + 'Start instance', + 'Stop instance', + 'Reboot instance', + 'Hibernate instance', + 'Terminate instance', + 'Change instance type', + 'Change termination protection', + 'Change stop protection', + 'Change shutdown behavior', + 'Modify user data', + 'Modify IAM role', + 'Modify instance placement', + 'Modify capacity reservation settings', + 'Edit auto-recovery behavior', + 'Manage tags', + 'Manage detailed monitoring', + 'Attach to Auto Scaling group', + 'Launch more like this', + 'Create image', + 'Create template from instance', + 'Get system log', + 'Get instance screenshot', + 'Get Windows password', + 'Replace root volume', + 'Attach network interface', + 'Detach network interface', + 'Manage IP addresses', + 'Change security groups', + 'Change source/destination check', +]; + +const flatActions: ButtonDropdownProps.Item[] = ACTION_NAMES.map((text, index) => ({ + id: `action-${index + 1}`, + text, + secondaryText: index % 4 === 0 ? `Applies to the selected instance` : undefined, + disabled: index === 5, + disabledReason: index === 5 ? 'Termination protection is enabled' : undefined, +})); + +const GROUPS = { + networking: 'Networking (loads, may fail randomly)', + storage: 'Storage (always fails)', + monitoring: 'Monitoring (loads forever)', + tags: 'Tags (loads empty)', +} as const; +type GroupId = keyof typeof GROUPS; + +const groupSource: Record = { + networking: [ + 'Attach network interface', + 'Detach network interface', + 'Manage IP addresses', + 'Change security groups', + 'Change source/destination check', + 'Associate Elastic IP', + 'Disassociate Elastic IP', + ].map((text, i) => ({ id: `networking-${i + 1}`, text })), + storage: [], + monitoring: [], + tags: [], +}; + +// ---- Per-group fake loader ---- + +interface GroupLoaderConfig { + delay: number; // Infinity = never resolves + failRate: number; // 0..1 +} + +function useGroupLoader(groupId: GroupId, { delay, failRate }: GroupLoaderConfig) { + const [items, setItems] = useState([]); + const [status, setStatus] = useState('pending'); + const requestId = useRef(0); + + function load({ samePage }: { samePage: boolean }) { + const id = ++requestId.current; + if (!samePage) { + setItems([]); + } + setStatus('loading'); + if (!isFinite(delay)) { + return; + } + setTimeout(() => { + if (id !== requestId.current) { + return; + } + if (Math.random() < failRate) { + setStatus('error'); + } else { + setItems(groupSource[groupId]); + setStatus('finished'); + } + }, delay); + } + + return { items, status, load }; +} + +export default function Page() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const { fakeResponses = true, randomErrors = true, expandToViewport = false } = urlParams; + + // Root list: paginated, server-side filtered, random errors (same loader as Select/Multiselect pages). + const root = useOptionsLoader({ pageSize: 10, timeout: 5000, randomErrors }); + + // Groups: one loader each so every group state is reachable on demand. + const groups: Record> = { + networking: useGroupLoader('networking', { delay: 5000, failRate: randomErrors ? 0.3 : 0 }), + storage: useGroupLoader('storage', { delay: 5000, failRate: 1 }), + monitoring: useGroupLoader('monitoring', { delay: Infinity, failRate: 0 }), + tags: useGroupLoader('tags', { delay: 5000, failRate: 0 }), + }; + + // Groups are part of the "server" response so they paginate and filter like everything else. + const allItems: ButtonDropdownProps.ItemOrGroup[] = [ + ...flatActions.slice(0, 4), + ...(Object.keys(GROUPS) as GroupId[]).map(id => ({ id, text: GROUPS[id], items: [] })), + ...flatActions.slice(4), + ]; + + // Group items live in their own loaders, so merge them in at render time. + const items: ButtonDropdownProps.Items = root.items.map(item => + item.id && item.id in groups ? { ...item, items: groups[item.id as GroupId].items } : item + ); + + function filteringResultsText(matchesCount: number, totalCount: number) { + if (root.status === 'pending') { + return `${matchesCount}+ results`; + } + if (root.status === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + } + + return ( + + setUrlParams({ fakeResponses: e.detail.checked })}> + Fake responses (off: requests stay pending for integ tests) + + setUrlParams({ randomErrors: e.detail.checked })}> + Random errors (30%) + + setUrlParams({ expandToViewport: e.detail.checked })}> + Expand to viewport + + + } + > + + (groupId ? `Loading ${groupId} actions` : 'Loading actions'), + errorText: groupId => (groupId ? `Error fetching ${groupId} actions.` : 'Error fetching actions.'), + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + finishedText: groupId => + groupId + ? `End of ${groupId} actions` + : root.filteringText + ? `End of "${root.filteringText}" results` + : 'End of all actions', + empty: groupId => (groupId ? `No ${groupId} actions` : 'No actions available'), + }} + getExpandableItemsAsyncLoadingState={({ item }) => + item.id && item.id in groups ? groups[item.id as GroupId].status : null + } + onLoadItems={({ detail: { firstPage, filteringText, samePage, expandedGroupId } }) => { + if (expandedGroupId) { + if (expandedGroupId in groups) { + groups[expandedGroupId as GroupId].load({ samePage }); + } + return; + } + const normalized = filteringText.toLowerCase(); + const filtered = allItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); + root.fetchItems({ firstPage, filteringText, sourceItems: fakeResponses ? filtered : undefined }); + }} + onItemClick={({ detail }) => console.log('onItemClick', detail)} + > + Instance actions + + + + Groups sit between the 4th and 5th action so they are on the first page. Type a word with no match (for + example zzz) for the no-match state. Clear the filter to reload all actions. + + + + ); +} 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 5b819292fe..a1dcb2b8b4 100644 --- a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -6,8 +6,11 @@ import { render, waitFor } from '@testing-library/react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; +import { KeyCode } from '../../../lib/components/internal/keycode'; import createWrapper from '../../../lib/components/test-utils/dom'; +import dropdownFooterStyles from '../../../lib/components/internal/components/dropdown-footer/styles.selectors.js'; + jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), warnOnce: jest.fn(), @@ -124,6 +127,63 @@ describe('ButtonDropdown async loading', () => { '`onLoadItems` must be provided for `recoveryText` to be displayed.' ); }); + + describe('recovery button keyboard access', () => { + const errorProps: Partial = { + asyncLoadingProps: { statusType: 'error', errorText: () => 'Error fetching items', recoveryText: 'Retry' }, + }; + + test('Tab does not close the dropdown while the recovery button is shown', () => { + const { wrapper } = renderDropdown({ ...errorProps, onLoadItems: () => {} }); + wrapper.openDropdown(); + wrapper.findHighlightedItem()!.keydown(KeyCode.tab); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(wrapper.findErrorRecoveryButton()).not.toBeNull(); + }); + + test('Tab closes the dropdown in error state when there is no recovery button', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { statusType: 'error', errorText: () => 'Error fetching items' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findHighlightedItem()!.keydown(KeyCode.tab); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Enter on the recovery button retries without activating the highlighted item or closing the dropdown', () => { + const onLoadItems = jest.fn(); + const onItemClick = jest.fn(); + const { wrapper } = renderDropdown({ + ...errorProps, + onLoadItems: event => onLoadItems(event.detail), + onItemClick, + }); + wrapper.openDropdown(); + onLoadItems.mockClear(); + wrapper.findErrorRecoveryButton()!.keydown(KeyCode.enter); + expect(onLoadItems).toHaveBeenCalledTimes(1); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: false, samePage: true }); + expect(onItemClick).not.toHaveBeenCalled(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('moves focus to the trigger after the recovery button is activated', () => { + const { wrapper } = renderDropdown({ ...errorProps, onLoadItems: () => {} }); + wrapper.openDropdown(); + wrapper.findErrorRecoveryButton()!.click(); + expect(document.activeElement).toBe(wrapper.findNativeButton().getElement()); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('moves focus to the filter input after the recovery button is activated in filtering mode', () => { + const { wrapper } = renderDropdown({ ...errorProps, filteringType: 'manual', onLoadItems: () => {} }); + wrapper.openDropdown(); + wrapper.findErrorRecoveryButton()!.click(); + expect(document.activeElement).toBe(wrapper.findFilteringInput()!.findNativeInput().getElement()); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + }); }); describe('ButtonDropdown status display', () => { @@ -170,6 +230,47 @@ describe('ButtonDropdown status display', () => { expect(status!.getElement()).toHaveTextContent('End of results'); }); + test('renders finished text inside the menu list after the last item, with a divider', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { statusType: 'finished', finishedText: () => 'End of results' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + const menu = wrapper.findOpenDropdown()!.find('ul[role="menu"]')!.getElement(); + const status = wrapper.findStatusIndicator()!.getElement(); + // Scrolls together with the items instead of covering the last one. + expect(menu.lastElementChild!.contains(status)).toBe(true); + expect(menu.lastElementChild!.previousElementSibling).toBe(wrapper.findItemById('i3')!.getElement()); + // Divider between the last item and the text. + expect(wrapper.findOpenDropdown()!.findByClassName(dropdownFooterStyles.root)!.getElement()).not.toHaveClass( + dropdownFooterStyles['no-items'] + ); + }); + + test('renders sticky status without the divider when there are no items', () => { + const { wrapper } = renderDropdown({ + items: [], + asyncLoadingProps: { statusType: 'loading', loadingText: () => 'Loading actions' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + expect(wrapper.findOpenDropdown()!.findByClassName(dropdownFooterStyles.root)!.getElement()).toHaveClass( + dropdownFooterStyles['no-items'] + ); + }); + + test('announces the status through a live region, also when there are no items', () => { + const { wrapper } = renderDropdown({ + items: [], + asyncLoadingProps: { statusType: 'loading', loadingText: () => 'Loading actions' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + const liveRegion = wrapper.findOpenDropdown()!.findLiveRegion(); + expect(liveRegion).not.toBeNull(); + expect(liveRegion!.getElement()).toHaveTextContent('Loading actions'); + }); + test('shows empty text when items are empty and statusType is "finished"', () => { const { wrapper } = renderDropdown({ items: [], @@ -195,6 +296,21 @@ describe('ButtonDropdown status display', () => { wrapper.openDropdown(); expect(wrapper.findStatusIndicator()).toBeNull(); }); + + test('shows noMatch when filteringType="manual" and items are empty due to filtering', () => { + const { wrapper } = renderDropdown({ + items: [], + filteringType: 'manual', + noMatch: No actions match, + asyncLoadingProps: { statusType: 'finished', empty: () => 'No actions found' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('xyz'); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('No actions match'); + }); }); describe('ButtonDropdown async loading with expandable groups', () => { @@ -260,7 +376,7 @@ describe('ButtonDropdown async loading with expandable groups', () => { expect(onLoadItems).toHaveBeenCalledWith(expect.objectContaining({ samePage: true, expandedGroupId: 'g1' })); }); - test('shows loading status inside expanded group without a border when items are empty', () => { + test('shows loading status inside expanded group without a divider when items are empty', () => { const { wrapper } = renderDropdown({ items: groupItems, expandableGroups: true, @@ -272,9 +388,112 @@ describe('ButtonDropdown async loading with expandable groups', () => { }); wrapper.openDropdown(); wrapper.findExpandableCategoryById('g1')!.click(); - // g1 has no items yet - status renders via DropdownStatus (no DropdownFooter border) const groupStatus = wrapper.findStatusIndicator({ expandedGroupDropdown: true }); expect(groupStatus).not.toBeNull(); expect(groupStatus!.getElement()).toHaveTextContent('Loading group items'); + const groupDropdown = wrapper.findOpenDropdown()!.find('[data-open=true]')!; + expect(groupDropdown.findByClassName(dropdownFooterStyles.root)!.getElement()).toHaveClass( + dropdownFooterStyles['no-items'] + ); + }); + + test('announces the group status through a live region', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'loading' : null), + asyncLoadingProps: { loadingText: () => 'Loading group items' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + const liveRegion = wrapper.findOpenDropdown()!.find('[data-open=true]')!.findLiveRegion(); + expect(liveRegion).not.toBeNull(); + expect(liveRegion!.getElement()).toHaveTextContent('Loading group items'); + }); + + test('renders group finished text inside the group menu after its items', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g2' ? 'finished' : null), + asyncLoadingProps: { finishedText: (groupId?: string) => `End of ${groupId}` }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g2')!.click(); + const groupMenu = wrapper.findOpenDropdown()!.find('[data-open=true]')!.find('ul[role="menu"]')!.getElement(); + const status = wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement(); + expect(status).toHaveTextContent('End of g2'); + expect(groupMenu.lastElementChild!.contains(status)).toBe(true); + expect(groupMenu.lastElementChild!.previousElementSibling).toBe(wrapper.findItemById('g2i1')!.getElement()); + }); + + test('clicking the recovery button inside a group keeps the group expanded', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Error', recoveryText: 'Retry' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })!.click(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(wrapper.findExpandableCategoryById('g1')!.find('[aria-expanded="true"]')).not.toBeNull(); + }); + + test('Tab does not close the dropdown while a group recovery button is shown', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Error', recoveryText: 'Retry' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + wrapper.findOpenDropdown()!.keydown(KeyCode.tab); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })).not.toBeNull(); + }); + + test.each([ + ['right arrow', KeyCode.right], + ['Enter', KeyCode.enter], + ])('fires onLoadItems with expandedGroupId when a group is expanded with %s', (_, keyCode) => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'pending' : null), + onLoadItems: event => onLoadItems(event.detail), + }); + // Opening with the keyboard highlights the first group. + wrapper.findNativeButton().keydown(KeyCode.down); + onLoadItems.mockClear(); + wrapper.findOpenDropdown()!.keydown(keyCode); + expect(onLoadItems).toHaveBeenCalledTimes(1); + expect(onLoadItems).toHaveBeenCalledWith({ + filteringText: '', + firstPage: true, + samePage: false, + expandedGroupId: 'g1', + }); + }); + + test('does not fire onLoadItems when a group is collapsed', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g2')!.click(); + onLoadItems.mockClear(); + wrapper.findExpandableCategoryById('g2')!.click(); + expect(onLoadItems).not.toHaveBeenCalled(); }); }); diff --git a/src/button-dropdown/__tests__/use-load-items.test.tsx b/src/button-dropdown/__tests__/use-load-items.test.tsx index 1affbbccd9..42c0546b98 100644 --- a/src/button-dropdown/__tests__/use-load-items.test.tsx +++ b/src/button-dropdown/__tests__/use-load-items.test.tsx @@ -102,4 +102,18 @@ describe('useLoadItems', () => { fns.handleRecoveryClick('group-1'); expect(onLoadItems).toHaveBeenCalledWith(expect.objectContaining({ expandedGroupId: 'group-1', samePage: true })); }); + + test('fireGroupLoadItems fires a first-page request for the group without the main filtering text', () => { + const onLoadItems = jest.fn(); + const fns = renderHook(onLoadItems, items, 'finished'); + fns.fireLoadItems('search'); + onLoadItems.mockClear(); + fns.fireGroupLoadItems('group-1'); + expect(onLoadItems).toHaveBeenCalledWith({ + filteringText: '', + firstPage: true, + samePage: false, + expandedGroupId: 'group-1', + }); + }); }); diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index 0b8150a766..9e110c246f 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -11,7 +11,6 @@ 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 DropdownStatus 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'; @@ -22,6 +21,7 @@ import { import { ButtonDropdownProps } from '../interfaces'; import { CategoryProps } from '../internal-interfaces'; import ItemsList from '../items-list'; +import StatusFooter from '../status-footer'; import Tooltip from '../tooltip.js'; import { getMenuItemProps } from '../utils/menu-item'; @@ -77,13 +77,19 @@ const ExpandableCategoryElement = ({ isEmpty: !item.items || item.items.length === 0, isNoMatch: false, hasRecoveryCallback: !!onLoadItems, - onRecoveryClick: () => + onRecoveryClick: () => { fireNonCancelableEvent(onLoadItems, { filteringText: '', firstPage: false, samePage: true, expandedGroupId: groupId, - }), + }); + // The recovery button disappears once loading starts. Keep focus inside the group so the + // dropdown stays open; in filtering mode focus never left the filter input. + if (!filteringEnabled) { + triggerRef.current?.focus(); + } + }, }); useEffect(() => { @@ -95,15 +101,6 @@ 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(); @@ -205,20 +202,17 @@ const ExpandableCategoryElement = ({ } else if (disabled) { content = trigger; } else { + const hasGroupItems = !!item.items && item.items.length > 0; content = ( 0 ? ( - + expanded && groupDropdownStatus.content && groupDropdownStatus.isSticky ? ( + ) : undefined } content={ @@ -228,15 +222,7 @@ const ExpandableCategoryElement = ({ aria-label={item.text} className={clsx(styles['items-list-container'], styles['in-dropdown'])} > - {groupDropdownStatus.content && !groupDropdownStatus.isSticky ? ( - - ) : null} - {groupDropdownStatus.content && - groupDropdownStatus.isSticky && - (!item.items || item.items.length === 0) ? ( - {groupDropdownStatus.content} - ) : null} - {item.items && item.items.length > 0 ? ( + {hasGroupItems ? ( ) : null} + {groupDropdownStatus.content && !groupDropdownStatus.isSticky ? ( + // Non-sticky status (finished text) follows the items, like in the main dropdown. +
  • + +
  • + ) : null} ) : undefined } diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index d5f9395fc2..bf8815fe7d 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -16,7 +16,6 @@ import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; -import DropdownStatus from '../internal/components/dropdown-status'; import OptionsList from '../internal/components/options-list'; import useHiddenDescription from '../internal/hooks/use-hidden-description'; import { useMobile } from '../internal/hooks/use-mobile'; @@ -32,10 +31,11 @@ import ButtonDropdownFilter from './filter'; import { ButtonDropdownProps } from './interfaces'; import { InternalButtonDropdownProps, InternalItem } from './internal-interfaces'; import ItemsList from './items-list'; +import StatusFooter from './status-footer'; 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 { isItemGroup, isLinkItem } from './utils/utils.js'; import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './styles.css.js'; @@ -128,12 +128,17 @@ const InternalButtonDropdown = React.forwardRef( const statusType = asyncLoadingProps?.statusType ?? 'finished'; - const { fireLoadItems, handleLoadMore, handleRecoveryClick } = useLoadItems({ + const { fireLoadItems, handleLoadMore, handleRecoveryClick, fireGroupLoadItems } = useLoadItems({ onLoadItems, items, statusType, }); + // Whether a recovery button is rendered anywhere in the dropdown. It is derived from the + // dropdown status below, which in turn depends on the hook output, so the hook reads the + // latest value through a ref instead of receiving it as an argument. + const hasRecoveryButtonRef = useRef(false); + const { isOpen, targetItem, @@ -165,6 +170,12 @@ const InternalButtonDropdown = React.forwardRef( isInRestrictedView, filteringType, fireLoadItems, + onGroupExpand: group => { + if (group.id && onLoadItems) { + fireGroupLoadItems(group.id); + } + }, + hasRecoveryButton: () => hasRecoveryButtonRef.current, }); const filterRef = useRef(null); @@ -412,7 +423,11 @@ const InternalButtonDropdown = React.forwardRef( const matchesCount = useMemo(() => countLeafItems(filteredItems), [filteredItems]); const filteredText = isFiltered ? filteringResultsText?.(matchesCount, totalCount) : undefined; - const isEmpty = !items || items.length === 0; + // Only treat as "truly empty" (no data at all) when the user is not actively filtering. + // When filteringValue is set, zero items means "no match" not "empty". + const isEmpty = (!items || items.length === 0) && !filteringValue; + + const hasItems = filteredItems.length > 0; const dropdownStatus = useDropdownStatus({ statusType, @@ -427,9 +442,29 @@ const InternalButtonDropdown = React.forwardRef( noMatch, filteringResultsText: filteredText, hasRecoveryCallback: !!onLoadItems, - onRecoveryClick: () => handleRecoveryClick(), + onRecoveryClick: () => { + handleRecoveryClick(); + // The recovery button disappears once loading starts, so move focus back to the + // element that owns keyboard interaction to keep the dropdown open. + if (hasFiltering) { + filterRef.current?.focus(); + } else { + triggerRef.current?.focus({ preventScroll: true }); + } + }, }); + // A recovery button inside an expanded group is rendered by the category element with the + // same conditions as the main status (error state, recovery text, onLoadItems callback). + const expandedGroupHasRecoveryButton = + showExpandableGroups && + !!recoveryText && + !!onLoadItems && + items.some( + item => isItemGroup(item) && isExpanded(item) && getExpandableItemsAsyncLoadingState?.({ item }) === 'error' + ); + hasRecoveryButtonRef.current = dropdownStatus.hasRecoveryButton || expandedGroupHasRecoveryButton; + // Only create a filteringDescription element if filtering is actually enabled, // not just if the string is provided. const filteringItemDescription = hasFiltering ? i18nStrings?.filteringItemAriaDescription : undefined; @@ -501,8 +536,8 @@ const InternalButtonDropdown = React.forwardRef( ariaRole={hasFiltering ? 'dialog' : undefined} ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ - dropdownStatus.content && dropdownStatus.isSticky && !isEmpty ? ( - + dropdownStatus.content && dropdownStatus.isSticky ? ( + ) : null } content={ @@ -567,13 +602,18 @@ const InternalButtonDropdown = React.forwardRef( getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} onLoadItems={onLoadItems} /> + {dropdownStatus.content && !dropdownStatus.isSticky ? ( + // Non-sticky status (finished text) scrolls together with the items, like the + // list bottom in Select, instead of covering the last item. +
  • + +
  • + ) : null} - {dropdownStatus.content && !dropdownStatus.isSticky ? ( - - ) : null} - {dropdownStatus.content && dropdownStatus.isSticky && isEmpty ? ( - {isOpen ? dropdownStatus.content : null} - ) : null} {filteringDescriptionEl} } diff --git a/src/button-dropdown/status-footer.tsx b/src/button-dropdown/status-footer.tsx new file mode 100644 index 0000000000..a7b7301923 --- /dev/null +++ b/src/button-dropdown/status-footer.tsx @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import DropdownFooter from '../internal/components/dropdown-footer'; +import { KeyCode } from '../internal/keycode'; + +interface StatusFooterProps { + content: React.ReactNode | null; + id: string; + hasItems: boolean; +} + +// Wraps the shared DropdownFooter so that interactions with the recovery button inside it are +// handled exclusively by the button and are not interpreted by the button dropdown handlers: +// a click must not toggle the enclosing expandable group, and Enter/Space must not activate the +// highlighted menu item or toggle the dropdown. +const StatusFooter = ({ content, id, hasItems }: StatusFooterProps) => { + const stopActivationKeys = (event: React.KeyboardEvent) => { + if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) { + event.stopPropagation(); + } + }; + return ( +
    event.stopPropagation()} onKeyDown={stopActivationKeys} onKeyUp={stopActivationKeys}> + +
    + ); +}; + +export default StatusFooter; diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index 5248a41ca4..a152d57b74 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -19,6 +19,16 @@ interface UseButtonDropdownOptions extends ButtonDropdownSettings { expandToViewport?: boolean; filteringType?: ButtonDropdownProps.FilteringType; fireLoadItems?: (filteringText: string) => void; + /** + * Called whenever an expandable group gets expanded, regardless of whether it was + * expanded with the pointer or with the keyboard. + */ + onGroupExpand?: (group: ButtonDropdownProps.ItemGroup) => void; + /** + * Returns whether a recovery button is currently rendered inside the dropdown. + * While it is, Tab moves focus to it instead of closing the dropdown. + */ + hasRecoveryButton?: () => boolean; } interface UseButtonDropdownApi extends HighlightProps { @@ -48,6 +58,8 @@ export function useButtonDropdown({ expandToViewport = false, filteringType, fireLoadItems, + onGroupExpand, + hasRecoveryButton, }: UseButtonDropdownOptions): UseButtonDropdownApi { const [filteringValue, setFilteringValue] = useState(''); const hasFiltering = filteringType === 'auto' || filteringType === 'manual'; @@ -131,7 +143,14 @@ export function useButtonDropdown({ } }; - const onGroupToggle: GroupToggle = item => (!isExpanded(item) ? expandGroup(item) : collapseGroup()); + // Single entry point for expanding a group so that pointer and keyboard expansion + // both notify the consumer (used to load the group's items asynchronously). + const expandGroupAndNotify = (group: ButtonDropdownProps.ItemGroup) => { + expandGroup(group); + onGroupExpand?.(group); + }; + + const onGroupToggle: GroupToggle = item => (!isExpanded(item) ? expandGroupAndNotify(item) : collapseGroup()); const onItemActivate: ItemActivate = (item, event) => { const isCheckbox = isCheckboxItem(item); @@ -242,7 +261,7 @@ export function useButtonDropdown({ break; } if (targetItem && !targetItem.disabled && isItemGroup(targetItem) && !isExpanded(targetItem)) { - expandGroup(); + expandGroupAndNotify(targetItem); } else if (hasExpandableGroups) { collapseGroup(); } @@ -267,6 +286,11 @@ export function useButtonDropdown({ if (hasFiltering) { break; } + // A recovery button rendered in the status footer must be reachable with Tab. The + // dropdown then closes through onDropdownBlur once focus actually leaves it. + if (hasRecoveryButton?.()) { + break; + } // When expanded to viewport the focus can't move naturally to the next element. // Returning the focus to the trigger instead. if (expandToViewport) { diff --git a/src/button-dropdown/utils/use-load-items.ts b/src/button-dropdown/utils/use-load-items.ts index 8d7649d6f0..66247bcaed 100644 --- a/src/button-dropdown/utils/use-load-items.ts +++ b/src/button-dropdown/utils/use-load-items.ts @@ -41,9 +41,13 @@ export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsPro expandedGroupId, }); + const fireGroupLoadItems = (expandedGroupId: string) => + fireNonCancelableEvent(onLoadItems, { filteringText: '', firstPage: true, samePage: false, expandedGroupId }); + return { fireLoadItems, handleLoadMore, handleRecoveryClick, + fireGroupLoadItems, }; }; From 0cfdfa8147b59546387b3d6747b17981d0eb3db9 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 29 Sep 2026 14:08:56 +0200 Subject: [PATCH 05/14] fix: Show async group status on mobile and restore focus after group recovery - Share the expandable group status logic between the desktop fly-out and the mobile inline group so both render loading, error, empty, and finished states - Route group recovery through the root so focus returns to the filter input in filtering mode; group events never carry the root filtering text - Pass the real async status to the options list so a short first page loads the next one, as in Select - Treat a group status of pending as finished: groups load as a single page - Test utils: expandedGroupDropdown also resolves inline mobile groups; drop the duplicate stylesheet import --- .../test-utils-selectors.test.tsx.snap | 1 + ...ton-dropdown-async-loading-mobile.test.tsx | 126 ++++++++++++++++++ .../button-dropdown-async-loading.test.tsx | 98 ++++++++++++++ .../__tests__/use-load-items.test.tsx | 11 +- .../expandable-category-element.tsx | 57 +++----- .../mobile-expandable-category-element.tsx | 76 ++++++++--- .../use-expandable-group-status.ts | 76 +++++++++++ src/button-dropdown/internal-interfaces.ts | 12 +- src/button-dropdown/internal.tsx | 16 ++- src/button-dropdown/items-list.tsx | 7 +- src/button-dropdown/utils/use-load-items.ts | 4 +- src/test-utils/dom/button-dropdown/index.ts | 11 +- 12 files changed, 419 insertions(+), 76 deletions(-) create mode 100644 src/button-dropdown/__tests__/button-dropdown-async-loading-mobile.test.tsx create mode 100644 src/button-dropdown/category-elements/use-expandable-group-status.ts diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index e301c3b8b9..ce22f4094b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -101,6 +101,7 @@ exports[`test-utils selectors 1`] = ` "awsui_description_sne0l", "awsui_disabled_93a1u", "awsui_dropdown-trigger_sne0l", + "awsui_dropdown_14cnr", "awsui_expandable_16mm3", "awsui_highlighted_93a1u", "awsui_item-element_93a1u", diff --git a/src/button-dropdown/__tests__/button-dropdown-async-loading-mobile.test.tsx b/src/button-dropdown/__tests__/button-dropdown-async-loading-mobile.test.tsx new file mode 100644 index 0000000000..843358068a --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading-mobile.test.tsx @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import mobileGroupStyles from '../../../lib/components/button-dropdown/mobile-expandable-group/styles.selectors.js'; + +jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({ + useMobile: jest.fn().mockReturnValue(true), +})); + +const groupItems: ButtonDropdownProps.Items = [ + { id: 'g1', text: 'Group 1', items: [] as ButtonDropdownProps.Items } as ButtonDropdownProps.ItemGroup, + { id: 'g2', text: 'Group 2', items: [{ id: 'g2i1', text: 'Action 1' }] } as ButtonDropdownProps.ItemGroup, +]; + +function renderDropdown(props: Partial = {}) { + const result = render( + + Actions + + ); + const wrapper = createWrapper(result.container).findButtonDropdown()!; + return { ...result, wrapper }; +} + +function findOpenMobileGroup(wrapper: ReturnType['wrapper']) { + return wrapper.findOpenDropdown()!.find(`.${mobileGroupStyles.dropdown}[data-open=true]`); +} + +describe('ButtonDropdown async loading with expandable groups on mobile', () => { + test('renders the group inline instead of as a fly-out', () => { + const { wrapper } = renderDropdown(); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g2')!.click(); + expect(findOpenMobileGroup(wrapper)).not.toBeNull(); + expect(wrapper.findExpandableCategoryById('g2')!.findAll('li').length).toBe(1); + }); + + test('shows the loading status inside an expanded group without items', () => { + const { wrapper } = renderDropdown({ + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'loading' : null), + asyncLoadingProps: { loadingText: groupId => `Loading ${groupId}` }, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement()).toHaveTextContent('Loading g1'); + }); + + test('shows the empty text when the group finished loading without items', () => { + const { wrapper } = renderDropdown({ + getExpandableItemsAsyncLoadingState: () => 'finished', + asyncLoadingProps: { empty: () => 'No actions in this group' }, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement()).toHaveTextContent( + 'No actions in this group' + ); + }); + + test('renders the finished text after the group items', () => { + const { wrapper } = renderDropdown({ + getExpandableItemsAsyncLoadingState: () => 'finished', + asyncLoadingProps: { finishedText: () => 'End of group' }, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g2')!.click(); + const group = findOpenMobileGroup(wrapper)!; + const listItems = group.findAll('li'); + expect(listItems.length).toBe(2); + expect(listItems[0].getElement()).toHaveTextContent('Action 1'); + expect(listItems[1].getElement()).toHaveTextContent('End of group'); + }); + + test('shows the error status with a recovery button that reloads the group and keeps it expanded', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Failed to load', recoveryText: 'Retry' }, + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + onLoadItems.mockClear(); + + const recoveryButton = wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })!; + expect(recoveryButton.getElement()).toHaveTextContent('Retry'); + recoveryButton.click(); + + expect(onLoadItems).toHaveBeenCalledTimes(1); + expect(onLoadItems).toHaveBeenCalledWith({ + filteringText: '', + firstPage: false, + samePage: true, + expandedGroupId: 'g1', + }); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(findOpenMobileGroup(wrapper)).not.toBeNull(); + }); + + test('does not render a recovery button without onLoadItems', () => { + const { wrapper } = renderDropdown({ + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Failed to load', recoveryText: 'Retry' }, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement()).toHaveTextContent( + 'Failed to load' + ); + expect(wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })).toBeNull(); + }); + + test('shows no status inside a group that is not loaded asynchronously', () => { + const { wrapper } = renderDropdown({ + asyncLoadingProps: { loadingText: () => 'Loading', empty: () => 'Empty' }, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g2')!.click(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })).toBeNull(); + }); +}); 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 a1dcb2b8b4..5963d2cfdb 100644 --- a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -93,6 +93,28 @@ describe('ButtonDropdown async loading', () => { expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: false, samePage: true }); }); + test('requests the next page when the dropdown opens with statusType "pending" and the list does not fill it', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + asyncLoadingProps: { statusType: 'pending' }, + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + // In addition to the first-page request fired on open, the list reports it is not scrollable yet. + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: false, samePage: false }); + }); + + test('does not request the next page on open when statusType is "finished"', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + asyncLoadingProps: { statusType: 'finished' }, + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + expect(onLoadItems).toHaveBeenCalledTimes(1); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: true, samePage: false }); + }); + test('does not apply client-side filtering when filteringType is "manual"', () => { const { wrapper } = renderDropdown({ filteringType: 'manual', @@ -444,6 +466,82 @@ describe('ButtonDropdown async loading with expandable groups', () => { expect(wrapper.findExpandableCategoryById('g1')!.find('[aria-expanded="true"]')).not.toBeNull(); }); + test('fires a same-page request for the group when its recovery button is clicked', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Error', recoveryText: 'Retry' }, + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + onLoadItems.mockClear(); + wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })!.click(); + expect(onLoadItems).toHaveBeenCalledTimes(1); + expect(onLoadItems).toHaveBeenCalledWith({ + filteringText: '', + firstPage: false, + samePage: true, + expandedGroupId: 'g1', + }); + }); + + test('moves focus to the group header after its recovery button is activated', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Error', recoveryText: 'Retry' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })!.click(); + expect(document.activeElement).toBe( + wrapper.findExpandableCategoryById('g1')!.find('[aria-haspopup="true"]')!.getElement() + ); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('moves focus to the filter input after a group recovery button is activated in filtering mode', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + filteringType: 'manual', + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { errorText: () => 'Error', recoveryText: 'Retry' }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + const recoveryButton = wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })!; + // Tab from the filter input lands on the recovery button, so focus is no longer on the input. + recoveryButton.focus(); + recoveryButton.click(); + expect(document.activeElement).toBe(wrapper.findFilteringInput()!.findNativeInput().getElement()); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('treats a group status of "pending" as "finished"', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: () => 'pending', + asyncLoadingProps: { empty: () => 'No actions in this group', finishedText: () => 'End of group' }, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement()).toHaveTextContent( + 'No actions in this group' + ); + wrapper.findExpandableCategoryById('g2')!.click(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement()).toHaveTextContent( + 'End of group' + ); + }); + test('Tab does not close the dropdown while a group recovery button is shown', () => { const { wrapper } = renderDropdown({ items: groupItems, diff --git a/src/button-dropdown/__tests__/use-load-items.test.tsx b/src/button-dropdown/__tests__/use-load-items.test.tsx index 42c0546b98..c3042664c3 100644 --- a/src/button-dropdown/__tests__/use-load-items.test.tsx +++ b/src/button-dropdown/__tests__/use-load-items.test.tsx @@ -96,11 +96,18 @@ describe('useLoadItems', () => { expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'search', firstPage: false, samePage: true }); }); - test('handleRecoveryClick passes expandedGroupId when provided', () => { + test('handleRecoveryClick for a group carries the group id and not the root filtering text', () => { const onLoadItems = jest.fn(); const fns = renderHook(onLoadItems, items, 'error'); + fns.fireLoadItems('search'); + onLoadItems.mockClear(); fns.handleRecoveryClick('group-1'); - expect(onLoadItems).toHaveBeenCalledWith(expect.objectContaining({ expandedGroupId: 'group-1', samePage: true })); + expect(onLoadItems).toHaveBeenCalledWith({ + filteringText: '', + firstPage: false, + samePage: true, + expandedGroupId: 'group-1', + }); }); test('fireGroupLoadItems fires a first-page request for the group without the main filtering text', () => { diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index 6b94a27a86..e826dd3a74 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -3,15 +3,11 @@ import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; -import { isThemeActive, Theme, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme } 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 { @@ -24,6 +20,7 @@ import ItemsList from '../items-list'; import StatusFooter from '../status-footer'; import Tooltip from '../tooltip.js'; import { getMenuItemProps } from '../utils/menu-item'; +import { useExpandableGroupStatus } from './use-expandable-group-status'; import styles from './styles.css.js'; @@ -49,47 +46,25 @@ const ExpandableCategoryElement = ({ filteringDescriptionId, asyncLoadingProps, getExpandableItemsAsyncLoadingState, - onLoadItems, + onGroupRecoveryClick, }: 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, - }); - // The recovery button disappears once loading starts. Keep focus inside the group so the - // dropdown stays open; in filtering mode focus never left the filter input. - if (!filteringEnabled) { - triggerRef.current?.focus(); - } - }, + const { + status: groupDropdownStatus, + footerId, + hasGroupItems, + } = useExpandableGroupStatus({ + item, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onGroupRecoveryClick, + filteringEnabled, + triggerRef, }); useEffect(() => { @@ -204,7 +179,6 @@ const ExpandableCategoryElement = ({ } else if (disabled) { content = trigger; } else { - const hasGroupItems = !!item.items && item.items.length > 0; content = ( {hasGroupItems ? ( @@ -248,7 +223,7 @@ const ExpandableCategoryElement = ({ {groupDropdownStatus.content && !groupDropdownStatus.isSticky ? ( // Non-sticky status (finished text) follows the items, like in the main dropdown.
  • - +
  • ) : null} diff --git a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx index 076376ce51..dc92f15738 100644 --- a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx @@ -13,8 +13,10 @@ import { ButtonDropdownProps } from '../interfaces'; import { CategoryProps } from '../internal-interfaces'; import ItemsList from '../items-list'; import MobileExpandableGroup from '../mobile-expandable-group/mobile-expandable-group'; +import StatusFooter from '../status-footer'; import Tooltip from '../tooltip.js'; import { getMenuItemProps } from '../utils/menu-item.js'; +import { useExpandableGroupStatus } from './use-expandable-group-status'; import styles from './styles.css.js'; @@ -37,12 +39,28 @@ const MobileExpandableCategoryElement = ({ filteringEnabled, menuId, filteringDescriptionId, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onGroupRecoveryClick, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); const isKeyboardHighlighted = isKeyboardHighlight(item); const triggerRef = React.useRef(null); + const { + status: groupDropdownStatus, + footerId, + hasGroupItems, + } = useExpandableGroupStatus({ + item, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onGroupRecoveryClick, + filteringEnabled, + triggerRef, + }); + useEffect(() => { if (triggerRef.current && highlighted && !expanded && !filteringEnabled) { triggerRef.current.focus(); @@ -148,28 +166,42 @@ const MobileExpandableCategoryElement = ({ } else { content = ( - {item.items && expanded && ( -
      - + {expanded && (hasGroupItems || groupDropdownStatus.content) && ( +
        + {hasGroupItems ? ( + + ) : null} + {groupDropdownStatus.content ? ( + // The group is inline in the main list, so every status (loading, error, empty, + // finished) follows the items instead of being a sticky footer. +
      • + +
      • + ) : null}
      )} diff --git a/src/button-dropdown/category-elements/use-expandable-group-status.ts b/src/button-dropdown/category-elements/use-expandable-group-status.ts new file mode 100644 index 0000000000..46f037c84a --- /dev/null +++ b/src/button-dropdown/category-elements/use-expandable-group-status.ts @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { useInternalI18n } from '../../i18n/context'; +import { DropdownStatusResult, useDropdownStatus } from '../../internal/components/dropdown-status'; +import { ButtonDropdownProps } from '../interfaces'; +import { CategoryProps } from '../internal-interfaces'; + +type UseExpandableGroupStatusProps = Pick< + CategoryProps, + 'item' | 'asyncLoadingProps' | 'getExpandableItemsAsyncLoadingState' | 'onGroupRecoveryClick' | 'filteringEnabled' +> & { + // Focused after the recovery action in menu (non-filtering) mode, where focus sits on the group header. + triggerRef: React.RefObject; +}; + +interface ExpandableGroupStatus { + status: DropdownStatusResult; + footerId: string; + hasGroupItems: boolean; +} + +/** + * Derives the async loading status UI of an expandable group from `getExpandableItemsAsyncLoadingState`. + * Shared by the desktop (fly-out) and mobile (inline) expandable category elements so both render the + * same loading, error, empty, and finished states. + */ +export function useExpandableGroupStatus({ + item, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onGroupRecoveryClick, + filteringEnabled, + triggerRef, +}: UseExpandableGroupStatusProps): ExpandableGroupStatus { + const groupId = item.id; + const footerId = useUniqueId('awsui-button-dropdown__group-footer'); + const hasGroupItems = !!item.items && item.items.length > 0; + + // A group is loaded as a whole, so `pending` (more pages available) has no meaning here. + const reportedStatus = groupId ? getExpandableItemsAsyncLoadingState?.({ item }) : undefined; + const statusType: ButtonDropdownProps.AsyncLoadingStatusType | undefined = + reportedStatus === 'pending' ? 'finished' : (reportedStatus ?? undefined); + + const i18n = useInternalI18n('button-dropdown'); + const recoveryText = i18n('recoveryText', asyncLoadingProps?.recoveryText); + const errorIconAriaLabel = i18n('errorIconAriaLabel', asyncLoadingProps?.errorIconAriaLabel); + + const status = useDropdownStatus({ + statusType, + empty: asyncLoadingProps?.empty?.(groupId), + loadingText: asyncLoadingProps?.loadingText?.(groupId), + finishedText: asyncLoadingProps?.finishedText?.(groupId), + errorText: asyncLoadingProps?.errorText?.(groupId), + recoveryText, + errorIconAriaLabel, + isEmpty: !hasGroupItems, + isNoMatch: false, + hasRecoveryCallback: !!onGroupRecoveryClick, + onRecoveryClick: () => { + if (groupId) { + onGroupRecoveryClick?.(groupId); + } + // The recovery button disappears once loading starts. In menu mode keep focus on the group + // header so the dropdown stays open; in filtering mode the root moves focus back to the filter. + if (!filteringEnabled) { + triggerRef.current?.focus(); + } + }, + }); + + return { status, footerId, hasGroupItems }; +} diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 3477eaa75b..df677908be 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -31,7 +31,11 @@ export interface CategoryProps extends HighlightProps { filteringDescriptionId?: string; asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; - onLoadItems?: ButtonDropdownProps['onLoadItems']; + /** + * Fires the recovery request for an expandable group in error state. Defined only when the + * consumer listens to `onLoadItems`, which is what makes the recovery action available. + */ + onGroupRecoveryClick?: (groupId: string) => void; } export interface ItemListProps extends HighlightProps { @@ -55,7 +59,11 @@ export interface ItemListProps extends HighlightProps { filteringDescriptionId?: string; asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; - onLoadItems?: ButtonDropdownProps['onLoadItems']; + /** + * Fires the recovery request for an expandable group in error state. Defined only when the + * consumer listens to `onLoadItems`, which is what makes the recovery action available. + */ + onGroupRecoveryClick?: (groupId: string) => void; } export interface ItemProps { diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 8613e6c559..4119cd3589 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -469,6 +469,17 @@ const InternalButtonDropdown = React.forwardRef( }, }); + // Recovery inside an expanded group. In filtering mode the recovery button is reached with Tab + // from the filter input, so focus returns there; in menu mode the group keeps focus on its header. + const onGroupRecoveryClick = onLoadItems + ? (groupId: string) => { + handleRecoveryClick(groupId); + if (hasFiltering) { + filterRef.current?.focus(); + } + } + : undefined; + // A recovery button inside an expanded group is rendered by the category element with the // same conditions as the main status (error state, recovery text, onLoadItems callback). const expandedGroupHasRecoveryButton = @@ -477,6 +488,7 @@ const InternalButtonDropdown = React.forwardRef( items.some( item => isItemGroup(item) && + !!item.id && isExpandable(item) && isExpanded(item) && getExpandableItemsAsyncLoadingState?.({ item }) === 'error' @@ -592,7 +604,7 @@ 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 ? ( // Non-sticky status (finished text) scrolls together with the items, like the diff --git a/src/button-dropdown/items-list.tsx b/src/button-dropdown/items-list.tsx index 6c8b3f7d2c..52c8851339 100644 --- a/src/button-dropdown/items-list.tsx +++ b/src/button-dropdown/items-list.tsx @@ -36,7 +36,7 @@ export default function ItemsList({ filteringDescriptionId, asyncLoadingProps, getExpandableItemsAsyncLoadingState, - onLoadItems, + onGroupRecoveryClick, }: ItemListProps) { const isMobile = useMobile(); @@ -92,6 +92,9 @@ export default function ItemsList({ filteringEnabled={filteringEnabled} menuId={menuId} filteringDescriptionId={filteringDescriptionId} + asyncLoadingProps={asyncLoadingProps} + getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} + onGroupRecoveryClick={onGroupRecoveryClick} /> ) : ( ) ) : null; diff --git a/src/button-dropdown/utils/use-load-items.ts b/src/button-dropdown/utils/use-load-items.ts index 66247bcaed..8e3f336cbf 100644 --- a/src/button-dropdown/utils/use-load-items.ts +++ b/src/button-dropdown/utils/use-load-items.ts @@ -33,11 +33,13 @@ export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsPro } }; + // Events about an expandable group never carry the root filtering text: the filter applies to + // the root list, while a group is always loaded as a whole. const handleRecoveryClick = (expandedGroupId?: string) => fireNonCancelableEvent(onLoadItems, { firstPage: false, samePage: true, - filteringText: prevFilteringText.current || '', + filteringText: expandedGroupId ? '' : prevFilteringText.current || '', expandedGroupId, }); diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 18df64bd8c..52d2bb8dc7 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -9,11 +9,14 @@ import InputWrapper from '../input/index.js'; import buttonStyles from '../../../button/styles.selectors.js'; import categoryStyles from '../../../button-dropdown/category-elements/styles.selectors.js'; import itemStyles from '../../../button-dropdown/item-element/styles.selectors.js'; +import mobileGroupStyles from '../../../button-dropdown/mobile-expandable-group/styles.selectors.js'; 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'; + +// An expanded group renders as a fly-out dropdown on desktop and as an inline section on mobile. +const expandedGroupSelector = `.${dropdownStyles.dropdown}[data-open=true], .${mobileGroupStyles.dropdown}[data-open=true]`; function getItemSelector({ disabled }: { disabled?: boolean }): string { let selector = `.${itemStyles['item-element']}`; @@ -141,7 +144,7 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { findErrorRecoveryButton(options = { expandedGroupDropdown: false }): ElementWrapper | null { let dropdown = this.findOpenDropdown(); if (options.expandedGroupDropdown && dropdown) { - dropdown = dropdown.find(`.${dropdownStyles.dropdown}[data-open=true]`); + dropdown = dropdown.find(expandedGroupSelector); } return dropdown?.findByClassName(footerStyles.recovery) ?? null; } @@ -154,9 +157,9 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { findStatusIndicator(options = { expandedGroupDropdown: false }): ElementWrapper | null { let dropdown = this.findOpenDropdown(); if (options.expandedGroupDropdown && dropdown) { - dropdown = dropdown.find(`.${dropdownStyles.dropdown}[data-open=true]`); + dropdown = dropdown.find(expandedGroupSelector); } - return dropdown?.findByClassName(dropdownStatusStyles.root) ?? null; + return dropdown?.findByClassName(footerStyles.root) ?? null; } /** From 6a4039401c26817585a202ec6a3f6f4dce23e1f5 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 29 Sep 2026 14:09:00 +0200 Subject: [PATCH 06/14] docs: Fix button dropdown async loading API documentation Reference getExpandableItemsAsyncLoadingState instead of a non-existent prop, describe when onLoadItems fires, document that the text properties receive the group id, and use items instead of options throughout. --- .../__snapshots__/documenter.test.ts.snap | 37 ++++++++-------- src/button-dropdown/interfaces.ts | 43 ++++++++++--------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 6cae90134c..6fbab8c6ff 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6285,17 +6285,17 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se "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 dropdown opens. +* The user types inside the filtering input field. +* The user scrolls to the end of the list of items, 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 user expands an expandable group. 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\`. +* \`filteringText\` - The value that you need to use to fetch items. It is empty for events about an expandable group. +* \`firstPage\` - Indicates that you should fetch the first page of items 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.", +* \`expandedGroupId\` - Set when the event is about an expandable group: the ID of the group whose items you need to load.", "detailInlineType": { "name": "ButtonDropdownProps.LoadItemsDetail", "properties": [ @@ -6361,14 +6361,17 @@ Use this to provide an accessible name for buttons that don't have visible text. }, { "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. + +The text properties (\`empty\`, \`loadingText\`, \`finishedText\`, \`errorText\`) are functions that receive the +\`expandedGroupId\` when the status belongs to an expandable group, and no argument for the root list. +* \`empty\` - (Optional) Displayed when there are no items 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. +* \`statusType\` - (Optional) Specifies the current status of loading more items. +* * \`pending\` - Indicates that no request is in progress, but more items 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.", @@ -6495,7 +6498,7 @@ If provided, the disabled button becomes focusable.", { "defaultValue": "false", "description": "Controls expandability of the item groups. -If async loading, make sure to define expandable groups' statuses in \`expandableItemsAsyncLoadingStates\`.", +If group items are loaded asynchronously, return each group's status from \`getExpandableItemsAsyncLoadingState\`.", "name": "expandableGroups", "optional": true, "type": "boolean", @@ -6560,7 +6563,7 @@ because fixed positioning results in a slight, visible lag when scrolling comple "defaultValue": "'none'", "description": "Determines how filtering is applied to the dropdown \`items\`: -* \`auto\` - The component will automatically filter options based on user input. +* \`auto\` - The component will automatically filter items based on user input. * \`manual\` - You will set up \`onLoadItems\` event listeners and filter items on your side or request them from server. @@ -6590,16 +6593,16 @@ to set the \`items\` property to the items that are relevant for the user, given "type": "boolean", }, { - "description": "Specifies the async loading status of individual expandable items. -Use only if you load nested items in asynchronously upon expanding a group. + "description": "Specifies the async loading status of individual expandable groups. +Use only if you load the nested items asynchronously upon expanding a group. 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. +* \`finished\` - Indicates that the group's items are loaded 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\`.", +The items of a group are loaded in a single page: \`pending\` is treated as \`finished\`, and scrolling inside a group +does not fire \`onLoadItems\`. If null or undefined, the status will be treated as \`finished\`.", "inlineType": { "name": "(options: { item: ButtonDropdownProps.ItemOrGroup; }) => ButtonDropdownProps.AsyncLoadingStatusType | null", "parameters": [ diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index 8816c68b14..f8ff5d7876 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -155,7 +155,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor iconSvg?: React.ReactNode; /** * Controls expandability of the item groups. - * If async loading, make sure to define expandable groups' statuses in `expandableItemsAsyncLoadingStates`. + * If group items are loaded asynchronously, return each group's status from `getExpandableItemsAsyncLoadingState`. */ expandableGroups?: boolean; /** @@ -198,14 +198,17 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor /** * 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. + * + * The text properties (`empty`, `loadingText`, `finishedText`, `errorText`) are functions that receive the + * `expandedGroupId` when the status belongs to an expandable group, and no argument for the root list. + * * `empty` - (Optional) Displayed when there are no items 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. + * * `statusType` - (Optional) Specifies the current status of loading more items. + * * * `pending` - Indicates that no request is in progress, but more items 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. @@ -216,31 +219,31 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * 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 dropdown opens. + * * The user types inside the filtering input field. + * * The user scrolls to the end of the list of items, 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 user expands an expandable group. * * 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`. + * * `filteringText` - The value that you need to use to fetch items. It is empty for events about an expandable group. + * * `firstPage` - Indicates that you should fetch the first page of items 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. + * * `expandedGroupId` - Set when the event is about an expandable group: the ID of the group whose items you need to load. **/ onLoadItems?: NonCancelableEventHandler; /** - * Specifies the async loading status of individual expandable items. - * Use only if you load nested items in asynchronously upon expanding a group. + * Specifies the async loading status of individual expandable groups. + * Use only if you load the nested items asynchronously upon expanding a group. * * 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. + * * `finished` - Indicates that the group's items are loaded 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`. + * The items of a group are loaded in a single page: `pending` is treated as `finished`, and scrolling inside a group + * does not fire `onLoadItems`. If null or undefined, the status will be treated as `finished`. */ getExpandableItemsAsyncLoadingState?: (options: { item: ButtonDropdownProps.ItemOrGroup; @@ -249,7 +252,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor /** * Determines how filtering is applied to the dropdown `items`: * - * * `auto` - The component will automatically filter options based on user input. + * * `auto` - The component will automatically filter items based on user input. * * `manual` - You will set up `onLoadItems` event listeners and filter items on your side or request * them from server. * @@ -334,7 +337,7 @@ export namespace ButtonDropdownProps { export interface AsyncLoadingProps { /** - * Displayed when there are no options to display. + * Displayed when there are no items to display. * This is only shown when `statusType` is set to `finished` or not set at all. */ empty?: (expandedGroupId?: string) => ReactNode; @@ -362,8 +365,8 @@ export namespace ButtonDropdownProps { */ errorIconAriaLabel?: string; /** - * Specifies the current status of loading more options. - * * `pending` - Indicates that no request in progress, but more options may be loaded. + * Specifies the current status of loading more items. + * * `pending` - Indicates that no request is in progress, but more items 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. From 6f6ee007d2231f38d17f06bb923cdca2891734dc Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 29 Sep 2026 14:09:04 +0200 Subject: [PATCH 07/14] chore: Keep button dropdown async dev page configuration in URL params Status, item presets, filtering type, delay, and expandToViewport are now AppContext URL params so each state is linkable; fetched results stay local. Descriptions now match the actual 5 s timeouts. --- pages/button-dropdown/async-loading.page.tsx | 73 ++++++++++++------- .../button-dropdown/manual-filtering.page.tsx | 31 +++++--- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx index dba5c0d1aa..3461e9ed83 100644 --- a/pages/button-dropdown/async-loading.page.tsx +++ b/pages/button-dropdown/async-loading.page.tsx @@ -1,6 +1,6 @@ // 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 ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import Checkbox from '~components/checkbox'; @@ -8,9 +8,24 @@ import FormField from '~components/form-field'; import Select from '~components/select'; import SpaceBetween from '~components/space-between'; +import AppContext, { AppContextType } from '../app/app-context'; import { SimplePage } from '../app/templates'; import { useOptionsLoader } from '../common/options-loader'; +type StatusType = ButtonDropdownProps.AsyncLoadingStatusType; + +type PageContext = React.Context< + AppContextType<{ + expandToViewport: boolean; + flatStatus: StatusType; + flatItems: string; + groupAStatus: StatusType; + groupAItems: string; + groupBStatus: StatusType; + groupBItems: string; + }> +>; + // ---- Source data ---- const ALL_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({ @@ -37,8 +52,6 @@ const ITEMS_OPTIONS = [ { value: 'all', label: '12 items' }, ]; -type StatusType = ButtonDropdownProps.AsyncLoadingStatusType; - function itemsFromPreset(preset: string, source: ButtonDropdownProps.Item[]): ButtonDropdownProps.Items { if (preset === 'none') { return []; @@ -59,37 +72,43 @@ const groupSourceItems: Record = { 'group-files': Array.from({ length: 8 }, (_, i) => ({ id: `file-${i + 1}`, text: `File action ${i + 1}` })), }; +// Long enough to inspect the loading state and for integration tests to assert on it. +const FETCH_DELAY_MS = 5000; + function fetchGroupItems(groupId: string): Promise { if (groupId === 'group-files') { - return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), 5000)); + return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), FETCH_DELAY_MS)); } if (groupId === 'group-edit') { - return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), 5000)); + return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), FETCH_DELAY_MS)); } return new Promise(() => {}); } export default function ButtonDropdownAsyncLoadingPage() { - const [expandToViewport, setExpandToViewport] = useState(false); + // Page configuration lives in the URL so that every status/items combination is directly linkable + // and targetable by integration tests. Fetched results below stay in local state. + const { + urlParams: { + expandToViewport = false, + flatStatus = 'loading', + flatItems: flatItemsPreset = 'none', + groupAStatus = 'loading', + groupAItems: groupAPreset = 'none', + groupBStatus = 'error', + groupBItems: groupBPreset = 'none', + }, + setUrlParams, + } = useContext(AppContext as PageContext); const onItemClick = (e: CustomEvent) => console.log('clicked', e.detail.id); - // Interactive controls - flat - const [flatStatus, setFlatStatus] = useState('loading'); - const [flatItemsPreset, setFlatItemsPreset] = useState('none'); - - // Interactive controls - groups - const [groupAStatus, setGroupAStatus] = useState('loading'); - const [groupAPreset, setGroupAPreset] = useState('none'); - const [groupBStatus, setGroupBStatus] = useState('error'); - const [groupBPreset, setGroupBPreset] = useState('none'); - // Preconfigured - paginated async const { items: paginatedItems, status: paginatedStatus, filteringText: paginatedFilteringText, fetchItems, - } = useOptionsLoader({ pageSize: 10, timeout: 5000 }); + } = useOptionsLoader({ pageSize: 10, timeout: FETCH_DELAY_MS }); // Preconfigured - groups const [groupItems, setGroupItems] = useState>({}); @@ -103,7 +122,7 @@ export default function ButtonDropdownAsyncLoadingPage() { setExpandToViewport(e.detail.checked)}> + setUrlParams({ expandToViewport: e.detail.checked })}> Expand to viewport } @@ -117,14 +136,14 @@ export default function ButtonDropdownAsyncLoadingPage() { o.value === flatItemsPreset) ?? null} - onChange={e => setFlatItemsPreset(e.detail.selectedOption.value!)} + onChange={e => setUrlParams({ flatItems: e.detail.selectedOption.value! })} options={ITEMS_OPTIONS} /> @@ -157,28 +176,28 @@ export default function ButtonDropdownAsyncLoadingPage() { o.value === groupAPreset) ?? null} - onChange={e => setGroupAPreset(e.detail.selectedOption.value!)} + onChange={e => setUrlParams({ groupAItems: e.detail.selectedOption.value! })} options={ITEMS_OPTIONS} /> o.value === groupBPreset) ?? null} - onChange={e => setGroupBPreset(e.detail.selectedOption.value!)} + onChange={e => setUrlParams({ groupBItems: e.detail.selectedOption.value! })} options={ITEMS_OPTIONS} /> @@ -252,7 +271,7 @@ export default function ButtonDropdownAsyncLoadingPage() { {/* Preconfigured: per-group async */}

      Preconfigured - per-group async

      -

      File loads in 600 ms. Edit always errors. View loads forever.

      +

      File loads after 5 s. Edit always errors after 5 s. View loads forever.

      Preconfigured - error with recovery

      -

      Opens in error state. Retry recovers after 1 s.

      +

      Opens in error state. Retry recovers after 5 s.

      { setErrorItems(flatSourceItems.slice(0, 8)); setErrorStatus('finished'); - }, 5000); + }, FETCH_DELAY_MS); } else { setErrorItems([]); setErrorStatus('error'); diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx index dd0e3eb365..f3d0de8d3a 100644 --- a/pages/button-dropdown/manual-filtering.page.tsx +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -1,6 +1,6 @@ // 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 ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import Checkbox from '~components/checkbox'; @@ -8,8 +8,17 @@ import FormField from '~components/form-field'; import RadioGroup from '~components/radio-group'; import SpaceBetween from '~components/space-between'; +import AppContext, { AppContextType } from '../app/app-context'; import { SimplePage } from '../app/templates'; +type PageContext = React.Context< + AppContextType<{ + expandToViewport: boolean; + filteringType: ButtonDropdownProps.FilteringType; + serverDelay: string; + }> +>; + const SOURCE_ITEMS: ButtonDropdownProps.Item[] = [ { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, @@ -31,15 +40,18 @@ function simulateServer(text: string, delayMs: number): Promise) => console.log('clicked', e.detail.id); // Interactive: filteringType switcher - const [filteringType, setFilteringType] = useState('manual'); const [switcherItems, setSwitcherItems] = useState(SOURCE_ITEMS); // Interactive: server delay control - const [serverDelay, setServerDelay] = useState('400'); const [serverItems, setServerItems] = useState(SOURCE_ITEMS); const [serverStatus, setServerStatus] = useState('finished'); @@ -54,7 +66,7 @@ export default function ButtonDropdownManualFilteringPage() { setExpandToViewport(e.detail.checked)}> + setUrlParams({ expandToViewport: e.detail.checked })}> Expand to viewport } @@ -67,7 +79,7 @@ export default function ButtonDropdownManualFilteringPage() { setFilteringType(e.detail.value as ButtonDropdownProps.FilteringType)} + onChange={e => setUrlParams({ filteringType: e.detail.value as ButtonDropdownProps.FilteringType })} items={[ { value: 'none', label: 'none' }, { value: 'auto', label: 'auto (client-side)' }, @@ -95,14 +107,11 @@ export default function ButtonDropdownManualFilteringPage() { {/* Interactive: server delay control */}

      Interactive - server delay control

      -

      - Set delay to 0 ms to stay in the loading state long enough to inspect it, or use 1500 ms for slow network - testing. -

      +

      Use 1500 ms to inspect the loading state; 0 ms resolves the request immediately.

      setServerDelay(e.detail.value)} + onChange={e => setUrlParams({ serverDelay: e.detail.value })} items={[ { value: '0', label: '0 ms (instant)' }, { value: '400', label: '400 ms' }, From 0cd686219415bc8fc55dfe23c513f1c58caa5123 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 29 Sep 2026 14:52:55 +0200 Subject: [PATCH 08/14] fix: Distinguish root and group status regions in button dropdown test utils The root and group status footers now carry distinct markers, so findErrorRecoveryButton, findStatusIndicator, and findFooterRegion never return an expanded group's status when the root status was requested, and vice versa. Also documents noMatch for manual filtering and when the open event is deduplicated. --- .../__snapshots__/documenter.test.ts.snap | 5 ++-- .../test-utils-selectors.test.tsx.snap | 3 +- .../button-dropdown-async-loading.test.tsx | 21 ++++++++++++++ .../expandable-category-element.tsx | 9 ++++-- .../mobile-expandable-category-element.tsx | 7 ++++- src/button-dropdown/interfaces.ts | 5 ++-- src/button-dropdown/internal.tsx | 11 ++++++-- src/button-dropdown/status-footer.tsx | 13 +++++++-- src/button-dropdown/styles.scss | 5 ++++ src/test-utils/dom/button-dropdown/index.ts | 28 +++++++++---------- 10 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 6fbab8c6ff..3a1bd1ee40 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6285,7 +6285,7 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se "description": "Use this event to implement the asynchronous behavior for the component. The event is called in the following situations: -* The dropdown opens. +* The dropdown opens, unless the same filtering text was already requested. * The user types inside the filtering input field. * The user scrolls to the end of the list of items, if \`statusType\` is set to \`pending\`. * The user clicks on the recovery button in the error state. @@ -7336,7 +7336,8 @@ If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.", "name": "iconSvg", }, { - "description": "Displayed for \`filteringType="auto"\` when filtering is enabled and there are no matches for the filtering input.", + "description": "Displayed when filtering is enabled and there are no matches for the filtering input. +With \`filteringType="manual"\`, this is shown when you set \`items\` to an empty array while the filtering input has a value.", "isDefault": false, "name": "noMatch", }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index ce22f4094b..b1567f4401 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -101,12 +101,13 @@ exports[`test-utils selectors 1`] = ` "awsui_description_sne0l", "awsui_disabled_93a1u", "awsui_dropdown-trigger_sne0l", - "awsui_dropdown_14cnr", "awsui_expandable_16mm3", "awsui_highlighted_93a1u", "awsui_item-element_93a1u", "awsui_split-trigger_sne0l", "awsui_test-utils-button-trigger_sne0l", + "awsui_test-utils-group-status_sne0l", + "awsui_test-utils-root-status_sne0l", "awsui_title_sne0l", ], "button-group": [ 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 5963d2cfdb..bfc20637f3 100644 --- a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -524,6 +524,27 @@ describe('ButtonDropdown async loading with expandable groups', () => { expect(wrapper.findOpenDropdown()).not.toBeNull(); }); + test('root status lookups ignore the status of an expanded group', () => { + const { wrapper } = renderDropdown({ + items: groupItems, + expandableGroups: true, + getExpandableItemsAsyncLoadingState: ({ item }) => (item.id === 'g1' ? 'error' : null), + asyncLoadingProps: { + statusType: 'finished', + finishedText: () => 'End of results', + errorText: () => 'Error', + recoveryText: 'Retry', + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findExpandableCategoryById('g1')!.click(); + expect(wrapper.findErrorRecoveryButton({ expandedGroupDropdown: true })).not.toBeNull(); + expect(wrapper.findErrorRecoveryButton()).toBeNull(); + expect(wrapper.findStatusIndicator({ expandedGroupDropdown: true })!.getElement()).toHaveTextContent('Error'); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('End of results'); + }); + test('treats a group status of "pending" as "finished"', () => { const { wrapper } = renderDropdown({ items: groupItems, diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index e826dd3a74..a5bbf5c8e2 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -188,7 +188,7 @@ const ExpandableCategoryElement = ({ trigger={trigger} footer={ expanded && groupDropdownStatus.content && groupDropdownStatus.isSticky ? ( - + ) : undefined } content={ @@ -223,7 +223,12 @@ const ExpandableCategoryElement = ({ {groupDropdownStatus.content && !groupDropdownStatus.isSticky ? ( // Non-sticky status (finished text) follows the items, like in the main dropdown.
    • - +
    • ) : null}
    diff --git a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx index dc92f15738..2cb991aeee 100644 --- a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx @@ -199,7 +199,12 @@ const MobileExpandableCategoryElement = ({ // The group is inline in the main list, so every status (loading, error, empty, // finished) follows the items instead of being a sticky footer.
  • - +
  • ) : null} diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index f8ff5d7876..58f4e31d31 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -219,7 +219,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * Use this event to implement the asynchronous behavior for the component. * * The event is called in the following situations: - * * The dropdown opens. + * * The dropdown opens, unless the same filtering text was already requested. * * The user types inside the filtering input field. * * The user scrolls to the end of the list of items, if `statusType` is set to `pending`. * * The user clicks on the recovery button in the error state. @@ -289,7 +289,8 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor filteringResultsText?: (matchesCount: number, totalCount: number) => string; /** - * Displayed for `filteringType="auto"` when filtering is enabled and there are no matches for the filtering input. + * Displayed when filtering is enabled and there are no matches for the filtering input. + * With `filteringType="manual"`, this is shown when you set `items` to an empty array while the filtering input has a value. */ noMatch?: React.ReactNode; diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 4119cd3589..389b0c3dfa 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -14,7 +14,6 @@ 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'; -import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; import OptionsList from '../internal/components/options-list'; import useHiddenDescription from '../internal/hooks/use-hidden-description'; @@ -567,7 +566,12 @@ const InternalButtonDropdown = React.forwardRef( ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ dropdownStatus.content && dropdownStatus.isSticky ? ( - + ) : null } content={ @@ -636,10 +640,11 @@ const InternalButtonDropdown = React.forwardRef( // Non-sticky status (finished text) scrolls together with the items, like the // list bottom in Select, instead of covering the last item.
  • -
  • ) : null} diff --git a/src/button-dropdown/status-footer.tsx b/src/button-dropdown/status-footer.tsx index a7b7301923..7e8de69eed 100644 --- a/src/button-dropdown/status-footer.tsx +++ b/src/button-dropdown/status-footer.tsx @@ -5,24 +5,33 @@ import React from 'react'; import DropdownFooter from '../internal/components/dropdown-footer'; import { KeyCode } from '../internal/keycode'; +import styles from './styles.css.js'; + interface StatusFooterProps { content: React.ReactNode | null; id: string; hasItems: boolean; + /** Whether the status belongs to the root list or to an expanded group. Used by test-utils to tell them apart. */ + scope: 'root' | 'group'; } // Wraps the shared DropdownFooter so that interactions with the recovery button inside it are // handled exclusively by the button and are not interpreted by the button dropdown handlers: // a click must not toggle the enclosing expandable group, and Enter/Space must not activate the // highlighted menu item or toggle the dropdown. -const StatusFooter = ({ content, id, hasItems }: StatusFooterProps) => { +const StatusFooter = ({ content, id, hasItems, scope }: StatusFooterProps) => { const stopActivationKeys = (event: React.KeyboardEvent) => { if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) { event.stopPropagation(); } }; return ( -
    event.stopPropagation()} onKeyDown={stopActivationKeys} onKeyUp={stopActivationKeys}> +
    event.stopPropagation()} + onKeyDown={stopActivationKeys} + onKeyUp={stopActivationKeys} + >
    ); diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index 1535ba235d..bc25c329cd 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -161,3 +161,8 @@ $dropdown-trigger-icon-offset: 2px; .test-utils-button-trigger { /* used in test-utils */ } + +.test-utils-root-status, +.test-utils-group-status { + /* used in test-utils */ +} diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 52d2bb8dc7..13a6d320d5 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -9,14 +9,15 @@ import InputWrapper from '../input/index.js'; import buttonStyles from '../../../button/styles.selectors.js'; import categoryStyles from '../../../button-dropdown/category-elements/styles.selectors.js'; import itemStyles from '../../../button-dropdown/item-element/styles.selectors.js'; -import mobileGroupStyles from '../../../button-dropdown/mobile-expandable-group/styles.selectors.js'; 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'; -// An expanded group renders as a fly-out dropdown on desktop and as an inline section on mobile. -const expandedGroupSelector = `.${dropdownStyles.dropdown}[data-open=true], .${mobileGroupStyles.dropdown}[data-open=true]`; +// The status of the root list and the status of an expanded group carry distinct markers, so a lookup +// never falls through from one to the other. +const statusScopeSelector = (expandedGroup: boolean) => + `.${expandedGroup ? styles['test-utils-group-status'] : styles['test-utils-root-status']}`; function getItemSelector({ disabled }: { disabled?: boolean }): string { let selector = `.${itemStyles['item-element']}`; @@ -142,11 +143,11 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { * This utility does not open the dropdown. To find dropdown items, call `openDropdown()` first. */ findErrorRecoveryButton(options = { expandedGroupDropdown: false }): ElementWrapper | null { - let dropdown = this.findOpenDropdown(); - if (options.expandedGroupDropdown && dropdown) { - dropdown = dropdown.find(expandedGroupSelector); - } - return dropdown?.findByClassName(footerStyles.recovery) ?? null; + return ( + this.findOpenDropdown()?.find( + `${statusScopeSelector(options.expandedGroupDropdown)} .${footerStyles.recovery}` + ) ?? null + ); } /** @@ -155,11 +156,10 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { * This utility does not open the dropdown. To find dropdown items, call `openDropdown()` first. */ findStatusIndicator(options = { expandedGroupDropdown: false }): ElementWrapper | null { - let dropdown = this.findOpenDropdown(); - if (options.expandedGroupDropdown && dropdown) { - dropdown = dropdown.find(expandedGroupSelector); - } - return dropdown?.findByClassName(footerStyles.root) ?? null; + return ( + this.findOpenDropdown()?.find(`${statusScopeSelector(options.expandedGroupDropdown)} .${footerStyles.root}`) ?? + null + ); } /** @@ -170,7 +170,7 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { * This utility does not open the dropdown. To find the footer region, call `openDropdown()` first. */ findFooterRegion(): ElementWrapper | null { - return this.findOpenDropdown()?.findByClassName(footerStyles.root) ?? null; + return this.findOpenDropdown()?.find(`${statusScopeSelector(false)} .${footerStyles.root}`) ?? null; } @usesDom From f46b3acb66c3d6612f16d08e4d076a1272304be1 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 29 Sep 2026 14:53:00 +0200 Subject: [PATCH 09/14] chore: Remove decorative prose from button dropdown async dev pages --- pages/button-dropdown/async-loading.page.tsx | 5 ----- .../button-dropdown/button-dropdown.async.example.page.tsx | 7 ------- pages/button-dropdown/manual-filtering.page.tsx | 4 ---- 3 files changed, 16 deletions(-) diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx index 3461e9ed83..eb36bb33bc 100644 --- a/pages/button-dropdown/async-loading.page.tsx +++ b/pages/button-dropdown/async-loading.page.tsx @@ -131,7 +131,6 @@ export default function ButtonDropdownAsyncLoadingPage() { {/* Interactive: flat */}

    Interactive - flat async

    -

    Set status and items directly to test any combination without waiting.

    Preconfigured - flat async (paginated)

    -

    Items load on open and paginate on scroll. Filter input triggers server-side search.

    Preconfigured - per-group async

    -

    File loads after 5 s. Edit always errors after 5 s. View loads forever.

    Preconfigured - error with recovery

    -

    Opens in error state. Retry recovers after 5 s.

    setUrlParams({ fakeResponses: e.detail.checked })}> @@ -221,11 +219,6 @@ export default function Page() { > Instance actions - - - Groups sit between the 4th and 5th action so they are on the first page. Type a word with no match (for - example zzz) for the no-match state. Clear the filter to reload all actions. -
    ); diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx index f3d0de8d3a..71a1ef5f31 100644 --- a/pages/button-dropdown/manual-filtering.page.tsx +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -75,7 +75,6 @@ export default function ButtonDropdownManualFilteringPage() { {/* Interactive: filteringType switcher */}

    Interactive - filteringType switcher

    -

    Switch between none, auto, and manual on the same dropdown to compare behavior.

    Interactive - server delay control

    -

    Use 1500 ms to inspect the loading state; 0 ms resolves the request immediately.

    Preconfigured - client-side manual filtering

    -

    App filters synchronously inside onLoadItems. No status indicators needed.

    Preconfigured - server-side manual filtering

    -

    App calls a fake API on every filter change. Loading spinner appears while the request is in flight.

    Date: Wed, 30 Sep 2026 09:31:08 +0200 Subject: [PATCH 10/14] fix: Fire first button dropdown load request when onLoadItems is attached later --- .../button-dropdown-async-loading.test.tsx | 14 ++++++++++++++ src/button-dropdown/utils/use-load-items.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) 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 bfc20637f3..8e02f3d45a 100644 --- a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -47,6 +47,20 @@ describe('ButtonDropdown async loading', () => { expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: true, samePage: false }); }); + test('fires onLoadItems on open when the handler is attached after an earlier open without it', () => { + const onLoadItems = jest.fn(); + const { wrapper, rerender } = renderDropdown({ filteringType: 'manual' }); + wrapper.openDropdown(); + wrapper.openDropdown(); + rerender( + onLoadItems(event.detail)}> + Actions + + ); + wrapper.openDropdown(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: true, samePage: false }); + }); + test('fires onLoadItems with firstPage=true when filteringText changes', async () => { const onLoadItems = jest.fn(); const { wrapper } = renderDropdown({ diff --git a/src/button-dropdown/utils/use-load-items.ts b/src/button-dropdown/utils/use-load-items.ts index 8e3f336cbf..56cdb3d768 100644 --- a/src/button-dropdown/utils/use-load-items.ts +++ b/src/button-dropdown/utils/use-load-items.ts @@ -15,7 +15,9 @@ export const useLoadItems = ({ onLoadItems, items, statusType }: UseLoadItemsPro const prevFilteringText = useRef(undefined); const fireLoadItems = (filteringText: string) => { - if (prevFilteringText.current === filteringText) { + // Without a handler nothing is requested, so the text must not count as requested either: + // otherwise a handler attached later would have its first request deduplicated away. + if (!onLoadItems || prevFilteringText.current === filteringText) { return; } prevFilteringText.current = filteringText; From 527c5d0343cd4d083547108e3ff1805807c79c3c Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 1 Oct 2026 15:40:37 +0200 Subject: [PATCH 11/14] chore: Address button dropdown review comments Narrow getExpandableItemsAsyncLoadingState to item groups, move test-utils-only classes to test-classes/styles.scss, and reduce the async-loading and manual-filtering dev pages to a single component selected through a scenario URL param. --- pages/button-dropdown/async-loading.page.tsx | 466 +++++++++--------- .../button-dropdown/manual-filtering.page.tsx | 219 ++++---- .../__snapshots__/documenter.test.ts.snap | 6 +- .../test-utils-selectors.test.tsx.snap | 4 +- src/button-dropdown/interfaces.ts | 2 +- src/button-dropdown/status-footer.tsx | 4 +- src/button-dropdown/styles.scss | 5 - src/button-dropdown/test-classes/styles.scss | 12 + src/test-utils/dom/button-dropdown/index.ts | 3 +- 9 files changed, 343 insertions(+), 378 deletions(-) create mode 100644 src/button-dropdown/test-classes/styles.scss diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx index eb36bb33bc..d751093413 100644 --- a/pages/button-dropdown/async-loading.page.tsx +++ b/pages/button-dropdown/async-loading.page.tsx @@ -5,6 +5,7 @@ import React, { useContext, useState } from 'react'; import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; import Checkbox from '~components/checkbox'; import FormField from '~components/form-field'; +import { NonCancelableEventHandler } from '~components/internal/events'; import Select from '~components/select'; import SpaceBetween from '~components/space-between'; @@ -14,8 +15,11 @@ import { useOptionsLoader } from '../common/options-loader'; type StatusType = ButtonDropdownProps.AsyncLoadingStatusType; +type Scenario = 'flat' | 'groups' | 'paginated' | 'per-group' | 'error'; + type PageContext = React.Context< AppContextType<{ + scenario: Scenario; expandToViewport: boolean; flatStatus: StatusType; flatItems: string; @@ -26,18 +30,13 @@ type PageContext = React.Context< }> >; -// ---- Source data ---- - -const ALL_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({ - id: `action-${i + 1}`, - text: `Action ${i + 1}`, - secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, -})); - -const GROUP_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 6 }, (_, i) => ({ - id: `sub-${i + 1}`, - text: `Sub-action ${i + 1}`, -})); +const SCENARIO_OPTIONS: { value: Scenario; label: string }[] = [ + { value: 'flat', label: 'Flat list, configurable status' }, + { value: 'groups', label: 'Expandable groups, configurable status' }, + { value: 'paginated', label: 'Flat list, paginated with manual filtering' }, + { value: 'per-group', label: 'Expandable groups, loaded on expand' }, + { value: 'error', label: 'Error with recovery' }, +]; const STATUS_OPTIONS = [ { value: 'loading', label: 'loading' }, @@ -52,6 +51,17 @@ const ITEMS_OPTIONS = [ { value: 'all', label: '12 items' }, ]; +const ALL_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({ + id: `action-${i + 1}`, + text: `Action ${i + 1}`, + secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, +})); + +const GROUP_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 6 }, (_, i) => ({ + id: `sub-${i + 1}`, + text: `Sub-action ${i + 1}`, +})); + function itemsFromPreset(preset: string, source: ButtonDropdownProps.Item[]): ButtonDropdownProps.Items { if (preset === 'none') { return []; @@ -85,11 +95,21 @@ function fetchGroupItems(groupId: string): Promise { return new Promise(() => {}); } +const groupTexts: ButtonDropdownProps.AsyncLoadingProps = { + loadingText: (gid?: string) => `Loading ${gid ?? 'items'}...`, + errorText: (gid?: string) => `Failed to load ${gid ?? 'items'}.`, + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + finishedText: (gid?: string) => `End of ${gid ?? 'results'}`, + empty: (gid?: string) => `No items in ${gid ?? 'group'}.`, +}; + export default function ButtonDropdownAsyncLoadingPage() { - // Page configuration lives in the URL so that every status/items combination is directly linkable - // and targetable by integration tests. Fetched results below stay in local state. + // Page configuration lives in the URL so that every scenario and status/items combination is directly + // linkable and targetable by integration tests. Fetched results below stay in local state. const { urlParams: { + scenario = 'flat', expandToViewport = false, flatStatus = 'loading', flatItems: flatItemsPreset = 'none', @@ -101,8 +121,10 @@ export default function ButtonDropdownAsyncLoadingPage() { setUrlParams, } = useContext(AppContext as PageContext); const onItemClick = (e: CustomEvent) => console.log('clicked', e.detail.id); + const logLoadItems: NonCancelableEventHandler = ({ detail }) => + console.log('onLoadItems', detail); - // Preconfigured - paginated async + // Scenario "paginated" const { items: paginatedItems, status: paginatedStatus, @@ -110,236 +132,214 @@ export default function ButtonDropdownAsyncLoadingPage() { fetchItems, } = useOptionsLoader({ pageSize: 10, timeout: FETCH_DELAY_MS }); - // Preconfigured - groups + // Scenario "per-group" const [groupItems, setGroupItems] = useState>({}); const [groupStatuses, setGroupStatuses] = useState>({}); - // Preconfigured - error + recovery + // Scenario "error" const [errorStatus, setErrorStatus] = useState('error'); const [errorItems, setErrorItems] = useState([]); + const statusSelect = (label: string, value: StatusType, onChange: (value: StatusType) => void) => ( + + o.value === value) ?? null} + onChange={e => onChange(e.detail.selectedOption.value!)} + options={ITEMS_OPTIONS} + /> + + ); + + const commonProps = { expandToViewport, onItemClick }; + + let content: React.ReactNode = null; + switch (scenario) { + case 'flat': + content = ( + 'Loading actions...', + errorText: () => 'Failed to load actions.', + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + finishedText: () => 'End of results', + empty: () => 'No actions found', + }} + onLoadItems={logLoadItems} + > + Actions + + ); + break; + case 'groups': + content = ( + { + if (item.id === 'group-a') { + return groupAStatus; + } + if (item.id === 'group-b') { + return groupBStatus; + } + return null; + }} + onLoadItems={logLoadItems} + > + Instance actions + + ); + break; + case 'paginated': + content = ( + 'Loading actions...', + errorText: () => 'Error fetching actions.', + recoveryText: 'Retry', + finishedText: () => + paginatedFilteringText ? `End of "${paginatedFilteringText}" results` : 'End of all results', + empty: () => 'No actions found', + }} + filteringResultsText={(matchesCount, totalCount) => + paginatedStatus === 'pending' ? `${matchesCount}+ results` : `${matchesCount} of ${totalCount}` + } + 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 + + ); + break; + case 'per-group': + content = ( + (item.id ? (groupStatuses[item.id] ?? null) : null)} + onLoadItems={({ detail: { expandedGroupId, samePage } }) => { + if (!expandedGroupId) { + return; + } + 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 + + ); + break; + case 'error': + content = ( + 'Loading actions...', + errorText: () => 'Error fetching actions.', + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + empty: () => 'No actions found', + }} + onLoadItems={({ detail: { samePage } }) => { + if (samePage) { + setErrorStatus('loading'); + setTimeout(() => { + setErrorItems(flatSourceItems.slice(0, 8)); + setErrorStatus('finished'); + }, FETCH_DELAY_MS); + } else { + setErrorItems([]); + setErrorStatus('error'); + } + }} + > + Actions (error) + + ); + break; + } + return ( setUrlParams({ expandToViewport: e.detail.checked })}> - Expand to viewport - + + + o.value === flatStatus) ?? null} - onChange={e => setUrlParams({ flatStatus: e.detail.selectedOption.value as StatusType })} - options={STATUS_OPTIONS} - /> - - - o.value === groupAStatus) ?? null} - onChange={e => setUrlParams({ groupAStatus: e.detail.selectedOption.value as StatusType })} - options={STATUS_OPTIONS} - /> - - - o.value === groupBStatus) ?? null} - onChange={e => setUrlParams({ groupBStatus: e.detail.selectedOption.value as StatusType })} - options={STATUS_OPTIONS} - /> - - -