Skip to content

feat: Add filtering to button dropdown#4637

Open
avinashbot wants to merge 1 commit into
mainfrom
feat/actions-search
Open

feat: Add filtering to button dropdown#4637
avinashbot wants to merge 1 commit into
mainfrom
feat/actions-search

Conversation

@avinashbot

@avinashbot avinashbot commented Jun 16, 2026

Copy link
Copy Markdown
Member

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-activedescendant rather 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 using aria-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

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.58%. Comparing base (d326da3) to head (e553244).
⚠️ Report is 2 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@avinashbot avinashbot force-pushed the feat/actions-search branch from 0d5d891 to 14e5718 Compare June 18, 2026 23:10
@avinashbot avinashbot force-pushed the feat/actions-search branch from 14e5718 to b0fd947 Compare June 18, 2026 23:29
@avinashbot avinashbot force-pushed the feat/actions-search branch 2 times, most recently from 042705d to 50e2c55 Compare June 23, 2026 16:07
@avinashbot avinashbot force-pushed the feat/actions-search branch from 50e2c55 to d5f7211 Compare June 24, 2026 10:57
const onItemClick = (event: CustomEvent<ButtonDropdownProps.ItemClickDetails>) => console.log(event.detail);

return (
<article>

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.

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.

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.

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;

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.

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 {

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.

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) {

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.

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();

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 makes clear filter button irresponsive when pressing Enter

}
case KeyCode.left:
case KeyCode.right: {
if (hasFiltering && filteringValue) {

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.

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.

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.

Why was this comment removed?

}
};
const onKeyUp = (event: React.KeyboardEvent) => {
// We need to handle activating items with Space separately because there is a bug

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.

why was this comment removed?

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}

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 this be un-conditonal?

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;

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.

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?

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.

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"]'));

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.

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', () => {

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.

Should we check for both filteringType=undefined and filteringType="none"?


const dropdownEl = wrapper.findOpenDropdown()!.getElement();
act(() => {
fireEvent.keyDown(dropdownEl, { keyCode: KeyCode.down });

@pan-kot pan-kot Jun 25, 2026

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.

nit: alternatively, we can do wrapper.findOpenDropdown()!.keyDown(KeyCode.down)

input.focus();
});
act(() => {
clearButton.focus();

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.

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.

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.

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();

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 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.

@pan-kot pan-kot Jun 25, 2026

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.

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.

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.

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';

@pan-kot pan-kot Jun 25, 2026

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.

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', () => {

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.

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.

@SpyZzey SpyZzey requested a review from amanabiy June 25, 2026 09:33
};

const actOnParentDropdown = (event: React.KeyboardEvent) => {
// if there is no highlighted item we act on the trigger by opening or closing dropdown

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.

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

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.

Same, Did the behavior change here? or we don't need the comment?

});

const { isOpen, closeDropdown, ...openStateProps } = useOpenState({ onClose: reset });
const prevFilteringValue = useRef(filteringValue);

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.

Should we use usePrevious here?

const prevFilteringValue = usePrevious(filteringValue);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants