Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
105 changes: 105 additions & 0 deletions semcore/dropdown-menu/__tests__/dropdown-menu.browser-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,111 @@ test.describe(`${TAG.FUNCTIONAL}`, () => {
await test.step('Verify trigger is still focused', async () => {
await expect(locators.button(page)).toBeFocused();
});

await test.step('Verify arrows do not focus disabled items', async () => {
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowDown');
const count = await locators.menuitemradio(page).count();
for (let i = 0; i < count; i++) {
await expect(locators.menuitemradio(page, i)).not.toBeFocused();
}
await expect(locators.button(page)).toBeFocused();
});
});

test('Verify ArrowUp reaches all enabled selectable items when first items are disabled', {
tag: [TAG.PRIORITY_HIGH,
TAG.KEYBOARD,
'@dropdown-menu'],
}, async ({ page }) => {
await loadPage(page, 'stories/components/dropdown-menu/tests/examples/selectable-props.tsx', 'en', {
size: 'm',
disabledFirstItem: true,
disabledSecondItem: true,
});

await test.step('Verify opens by Tab and Enter and skips disabled first items', async () => {
await page.keyboard.press('Tab');
await expect(locators.button(page)).toBeFocused();
await page.keyboard.press('Enter');
await locators.menuitemradio(page, 0).waitFor({ state: 'visible' });
await expect(locators.menuitemradio(page, 0)).not.toBeFocused();
await expect(locators.menuitemradio(page, 1)).not.toBeFocused();
await expect(locators.menuitemradio(page, 2)).toBeFocused();
await expect(locators.itemInGroup(page).nth(2)).toHaveClass(/highlighted/);
});

await test.step('Verify ArrowUp wraps to the last enabled item', async () => {
await page.keyboard.press('ArrowUp');
await expect(locators.menuitemradio(page, 0)).not.toBeFocused();
await expect(locators.menuitemradio(page, 1)).not.toBeFocused();
await expect(locators.menuitemradio(page, 9)).toBeFocused();
await expect(locators.itemInGroup(page).nth(9)).toHaveClass(/highlighted/);
});

await test.step('Verify Enter activates the highlighted enabled item', async () => {
await page.keyboard.press('Enter');
await locators.menuitemradio(page, 0).waitFor({ state: 'hidden' });
await expect(locators.button(page)).toBeFocused();

await page.keyboard.press('Enter');
await locators.menuitemradio(page, 0).waitFor({ state: 'visible' });
await expect(locators.menuitemradio(page, 9)).toHaveAttribute('aria-checked', 'true');
await expect(locators.menuitemradio(page, 9)).toBeFocused();
await expect(locators.itemInGroup(page).nth(9)).toHaveClass(/highlighted/);
});

await test.step('Verify repeated ArrowUp reaches all enabled items and skips disabled first items', async () => {
for (const index of [8, 7, 6, 5, 4, 3, 2]) {
await page.keyboard.press('ArrowUp');
await expect(locators.menuitemradio(page, index)).toBeFocused();
await expect(locators.itemInGroup(page).nth(index)).toHaveClass(/highlighted/);
await expect(locators.menuitemradio(page, 0)).not.toBeFocused();
await expect(locators.menuitemradio(page, 1)).not.toBeFocused();
await expect(locators.itemInGroup(page).nth(0)).not.toHaveClass(/highlighted/);
await expect(locators.itemInGroup(page).nth(1)).not.toHaveClass(/highlighted/);
}

await page.keyboard.press('ArrowUp');
await expect(locators.menuitemradio(page, 9)).toBeFocused();
await expect(locators.itemInGroup(page).nth(9)).toHaveClass(/highlighted/);
});
});

