diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx new file mode 100644 index 0000000000..46b0e49435 --- /dev/null +++ b/pages/button-dropdown/async-loading.page.tsx @@ -0,0 +1,338 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Checkbox } from '~components'; +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; +import { useOptionsLoader } from '../common/options-loader'; + +import styles from './styles.scss'; + +// Source data for the flat async-loading example (25 items, paginated). +const flatSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, i) => ({ + id: `action-${i + 1}`, + text: `Action ${i + 1}`, + secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined, +})); + +// Source data for the combined example: flat async items plus two expandable groups. +const combinedFlatSource: ButtonDropdownProps.Item[] = Array.from({ length: 20 }, (_, i) => ({ + id: `combined-action-${i + 1}`, + text: `Instance action ${i + 1}`, + secondaryText: i % 4 === 0 ? `Details for instance action ${i + 1}` : undefined, +})); + +const combinedGroupSource: Record = { + 'combined-file': Array.from({ length: 6 }, (_, i) => ({ id: `cf-${i + 1}`, text: `File action ${i + 1}` })), + 'combined-edit': Array.from({ length: 5 }, (_, i) => ({ id: `ce-${i + 1}`, text: `Edit action ${i + 1}` })), +}; + +function fetchCombinedGroupItems(groupId: string): Promise { + return new Promise(resolve => setTimeout(() => resolve(combinedGroupSource[groupId] ?? []), 600)); +} + +// Groups for the expandable async-loading example. +// group-files: loads successfully after 600ms. +// group-edit: starts loading then fails after 800ms (error + retry button visible; retry also fails). +// group-view: stays loading indefinitely to show a persistent loading spinner. +const groupSourceItems: Record = { + 'group-files': Array.from({ length: 8 }, (_, i) => ({ id: `file-${i + 1}`, text: `File action ${i + 1}` })), +}; + +function fetchGroupItems(groupId: string): Promise { + if (groupId === 'group-files') { + return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), 600)); + } + if (groupId === 'group-edit') { + // Always rejects — simulates a persistent server error. + return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), 800)); + } + // group-view: never resolves — shows a permanent loading spinner. + return new Promise(() => {}); +} + +export default function ButtonDropdownAsyncLoadingPage() { + const [expandToViewport, setExpandToViewport] = useState(false); + const onItemClick = (event: CustomEvent) => console.log(event.detail); + + // --- Flat async loading --- + const { + items: flatItems, + status: flatStatus, + filteringText: flatFilteringText, + fetchItems, + } = useOptionsLoader({ pageSize: 10 }); + + const flatFilteringResultsText = (matchesCount: number, totalCount: number) => { + if (flatStatus === 'pending') { + return `${matchesCount}+ results`; + } + if (flatStatus === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + }; + + // --- Expandable groups async loading --- + // Track loaded items and status per group id. + const [groupItems, setGroupItems] = useState>({}); + const [groupStatuses, setGroupStatuses] = useState>({}); + + const expandableItems: ButtonDropdownProps.Items = [ + { id: 'group-files', text: 'File (loads successfully)', items: groupItems['group-files'] ?? [] }, + { id: 'group-edit', text: 'Edit (always errors)', items: groupItems['group-edit'] ?? [] }, + { id: 'group-view', text: 'View (loading forever)', items: groupItems['group-view'] ?? [] }, + ]; + + // --- Combined: filtering + async loading + expandable groups --- + const { + items: combinedItems, + status: combinedStatus, + filteringText: combinedFilteringText, + fetchItems: fetchCombinedItems, + } = useOptionsLoader({ pageSize: 10 }); + + const [combinedGroupItems, setCombinedGroupItems] = useState>({}); + const [combinedGroupStatuses, setCombinedGroupStatuses] = useState< + Record + >({}); + // Track the current filter value directly so group visibility updates immediately on input, + // without waiting for useOptionsLoader's filteringText (which only updates after firstPage resolves). + const [combinedFilter, setCombinedFilter] = useState(''); + + // The top-level items are a mix of flat async results and expandable groups. + // Groups themselves have their children loaded on demand. + // When a filter is active: + // - Groups whose text matches are kept and their already-loaded children are shown inline. + // - Groups with no loaded children yet show a loading/pending status so they can be fetched. + // - Groups whose text doesn't match are hidden. + const combinedGroups: ButtonDropdownProps.ItemGroup[] = [ + { id: 'combined-file', text: 'File', items: combinedGroupItems['combined-file'] ?? [] }, + { id: 'combined-edit', text: 'Edit', items: combinedGroupItems['combined-edit'] ?? [] }, + ]; + const visibleCombinedGroups = combinedFilter + ? combinedGroups.filter(g => (g.text ?? '').toLowerCase().includes(combinedFilter.toLowerCase())) + : combinedGroups; + const combinedTopLevelItems: ButtonDropdownProps.Items = [...visibleCombinedGroups, ...combinedItems]; + + const combinedFilteringResultsText = (matchesCount: number, totalCount: number) => { + if (combinedStatus === 'pending') { + return `${matchesCount}+ results`; + } + if (combinedStatus === 'finished') { + return `${matchesCount} out of ${totalCount} results`; + } + return ''; + }; + + // Error state example for the flat async loading. + const [errorStatus, setErrorStatus] = useState('error'); + const [errorItems, setErrorItems] = useState([]); + const manualSourceItems: ButtonDropdownProps.Item[] = flatSourceItems.slice(0, 8); + + return ( + + + setExpandToViewport(event.detail.checked)} + data-testid="expand-to-viewport" + > + Expand to viewport + + +
+

