Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
92c0d58
chore: Implement dropdown async
NathanZlion Sep 3, 2026
be60a62
fix: remove border on loading/error status when dropdown has no items
NathanZlion Sep 3, 2026
417215d
chore: redesign async dev pages for bug bashing
NathanZlion Sep 3, 2026
03462fc
fix: Add bug fixes
NathanZlion Sep 29, 2026
9b6c528
fix: Show async group status on mobile and restore focus after group …
NathanZlion Sep 29, 2026
e89ad1a
docs: Fix button dropdown async loading API documentation
NathanZlion Sep 29, 2026
343c1bc
chore: Keep button dropdown async dev page configuration in URL params
NathanZlion Sep 29, 2026
6db34c7
fix: Distinguish root and group status regions in button dropdown tes…
NathanZlion Sep 29, 2026
565910d
chore: Remove decorative prose from button dropdown async dev pages
NathanZlion Sep 29, 2026
9a2c75d
fix: Fire first button dropdown load request when onLoadItems is atta…
NathanZlion Sep 30, 2026
865d2c0
chore: Address button dropdown review comments
NathanZlion Oct 1, 2026
793d9b1
Update documentation for loading state handling
NathanZlion Oct 5, 2026
9c72c40
fix: Address button dropdown async a11y review comments
NathanZlion Oct 5, 2026
bc08e8d
fix: Hide button dropdown empty state while pending
NathanZlion Oct 5, 2026
6ed32b3
fix: Remove leftover showExpandableGroups reference
NathanZlion Oct 14, 2026
251fa0d
fix: Use disabled menuitem role for status rows
NathanZlion Oct 14, 2026
57d2142
fix: Align desktop group render condition with mobile
NathanZlion Oct 14, 2026
0f6ea73
chore: Move inline group test to mobile test file
NathanZlion Oct 14, 2026
ce3fbee
chore: Add button dropdown translations
NathanZlion Oct 14, 2026
bb0e858
feat: Move virtual focus through group status in filtering mode
NathanZlion Oct 14, 2026
342776f
chore: Increase widget-exports size limit
NathanZlion Oct 16, 2026
fa6645d
fix: DevPage bugs and Suppress menu activation after Retry keydown bug
NathanZlion Oct 20, 2026
010ba99
chore: Update documenter snapshot
NathanZlion Oct 20, 2026
54f6e6e
fix: Align group status and Tab order with root list
NathanZlion Oct 20, 2026
7972d72
fix: Point active descendant at group header without status
NathanZlion Oct 21, 2026
c5a48fb
fix: Prefix group status ids to avoid item id collisions
NathanZlion Oct 21, 2026
cfa3f5e
chore: Reuse select load items hook in button dropdown
NathanZlion Oct 21, 2026
644a8f6
Merge branch 'main' into dev-v3-natidere-button-dropdown-async-filtering
NathanZlion Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 346 additions & 0 deletions pages/button-dropdown/async-loading.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
// 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 { NonCancelableEventHandler } from '~components/internal/events';
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 Scenario = 'flat' | 'groups' | 'paginated' | 'per-group' | 'error';

type PageContext = React.Context<
AppContextType<{
scenario: Scenario;
expandToViewport: boolean;
flatStatus: StatusType;
flatItems: string;
groupAStatus: StatusType;
groupAItems: string;
groupBStatus: StatusType;
groupBItems: string;
}>
>;

const SCENARIO_OPTIONS: { value: Scenario; label: string }[] = [
{ value: 'flat', label: 'Flat list, configurable status' },
{ value: 'groups', label: 'Expandable groups, configurable status' },
{ value: 'paginated', label: 'Flat list, paginated with manual filtering' },
{ value: 'per-group', label: 'Expandable groups, loaded on expand' },
{ value: 'error', label: 'Error with recovery' },
];

const STATUS_OPTIONS = [
{ value: 'loading', label: 'loading' },
{ 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' },
];

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_A_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({
id: `group-a-sub-${i + 1}`,
text: `Sub-action A${i + 1}`,
}));

const GROUP_B_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({
id: `group-b-sub-${i + 1}`,
text: `Sub-action B${i + 1}`,
}));

const PRESET_COUNTS: Record<string, number> = { none: 0, some: 6, all: 12 };