test('Verify arrows skip disabled last selectable item', {
tag: [TAG.PRIORITY_HIGH,
TAG.KEYBOARD,
'@dropdown-menu'],
}, async ({ page }) => {
await loadPage(page, 'stories/components/dropdown-menu/tests/examples/selectable-props.tsx', 'en', {
size: 'm',
disabledLastItem: true,
});

await test.step('Verify opens by Tab and Enter and focuses first item', async () => {
await page.keyboard.press('Tab');
await expect(locators.button(page)).toBeFocused();
await page.keyboard.press('Enter');
await locators.menuitemradio(page, 0).waitFor({ state: 'visible' });
await expect(locators.menuitemradio(page, 0)).toBeFocused();
await expect(locators.itemInGroup(page).nth(0)).toHaveClass(/highlighted/);
});

await test.step('Verify ArrowUp skips disabled last item', async () => {
await page.keyboard.press('ArrowUp');
await expect(locators.menuitemradio(page, 9)).not.toBeFocused();
await expect(locators.itemInGroup(page).nth(9)).not.toHaveClass(/highlighted/);
await expect(locators.menuitemradio(page, 8)).toBeFocused();
await expect(locators.itemInGroup(page).nth(8)).toHaveClass(/highlighted/);
});

await test.step('Verify ArrowDown skips disabled last item and wraps to first item', async () => {
await page.keyboard.press('ArrowDown');
await expect(locators.menuitemradio(page, 9)).not.toBeFocused();
await expect(locators.itemInGroup(page).nth(9)).not.toHaveClass(/highlighted/);
await expect(locators.menuitemradio(page, 0)).toBeFocused();
await expect(locators.itemInGroup(page).nth(0)).toHaveClass(/highlighted/);
});
});

test('Verify focus skips first disabled item in nested menu', {
Expand Down
19 changes: 13 additions & 6 deletions semcore/dropdown/src/AbstractDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,16 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, typeof
});
}