Async loading (flat items, paginated)

+

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

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

Async loading per expandable group

+

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

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

Error state with recovery

+

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

+ 'Loading actions', + errorText: () => 'Error fetching actions.', + recoveryText: 'Retry', + errorIconAriaLabel: 'Error', + empty: () => 'No actions found', + }} + expandToViewport={expandToViewport} + onItemClick={onItemClick} + onLoadItems={({ detail: { samePage } }) => { + if (samePage) { + setErrorStatus('loading'); + setTimeout(() => { + setErrorItems(manualSourceItems); + setErrorStatus('finished'); + }, 1000); + } else { + setErrorItems([]); + setErrorStatus('error'); + } + }} + > + Actions (error) + +
+
+

Async loading with filtering and expandable groups

+

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

+ + combinedFilteringText ? `End of "${combinedFilteringText}" results` : 'End of all results', + empty: () => 'No actions found', + loadingText: (groupId?: string) => (groupId ? `Loading ${groupId} items…` : 'Loading actions'), + errorText: (groupId?: string) => + groupId ? `Failed to load ${groupId} items.` : 'Error fetching actions.', + }} + getExpandableItemsAsyncLoadingState={({ item }) => { + const id = item.id; + return id ? (combinedGroupStatuses[id] ?? null) : null; + }} + expandToViewport={expandToViewport} + filteringResultsText={combinedFilteringResultsText} + onItemClick={onItemClick} + onLoadItems={({ detail: { firstPage, filteringText, expandedGroupId, samePage } }) => { + if (expandedGroupId) { + // Group expansion — load the group's children. + if (!samePage) { + setCombinedGroupItems(prev => ({ ...prev, [expandedGroupId]: [] })); + } + setCombinedGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'loading' })); + fetchCombinedGroupItems(expandedGroupId).then(items => { + setCombinedGroupItems(prev => ({ ...prev, [expandedGroupId]: items })); + setCombinedGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'finished' })); + }); + } else { + // Top-level filtering / pagination / open. + if (firstPage) { + setCombinedFilter(filteringText); + // Clear group items so nothing stale shows while the new filter is in-flight. + setCombinedGroupItems({}); + setCombinedGroupStatuses({}); + // Eagerly load any group whose text matches the filter. + combinedGroups + .filter(g => filteringText && (g.text ?? '').toLowerCase().includes(filteringText.toLowerCase())) + .forEach(g => { + const gid = g.id!; + setCombinedGroupStatuses(prev => ({ ...prev, [gid]: 'loading' })); + fetchCombinedGroupItems(gid).then(items => { + setCombinedGroupItems(prev => ({ ...prev, [gid]: items })); + setCombinedGroupStatuses(prev => ({ ...prev, [gid]: 'finished' })); + }); + }); + } + const normalized = filteringText.toLowerCase(); + const filtered = combinedFlatSource.filter(item => + (item.text ?? '').toLowerCase().includes(normalized) + ); + fetchCombinedItems({ firstPage, filteringText, sourceItems: filtered }); + } + }} + > + Actions + +
+
+
+ ); +} diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx new file mode 100644 index 0000000000..7268bc4fcc --- /dev/null +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -0,0 +1,118 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Checkbox } from '~components'; +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; + +import styles from './styles.scss'; + +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)); +} + +// Simulates a 400ms server round-trip. +function fetchFromServer(filteringText: string): Promise { + const normalized = filteringText.toLowerCase(); + const results = sourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized)); + return new Promise(resolve => setTimeout(() => resolve(results), 400)); +} + +export default function ButtonDropdownManualFilteringPage() { + const [expandToViewport, setExpandToViewport] = useState(false); + const onItemClick = (event: CustomEvent) => console.log(event.detail); + const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; + + // Client-side manual filtering: the app filters synchronously in onLoadItems. + const [clientItems, setClientItems] = useState(sourceItems); + + // Server-side manual filtering: onLoadItems triggers a fake async request. + 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 the items synchronously inside onLoadItems. No status indicators are 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 (fake)

+

+ The app calls a fake async API on every filtering 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..71b79e3cf8 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,9 @@ 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\`. +If manual filtering and there are filtered items, make sure to disable this to ensure filtered items are easily discoverable.", "name": "expandableGroups", "optional": true, "type": "boolean", @@ -6396,15 +6555,24 @@ because fixed positioning results in a slight, visible lag when scrolling comple "defaultValue": "'none'", "description": "Enables filtering of the dropdown items. -When set to \`auto\`, a search input is rendered inside the dropdown and the items are filtered as the user -types. Items are matched client-side using a case-insensitive substring match against their \`text\`, -\`secondaryText\`, and \`labelTag\`.", +* \`auto\` - A search input is rendered inside the dropdown and the items are automatically filtered as the user types. +* \`manual\` - You will set up \`onLoadItems\` event listeners and filter items on your side or request +them from server. + +If you set this property to \`auto\`, the component will filter the provided \`items\` based on the value of the filtering input field. +The filtering text is matched against the item's \`text\`, \`secondaryText\`, and \`labelTag\`. + +If you set this property to \`manual\`, the default filtering mechanism is disabled and all provided \`items\` are +displayed in the dropdown list. In that case make sure that you use the \`onLoadItems\` events in order +to set the \`items\` property to the items that are relevant for the user, given the filtering input value. +When there are filtered items, disable \`expandableGroups\` to ensure filtered items are easily discoverable.", "inlineType": { "name": "ButtonDropdownProps.FilteringType", "type": "union", "values": [ "auto", "none", + "manual", ], }, "name": "filteringType", @@ -6417,6 +6585,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 asynchronously upon expanding an item. + +Return values are: +* \`pending\` - Indicates that no request in progress, but more options may be loaded. +* \`loading\` - Indicates that data fetching is in progress. +* \`finished\` - Indicates that pagination has finished and no more requests are expected. +* \`error\` - Indicates that an error occurred during fetch. You should use \`recoveryText\` to enable the user to recover. + +If null or undefined, the status will be treated as \`finished\`.", + "inlineType": { + "name": "(options: { item: ButtonDropdownProps.ItemOrGroup; }) => ButtonDropdownProps.AsyncLoadingStatusType | null", + "parameters": [ + { + "name": "options", + "type": "{ item: ButtonDropdownProps.ItemOrGroup; }", + }, + ], + "returnType": "ButtonDropdownProps.AsyncLoadingStatusType | null", + "type": "function", + }, + "name": "getExpandableItemsAsyncLoadingState", + "optional": true, + "type": "((options: { item: ButtonDropdownProps.ItemOrGroup; }) => ButtonDropdownProps.AsyncLoadingStatusType | null | undefined)", + }, { "description": "An object containing all the necessary localized strings required by the component.", "i18nTag": true, @@ -6662,6 +6856,12 @@ An item which belongs to nested group has the following properties: \`id\`, \`te "optional": false, "type": "ReadonlyArray", }, + { + "description": "Specifies the text to display inside the dropdown when items are loading.", + "name": "itemsLoadingText", + "optional": true, + "type": "string", + }, { "defaultValue": "false", "description": "Renders the button as being in a loading state. It takes precedence over the \`disabled\` if both are set to \`true\`. @@ -7133,7 +7333,7 @@ If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.", "name": "iconSvg", }, { - "description": "Displayed when filtering is enabled and there are no matches for the filtering input.", + "description": "Displayed for \`filteringType="auto"\` when there are no matches for the filtering input.", "isDefault": false, "name": "noMatch", }, @@ -35465,6 +35665,21 @@ Use this method to assert the panel position.", ], }, }, + { + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "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. @@ -35489,6 +35704,58 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -35651,6 +35918,21 @@ Supported options: ], }, }, + { + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTriggerButton", "parameters": [], @@ -45157,6 +45439,24 @@ Supported options: ], }, }, + { + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -45184,6 +45484,64 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -45451,6 +45809,24 @@ Supported options: ], }, }, + { + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "ButtonDropdownWrapper.findTriggerButton", @@ -46806,6 +47182,24 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", ], }, }, + { + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -46833,6 +47227,64 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -47019,6 +47471,24 @@ Supported options: ], }, }, + { + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTitle", "parameters": [], @@ -48238,6 +48708,16 @@ Use this method to assert the panel position.", "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -48257,6 +48737,48 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -48375,6 +48897,16 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findTriggerButton", "parameters": [], @@ -55136,6 +55668,19 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -55158,6 +55703,54 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -55363,6 +55956,19 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "inheritedFrom": { "name": "ButtonDropdownWrapper.findTriggerButton", @@ -56317,6 +56923,19 @@ Searches within this tooltip's scope to avoid conflicts with popovers.", "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button when item loading fails. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryErrorRecoveryButton\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findErrorRecoveryButton", + }, + "name": "findErrorRecoveryButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. @@ -56339,6 +56958,54 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the error recovery button inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryErrorRecoveryButton", + }, + "name": "findExpandableCategoryErrorRecoveryButton", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Finds the status indicator inside a specific expanded group's nested dropdown. +Use this when async loading is enabled per expandable group via \`getExpandableItemsAsyncLoadingState\`. + +This utility does not open the dropdown or expand the group. Call \`openDropdown()\` and expand the group first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryStatusIndicator", + }, + "name": "findExpandableCategoryStatusIndicator", + "parameters": [ + { + "description": "The \`id\` of the expandable group item.", + "flags": { + "isOptional": false, + }, + "name": "groupId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. Returns null if there is no open dropdown or filtering is not enabled. @@ -56478,6 +57145,19 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Finds the status displayed at the footer of the dropdown. +Use this for the top-level dropdown status. For the status of a specific expandable group, use \`findExpandableCategoryStatusIndicator\`.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findStatusIndicator", + }, + "name": "findStatusIndicator", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findTitle", "parameters": [], diff --git a/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx new file mode 100644 index 0000000000..e67d5be099 --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -0,0 +1,149 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: jest.fn(), +})); + +const items: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Cut' }, + { id: 'i2', text: 'Copy' }, + { id: 'i3', text: 'Paste' }, +]; + +function renderDropdown(props: Partial = {}) { + const result = render( + + Actions + + ); + const wrapper = createWrapper(result.container).findButtonDropdown()!; + return { ...result, wrapper }; +} + +beforeEach(() => { + jest.mocked(warnOnce).mockClear(); +}); + +describe('ButtonDropdown async loading', () => { + test('fires onLoadItems with the initial (empty) filtering text when the dropdown opens', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: '', firstPage: true, samePage: false }); + }); + + test('fires onLoadItems after a delay when the filtering input changes', async () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: event => onLoadItems(event.detail), + }); + wrapper.openDropdown(); + onLoadItems.mockClear(); + + wrapper.findFilteringInput()!.setInputValue('test'); + expect(wrapper.findFilteringInput()!.findNativeInput().getElement()).toHaveValue('test'); + + await waitFor(() => + expect(onLoadItems).toHaveBeenCalledWith({ filteringText: 'test', firstPage: true, samePage: false }) + ); + }); + + test('fires onLoadItems to retry a failed request when the recovery button is clicked', () => { + const onLoadItems = jest.fn(); + const { wrapper } = renderDropdown({ + filteringType: 'manual', + 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('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.' + ); + }); + + test('does not apply client-side filtering when filteringType is "manual"', () => { + const { wrapper } = renderDropdown({ + filteringType: 'manual', + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + // All provided items remain visible regardless of the filtering value in manual mode. + wrapper.findFilteringInput()!.setInputValue('zzz'); + expect(wrapper.findItems()).toHaveLength(items.length); + }); +}); + +describe('ButtonDropdown status display', () => { + test.each([ + ['loading', true], + ['error', true], + ['finished', false], + ])('displays %s status text as %s footer', (statusType, isSticky) => { + const statusText = + statusType === 'loading' + ? { loadingText: () => 'Test loading text' } + : { [`${statusType}Text`]: () => `Test ${statusType} text` }; + const expectedText = statusType === 'loading' ? 'Test loading text' : `Test ${statusType} text`; + + const { wrapper } = renderDropdown({ + asyncLoadingProps: { + statusType: statusType as ButtonDropdownProps.AsyncLoadingStatusType, + ...statusText, + }, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + + const statusIndicator = wrapper.findStatusIndicator(); + expect(statusIndicator).not.toBeNull(); + expect(statusIndicator!.getElement()).toHaveTextContent(expectedText); + void isSticky; + }); + + test('displays the empty state when there are no items', () => { + const { wrapper } = renderDropdown({ + items: [], + asyncLoadingProps: { + empty: () => 'No items available', + }, + }); + wrapper.openDropdown(); + const status = wrapper.findStatusIndicator(); + expect(status).not.toBeNull(); + expect(status!.getElement()).toHaveTextContent('No items available'); + }); +}); diff --git a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx index 9bb5e30fde..c18cbce22a 100644 --- a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx @@ -465,6 +465,22 @@ describe('Button dropdown filtering', () => { }); describe('filtered expandable groups', () => { + test('keeps expandable groups expandable while filtering in manual mode', () => { + // In manual mode the parent controls the returned items (they are not flattened + // client-side), so the provided expandable group structure must be preserved even + // while the filtering input has a value. + const { wrapper } = renderDropdown({ + filteringType: 'manual', + items: expandableItems, + expandableGroups: true, + onLoadItems: () => {}, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Start'); + + expect(wrapper.findExpandableCategoryById('states')).not.toBeNull(); + }); + test('renders matching nested items and collapses expandable groups', () => { const { wrapper } = renderDropdown({ filteringType: 'auto', diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index 11e87689cd..aba8019124 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -3,11 +3,15 @@ import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; -import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import Dropdown from '../../dropdown/internal'; +import { useInternalI18n } from '../../i18n/context'; import InternalIcon from '../../icon/internal'; +import DropdownFooter from '../../internal/components/dropdown-footer'; +import { useDropdownStatus } from '../../internal/components/dropdown-status'; +import { fireNonCancelableEvent } from '../../internal/events'; import useHiddenDescription from '../../internal/hooks/use-hidden-description'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { @@ -42,12 +46,44 @@ const ExpandableCategoryElement = ({ filteringEnabled, menuId, filteringDescriptionId, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onLoadItems, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); const isKeyboardHighlighted = isKeyboardHighlight(item); const triggerRef = React.useRef(null); const ref = useRef(null); + const footerId = useUniqueId('awsui-button-dropdown__group-footer'); + + // Per-group async loading status derived from the callback prop. + const groupId = item.id; + const groupStatusType = groupId ? (getExpandableItemsAsyncLoadingState?.({ item }) ?? undefined) : undefined; + + const i18n = useInternalI18n('button-dropdown'); + const recoveryText = i18n('recoveryText', asyncLoadingProps?.recoveryText); + const errorIconAriaLabel = i18n('errorIconAriaLabel', asyncLoadingProps?.errorIconAriaLabel); + + const groupDropdownStatus = useDropdownStatus({ + statusType: groupStatusType, + empty: asyncLoadingProps?.empty?.(groupId), + loadingText: asyncLoadingProps?.loadingText?.(groupId), + finishedText: asyncLoadingProps?.finishedText?.(groupId), + errorText: asyncLoadingProps?.errorText?.(groupId), + recoveryText, + errorIconAriaLabel, + isEmpty: !item.items || item.items.length === 0, + isNoMatch: false, + hasRecoveryCallback: !!onLoadItems, + onRecoveryClick: () => + fireNonCancelableEvent(onLoadItems, { + filteringText: '', + firstPage: false, + samePage: true, + expandedGroupId: groupId, + }), + }); useEffect(() => { if (triggerRef.current && highlighted && !expanded && !filteringEnabled) { @@ -58,6 +94,15 @@ const ExpandableCategoryElement = ({ const onClick: React.MouseEventHandler = event => { if (!disabled) { event.preventDefault(); + // Fire onLoadItems when expanding (not collapsing) a group. + if (!expanded && groupId && onLoadItems) { + fireNonCancelableEvent(onLoadItems, { + filteringText: '', + firstPage: true, + samePage: false, + expandedGroupId: groupId, + }); + } onGroupToggle(item, event); if (!filteringEnabled) { triggerRef.current?.focus(); @@ -192,6 +237,7 @@ const ExpandableCategoryElement = ({ menuId={menuId} filteringDescriptionId={filteringDescriptionId} /> + {groupDropdownStatus.content && } ) : undefined } diff --git a/src/button-dropdown/index.tsx b/src/button-dropdown/index.tsx index 48ebaeaa44..5a47bcd370 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -48,6 +48,9 @@ const ButtonDropdown = React.forwardRef( filteringClearAriaLabel, filteringResultsText, noMatch, + onLoadItems, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, i18nStrings, ...props }: ButtonDropdownProps, @@ -108,6 +111,9 @@ const ButtonDropdown = React.forwardRef( format => (matchesCount, totalCount) => format({ matchesCount, totalCount }) )} noMatch={noMatch} + onLoadItems={onLoadItems} + asyncLoadingProps={asyncLoadingProps} + getExpandableItemsAsyncLoadingState={getExpandableItemsAsyncLoadingState} i18nStrings={{ filteringItemAriaDescription: i18n( 'i18nStrings.filteringItemAriaDescription', diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index b88d197ed0..15d5bb6912 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -6,7 +6,7 @@ import { ButtonProps } from '../button/interfaces'; import { ExpandToViewport } 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 */ @@ -124,6 +124,25 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * Specifies the text that screen reader announces when the button dropdown is in a loading state. */ loadingText?: string; + /** + * Specifies the text to display inside the dropdown when items are loading. + **/ + itemsLoadingText?: string; + /** + * Specifies the async loading status of individual expandable items. + * Use only if you load nested items asynchronously upon expanding an item. + * + * Return values are: + * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * `loading` - Indicates that data fetching is in progress. + * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + * + * If null or undefined, the status will be treated as `finished`. + */ + getExpandableItemsAsyncLoadingState?: (options: { + item: ButtonDropdownProps.ItemGroup; + }) => ButtonDropdownProps.AsyncLoadingStatusType | null | undefined; /** Determines the general styling of the button dropdown. * * `primary` for primary buttons * * `normal` for secondary buttons @@ -155,6 +174,8 @@ 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 manual filtering and there are filtered items, make sure to disable this to ensure filtered items are easily discoverable. */ expandableGroups?: boolean; /** @@ -198,9 +219,18 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor /** * Enables filtering of the dropdown items. * - * When set to `auto`, a search input is rendered inside the dropdown and the items are filtered as the user - * types. Items are matched client-side using a case-insensitive substring match against their `text`, - * `secondaryText`, and `labelTag`. + * * `auto` - A search input is rendered inside the dropdown and the items are automatically filtered as the user types. + * * `manual` - You will set up `onLoadItems` event listeners and filter items on your side or request + * them from server. + * + * If you set this property to `auto`, the component will filter the provided `items` based on the value of the filtering input field. + * The filtering text is matched against the item's `text`, `secondaryText`, and `labelTag`. + * + * If you set this property to `manual`, the default filtering mechanism is disabled and all provided `items` are + * displayed in the dropdown list. In that case make sure that you use the `onLoadItems` events in order + * to set the `items` property to the items that are relevant for the user, given the filtering input value. + * When there are filtered items, disable `expandableGroups` to ensure filtered items are easily discoverable. + * */ filteringType?: ButtonDropdownProps.FilteringType; @@ -227,7 +257,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 there are no matches for the filtering input. */ noMatch?: React.ReactNode; @@ -237,6 +267,40 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor */ i18nStrings?: ButtonDropdownProps.I18nStrings; + /** + * Contains all the properties for async loading. Make sure to listen to `onLoadItems`. + * * `empty` - (Optional) Displayed when there are no options to display. This is only shown when `statusType` is set to `finished` or not set at all. + * * `loadingText` - (Optional) Specifies the text to display when in the loading state. + * * `finishedText` - (Optional) Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. + * * `errorText` - (Optional) Specifies the text to display when a data fetching error occurs. Make sure that you provide `recoveryText`. + * * `recoveryText` (i18n) - (Optional) Specifies the text for the recovery button. The text is displayed next to the error text. Use the `onLoadItems` event to perform a recovery action (for example, retrying the request). + * * `errorIconAriaLabel` (i18n) - (Optional) Provides a text alternative for the error icon in the error message. + * * `statusType` - (Optional) Specifies the current status of loading more options. + * * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * * `loading` - Indicates that data fetching is in progress. + * * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + */ + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + + /** + * Use this event to implement the asynchronous behavior for the component. + * + * The event is called in the following situations: + * * The user scrolls to the end of the list of options, if `statusType` is set to `pending`. + * * The user clicks on the recovery button in the error state. + * * The user types inside the input field. + * * The user focuses the input field. + * * The user expands an expandable group item. + * + * The detail object contains the following properties: + * * `filteringText` - The value that you need to use to fetch options. + * * `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. + * * `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). + * * `expandedGroupId` - The ID of the expanded group that you need to load the items of. + **/ + onLoadItems?: NonCancelableEventHandler; + /** * Attributes to add to the native `button` element. * Some attributes will be automatically combined with internal attribute values: @@ -271,7 +335,54 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor export namespace ButtonDropdownProps { export type Variant = 'normal' | 'primary' | 'icon' | 'inline-icon'; export type ItemType = 'action' | 'group'; - export type FilteringType = 'auto' | 'none'; + export type FilteringType = 'auto' | 'manual' | 'none'; + export type AsyncLoadingStatusType = 'pending' | 'loading' | 'finished' | 'error'; + + export interface LoadItemsDetail { + expandedGroupId?: string; + filteringText: string; + firstPage: boolean; + samePage: boolean; + } + + export interface AsyncLoadingProps { + /** + * Displayed when there are no options to display. + * This is only shown when `statusType` is set to `finished` or not set at all. + */ + empty?: (expandedGroupId?: string) => ReactNode; + /** + * Specifies the text to display when in the loading state. + **/ + loadingText?: (expandedGroupId?: string) => string; + /** + * Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. + **/ + finishedText?: (expandedGroupId?: string) => string; + /** + * Specifies the text to display when a data fetching error occurs. Make sure that you provide `recoveryText`. + **/ + errorText?: (expandedGroupId?: string) => string; + /** + * Specifies the text for the recovery button. The text is displayed next to the error text. + * Use the `onLoadItems` event to perform a recovery action (for example, retrying the request). + * @i18n + **/ + recoveryText?: string; + /** + * Provides a text alternative for the error icon in the error message. + * @i18n + */ + errorIconAriaLabel?: string; + /** + * Specifies the current status of loading more options. + * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * `loading` - Indicates that data fetching is in progress. + * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + **/ + statusType?: ButtonDropdownProps.AsyncLoadingStatusType; + } export interface I18nStrings { filteringItemAriaDescription?: string; diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 65dd6ba282..798a0cffaf 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -29,6 +29,9 @@ export interface CategoryProps extends HighlightProps { filteringEnabled?: boolean; menuId?: string; filteringDescriptionId?: string; + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; + onLoadItems?: ButtonDropdownProps['onLoadItems']; } export interface ItemListProps extends HighlightProps { @@ -50,6 +53,9 @@ export interface ItemListProps extends HighlightProps { filteringEnabled?: boolean; menuId?: string; filteringDescriptionId?: string; + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; + onLoadItems?: ButtonDropdownProps['onLoadItems']; } export interface ItemProps { diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 6f62cc522d..a6acf4374f 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -10,6 +10,7 @@ import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton, InternalButtonProps } from '../button/internal'; import Dropdown from '../dropdown/internal'; +import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; @@ -32,6 +33,7 @@ import { InternalButtonDropdownProps, InternalItem } from './internal-interfaces import ItemsList from './items-list'; import { countLeafItems } from './utils/filter-items'; import { useButtonDropdown } from './utils/use-button-dropdown'; +import { useLoadItems } from './utils/use-load-items'; import { isLinkItem } from './utils/utils.js'; import analyticsSelectors from './analytics-metadata/styles.css.js'; @@ -75,7 +77,10 @@ const InternalButtonDropdown = React.forwardRef( filteringAriaLabel, filteringClearAriaLabel, filteringResultsText, + onLoadItems, noMatch, + 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); @@ -109,6 +114,21 @@ const InternalButtonDropdown = React.forwardRef( const hasMainAction = mainAction && (variant === 'primary' || variant === 'normal'); const isVisualRefresh = useVisualRefresh(); const isOneTheme = isThemeActive(Theme.OneTheme); + const i18n = useInternalI18n('button-dropdown'); + const errorIconAriaLabel = i18n('errorIconAriaLabel', asyncLoadingProps?.errorIconAriaLabel); + const recoveryText = i18n('recoveryText', asyncLoadingProps?.recoveryText); + + 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, @@ -139,7 +159,8 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport, hasExpandableGroups: expandableGroups, isInRestrictedView, - hasFiltering, + filteringType, + fireLoadItems, }); const filterRef = useRef(null); @@ -380,6 +401,7 @@ const InternalButtonDropdown = React.forwardRef( const headerId = useUniqueId('awsui-button-dropdown__header'); const footerId = useUniqueId('awsui-button-dropdown__footer'); + const isEmpty = !items || items.length === 0; const isNoMatch = hasFiltering && !!filteringValue && filteredItems.length === 0; const isFiltered = hasFiltering && !!filteringValue && filteredItems.length > 0; @@ -388,10 +410,19 @@ const InternalButtonDropdown = React.forwardRef( const filteredText = isFiltered ? filteringResultsText?.(matchesCount, totalCount) : undefined; const dropdownStatus = useDropdownStatus({ - statusType: 'finished', + statusType, + empty: asyncLoadingProps?.empty?.(), + loadingText: asyncLoadingProps?.loadingText?.(), + finishedText: asyncLoadingProps?.finishedText?.(), + errorText: asyncLoadingProps?.errorText?.(), + recoveryText, + isEmpty, isNoMatch, noMatch, filteringResultsText: filteredText, + errorIconAriaLabel, + onRecoveryClick: () => handleRecoveryClick(), + hasRecoveryCallback: !!onLoadItems, }); // Only create a filteringDescription element if filtering is actually enabled, @@ -410,6 +441,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 +496,7 @@ const InternalButtonDropdown = React.forwardRef( ariaRole={hasFiltering ? 'dialog' : undefined} ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ - dropdownStatus.content ? ( + dropdownStatus.content && dropdownStatus.isSticky ? ( ) : null } @@ -502,7 +534,8 @@ const InternalButtonDropdown = React.forwardRef( ariaLabel={ariaLabel} ariaLabelledby={hasHeader ? headerId : shouldLabelWithTrigger ? triggerId : undefined} ariaDescribedby={dropdownStatus.content ? footerId : undefined} - statusType="finished" + statusType={statusType} + onLoadMore={handleLoadMore} > + {dropdownStatus.content && !dropdownStatus.isSticky ? ( + + ) : null} {filteringDescriptionEl} } diff --git a/src/button-dropdown/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..fd9b0147f4 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -75,6 +75,8 @@ export interface I18nFormatArgTypes { 'i18nStrings.externalIconAriaLabel': never; }; 'button-dropdown': { + errorIconAriaLabel: never; + recoveryText: never; filteringResultsText: { matchesCount: string | number; totalCount: string | number; diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index d04d9d1332..735140b75b 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -63,6 +63,10 @@ "button": { "i18nStrings.externalIconAriaLabel": "Opens in a new tab" }, + "button-dropdown": { + "errorIconAriaLabel": "Error", + "recoveryText": "Retry" + }, "calendar": { "nextMonthAriaLabel": "Next month", "previousMonthAriaLabel": "Previous month", diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 04f247f2a8..46796f93fb 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -13,6 +13,7 @@ import styles from '../../../button-dropdown/styles.selectors.js'; import dropdownStyles from '../../../dropdown/styles.selectors.js'; import inputStyles from '../../../input/styles.selectors.js'; import footerStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; +import dropdownStatusStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; function getItemSelector({ disabled }: { disabled?: boolean }): string { let selector = `.${itemStyles['item-element']}`; @@ -122,6 +123,36 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { return createWrapper().find(`[data-testid="button-dropdown-disabled-reason"]`); } + /** + * 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 filtering input rendered inside the open dropdown when filtering is enabled. * Returns null if there is no open dropdown or filtering is not enabled.