function itemsFromPreset(preset: string, source: ButtonDropdownProps.Item[]): ButtonDropdownProps.Items {
return source.slice(0, PRESET_COUNTS[preset] ?? 0);
}

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<string, ButtonDropdownProps.Item[]> = {
'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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make it so this is can be changed in the settings? It's quite annoying when testing manually


function fetchGroupItems(groupId: string): Promise<ButtonDropdownProps.Item[]> {
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(() => {});
}

const groupTexts: ButtonDropdownProps.AsyncLoadingProps = {
loadingText: (gid?: string) => `Loading ${gid ?? 'items'}...`,
errorText: (gid?: string) => `Failed to load ${gid ?? 'items'}.`,
recoveryText: 'Retry',
errorIconAriaLabel: 'Error',
finishedText: (gid?: string) => `End of ${gid ?? 'results'}`,
empty: (gid?: string) => `No items in ${gid ?? 'group'}.`,
};

export default function ButtonDropdownAsyncLoadingPage() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is huge. Can we move the switch out to clean up the code?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be a follow-up

// Page configuration lives in the URL so that every scenario and status/items combination is directly
// linkable and targetable by integration tests. Fetched results below stay in local state.
const {
urlParams: {
scenario = 'flat',
expandToViewport = false,
flatStatus = 'loading',
flatItems: flatItemsPreset = 'none',
groupAStatus = 'loading',
groupAItems: groupAPreset = 'none',
groupBStatus = 'error',
groupBItems: groupBPreset = 'none',
},
setUrlParams,
} = useContext(AppContext as PageContext);
const onItemClick = (e: CustomEvent<ButtonDropdownProps.ItemClickDetails>) => console.log('clicked', e.detail.id);
const logLoadItems: NonCancelableEventHandler<ButtonDropdownProps.LoadItemsDetail> = ({ detail }) =>
console.log('onLoadItems', detail);

// Scenario "paginated"
const {
items: paginatedItems,
status: paginatedStatus,
filteringText: paginatedFilteringText,
fetchItems,
} = useOptionsLoader<ButtonDropdownProps.Item>({ pageSize: 10, timeout: FETCH_DELAY_MS });

// Scenario "per-group"
const [groupItems, setGroupItems] = useState<Record<string, ButtonDropdownProps.Item[]>>({});
const [groupStatuses, setGroupStatuses] = useState<Record<string, StatusType>>({});

// Scenario "error"
const [errorStatus, setErrorStatus] = useState<StatusType>('error');
const [errorItems, setErrorItems] = useState<ButtonDropdownProps.Items>([]);

const statusSelect = (label: string, value: StatusType, onChange: (value: StatusType) => void) => (
<FormField label={label}>
<Select
selectedOption={STATUS_OPTIONS.find(o => o.value === value) ?? null}
onChange={e => onChange(e.detail.selectedOption.value as StatusType)}
options={STATUS_OPTIONS}
/>
</FormField>
);
const itemsSelect = (label: string, value: string, onChange: (value: string) => void) => (
<FormField label={label}>
<Select
selectedOption={ITEMS_OPTIONS.find(o => o.value === value) ?? null}
onChange={e => onChange(e.detail.selectedOption.value!)}
options={ITEMS_OPTIONS}
/>
</FormField>
);

const commonProps = { expandToViewport, onItemClick };

let content: React.ReactNode = null;
switch (scenario) {
case 'flat':
content = (
<ButtonDropdown
{...commonProps}
items={itemsFromPreset(flatItemsPreset, ALL_ITEMS)}
asyncLoadingProps={{
statusType: flatStatus,
loadingText: () => 'Loading actions...',
errorText: () => 'Failed to load actions.',
recoveryText: 'Retry',
errorIconAriaLabel: 'Error',
finishedText: () => 'End of results',
empty: () => 'No actions found',
}}
onLoadItems={logLoadItems}
>
Actions
</ButtonDropdown>
);
break;
case 'groups':
content = (
<ButtonDropdown
{...commonProps}
items={
[
{ id: 'group-a', text: 'Group A', items: itemsFromPreset(groupAPreset, GROUP_A_ITEMS) },
{ id: 'group-b', text: 'Group B', items: itemsFromPreset(groupBPreset, GROUP_B_ITEMS) },
] as ButtonDropdownProps.Items
}
expandableGroups={true}
asyncLoadingProps={groupTexts}
getExpandableItemsAsyncLoadingState={({ item }) => {
if (item.id === 'group-a') {
return groupAStatus;
}
if (item.id === 'group-b') {
return groupBStatus;
}
return null;
}}
onLoadItems={logLoadItems}
>
Instance actions
</ButtonDropdown>
);
break;
case 'paginated':
content = (
<ButtonDropdown
{...commonProps}
items={paginatedItems as ButtonDropdownProps.Items}
filteringType="manual"
filteringPlaceholder="Filter actions"
asyncLoadingProps={{
statusType: paginatedStatus,
loadingText: () => 'Loading actions...',
errorText: () => 'Error fetching actions.',
recoveryText: 'Retry',
finishedText: () =>
paginatedFilteringText ? `End of "${paginatedFilteringText}" results` : 'End of all results',
empty: () => 'No actions found',
}}
filteringResultsText={(matchesCount, totalCount) =>
paginatedStatus === 'pending' ? `${matchesCount}+ results` : `${matchesCount} of ${totalCount}`
}
onLoadItems={({ detail: { firstPage, filteringText } }) => {
const normalized = filteringText.toLowerCase();
const filtered = flatSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized));
fetchItems({ firstPage, filteringText, sourceItems: filtered });
}}
>
Async actions
</ButtonDropdown>
);
break;
case 'per-group':
content = (
<ButtonDropdown
{...commonProps}
items={
[
{ id: 'group-files', text: 'File (loads successfully)', items: groupItems['group-files'] ?? [] },
{ id: 'group-edit', text: 'Edit (always errors)', items: groupItems['group-edit'] ?? [] },
{ id: 'group-view', text: 'View (loading forever)', items: groupItems['group-view'] ?? [] },
] as ButtonDropdownProps.Items
}
expandableGroups={true}
asyncLoadingProps={groupTexts}
getExpandableItemsAsyncLoadingState={({ item }) => (item.id ? (groupStatuses[item.id] ?? null) : null)}
onLoadItems={({ detail: { expandedGroupId, samePage } }) => {
if (!expandedGroupId) {
return;
}
if (!samePage) {
setGroupItems(prev => ({ ...prev, [expandedGroupId]: [] }));
}
setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'loading' }));
fetchGroupItems(expandedGroupId)
.then(items => {
setGroupItems(prev => ({ ...prev, [expandedGroupId]: items }));
setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'finished' }));
})
.catch(() => {
setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'error' }));
});
Comment on lines +265 to +272

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is technically a race condition here with fetchGroupItems. When a user opens and closes the group multiple times, it will create multiple promises, which all resolve independent form each other, populating the setGroupItems and Status. This way, the 5s delay is not really visible.

