feat: Add filtering to button dropdown#4637
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4637 +/- ##
========================================
Coverage 97.57% 97.57%
========================================
Files 948 950 +2
Lines 30489 30603 +114
Branches 11148 11204 +56
========================================
+ Hits 29749 29862 +113
+ Misses 733 694 -39
- Partials 7 47 +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
| // 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);
e553244 to
9fb88b8
Compare
9fb88b8 to
71b731a
Compare
71b731a to
3371c37
Compare
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.