(null);
+ const [resizeRef] = useResizeObserver(setSize);
+
+ const ref = useMergedRefs(resizeRef, forwardedRef);
+
+ const key =
+ `${itemsCount}` +
+ `${overflowOrientation === 'vertical' ? size?.height : size?.width}`;
+
+ return (
+
+ );
+}) as PolymorphicForwardRefComponent<'div', OverflowContainerProps>;
+
+// ----------------------------------------------------------------------------
+
/**
+ * @private
* Wrapper over `useOverflow`.
*
* - Use `OverflowContainer.useContext()` to get overflow related properties.
@@ -96,6 +160,272 @@ if (process.env.NODE_ENV === 'development') {
OverflowContainerContext.displayName = 'OverflowContainerContext';
}
+/**
+ * @private
+ * Hook that returns the number of items that should be visible based on the size of the container element.
+ *
+ * The returned number should be used to render the element with fewer items.
+ *
+ * @param itemsCount Number of items that this element contains.
+ * @param orientation 'horizontal' (default) or 'vertical'
+ * @returns [callback ref to set on container, stateful count of visible items]
+ *
+ * @example
+ * const items = Array(10).fill().map((_, i) => Item {i});
+ * const [ref, visibleCount] = useOverflow(items.count);
+ * ...
+ * return (
+ *
+ * {items.slice(0, visibleCount)}
+ *
+ * );
+ */
+const useOverflow = (
+ itemsCount: number,
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
+) => {
+ const [guessState, dispatch] = React.useReducer(
+ overflowGuessReducer,
+ { itemsCount },
+ overflowGuessReducerInitialState,
+ );
+
+ const { minGuess, maxGuess, isStabilized, visibleCount } = guessState;
+
+ const containerRef = React.useRef(null);
+ const isGuessing = React.useRef(false);
+
+ /**
+ * Call this function to guess the new `visibleCount`.
+ * The `visibleCount` is not changed if the correct `visibleCount` has already been found.
+ *
+ * The logic of finding the correct `visibleCount` is similar to binary search.
+ * Logic (without all edge cases):
+ * - Have a guess range for `visibleCount` of `(0, x]` (0 is exclusive and x is inclusive)
+ * - 0 is exclusive as the minimum `visibleItems` always has to be 1.
+ * - The only exception is when the `itemsCount` is itself 0.
+ * - x can be an any arbitrary number ≤ `itemsCount`.
+ * - Initial `visibleCount` = max guess.
+ * - We NEED an overflow in the beginning for the algorithm to work.
+ * - Because the max guess should always be `≥` the correct `visibleCount`.
+ * - So, if not overflow, shift the guess range forward by:
+ * - doubling the max guess: since we need to overflow
+ * - setting min guess to current visibleCount: since not overflow means correct visibleCount ≥ current visibleCount
+ * - setting visible count to the new max guess
+ * - Shift the guess range forward repeatedly until the container overflows.
+ * - After the first overflow, `visibleCount` = average of the two guesses.
+ * - Repeat the following (`guessVisibleCount()`):
+ * - If container overflows, new max guess = current `visibleCount`.
+ * - If container does not overflow, new min guess = current `visibleCount`.
+ * - new `visibleCount` = the average of the new min and max guesses.
+ * - Stop when the average of the two guesses is the min guess itself. i.e. no more averaging possible.
+ * - The min guess is then the correct `visibleCount`.
+ */
+ const guessVisibleCount = React.useCallback(() => {
+ // If already stabilized, already guessing, or in unit test, do not guess.
+ if (isStabilized || isGuessing.current || isUnitTest) {
+ return;
+ }
+
+ try {
+ isGuessing.current = true;
+
+ // We need to wait for the ref to be attached so that we can measure available and required sizes.
+ if (containerRef.current == null) {
+ return;
+ }
+
+ const dimension = orientation === 'horizontal' ? 'Width' : 'Height';
+ const availableSize = containerRef.current[`offset${dimension}`];
+ const requiredSize = containerRef.current[`scroll${dimension}`];
+
+ const isOverflowing = availableSize < requiredSize;
+
+ if (
+ // there are no items
+ itemsCount === 0 ||
+ // overflowing when even 1 item is present
+ (visibleCount === 1 && isOverflowing) ||
+ // no overflow when rendering all items
+ (visibleCount === itemsCount && !isOverflowing) ||
+ // if the new average of the guess range will never change the visibleCount anymore (infinite loop)
+ (maxGuess - minGuess === 1 && visibleCount === minGuess)
+ ) {
+ dispatch({ type: 'stabilize' });
+ return;
+ }
+
+ // Before the main logic, the max guess MUST be ≥ the correct visibleCount for the algorithm to work.
+ // If not, should shift the guess range forward to induce the first overflow.
+ if (maxGuess === visibleCount && !isOverflowing) {
+ dispatch({ type: 'shiftGuessRangeForward' });
+ return;
+ }
+
+ if (isOverflowing) {
+ // overflowing = we guessed too high. So, decrease max guess
+ dispatch({ type: 'decreaseMaxGuess' });
+ } else {
+ // not overflowing = maybe we guessed too low? So, increase min guess
+ dispatch({ type: 'increaseMinGuess' });
+ }
+ } finally {
+ isGuessing.current = false;
+ }
+ }, [isStabilized, itemsCount, maxGuess, minGuess, orientation, visibleCount]);
+
+ // Guess the visible count until stabilized.
+ // To prevent flicking, use useLayoutEffect to paint only after stabilized.
+ useLayoutEffect(() => {
+ if (!isStabilized) {
+ guessVisibleCount();
+ }
+ }, [guessVisibleCount, isStabilized]);
+
+ return [containerRef, visibleCount] as const;
+};
+
+// ----------------------------------------------------------------------------
+
+type GuessState = (
+ | {
+ isStabilized: true;
+ minGuess: null;
+ maxGuess: null;
+ }
+ | {
+ isStabilized: false;
+ minGuess: number;
+ maxGuess: number;
+ }
+) & {
+ itemsCount: number;
+ visibleCount: number;
+};
+
+type GuessAction =
+ | {
+ /**
+ * - `"decreaseMaxGuess"`: When overflowing, do the following:
+ * - New `maxGuess` = current `visibleCount`.
+ * - New `visibleCount` = average of the `minGuess` and new `maxGuess`.
+ */
+ type: 'decreaseMaxGuess';
+ }
+ | {
+ /**
+ * - `"increaseMinGuess"`: When not overflowing, do the following:
+ * - New `minGuess` = current `visibleCount`.
+ * - New `visibleCount` = average of the `maxGuess` and new `minGuess`.
+ */
+ type: 'increaseMinGuess';
+ }
+ | {
+ /**
+ * - `"shiftGuessRangeForward"`: Useful to induce the first overflow to start guessing.
+ * - Shift the guess range forward by:
+ * - doubling the max guess: since we need to overflow
+ * - setting min guess to current visibleCount: since underflow means correct visibleCount ≥ current
+ * visibleCount
+ * - setting visible count to the new max guess
+ */
+ type: 'shiftGuessRangeForward';
+ }
+ | {
+ /**
+ * - `"stabilize"`: Stop guessing as `visibleCount` is the correct value.
+ */
+ type: 'stabilize';
+ };
+
+/** Arbitrary initial max guess for `visibleCount`. We refine this max guess with subsequent renders. */
+const STARTING_MAX_ITEMS_COUNT = 32;
+
+const overflowGuessReducerInitialState = ({
+ itemsCount,
+}: Pick): GuessState => {
+ const initialVisibleCount = Math.min(itemsCount, STARTING_MAX_ITEMS_COUNT);
+
+ // In unit test environments, always show all items
+ return isUnitTest
+ ? {
+ isStabilized: true,
+ minGuess: null,
+ maxGuess: null,
+ itemsCount,
+ visibleCount: itemsCount,
+ }
+ : {
+ isStabilized: false,
+ minGuess: 0,
+ maxGuess: initialVisibleCount,
+ itemsCount,
+ visibleCount: initialVisibleCount,
+ };
+};
+
+const overflowGuessReducer = (
+ state: GuessState,
+ action: GuessAction,
+): GuessState => {
+ /** Ensure that the visibleCount is always ≤ itemsCount */
+ const getSafeVisibleCount = (visibleCount: number) =>
+ Math.min(state.itemsCount, visibleCount);
+
+ switch (action.type) {
+ case 'decreaseMaxGuess':
+ case 'increaseMinGuess':
+ if (state.isStabilized) {
+ return state;
+ }
+
+ let newMinGuess = state.minGuess;
+ let newMaxGuess = state.maxGuess;
+
+ if (action.type === 'decreaseMaxGuess') {
+ newMaxGuess = state.visibleCount;
+ } else {
+ newMinGuess = state.visibleCount;
+ }
+
+ // Next guess is always the middle of the new guess range
+ const newVisibleCount = Math.floor((newMinGuess + newMaxGuess) / 2);
+
+ return {
+ ...state,
+ isStabilized: false,
+ minGuess: newMinGuess,
+ maxGuess: newMaxGuess,
+ visibleCount: getSafeVisibleCount(newVisibleCount),
+ };
+ case 'shiftGuessRangeForward':
+ if (state.isStabilized) {
+ return state;
+ }
+
+ const doubleOfMaxGuess = state.maxGuess * 2;
+
+ return {
+ ...state,
+ isStabilized: false,
+ minGuess: state.maxGuess,
+ maxGuess: doubleOfMaxGuess,
+ visibleCount: getSafeVisibleCount(doubleOfMaxGuess),
+ };
+ case 'stabilize':
+ return {
+ ...state,
+ isStabilized: true,
+ minGuess: null,
+ maxGuess: null,
+ };
+ default:
+ return state;
+ }
+};
+
+// ----------------------------------------------------------------------------
+
function useOverflowContainerContext() {
const overflowContainerContext = useSafeContext(OverflowContainerContext);
return overflowContainerContext;
diff --git a/packages/itwinui-react/src/utils/hooks/index.ts b/packages/itwinui-react/src/utils/hooks/index.ts
index 03d8878ed1d..c7b890b917b 100644
--- a/packages/itwinui-react/src/utils/hooks/index.ts
+++ b/packages/itwinui-react/src/utils/hooks/index.ts
@@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
export * from './useEventListener.js';
export * from './useMergedRefs.js';
-export * from './useOverflow.js';
export * from './useResizeObserver.js';
export * from './useContainerWidth.js';
export * from './useGlobals.js';
diff --git a/packages/itwinui-react/src/utils/hooks/useOverflow.test.tsx b/packages/itwinui-react/src/utils/hooks/useOverflow.test.tsx
deleted file mode 100644
index 9b67c5dae48..00000000000
--- a/packages/itwinui-react/src/utils/hooks/useOverflow.test.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Bentley Systems, Incorporated. All rights reserved.
- * See LICENSE.md in the project root for license terms and full copyright notice.
- *--------------------------------------------------------------------------------------------*/
-import { act, render, waitFor } from '@testing-library/react';
-import * as React from 'react';
-import { useOverflow } from './useOverflow.js';
-import * as UseResizeObserver from './useResizeObserver.js';
-
-const MockComponent = ({
- children,
- disableOverflow = false,
- orientation = 'horizontal',
-}: {
- children: React.ReactNode[] | string;
- disableOverflow?: boolean;
- orientation?: 'horizontal' | 'vertical';
-}) => {
- const [overflowRef, visibleCount] = useOverflow(
- children.length,
- disableOverflow,
- orientation,
- );
- return {children.slice(0, visibleCount)}
;
-};
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-it.each(['horizontal', 'vertical'] as const)(
- 'should overflow when there is not enough space (%s)',
- async (orientation) => {
- const dimension = orientation === 'horizontal' ? 'Width' : 'Height';
- vi.spyOn(HTMLDivElement.prototype, `scroll${dimension}`, 'get')
- .mockReturnValueOnce(120)
- .mockReturnValue(100);
- vi.spyOn(
- HTMLDivElement.prototype,
- `offset${dimension}`,
- 'get',
- ).mockReturnValue(100);
- vi.spyOn(
- HTMLSpanElement.prototype,
- `offset${dimension}`,
- 'get',
- ).mockReturnValue(25);
-
- const { container } = render(
-
- {[...Array(5)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- await waitFor(() => {
- expect(container.querySelectorAll('span')).toHaveLength(4);
- });
- },
-);
-
-it('should overflow when there is not enough space (string)', async () => {
- vi.spyOn(HTMLDivElement.prototype, 'scrollWidth', 'get')
- .mockReturnValueOnce(50)
- .mockReturnValue(28);
- vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(28);
-
- // 20 symbols (default value taken), 50 width
- // avg 2.5px per symbol
- const { container } = render(
- This is a very long text.,
- );
-
- // have 28px of a place
- // 11 symbols can fit
- await waitFor(() => {
- expect(container.textContent).toBe('This is a v');
- });
-});
-
-it('should overflow when there is not enough space but container fits 30 items', async () => {
- vi.spyOn(HTMLDivElement.prototype, 'scrollWidth', 'get')
- .mockReturnValueOnce(300)
- .mockReturnValueOnce(600)
- .mockReturnValue(300);
- vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(300);
- vi.spyOn(HTMLSpanElement.prototype, 'offsetWidth', 'get').mockReturnValue(10);
-
- const { container } = render(
-
- {[...Array(100)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- await waitFor(() => {
- expect(container.querySelectorAll('span')).toHaveLength(30);
- });
-});
-
-it('should restore hidden items when space is available again', async () => {
- let onResizeFn: (size: DOMRectReadOnly) => void = vi.fn();
- vi.spyOn(UseResizeObserver, 'useResizeObserver').mockImplementation(
- (onResize) => {
- onResizeFn = onResize;
- return [vi.fn(), { disconnect: vi.fn() } as unknown as ResizeObserver];
- },
- );
- const scrollWidthSpy = vi
- .spyOn(HTMLDivElement.prototype, 'scrollWidth', 'get')
- .mockReturnValueOnce(120)
- .mockReturnValue(100);
- const offsetWidthSpy = vi
- .spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get')
- .mockReturnValue(100);
- vi.spyOn(HTMLSpanElement.prototype, 'offsetWidth', 'get').mockReturnValue(25);
-
- const { container, rerender } = render(
-
- {[...Array(5)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- await waitFor(() => {
- expect(container.querySelectorAll('span')).toHaveLength(4);
- });
-
- scrollWidthSpy.mockReturnValue(125);
- offsetWidthSpy.mockReturnValue(125);
- rerender(
-
- {[...Array(5)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- act(() => onResizeFn({ width: 125 } as DOMRectReadOnly));
-
- await waitFor(() => {
- expect(container.querySelectorAll('span')).toHaveLength(5);
- });
-});
-
-it('should not overflow when disabled', () => {
- vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
- .mockReturnValueOnce(120)
- .mockReturnValue(100);
- vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(100);
-
- const { container } = render(
-
- {[...Array(50)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- expect(container.querySelectorAll('span')).toHaveLength(50);
-});
-
-it('should hide items and then show them all when overflow is disabled', async () => {
- vi.spyOn(HTMLDivElement.prototype, 'scrollWidth', 'get')
- .mockReturnValueOnce(300)
- .mockReturnValueOnce(600)
- .mockReturnValue(300);
- vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(300);
- vi.spyOn(HTMLSpanElement.prototype, 'offsetWidth', 'get').mockReturnValue(10);
-
- const { container, rerender } = render(
-
- {[...Array(100)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- await waitFor(() => {
- expect(container.querySelectorAll('span')).toHaveLength(30);
- });
-
- rerender(
-
- {[...Array(100)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- await waitFor(() => {
- expect(container.querySelectorAll('span')).toHaveLength(100);
- });
-});
-
-it('should return 1 when item is bigger than the container', () => {
- vi.spyOn(HTMLDivElement.prototype, 'scrollWidth', 'get')
- .mockReturnValueOnce(50)
- .mockReturnValueOnce(100)
- .mockReturnValue(50);
- vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(50);
- vi.spyOn(HTMLSpanElement.prototype, 'offsetWidth', 'get').mockReturnValue(60);
-
- const { container } = render(
-
- {[...Array(5)].map((_, i) => (
- Test {i}
- ))}
- ,
- );
-
- expect(container.querySelectorAll('span')).toHaveLength(1);
-});
diff --git a/packages/itwinui-react/src/utils/hooks/useOverflow.tsx b/packages/itwinui-react/src/utils/hooks/useOverflow.tsx
deleted file mode 100644
index 14aa58641ea..00000000000
--- a/packages/itwinui-react/src/utils/hooks/useOverflow.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Bentley Systems, Incorporated. All rights reserved.
- * See LICENSE.md in the project root for license terms and full copyright notice.
- *--------------------------------------------------------------------------------------------*/
-import * as React from 'react';
-import { useMergedRefs } from './useMergedRefs.js';
-import { useResizeObserver } from './useResizeObserver.js';
-import { useLayoutEffect } from './useIsomorphicLayoutEffect.js';
-
-const STARTING_MAX_ITEMS_COUNT = 20;
-
-/**
- * Hook that observes the size of an element and returns the number of items
- * that should be visible based on the size of the container element.
- *
- * The returned number should be used to render the element with fewer items.
- *
- * @private
- * @param itemsCount Number of items that this element contains.
- * @param disabled Set to true to disconnect the observer.
- * @param dimension 'horizontal' (default) or 'vertical'
- * @returns [callback ref to set on container, stateful count of visible items]
- *
- * @example
- * const items = Array(10).fill().map((_, i) => Item {i});
- * const [ref, visibleCount] = useOverflow(items);
- * ...
- * return (
- *
- * {items.slice(0, visibleCount)}
- *
- * );
- */
-export const useOverflow = (
- itemsCount: number,
- disabled = false,
- orientation: 'horizontal' | 'vertical' = 'horizontal',
-) => {
- const containerRef = React.useRef(null);
-
- const [visibleCount, setVisibleCount] = React.useState(() =>
- disabled ? itemsCount : Math.min(itemsCount, STARTING_MAX_ITEMS_COUNT),
- );
-
- const needsFullRerender = React.useRef(true);
-
- const [containerSize, setContainerSize] = React.useState(0);
- const previousContainerSize = React.useRef(0);
- const updateContainerSize = React.useCallback(
- ({ width, height }: DOMRectReadOnly) =>
- setContainerSize(orientation === 'horizontal' ? width : height),
- [orientation],
- );
- const [resizeRef, observer] = useResizeObserver(updateContainerSize);
- const resizeObserverRef = React.useRef(observer);
-
- useLayoutEffect(() => {
- if (disabled) {
- setVisibleCount(itemsCount);
- } else {
- setVisibleCount(Math.min(itemsCount, STARTING_MAX_ITEMS_COUNT));
- needsFullRerender.current = true;
- }
- }, [containerSize, disabled, itemsCount]);
-
- const mergedRefs = useMergedRefs(containerRef, resizeRef);
-
- useLayoutEffect(() => {
- if (!containerRef.current || disabled) {
- resizeObserverRef.current?.disconnect();
- return;
- }
- const dimension = orientation === 'horizontal' ? 'Width' : 'Height';
-
- const availableSize = containerRef.current[`offset${dimension}`];
- const requiredSize = containerRef.current[`scroll${dimension}`];
-
- if (availableSize < requiredSize) {
- const avgItemSize = requiredSize / visibleCount;
- const visibleItems = Math.floor(availableSize / avgItemSize);
- /* When first item is larger than the container - visibleItems count is 0,
- We can assume that at least some part of the first item is visible and return 1. */
- setVisibleCount(visibleItems > 0 ? visibleItems : 1);
- } else if (needsFullRerender.current) {
- const childrenSize = Array.from(containerRef.current.children).reduce(
- (sum: number, child: HTMLElement) => sum + child[`offset${dimension}`],
- 0,
- );
- // Previous `useEffect` might have updated visible count, but we still have old one
- // If it is 0, lets try to update it with items length.
- const currentVisibleCount =
- visibleCount || Math.min(itemsCount, STARTING_MAX_ITEMS_COUNT);
- const avgItemSize = childrenSize / currentVisibleCount;
- const visibleItems = Math.floor(availableSize / avgItemSize);
-
- if (!isNaN(visibleItems)) {
- // Doubling the visible items to overflow the container. Just to be safe.
- setVisibleCount(Math.min(itemsCount, visibleItems * 2));
- }
- }
- needsFullRerender.current = false;
- }, [containerSize, visibleCount, disabled, itemsCount, orientation]);
-
- useLayoutEffect(() => {
- previousContainerSize.current = containerSize;
- }, [containerSize]);
-
- return [mergedRefs, visibleCount] as const;
-};
diff --git a/testing/e2e/app/routes/ButtonGroup/spec.ts b/testing/e2e/app/routes/ButtonGroup/spec.ts
index 9d7f0250300..2a8e0ebe67a 100644
--- a/testing/e2e/app/routes/ButtonGroup/spec.ts
+++ b/testing/e2e/app/routes/ButtonGroup/spec.ts
@@ -6,6 +6,8 @@ test.describe('ButtonGroup (toolbar)', () => {
}) => {
await page.goto('/ButtonGroup');
+ await page.waitForTimeout(50);
+
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused();
@@ -36,6 +38,8 @@ test.describe('ButtonGroup (toolbar)', () => {
}) => {
await page.goto('/ButtonGroup?orientation=vertical');
+ await page.waitForTimeout(50);
+
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused();
diff --git a/testing/e2e/app/routes/ComboBox/route.tsx b/testing/e2e/app/routes/ComboBox/route.tsx
index 9a81c59a126..6d117bc2b1a 100644
--- a/testing/e2e/app/routes/ComboBox/route.tsx
+++ b/testing/e2e/app/routes/ComboBox/route.tsx
@@ -1,10 +1,13 @@
-import { Button, ComboBox } from '@itwin/itwinui-react';
+import { Button, ComboBox, Flex } from '@itwin/itwinui-react';
import { useSearchParams } from '@remix-run/react';
import * as React from 'react';
export default function ComboBoxTest() {
const config = getConfigFromSearchParams();
+ if (config.exampleType === 'overflow') {
+ return ;
+ }
return ;
}
@@ -46,12 +49,66 @@ const Default = ({
);
};
+const Overflow = () => {
+ const data = new Array(15).fill(0).map((_, i) => ({
+ label: `option ${i}`,
+ value: i,
+ }));
+ const widths = new Array(10).fill(0).map((_, i) => 790 + i * 3);
+
+ const [
+ selectTagContainersDomChangeCount,
+ setSelectTagContainersDomChangeCount,
+ ] = React.useState(0);
+
+ React.useEffect(() => {
+ const observer = new MutationObserver(() =>
+ setSelectTagContainersDomChangeCount((count) => count + 1),
+ );
+
+ const selectTagContainers = document.querySelectorAll(
+ "[role='combobox'] + div:first-of-type",
+ );
+ selectTagContainers.forEach((container) => {
+ observer.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: false,
+ });
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ return (
+
+
+ Select tag containers DOM changes: {selectTagContainersDomChangeCount}
+
+ {widths.slice(0, 10).map((width) => (
+ x.value)}
+ />
+ ))}
+
+ );
+};
+
// ----------------------------------------------------------------------------
function getConfigFromSearchParams() {
const [searchParams] = useSearchParams();
- const exampleType = searchParams.get('exampleType') as 'default' | undefined;
+ const exampleType = searchParams.get('exampleType') as
+ | 'default'
+ | 'overflow'
+ | undefined;
const virtualization = searchParams.get('virtualization') === 'true';
const multiple = searchParams.get('multiple') === 'true';
const clearFilterOnOptionToggle =
diff --git a/testing/e2e/app/routes/ComboBox/spec.ts b/testing/e2e/app/routes/ComboBox/spec.ts
index e2bd3554f9e..aac88597a5f 100644
--- a/testing/e2e/app/routes/ComboBox/spec.ts
+++ b/testing/e2e/app/routes/ComboBox/spec.ts
@@ -74,6 +74,20 @@ test('should select multiple options', async ({ page }) => {
});
});
+test('should not have flickering tags (fixes #2112)', async ({ page }) => {
+ await page.goto('/ComboBox?exampleType=overflow');
+
+ // Wait for page to stabilize
+ await page.waitForTimeout(30);
+
+ const stabilizedCount = await getSelectTagContainerDomChangeCount(page);
+ await page.waitForTimeout(100);
+ const newCount = await getSelectTagContainerDomChangeCount(page);
+
+ // DOM should not change with time (i.e. no flickering)
+ expect(stabilizedCount).toBe(newCount);
+});
+
test(`should clear filter and set input value when an option is toggled and when focus is lost (multiple=false)`, async ({
page,
}) => {
@@ -444,3 +458,12 @@ const getSelectTagContainerTags = (page: Page) => {
// See: https://github.com/iTwin/iTwinUI/pull/2151#discussion_r1684394649
return page.getByRole('combobox').locator('+ div > span');
};
+
+const getSelectTagContainerDomChangeCount = async (page: Page) => {
+ const selectTagContainerDomChangeCounter = page.getByTestId(
+ 'select-tag-containers-dom-change-count',
+ );
+ return (await selectTagContainerDomChangeCounter.textContent())
+ ?.split(':')[1]
+ .trim();
+};