diff --git a/pages/button-dropdown/async-loading.page.tsx b/pages/button-dropdown/async-loading.page.tsx new file mode 100644 index 0000000000..eb36bb33bc --- /dev/null +++ b/pages/button-dropdown/async-loading.page.tsx @@ -0,0 +1,345 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +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 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) => ({ + 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' }, +]; + +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}` })), +}; + +// 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']), FETCH_DELAY_MS)); + } + if (groupId === 'group-edit') { + return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), FETCH_DELAY_MS)); + } + return new Promise(() => {}); +} + +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. + 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); + + // Preconfigured - paginated async + const { + items: paginatedItems, + status: paginatedStatus, + filteringText: paginatedFilteringText, + fetchItems, + } = useOptionsLoader({ pageSize: 10, timeout: FETCH_DELAY_MS }); + + // Preconfigured - groups + const [groupItems, setGroupItems] = useState>({}); + const [groupStatuses, setGroupStatuses] = useState>({}); + + // Preconfigured - error + recovery + const [errorStatus, setErrorStatus] = useState('error'); + const [errorItems, setErrorItems] = useState([]); + + return ( + setUrlParams({ expandToViewport: e.detail.checked })}> + Expand to viewport + + } + > + + {/* Interactive: flat */} +
+

Interactive - flat async

+ + + o.value === flatItemsPreset) ?? null} + onChange={e => setUrlParams({ flatItems: 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

+ + + o.value === groupAPreset) ?? null} + onChange={e => setUrlParams({ groupAItems: e.detail.selectedOption.value! })} + options={ITEMS_OPTIONS} + /> + + + o.value === groupBPreset) ?? null} + onChange={e => setUrlParams({ groupBItems: 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)

+ 'Loading actions...', + errorText: () => 'Error fetching actions.', + recoveryText: 'Retry', + finishedText: () => + paginatedFilteringText ? `End of "${paginatedFilteringText}" results` : 'End of all results', + empty: () => 'No actions found', + }} + expandToViewport={expandToViewport} + filteringResultsText={(matchesCount, totalCount) => + paginatedStatus === 'pending' ? `${matchesCount}+ results` : `${matchesCount} of ${totalCount}` + } + 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 + +
+ + {/* Preconfigured: per-group async */} +
+

Preconfigured - per-group async

