Skip to content
Open
1 change: 0 additions & 1 deletion semcore/add-filter/__tests__/add-filter.browser-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,6 @@ test.describe(`${TAG.FUNCTIONAL}`, () => {

for (let i = 0; i < 6; i++) {
await page.keyboard.press('Tab');
await page.waitForTimeout(50);
}
await page.keyboard.press('Enter');
await locators.addFilterMenuItem(page, 'Keywords').waitFor({ state: 'visible' });
Expand Down
14 changes: 12 additions & 2 deletions semcore/add-filter/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { runDependencyCheckTests } from '@semcore/testing-utils/shared-tests';
import { render, fireEvent, cleanup, waitFor } from '@semcore/testing-utils/testing-library';
import { expect, test, describe, beforeEach } from '@semcore/testing-utils/vitest';
import { expect, test, describe, beforeEach, vi } from '@semcore/testing-utils/vitest';
import React from 'react';

import AddFilter from '../src';
Expand All @@ -10,7 +10,17 @@ describe('AddFilter Dependency imports', () => {
});

describe('AddFilter', () => {
beforeEach(cleanup);
beforeEach(() => {
cleanup();

const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});

test('should render two menuitems in dropdown with displayName as text', async () => {
const { queryByText, getByText } = render(
Expand Down
6 changes: 6 additions & 0 deletions semcore/dropdown-menu/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

CHANGELOG.md standards are inspired by [keepachangelog.com](https://keepachangelog.com/en/1.0.0/).

## [16.1.12] - 2025-10-24

### Fixed

- "scroll to element" animation for keyboard interactions.

## [16.1.11] - 2025-10-06

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,7 @@ test.describe('Sticky groups', () => {
await expect(items.nth(30)).not.toBeVisible();

await page.keyboard.press('Enter');
await page.waitForTimeout(400);
await items.nth(30).waitFor({ state: 'visible' });

await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 });
Expand Down
12 changes: 11 additions & 1 deletion semcore/dropdown-menu/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@ describe('dropdown-menu Dependency imports', () => {
});

describe('DropdownMenu', () => {
beforeEach(cleanup);
beforeEach(() => {
cleanup();

const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});

test.concurrent('Verify does not trigger visibility change on Space key in input', () => {
const spy = vi.fn();
Expand Down
64 changes: 45 additions & 19 deletions semcore/dropdown-menu/src/DropdownMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,31 +59,57 @@ class DropdownMenuRoot extends AbstractDropdown {
null,
(visible) => {
if (visible === true) {
setTimeout(() => {
const options = this.menuRef.current?.querySelectorAll(
'[role="menuitemcheckbox"], [role="menuitemradio"]',
);
const selected = this.menuRef.current?.querySelector('[aria-checked="true"]');

if (selected && options && this.asProps.itemsCount === undefined) {
this.scrollToNode(selected, true);

for (let i = 0; i < options.length; i++) {
if (options[i] === selected) {
this.handlers.highlightedIndex(i);
break;
}
}
}
// for some reason, Google Chrome optimizes this timeout with 0 value with previous render (when we set aria-selected)
// and that's why its skip scrollToNodes. We selected the appropriate timeout manually.
}, 30);
requestAnimationFrame(() => {
this.focusAndScrollToSelected();
});
}
},
],
};
}

get menuElements() {
const menuElement = this.menuRef.current;

if (!menuElement) {
return { selected: null, options: null };
}

const options = menuElement.querySelectorAll(
'[role="menuitemcheckbox"], [role="menuitemradio"]',
);
const selected = menuElement.querySelector('[aria-checked="true"]');

return { selected, options };
}

focusAndScrollToSelected() {
const { selected, options } = this.menuElements;

if (!selected || !options || this.asProps.itemsCount !== undefined) return;

this.scrollToNodeAsync(selected, true).then(() => {
if (lastInteraction.isKeyboard()) {
selected.focus();
}
});

const selectedIndex = Array.from(options).indexOf(selected);

if (selectedIndex !== -1) {
this.handlers.highlightedIndex(selectedIndex);
}
}

afterOpenPopper() {
const { selected, options } = this.menuElements;

// this case is handled slightly differently on line 63.
if (selected && options && this.asProps.itemsCount === undefined) return;

super.afterOpenPopper();
}

itemRef(props, index, node) {
super.itemRef(props, index, node);

Expand Down
6 changes: 6 additions & 0 deletions semcore/dropdown/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

CHANGELOG.md standards are inspired by [keepachangelog.com](https://keepachangelog.com/en/1.0.0/).

## [16.1.1] - 2025-10-24

### Added

- Foundation to handle scroll ending.

## [16.1.0] - 2025-10-03

### Changed
Expand Down
8 changes: 8 additions & 0 deletions semcore/dropdown/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ describe('dropdown Dependency imports', () => {
describe('Dropdown', () => {
beforeEach(() => {
cleanup();

const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});

test('Verify not open popper by keyboard enter if interaction none', async ({ expect }) => {
Expand Down
89 changes: 80 additions & 9 deletions semcore/dropdown/src/AbstractDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
itemProps: any[] = [];
itemRefs: HTMLElement[] = [];

highlightedItemRef = React.createRef<HTMLElement>();
highlightedItem: HTMLElement | null = null;

prevHighlightedIndex: number | null = null;

scrollTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
scrollResolve: (() => void) | null = () => {};
scrollObserver: IntersectionObserver | null = null;

uncontrolledProps() {
return {
selectedIndex: null,
Expand All @@ -55,6 +59,15 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
};
}

componentDidMount() {
this.setupObserver();
}

componentWillUnmount() {
this.cleanupScroll();
this.scrollObserver?.disconnect();
}

get childRole() {
if (this.role === 'listbox') {
return 'option';
Expand Down Expand Up @@ -157,10 +170,9 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
};
}

scrollToNode(node: Element | null, withAnimation = false) {
scrollToNode(node: HTMLElement | null, withAnimation = false) {
if (node) {
// @ts-ignore
this.highlightedItemRef.current = node;
this.highlightedItem = node;
}
setTimeout(() => {
if (node?.scrollIntoView) {
Expand All @@ -176,6 +188,66 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
}, 0);
}

setupObserver() {
this.scrollObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.1) {
if (this.scrollResolve) {
this.scrollResolve();
}
}
});
},
{
threshold: [0.1],
},
);
}

cleanupScroll() {
clearTimeout(this.scrollTimeoutId);
if (this.highlightedItem) {
this.scrollObserver?.unobserve(this.highlightedItem);
}
this.scrollResolve = null;
}

scrollToNodeAsync(node: HTMLElement | null, withAnimation = false) {
return new Promise<void>((resolve) => {
this.cleanupScroll();

if (!node) {
resolve();
return;
}

this.highlightedItem = node;

this.scrollTimeoutId = setTimeout(() => {
this.cleanupScroll();
resolve();
}, 3000);

this.scrollResolve = () => {
clearTimeout(this.scrollTimeoutId);
this.scrollObserver?.unobserve(node);
this.scrollResolve = null;
resolve();
};

this.scrollObserver?.observe(node);

requestAnimationFrame(() => {
node.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: withAnimation ? 'smooth' : 'instant',
});
});
});
}

getHighlightedIndex(amount: number): number {
const { highlightedIndex, itemsCount } = this.asProps;
const itemsLastIndex = (itemsCount ?? this.itemProps.length) - 1;
Expand All @@ -188,7 +260,7 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
if (highlightedIndex == null) {
if (selectedIndex !== -1) {
innerHighlightedIndex = selectedIndex;
} else if (this.highlightedItemRef.current && this.prevHighlightedIndex !== null) {
} else if (this.highlightedItem && this.prevHighlightedIndex !== null) {
innerHighlightedIndex =
this.prevHighlightedIndex > itemsLastIndex ? itemsLastIndex : this.prevHighlightedIndex;
} else {
Expand Down Expand Up @@ -221,8 +293,7 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
if (visibilityChanged && !visible) {
this.handlers.highlightedIndex(this.props.defaultHighlightedIndex);
this.prevHighlightedIndex = null;
// @ts-ignore
this.highlightedItemRef.current = null;
this.highlightedItem = null;
this.itemProps = [];
this.itemRefs = [];
if (
Expand Down Expand Up @@ -321,13 +392,13 @@ export abstract class AbstractDropdown extends Component<AbstractDDProps, {}, {}
case ' ':
case 'Enter':
if (
this.highlightedItemRef.current &&
this.highlightedItem &&
highlightedIndex !== null &&
!this.itemProps[highlightedIndex].disabled
) {
e.stopPropagation();
e.preventDefault();
this.highlightedItemRef.current.click();
this.highlightedItem.click();
}

break;
Expand Down
12 changes: 11 additions & 1 deletion semcore/select/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@ describe('select Dependency imports', () => {
HTMLElement.prototype.scrollIntoView = () => {};

describe('Select Trigger', () => {
beforeEach(cleanup);
beforeEach(() => {
cleanup();

const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});

test.concurrent(
'Verify popper not opened by keyboard if interaction is none',
Expand Down
Loading