feat: Add filtering to button dropdown#4637
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4637 +/- ##
==========================================
+ Coverage 97.57% 97.58% +0.01%
==========================================
Files 948 950 +2
Lines 30489 30604 +115
Branches 11149 11203 +54
==========================================
+ Hits 29749 29866 +117
- Misses 693 731 +38
+ Partials 47 7 -40 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
0d5d891 to
14e5718
Compare
14e5718 to
b0fd947
Compare
042705d to
50e2c55
Compare
50e2c55 to
d5f7211
Compare
d5f7211 to
e553244
Compare
| const onItemClick = (event: CustomEvent<ButtonDropdownProps.ItemClickDetails>) => console.log(event.detail); | ||
|
|
||
| return ( | ||
| <article> |
There was a problem hiding this comment.
nit: You can use SimplePage helper. It comes with a slot for page's title and simplifies the addition of screenshot area and i18n.
| } | ||
|
|
||
| /** | ||
| * Finds the filtering input rendered inside the open dropdown when `filteringType` is set. |
There was a problem hiding this comment.
nit: filteringType can as well be "none", so the comment is not quite accurate.
| } | ||
|
|
||
| export type RenderItem = ActionRenderItem | CheckboxRenderItem | GroupRenderItem; | ||
| export type ItemRenderer = (props: { item: ButtonDropdownProps.RenderItem }) => ReactNode | null; |
There was a problem hiding this comment.
Should we pass filterText as a separate argument here, adjacent to item instead of adding it to each item?
| import { ButtonDropdownProps } from '../interfaces'; | ||
| import { isItemGroup } from './utils'; | ||
|
|
||
| function matchesString(value: string | undefined, searchText: string): boolean { |
There was a problem hiding this comment.
nit: matchesString, matchesItem, and collectMatchingLeaves implicitly rely on the searchText being lowercased. I think we should either explicitly document it e.g. with a var name, or instead move all three function definitions under filterItems - so that searchText can also be taken from context:
export function filterItems(...) {
if (!filterText) {
return items;
}
const searchText = filterText.toLowerCase();
function matchesString(value) {...}
function matchesItem(item) {...}
function collectMatchingLeaves(items) {...}
return items.reduce(...);
}
| // rather than by the Tab keydown. The Dropdown's onBlur only fires once focus moves outside | ||
| // the trigger and the dropdown content, which is exactly when we want to close. | ||
| const onDropdownBlur = () => { | ||
| if (hasFiltering && isOpen) { |
There was a problem hiding this comment.
Do we actually need to check for hasFiltering and isOpen - can we close it unconditionally instead?
| // While filtering, pressing Enter without a highlighted item should do nothing | ||
| // (matching select/multiselect) rather than closing the dropdown with no selection. | ||
| if (hasFiltering && !targetItem) { | ||
| event.preventDefault(); |
There was a problem hiding this comment.
This makes clear filter button irresponsive when pressing Enter
| } | ||
| case KeyCode.left: | ||
| case KeyCode.right: { | ||
| if (hasFiltering && filteringValue) { |
There was a problem hiding this comment.
nit: it would make sense to add code comments explaining this change. I assume in this case we want that left/right keys would move the filter cursor, while expanding sections is no longer relevant once there is input.
| break; | ||
| } | ||
| case KeyCode.tab: { | ||
| // When expanded to viewport the focus can't move naturally to the next element. |
| } | ||
| }; | ||
| const onKeyUp = (event: React.KeyboardEvent) => { | ||
| // We need to handle activating items with Space separately because there is a bug |
| onOutsideClick={() => toggleDropdown()} | ||
| // In filtering mode the dropdown is a dialog with several focusable elements, so we | ||
| // close it once focus leaves the trigger and the dropdown content rather than on Tab. | ||
| onBlur={hasFiltering ? onDropdownBlur : undefined} |
| const ref = useRef<HTMLSpanElement | null>(null); | ||
| const isReducedMotion = useReducedMotion(ref); | ||
| const { open, triggerProps } = useTooltipOpen(isReducedMotion ? 0 : DEFAULT_OPEN_TIMEOUT_IN_MS); | ||
| const isOpen = open || !!show; |
There was a problem hiding this comment.
The original visibility logic includes some delays, but the new "show" property does not come with anything like that - what is the problem that visibility state addresses with delayed open?
There was a problem hiding this comment.
Is there a chance that we can use a shared solution, no matter if filtering is used? Ideally, that solution should use the src/tooltip component instead of this custom one.
| } | ||
|
|
||
| function getMenuItems(container: HTMLElement): HTMLElement[] { | ||
| return Array.from(container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')); |
There was a problem hiding this comment.
Why don't we use test-utils for this selector?
| }); | ||
|
|
||
| describe('filter input rendering', () => { | ||
| test('does not render filter input when filteringType is not set', () => { |
There was a problem hiding this comment.
Should we check for both filteringType=undefined and filteringType="none"?
|
|
||
| const dropdownEl = wrapper.findOpenDropdown()!.getElement(); | ||
| act(() => { | ||
| fireEvent.keyDown(dropdownEl, { keyCode: KeyCode.down }); |
There was a problem hiding this comment.
nit: alternatively, we can do wrapper.findOpenDropdown()!.keyDown(KeyCode.down)
| input.focus(); | ||
| }); | ||
| act(() => { | ||
| clearButton.focus(); |
There was a problem hiding this comment.
I believe this does not capture the regression. Wasn't the issue due to the Tab keypress handling? I think we should rather secure this with an integration test then.
| input.focus(); | ||
| }); | ||
|
|
||
| // Shift+Tabbing back to the input stays within the dropdown, so it remains open. |
There was a problem hiding this comment.
The code above does not technically Shift+Tab. This test and the one before can be translated to the integ tests with the same design, but with proper Tab/Shift+Tab calls.
| fireEvent.keyDown(input, { keyCode: KeyCode.left }); | ||
| }); | ||
| // Filtering renders groups flat, so the nested items are visible regardless of expansion. | ||
| expect(wrapper.findOpenDropdown()).not.toBeNull(); |
There was a problem hiding this comment.
This assertion can work if the test was: "Left and Right arrow keys do not close the dropdown".
In the original button dropdown the expandable sections can be not only expanded, but also collapsed. One thing we can check is:
expect(getItemsCount()).toBe(filteredItemsCount);
// Make item inside a group highlighted
input.keyDown(KeyCode.down);
input.keyDown(KeyCode.down);
input.keyDown(KeyCode.right);
expect(getItemsCount()).toBe(filteredItemsCount);
input.keyDown(KeyCode.left);
expect(getItemsCount()).toBe(filteredItemsCount);
this would ensure that the item count stays the same when issuing left/right keypresses.
There was a problem hiding this comment.
I would also include this test:
// do not set filtering input
expect(getItemsCount()).toBe(collapsedItemsCount);
// Make category item highlighted
input.keyDown(KeyCode.down);
input.keyDown(KeyCode.down);
// Expands a group
input.keyDown(KeyCode.right);
expect(getItemsCount()).toBe(expandedItemsCount);
// Collapses a group
input.keyDown(KeyCode.left);
expect(getItemsCount()).toBe(collapsedItemsCount);
to ensure the Left/Right works normally when filtering is used, but filter is empty.
There was a problem hiding this comment.
I also found a bug, but not sure if it is related to the new changes. When expandable groups include more groups - the Left/Right does not work correctly.
In that case, pressing Left/Right does not close the open group once it is opened. It is also not possible to exit the expandable groups with keyboard nav anymore.
Screen.Recording.2026-06-25.at.09.56.15.mov
| // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { ButtonDropdownProps } from '../interfaces'; | ||
| import { filterItems } from '../utils/filter-items'; |
There was a problem hiding this comment.
All these tests are technically possible to run from the component level: render button dropdown with certain items, set filtering - check the actual rendered items. Should we do this instead of testing the helper directly? This can give us better real coverage, as it not only ensures that the helper works, but also that it is used correctly by the component.
| expect(elementWrapper).toHaveTextContent('Custom'); | ||
| }); | ||
|
|
||
| test('renders custom item content for link items', () => { |
There was a problem hiding this comment.
Why did we add this test? Is that also the expected behaviour? I find it strange that we still render a link when the item renderer is overridden.
| }; | ||
|
|
||
| const actOnParentDropdown = (event: React.KeyboardEvent) => { | ||
| // if there is no highlighted item we act on the trigger by opening or closing dropdown |
There was a problem hiding this comment.
Did the behavior change here? or we don't need the comment?
| const activate = (event: React.KeyboardEvent, isEnter?: boolean) => { | ||
| setIsUsingMouse(false); | ||
|
|
||
| // if item is a link we rely on default behavior of an anchor, no need to prevent |
There was a problem hiding this comment.
Same, Did the behavior change here? or we don't need the comment?
| }); | ||
|
|
||
| const { isOpen, closeDropdown, ...openStateProps } = useOpenState({ onClose: reset }); | ||
| const prevFilteringValue = useRef(filteringValue); |
There was a problem hiding this comment.
Should we use usePrevious here?
const prevFilteringValue = usePrevious(filteringValue);
Description
Button dropdown action filtering implemented a similar way to select/multiselect filtering. The big underlying change is that filtering requires the use of
aria-activedescendantrather than simply moving focus from item to item. So most of the internal changes are about disabling focusing logic when filtering is active, so that the real focus can stay on the input (and "accessibility" focus can move around usingaria-activedescendant).Rel: AWSUI-61960 (Chorus: qodG8KXdJXXk)
How has this been tested?
Updated unit tests, a bit of manual accessibility testing, but more validation with other screen readers is needed.
Review checklist
The following items are to be evaluated by the author(s) and the reviewer(s).
Correctness
CONTRIBUTING.md.CONTRIBUTING.md.Security
checkSafeUrlfunction.Testing
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.