+ `Loading ${gid ?? 'items'}...`, + errorText: (gid?: string) => `Failed to load ${gid ?? 'items'}.`, + recoveryText: 'Retry', + 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 } }) => { + 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 + +
+ + {/* Preconfigured: error + recovery */} +
+

Preconfigured - error with recovery

+ '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(flatSourceItems.slice(0, 8)); + setErrorStatus('finished'); + }, FETCH_DELAY_MS); + } else { + setErrorItems([]); + setErrorStatus('error'); + } + }} + > + Actions (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..4d2c70e124 --- /dev/null +++ b/pages/button-dropdown/button-dropdown.async.example.page.tsx @@ -0,0 +1,225 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useRef, useState } from 'react'; + +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 + + + + ); +} diff --git a/pages/button-dropdown/manual-filtering.page.tsx b/pages/button-dropdown/manual-filtering.page.tsx new file mode 100644 index 0000000000..71a1ef5f31 --- /dev/null +++ b/pages/button-dropdown/manual-filtering.page.tsx @@ -0,0 +1,197 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +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 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' }, + { 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 filterItems(text: string): ButtonDropdownProps.Items { + const q = text.toLowerCase(); + return SOURCE_ITEMS.filter(i => (i.text ?? '').toLowerCase().includes(q)); +} + +function simulateServer(text: string, delayMs: number): Promise { + return new Promise(resolve => setTimeout(() => resolve(filterItems(text)), delayMs)); +} + +export default function ButtonDropdownManualFilteringPage() { + // Page configuration lives in the URL so that each setup is directly linkable and targetable by + // integration tests. Request results below stay in local state. + const { + urlParams: { expandToViewport = false, filteringType = 'manual', serverDelay = '400' }, + setUrlParams, + } = useContext(AppContext as PageContext); + const onItemClick = (e: CustomEvent) => console.log('clicked', e.detail.id); + + // Interactive: filteringType switcher + const [switcherItems, setSwitcherItems] = useState(SOURCE_ITEMS); + + // Interactive: server delay control + 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 ( + setUrlParams({ expandToViewport: e.detail.checked })}> + Expand to viewport + + } + > + + {/* Interactive: filteringType switcher */} +
+

Interactive - filteringType switcher

+ + setUrlParams({ filteringType: 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.} + expandToViewport={expandToViewport} + filteringResultsText={(m, t) => `${m} of ${t}`} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + setSwitcherItems(filterItems(filteringText)); + }} + > + Actions + +
+ + {/* Interactive: server delay control */} +
+

Interactive - server delay control

+ + setUrlParams({ serverDelay: 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.} + expandToViewport={expandToViewport} + filteringResultsText={(m, t) => `${m} of ${t}`} + onItemClick={onItemClick} + onLoadItems={({ detail: { filteringText } }) => { + setServerStatus('loading'); + setServerItems([]); + simulateServer(filteringText, parseInt(serverDelay, 10)).then(results => { + setServerItems(results); + setServerStatus('finished'); + }); + }} + > + Actions + +
+ + {/* Preconfigured: client-side manual */} +
+

Preconfigured - client-side manual filtering

+ 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

+ '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 + +
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 2d079dab48..3a1bd1ee40 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6280,6 +6280,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 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. +* The user expands an expandable group. + +The detail object contains the following properties: +* \`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\` - 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": [ + { + "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": [ { @@ -6314,6 +6359,121 @@ 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\`. + +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 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.", + "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.", @@ -6337,7 +6497,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 group items are loaded asynchronously, return each group's status from \`getExpandableItemsAsyncLoadingState\`.", "name": "expandableGroups", "optional": true, "type": "boolean", @@ -6400,17 +6561,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\`: + +* \`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. -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\`.", +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", @@ -6423,6 +6592,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 groups. +Use only if you load the nested items asynchronously upon expanding a group. + +Return values are: +* \`loading\` - Indicates that data fetching is in progress. +* \`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. + +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": [ + { + "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, @@ -7141,7 +7336,8 @@ 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 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", }, @@ -36205,6 +36401,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. @@ -36391,6 +36612,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": [], @@ -46012,6 +46258,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. @@ -46306,6 +46580,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", @@ -47685,6 +47987,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. @@ -47898,6 +48228,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": [], @@ -49117,6 +49475,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. @@ -49254,6 +49634,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": [], @@ -56095,6 +56497,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. @@ -56322,6 +56749,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", @@ -57300,6 +57752,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. @@ -57461,6 +57938,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/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index e301c3b8b9..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 @@ -106,6 +106,8 @@ exports[`test-utils selectors 1`] = ` "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-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 new file mode 100644 index 0000000000..8e02f3d45a --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-async-loading.test.tsx @@ -0,0 +1,632 @@ +// 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 { 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(), +})); + +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 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({ + 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('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', + 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('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', () => { + 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('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: [], + 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(); + }); + + 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', () => { + 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' })); + }); + + test('shows loading status inside expanded group without a divider 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(); + 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('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('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, + 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, + 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 new file mode 100644 index 0000000000..c3042664c3 --- /dev/null +++ b/src/button-dropdown/__tests__/use-load-items.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 { 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 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({ + filteringText: '', + firstPage: false, + samePage: true, + expandedGroupId: 'group-1', + }); + }); + + 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 af89703dcc..a5bbf5c8e2 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -17,8 +17,10 @@ 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'; +import { useExpandableGroupStatus } from './use-expandable-group-status'; import styles from './styles.css.js'; @@ -42,6 +44,9 @@ const ExpandableCategoryElement = ({ filteringEnabled, menuId, filteringDescriptionId, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onGroupRecoveryClick, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -49,6 +54,19 @@ const ExpandableCategoryElement = ({ const triggerRef = React.useRef(null); const ref = useRef(null); + const { + status: groupDropdownStatus, + footerId, + hasGroupItems, + } = useExpandableGroupStatus({ + item, + asyncLoadingProps, + getExpandableItemsAsyncLoadingState, + onGroupRecoveryClick, + filteringEnabled, + triggerRef, + }); + useEffect(() => { if (triggerRef.current && highlighted && !expanded && !filteringEnabled) { triggerRef.current.focus(); @@ -165,35 +183,54 @@ const ExpandableCategoryElement = ({ + ) : undefined + } content={ - item.items && expanded ? ( + expanded ? (
    - + {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/category-elements/mobile-expandable-category-element.tsx b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx index 076376ce51..2cb991aeee 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,47 @@ 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/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 ae308304fe..58f4e31d31 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 group items are loaded asynchronously, return each group's status from `getExpandableItemsAsyncLoadingState`. */ expandableGroups?: boolean; /** @@ -196,11 +197,72 @@ 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`. + * + * 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 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. + */ + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + + /** + * Use this event to implement the asynchronous behavior for the component. + * + * The event is called in the following situations: + * * 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. + * * The user expands an expandable group. + * + * The detail object contains the following properties: + * * `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` - 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 groups. + * Use only if you load the nested items asynchronously upon expanding a group. + * + * Return values are: + * * `loading` - Indicates that data fetching is in progress. + * * `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. + * + * 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; + }) => ButtonDropdownProps.AsyncLoadingStatusType | null | undefined; + + /** + * Determines how filtering is applied to the dropdown `items`: + * + * * `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. + * + * 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; @@ -228,6 +290,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor /** * 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; @@ -271,7 +334,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 items 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 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. + **/ + 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 1018d79754..df677908be 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -29,6 +29,13 @@ export interface CategoryProps extends HighlightProps { filteringEnabled?: boolean; menuId?: string; filteringDescriptionId?: string; + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; + /** + * 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 { @@ -50,6 +57,13 @@ export interface ItemListProps extends HighlightProps { filteringEnabled?: boolean; menuId?: string; filteringDescriptionId?: string; + asyncLoadingProps?: ButtonDropdownProps.AsyncLoadingProps; + getExpandableItemsAsyncLoadingState?: ButtonDropdownProps['getExpandableItemsAsyncLoadingState']; + /** + * 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 4082b991d9..389b0c3dfa 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -10,10 +10,10 @@ 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'; -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'; @@ -30,9 +30,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 { isLinkItem } from './utils/utils.js'; +import { useLoadItems } from './utils/use-load-items'; +import { isItemGroup, isLinkItem } from './utils/utils.js'; import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './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,29 @@ 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, 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, @@ -141,7 +169,14 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport, hasExpandableGroups: expandableGroups, isInRestrictedView, - hasFiltering, + filteringType, + fireLoadItems, + onGroupExpand: group => { + if (group.id && onLoadItems) { + fireGroupLoadItems(group.id); + } + }, + hasRecoveryButton: () => hasRecoveryButtonRef.current, }); const filterRef = useRef(null); @@ -402,13 +437,63 @@ const InternalButtonDropdown = React.forwardRef( const matchesCount = useMemo(() => countLeafItems(filteredItems), [filteredItems]); const filteredText = isFiltered ? filteringResultsText?.(matchesCount, totalCount) : undefined; + // 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: '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(); + // 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 }); + } + }, }); + // 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 = + !!recoveryText && + !!onLoadItems && + items.some( + item => + isItemGroup(item) && + !!item.id && + isExpandable(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; @@ -425,6 +510,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} @@ -479,8 +565,13 @@ const InternalButtonDropdown = React.forwardRef( ariaRole={hasFiltering ? 'dialog' : undefined} ariaLabel={hasFiltering ? ariaLabel : undefined} footer={ - dropdownStatus.content ? ( - + dropdownStatus.content && dropdownStatus.isSticky ? ( + ) : null } content={ @@ -517,7 +608,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 ? ( + // Non-sticky status (finished text) scrolls together with the items, like the + // list bottom in Select, instead of covering the last item. +
  • + +
  • + ) : null} {filteringDescriptionEl} diff --git a/src/button-dropdown/items-list.tsx b/src/button-dropdown/items-list.tsx index 258fc22064..52c8851339 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, + onGroupRecoveryClick, }: ItemListProps) { const isMobile = useMobile(); @@ -89,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/status-footer.tsx b/src/button-dropdown/status-footer.tsx new file mode 100644 index 0000000000..7e8de69eed --- /dev/null +++ b/src/button-dropdown/status-footer.tsx @@ -0,0 +1,40 @@ +// 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'; + +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, 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} + > + +
    + ); +}; + +export default StatusFooter; 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/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index 75ce01eaa3..a02019af2f 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -23,7 +23,18 @@ interface UseButtonDropdownOptions extends ButtonDropdownSettings { // Returns whether the given element is (or is inside) the dropdown trigger. isTriggerElement: (element: Element) => boolean; expandToViewport?: boolean; - hasFiltering: 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 { @@ -52,13 +63,17 @@ export function useButtonDropdown({ hasExpandableGroups, isInRestrictedView = false, expandToViewport = false, - hasFiltering, + filteringType, + fireLoadItems, + onGroupExpand, + hasRecoveryButton, }: 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] ); // an active filter flattens every group; otherwise a group's own `expandable` flag wins, falling @@ -95,7 +110,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(''); @@ -144,7 +166,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); @@ -255,7 +284,7 @@ export function useButtonDropdown({ break; } if (targetItem && !targetItem.disabled && isItemGroup(targetItem) && !isExpanded(targetItem)) { - expandGroup(); + expandGroupAndNotify(targetItem); } else { collapseGroup(); } @@ -280,6 +309,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 new file mode 100644 index 0000000000..56cdb3d768 --- /dev/null +++ b/src/button-dropdown/utils/use-load-items.ts @@ -0,0 +1,57 @@ +// 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) => { + // 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; + 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 || '', + }); + } + }; + + // 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: expandedGroupId ? '' : prevFilteringText.current || '', + expandedGroupId, + }); + + const fireGroupLoadItems = (expandedGroupId: string) => + fireNonCancelableEvent(onLoadItems, { filteringText: '', 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 85cf6c105d..e5f1fc46a1 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 e8cdbdb299..2a757113a3 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" @@ -520,4 +522,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..13a6d320d5 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -14,6 +14,11 @@ import dropdownStyles from '../../../dropdown/styles.selectors.js'; import inputStyles from '../../../input/styles.selectors.js'; import footerStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; +// 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']}`; @@ -132,6 +137,31 @@ 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 { + return ( + this.findOpenDropdown()?.find( + `${statusScopeSelector(options.expandedGroupDropdown)} .${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 { + return ( + this.findOpenDropdown()?.find(`${statusScopeSelector(options.expandedGroupDropdown)} .${footerStyles.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` @@ -140,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