Skip to content

Commit

Permalink
feat: update focus states for Dropdown, Combobox & Multiselect (#18230)
Browse files Browse the repository at this point in the history
* feat: update focus states

* chore: revert story change

* test(avt): add avt test for fluid dropdown and dropdown

* test(avt): fluid combobox and combobox tests

* test(avt): multiselect avt test

* chore: adjust styles for focus states in fluid

* fix: remove styles setting outline to none

* chore: remove initial selected item for fluid dropdown story

* chore: make sure first option is focused for filterable multiselect

* chore: fix issue with no focus state dropdown

* chore: adjust focus stroke width

* chore: fix first selected item focus width

---------

Co-authored-by: Taylor Jones <tay1orjones@users.noreply.github.com>
Co-authored-by: Kritvi <158570656+Kritvi-bhatia17@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 23, 2025
1 parent 02d6f94 commit eee39f8
Show file tree
Hide file tree
Showing 18 changed files with 179 additions and 40 deletions.
16 changes: 16 additions & 0 deletions e2e/components/ComboBox/ComboBox-test.avt.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ test.describe('@avt ComboBox', () => {
const clearButton = page.getByRole('button', {
name: 'Clear selected item',
});
const exampleOption = page.getByRole('option', {
name: 'An example option that is really long to show what should be done to handle long text',
});
const optionOne = page.getByRole('option', {
name: 'An example option that is really long to show what should be done to handle long text',
});
Expand All @@ -71,6 +74,11 @@ test.describe('@avt ComboBox', () => {
await expect(combobox).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible();
// Expect focus to be on 1st item in menu after Arrow Down
// when there is no initial selected item
await expect(exampleOption).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--highlighted'
);
// Close with Escape, retain focus, and open with Spacebar
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
Expand All @@ -84,6 +92,8 @@ test.describe('@avt ComboBox', () => {
await expect(combobox).toBeFocused();
await page.keyboard.press('Enter');
await expect(menu).toBeVisible();
// Expect focus to be retained when no initial selected item after Enter
await expect(combobox).toBeFocused();
await page.keyboard.press('ArrowDown');
// Navigation inside the menu
// move to first option
Expand All @@ -101,8 +111,14 @@ test.describe('@avt ComboBox', () => {
await expect(combobox).toBeFocused();
await expect(menu).toBeHidden();
await expect(clearButton).toBeVisible();
// Expect focus to be on selected item when opening with Arrow Down
await page.keyboard.press('ArrowDown');
await expect(exampleOption).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// should only clear selection when escape is pressed when the menu is closed
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
await expect(clearButton).toBeHidden();
await expect(combobox).toHaveValue('');
// should highlight menu items based on text input
Expand Down
28 changes: 28 additions & 0 deletions e2e/components/Dropdown/Dropdown-test.avt.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ test.describe('@avt Dropdown', () => {
await expect(toggleButton).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible();
// Expect focus to be on 1st item in menu after Arrow Down
// when there is no initial selected item
await expect(
page.getByRole('option', {
name: 'Lorem, ipsum dolor sit amet consectetur adipisicing elit.',
})
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--highlighted'
);
// Close with Escape, retain focus, and open with Space
await page.keyboard.press('Escape');
await page.keyboard.press('Space');
Expand All @@ -71,6 +80,15 @@ test.describe('@avt Dropdown', () => {
await expect(menu).toBeHidden();
await expect(toggleButton).toBeFocused();
await page.keyboard.press('Enter');
// Expect focus to be retained when no initial selected item after Enter
await expect(toggleButton).toBeFocused();
await expect(menu).toBeVisible();
// Select item from menu
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
// Open with Enter after item has been selected
await page.keyboard.press('Enter');
// Should focus on selected item by default
await expect(
page.getByRole('option', {
Expand All @@ -79,6 +97,16 @@ test.describe('@avt Dropdown', () => {
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// Should focus on selected item by default on Arrow Down as well
await page.keyboard.press('Escape');
await page.keyboard.press('ArrowDown');
await expect(
page.getByRole('option', {
name: 'Option 1',
})
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// Navigation inside the menu
await page.keyboard.press('ArrowDown');
await expect(
Expand Down
10 changes: 10 additions & 0 deletions e2e/components/FluidComboBox/FluidComboBox-test.avt.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ test.describe('@avt FluidComboBox', () => {
const clearButton = page.getByRole('button', {
name: 'Clear selected item',
});
const exampleOption = page.getByRole('option', {
name: 'Lorem, ipsum dolor sit amet consectetur adipisicing elit.',
});
const optionOne = page.getByRole('option', {
name: 'Lorem, ipsum dolor sit amet consectetur adipisicing elit.',
});
Expand All @@ -71,6 +74,11 @@ test.describe('@avt FluidComboBox', () => {
await expect(combobox).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible();
// Expect focus to be on 1st item in menu after Arrow Down
// when there is no initial selected item
await expect(exampleOption).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--highlighted'
);
// Close with Escape, retain focus, and open with Spacebar
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
Expand All @@ -84,6 +92,8 @@ test.describe('@avt FluidComboBox', () => {
await expect(combobox).toBeFocused();
await page.keyboard.press('Enter');
await expect(menu).toBeVisible();
// Expect focus to be retained when no initial selected item after Enter
await expect(combobox).toBeFocused();
await page.keyboard.press('ArrowDown');
// Navigation inside the menu
// move to first option
Expand Down
29 changes: 29 additions & 0 deletions e2e/components/FluidDropdown/FluidDropdown-test.avt.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ test.describe('@avt FluidDropdown', () => {
await expect(toggleButton).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible();
// Expect focus to be on 1st item in menu after Arrow Down
// when there is no initial selected item
await expect(
page.getByRole('option', {
name: 'Lorem, ipsum dolor sit amet consectetur adipisicing elit.',
})
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--highlighted'
);
// Close with Escape, retain focus, and open with Space
await page.keyboard.press('Escape');
await page.keyboard.press('Space');
Expand All @@ -70,6 +79,16 @@ test.describe('@avt FluidDropdown', () => {
await expect(menu).toBeHidden();
await expect(toggleButton).toBeFocused();
await page.keyboard.press('Enter');
// Expect focus to be retained when no initial selected item after Enter
await expect(toggleButton).toBeFocused();
await expect(menu).toBeVisible();
// Select item from menu
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
// Open with Enter after item has been selected
await page.keyboard.press('Enter');
// Should focus on selected item by default
await expect(
page.getByRole('option', {
Expand All @@ -78,6 +97,16 @@ test.describe('@avt FluidDropdown', () => {
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// Should focus on selected item by default on Arrow Down as well
await page.keyboard.press('Escape');
await page.keyboard.press('ArrowDown');
await expect(
page.getByRole('option', {
name: 'Option 2',
})
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// Navigation inside the menu
await page.keyboard.press('ArrowDown');
await expect(
Expand Down
32 changes: 31 additions & 1 deletion e2e/components/MultiSelect/MultiSelect-test.avt.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ test.describe('@avt MultiSelect', () => {
const toggleButton = page.getByRole('combobox', {
expanded: false,
});
const toggleButtonExpanded = page.getByRole('combobox', {
expanded: true,
});
const selection = page.getByRole('button', {
name: 'Clear all selected items',
});
Expand All @@ -100,11 +103,22 @@ test.describe('@avt MultiSelect', () => {
await expect(toggleButton).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible();
// Expect focus to be on 1st item in menu after Arrow Down
// when there is no initial selected item
await expect(
page.getByRole('option', {
name: 'An example option that is really long to show what should be done to handle long text',
})
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--highlighted'
);
// Close with Escape, retain focus, and open with Enter
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await expect(toggleButton).toBeFocused();
await page.keyboard.press('Enter');
// Expect focus to be retained when no initial selected item after Enter
await expect(toggleButtonExpanded).toBeFocused();
await expect(menu).toBeVisible();
// Close with Escape, retain focus, and open with Spacebar
await page.keyboard.press('Escape');
Expand Down Expand Up @@ -143,7 +157,23 @@ test.describe('@avt MultiSelect', () => {
name: 'An example option that is really long to show what should be done to handle long text',
selected: true,
})
).toBeVisible();
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// Close with Escape, retain focus, and open with Arrow Down
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await expect(toggleButton).toBeFocused();
await page.keyboard.press('ArrowDown');
// On Arrow Down, selected item should be focused
await expect(
page.getByRole('option', {
name: 'An example option that is really long to show what should be done to handle long text',
selected: true,
})
).toHaveClass(
'cds--list-box__menu-item cds--list-box__menu-item--active cds--list-box__menu-item--highlighted'
);
// move to second option
await page.keyboard.press('ArrowDown');
await expect(
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,6 @@ const ComboBox = forwardRef(
containerClassName,
{
[`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && invalid,
[`${prefix}--list-box__wrapper--fluid--focus`]: isFluid && isFocused,
[`${prefix}--list-box__wrapper--slug`]: slug,
[`${prefix}--list-box__wrapper--decorator`]: decorator,
},
Expand Down
3 changes: 1 addition & 2 deletions packages/react/src/components/Dropdown/Dropdown.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,7 @@ export const Default = (args) => {
id="default"
titleText="Dropdown label"
helperText="This is some helper text"
initialSelectedItem={items[1]}
label="Option 1"
label="Choose an option"
items={items}
itemToString={(item) => (item ? item.text : '')}
{...args}
Expand Down
11 changes: 8 additions & 3 deletions packages/react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ const Dropdown = React.forwardRef(
[`${prefix}--dropdown--invalid`]: invalid,
[`${prefix}--dropdown--warning`]: showWarning,
[`${prefix}--dropdown--open`]: isOpen,
[`${prefix}--dropdown--focus`]: isFocused,
[`${prefix}--dropdown--inline`]: inline,
[`${prefix}--dropdown--disabled`]: disabled,
[`${prefix}--dropdown--light`]: light,
Expand Down Expand Up @@ -489,8 +490,6 @@ const Dropdown = React.forwardRef(
[`${prefix}--dropdown__wrapper--inline--invalid`]: inline && invalid,
[`${prefix}--list-box__wrapper--inline--invalid`]: inline && invalid,
[`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && invalid,
[`${prefix}--list-box__wrapper--fluid--focus`]:
isFluid && isFocused && !isOpen,
[`${prefix}--list-box__wrapper--slug`]: slug,
[`${prefix}--list-box__wrapper--decorator`]: decorator,
}
Expand Down Expand Up @@ -518,7 +517,7 @@ const Dropdown = React.forwardRef(
) : null;

const handleFocus = (evt: FocusEvent<HTMLDivElement>) => {
setIsFocused(evt.type === 'focus' ? true : false);
setIsFocused(evt.type === 'focus' && !selectedItem ? true : false);
};

const mergedRef = mergeRefs(toggleButtonProps.ref, ref);
Expand Down Expand Up @@ -548,6 +547,12 @@ const Dropdown = React.forwardRef(
}, 3000)
);
}
if (['ArrowDown'].includes(evt.key)) {
setIsFocused(false);
}
if (['Enter'].includes(evt.key) && !selectedItem && !isOpen) {
setIsFocused(true);
}
if (toggleButtonProps.onKeyDown) {
toggleButtonProps.onKeyDown(evt);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ const sharedArgTypes = {
export const Default = (args) => (
<div style={{ width: args.defaultWidth }}>
<FluidDropdown
initialSelectedItem={items[2]}
id="default"
titleText="Label"
label="Choose an option"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,6 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect<
[`${prefix}--list-box__wrapper--inline--invalid`]: inline && invalid,
[`${prefix}--list-box--up`]: direction === 'top',
[`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && invalid,
[`${prefix}--list-box__wrapper--fluid--focus`]: isFluid && isFocused,
[`${prefix}--list-box__wrapper--slug`]: slug,
[`${prefix}--list-box__wrapper--decorator`]: decorator,
[`${prefix}--autoalign`]: autoAlign,
Expand Down Expand Up @@ -610,6 +609,10 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect<
case InputKeyDownArrowDown:
if (InputKeyDownArrowDown === type && !isOpen) {
setIsOpen(true);
return {
...changes,
highlightedIndex: 0,
};
}
if (highlightedIndex > -1) {
const itemArray = document.querySelectorAll(
Expand Down
13 changes: 11 additions & 2 deletions packages/react/src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,20 @@ const MultiSelect = React.forwardRef(
setItemsCleared(false);
setIsOpenWrapper(true);
}
if (match(e, keys.ArrowDown) && selectedItems.length === 0) {
setInputFocused(false);
setIsFocused(false);
}
if (match(e, keys.Escape) && isOpen) {
setInputFocused(true);
}
if (match(e, keys.Enter) && isOpen) {
setInputFocused(true);
}
}
},
});

const mergedRef = mergeRefs(toggleButtonProps.ref, ref);

const selectedItems = selectedItem as ItemType[];
Expand Down Expand Up @@ -542,8 +553,6 @@ const MultiSelect = React.forwardRef(
inline && invalid,
[`${prefix}--list-box__wrapper--inline--invalid`]: inline && invalid,
[`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && invalid,
[`${prefix}--list-box__wrapper--fluid--focus`]:
!isOpen && isFluid && isFocused,
[`${prefix}--list-box__wrapper--slug`]: slug,
[`${prefix}--list-box__wrapper--decorator`]: decorator,
}
Expand Down
15 changes: 15 additions & 0 deletions packages/styles/scss/components/combo-box/_combo-box.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@
background-color: $field;
}

.#{$prefix}--combo-box
.#{$prefix}--list-box__menu-item:first-child.#{$prefix}--list-box__menu-item--highlighted::before {
position: absolute;
border: 2px solid $focus;
block-size: 100%;
border-block-start: 1px solid $focus;
content: '';
inline-size: 100%;
}

.#{$prefix}--combo-box
.#{$prefix}--list-box__menu-item:first-child.#{$prefix}--list-box__menu-item--highlighted {
@include focus-outline('reset');
}

// V11: Possibly deprecate
.#{$prefix}--combo-box.#{$prefix}--list-box--light:hover {
background-color: $field-02;
Expand Down
4 changes: 4 additions & 0 deletions packages/styles/scss/components/dropdown/_dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@
outline: none;
}

.#{$prefix}--dropdown--focus .#{$prefix}--list-box__field {
@include focus-outline('outline');
}

.#{$prefix}--dropdown--invalid {
@include focus-outline('invalid');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,6 @@
white-space: nowrap;
}

.#{$prefix}--list-box__wrapper--fluid.#{$prefix}--list-box__wrapper--fluid--focus
.#{$prefix}--combo-box.#{$prefix}--list-box--expanded:has(
input[aria-activedescendant]:not([aria-activedescendant=''])
)
.#{$prefix}--combo-box--input--focus.#{$prefix}--text-input {
outline-offset: convert.to-rem(-1px);
outline-width: convert.to-rem(1px);
}

.#{$prefix}--list-box__wrapper--fluid
.#{$prefix}--combo-box
.#{$prefix}--list-box__selection {
Expand Down
Loading

0 comments on commit eee39f8

Please sign in to comment.