getHighlightedIndex(amount: number): number {
const { highlightedIndex, itemsCount } = this.asProps;
getHighlightedIndex(amount: number, currentHighlightedIndex: number | null): number {
const { itemsCount } = this.asProps;
const itemsLastIndex = (itemsCount ?? this.itemProps.length) - 1;
const selectedIndex = this.itemProps.findIndex((item) => item?.selected);

if (itemsLastIndex < 0) return -1;

let innerHighlightedIndex: number;

if (highlightedIndex == null) {
if (currentHighlightedIndex === null) {
if (selectedIndex !== -1) {
innerHighlightedIndex = selectedIndex;
} else if (this.highlightedItem && this.prevHighlightedIndex !== null) {
Expand All @@ -285,7 +285,7 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, typeof
innerHighlightedIndex = amount < 0 ? 0 : itemsLastIndex;
}
} else {
innerHighlightedIndex = highlightedIndex > itemsLastIndex ? itemsLastIndex : highlightedIndex;
innerHighlightedIndex = currentHighlightedIndex > itemsLastIndex ? itemsLastIndex : currentHighlightedIndex;
}

let newIndex = innerHighlightedIndex + amount;
Expand All @@ -296,7 +296,14 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, typeof
}

if (this.itemProps[newIndex]?.disabled) {
return this.getHighlightedIndex(amount < 0 ? amount - 1 : amount + 1);
if (currentHighlightedIndex !== null && newIndex === 0 && amount < 0) {
return this.getHighlightedIndex(-1, currentHighlightedIndex - 1);
}
if (currentHighlightedIndex !== null && newIndex === itemsLastIndex && amount > 0) {
return this.getHighlightedIndex(1, currentHighlightedIndex + 1);
}

return this.getHighlightedIndex(amount < 0 ? amount - 1 : amount + 1, currentHighlightedIndex);
} else if (!this.itemProps[newIndex]) {
return -1;
} else {
Expand Down Expand Up @@ -427,7 +434,7 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, typeof
}

if (visible && amount !== null) {
const newHighlightedIndex = this.getHighlightedIndex(amount);
const newHighlightedIndex = this.getHighlightedIndex(amount, highlightedIndex);

if (
this.role === 'listbox' &&
Expand Down
62 changes: 62 additions & 0 deletions semcore/select/__tests__/select.browser-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,68 @@ test.describe(`${TAG.FUNCTIONAL} `, () => {
});
});

test('Verify ArrowUp reaches all enabled options when first options are disabled', {
tag: [TAG.PRIORITY_HIGH, TAG.KEYBOARD, '@select'],
}, async ({ page }) => {
await loadPage(page, 'stories/components/select/tests/examples/options_checkbox_group_and_hint.tsx', 'en', {
option1Disabled: true,
option2Disabled: true,
});

await test.step('Verify opens by Tab and Enter without highlighting disabled leading options', async () => {
await page.keyboard.press('Tab');
await expect(locators.selectTrigger(page)).toBeFocused();
await page.keyboard.press('Enter');
await locators.options(page).first().waitFor({ state: 'visible' });

await expect(locators.options(page).nth(0)).toHaveAttribute('aria-disabled', 'true');
await expect(locators.options(page).nth(1)).toHaveAttribute('aria-disabled', 'true');
await expect(locators.options(page).nth(0)).not.toHaveClass(/highlighted/);
await expect(locators.options(page).nth(1)).not.toHaveClass(/highlighted/);
await expect(locators.selectTrigger(page)).toBeFocused();
});

await test.step('Verify ArrowUp wraps to the last enabled option', async () => {
await page.keyboard.press('ArrowUp');
const option = locators.options(page).nth(5);
const optionId = await option.getAttribute('id');
if (optionId === null) throw new Error('Last option should have an id');
await expect(option).toHaveClass(/highlighted/);
await expect(locators.selectTrigger(page)).toHaveAttribute('aria-activedescendant', optionId);
});

await test.step('Verify repeated ArrowUp reaches options with group, checkbox, and hint content', async () => {
for (const index of [4, 3, 2]) {
await page.keyboard.press('ArrowUp');
const option = locators.options(page).nth(index);
const optionId = await option.getAttribute('id');
if (optionId === null) throw new Error(`Option ${index + 1} should have an id`);
await expect(option).toHaveClass(/highlighted/);
await expect(locators.selectTrigger(page)).toHaveAttribute('aria-activedescendant', optionId);
await expect(locators.options(page).nth(0)).not.toHaveClass(/highlighted/);
await expect(locators.options(page).nth(1)).not.toHaveClass(/highlighted/);
}

await expect(locators.optionHint(page)).toBeVisible();
});

await test.step('Verify ArrowUp and ArrowDown wrap past disabled leading options', async () => {
await page.keyboard.press('ArrowUp');
const lastOption = locators.options(page).nth(5);
const lastOptionId = await lastOption.getAttribute('id');
if (lastOptionId === null) throw new Error('Last option should have an id');
await expect(lastOption).toHaveClass(/highlighted/);
await expect(locators.selectTrigger(page)).toHaveAttribute('aria-activedescendant', lastOptionId);

await page.keyboard.press('ArrowDown');
const firstEnabledOption = locators.options(page).nth(2);
const firstEnabledOptionId = await firstEnabledOption.getAttribute('id');
if (firstEnabledOptionId === null) throw new Error('First enabled option should have an id');
await expect(firstEnabledOption).toHaveClass(/highlighted/);
await expect(locators.selectTrigger(page)).toHaveAttribute('aria-activedescendant', firstEnabledOptionId);
});
});

test('Verify custom selected label', {
tag: [TAG.PRIORITY_MEDIUM, '@select'],
}, async ({ page }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export const SelectableProps: StoryObj<typeof defaultDropDownSelectablePropsExam
disabledFirstItem: {
control: { type: 'boolean' },
},
disabledSecondItem: {
control: { type: 'boolean' },
},
disabledLastItem: {
control: { type: 'boolean' },
},
},
args: defaultDropDownSelectablePropsExample,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const menuItems: null[] = new Array(10).fill(null);
type DropDownPropsExample = DropdownMenuProps & DropdownMenuListProps & {
disabledAll?: boolean;
disabledFirstItem?: boolean;
disabledSecondItem?: boolean;
disabledLastItem?: boolean;
};
const Demo = (props: DropDownPropsExample) => {
const [selected, setSelected] = React.useState<number>(0);
Expand All @@ -24,7 +26,12 @@ const Demo = (props: DropDownPropsExample) => {
{menuItems.map((_, index) => (
<DropdownMenu.Item
size={props.size}
disabled={props.disabledAll || (index === 0 && props.disabledFirstItem)}
disabled={
props.disabledAll ||
(index === 0 && props.disabledFirstItem) ||
(index === 1 && props.disabledSecondItem) ||
(index === menuItems.length - 1 && props.disabledLastItem)
}
key={index}
selected={index === selected}
onClick={() => {
Expand Down Expand Up @@ -62,6 +69,8 @@ export const defaultDropDownSelectablePropsExample: DropDownPropsExample = {
size: 'm',
disabledAll: false,
disabledFirstItem: false,
disabledSecondItem: false,
disabledLastItem: false,
visible: undefined,
stretch: undefined,
disablePortal: undefined,
Expand Down
12 changes: 6 additions & 6 deletions stories/components/select/tests/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,13 @@ export const OptionsCheckboxGroupAndHint: StoryObj<typeof OptionsProps> = {
disablePortal: { control: { type: 'boolean' } },

// Option 1 - Default option (SelectOptionProps)
option1Value: { control: { type: 'text' } },
option1Value: { control: { type: 'number' } },
option1Text: { control: { type: 'text' } },
option1Disabled: { control: { type: 'boolean' } },
option1Selected: { control: { type: 'boolean' } },

// Option 2 - Checkbox option (SelectOptionProps + SelectOptionCheckboxProps)
option2Value: { control: { type: 'text' } },
option2Value: { control: { type: 'number' } },
option2Text: { control: { type: 'text' } },
option2Disabled: { control: { type: 'boolean' } },
option2Selected: { control: { type: 'boolean' } },
Expand All @@ -157,7 +157,7 @@ export const OptionsCheckboxGroupAndHint: StoryObj<typeof OptionsProps> = {
option2CheckboxIndeterminate: { control: { type: 'boolean' } },

// Option 3 - Option with hint (SelectOptionProps + SelectOptionCheckboxProps + Hint)
option3Value: { control: { type: 'text' } },
option3Value: { control: { type: 'number' } },
option3Text: { control: { type: 'text' } },
option3Disabled: { control: { type: 'boolean' } },
option3Selected: { control: { type: 'boolean' } },
Expand All @@ -166,7 +166,7 @@ export const OptionsCheckboxGroupAndHint: StoryObj<typeof OptionsProps> = {
option3HintText: { control: { type: 'text' } },

// Option 4 - Simple option (SelectOptionProps)
option4Value: { control: { type: 'text' } },
option4Value: { control: { type: 'number' } },
option4Text: { control: { type: 'text' } },
option4Disabled: { control: { type: 'boolean' } },
option4Selected: { control: { type: 'boolean' } },
Expand All @@ -175,9 +175,9 @@ export const OptionsCheckboxGroupAndHint: StoryObj<typeof OptionsProps> = {
showGroup: { control: { type: 'boolean' } },
groupTitle: { control: { type: 'text' } },
groupSubTitle: { control: { type: 'text' } },
groupOption1Value: { control: { type: 'text' } },
groupOption1Value: { control: { type: 'number' } },
groupOption1Text: { control: { type: 'text' } },
groupOption2Value: { control: { type: 'text' } },
groupOption2Value: { control: { type: 'number' } },
groupOption2Text: { control: { type: 'text' } },

// Bulk controls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,19 @@ export const defaultProps: SelectAdvancedConfigProps = {
option3CheckboxIndeterminate: true,
option3HintText: 'This is a hint for the option',

// Option 4
option4Value: 4,
option4Text: 'Simple option',
option4Disabled: false,
option4Selected: false,

// Group
showGroup: true,
groupTitle: 'Group title',
groupSubTitle: 'Group subtitle',
groupOption1Value: 4,
groupOption1Value: 5,
groupOption1Text: '1st option in group',
groupOption2Value: 5,
groupOption2Value: 6,
groupOption2Text: '2nd option in group',
};

Expand Down
Loading