Though this might be fine for a test page. You can decide whether you want to fix this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have solved this in the other files by using requestId

}}
>
Instance actions
</ButtonDropdown>
);
break;
case 'error':
content = (
<ButtonDropdown
{...commonProps}
items={errorItems}
asyncLoadingProps={{
statusType: errorStatus,
loadingText: () => 'Loading actions...',
errorText: () => 'Error fetching actions.',
recoveryText: 'Retry',
errorIconAriaLabel: 'Error',
empty: () => 'No actions found',
}}
onLoadItems={({ detail: { samePage } }) => {
if (samePage) {
setErrorStatus('loading');
setTimeout(() => {
setErrorItems(flatSourceItems.slice(0, 8));
setErrorStatus('finished');
}, FETCH_DELAY_MS);
} else {
setErrorItems([]);
setErrorStatus('error');
}
}}
>
Actions (error)
</ButtonDropdown>
);
break;
}

return (
<SimplePage
title="ButtonDropdown async loading"
settings={
<SpaceBetween size="s" direction="horizontal">
<FormField label="Scenario">
<Select
selectedOption={SCENARIO_OPTIONS.find(o => o.value === scenario) ?? null}
onChange={e => setUrlParams({ scenario: e.detail.selectedOption.value as Scenario })}
options={SCENARIO_OPTIONS}
/>
</FormField>
{scenario === 'flat' && (
<>
{statusSelect('statusType', flatStatus, value => setUrlParams({ flatStatus: value }))}
{itemsSelect('items', flatItemsPreset, value => setUrlParams({ flatItems: value }))}
</>
)}
{scenario === 'groups' && (
<>
{statusSelect('Group A - statusType', groupAStatus, value => setUrlParams({ groupAStatus: value }))}
{itemsSelect('Group A - items', groupAPreset, value => setUrlParams({ groupAItems: value }))}
{statusSelect('Group B - statusType', groupBStatus, value => setUrlParams({ groupBStatus: value }))}
{itemsSelect('Group B - items', groupBPreset, value => setUrlParams({ groupBItems: value }))}
</>
)}
<Checkbox checked={expandToViewport} onChange={e => setUrlParams({ expandToViewport: e.detail.checked })}>
Expand to viewport
</Checkbox>
</SpaceBetween>
}
>
{content}
</SimplePage>
);
}
Loading
Loading