,
- );
-
- const containerSpan = container.querySelector('span') as HTMLSpanElement;
- expect(containerSpan.textContent).toBe('This is some …lipsis');
-});
-
-it('should truncate the text and leave 20 symbols at the end', () => {
- vi.spyOn(UseOverflow, 'useOverflow').mockReturnValue([vi.fn(), 50]);
- const { container } = render(
-
-
-
,
- );
-
- const containerSpan = container.querySelector('span') as HTMLSpanElement;
- expect(containerSpan.textContent).toBe(
- 'This is some very long text t…f the truncated text',
- );
-});
-
-it('should leave original text (same length)', () => {
- vi.spyOn(UseOverflow, 'useOverflow').mockReturnValue([vi.fn(), 20]);
- const { container } = render(
-
-
-
,
- );
-
- const containerSpan = container.querySelector('span') as HTMLSpanElement;
- expect(containerSpan.textContent).toBe('This is a short text');
-});
-
-it('should leave original text', () => {
- vi.spyOn(UseOverflow, 'useOverflow').mockReturnValue([vi.fn(), 20]);
- const { container } = render(
-
-
-
,
- );
-
- const containerSpan = container.querySelector('span') as HTMLSpanElement;
- expect(containerSpan.textContent).toBe('No trunc');
-});
-
-it('should render custom text', () => {
- vi.spyOn(UseOverflow, 'useOverflow').mockReturnValue([vi.fn(), 20]);
- const text = 'This is some very long text to truncate and expect ellipsis';
- const { container } = render(
- (
-
- {truncatedText} - some additional text
-
- )}
- />,
- );
-
- const containerSpan = container.querySelector('span') as HTMLSpanElement;
- expect(containerSpan.textContent).toBe(
- 'This is some …lipsis - some additional text',
- );
- expect(
- containerSpan.querySelector('[data-testid="custom-text"]'),
- ).toBeTruthy();
-});
diff --git a/packages/itwinui-react/src/utils/components/MiddleTextTruncation.tsx b/packages/itwinui-react/src/utils/components/MiddleTextTruncation.tsx
index e7d81268fb0..2a215615db7 100644
--- a/packages/itwinui-react/src/utils/components/MiddleTextTruncation.tsx
+++ b/packages/itwinui-react/src/utils/components/MiddleTextTruncation.tsx
@@ -3,8 +3,8 @@
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import * as React from 'react';
-import { useOverflow } from '../hooks/useOverflow.js';
import type { CommonProps } from '../props.js';
+import { OverflowContainer } from './OverflowContainer.js';
const ELLIPSIS_CHAR = '…';
@@ -46,21 +46,23 @@ export type MiddleTextTruncationProps = {
export const MiddleTextTruncation = (props: MiddleTextTruncationProps) => {
const { text, endCharsCount = 6, textRenderer, style, ...rest } = props;
- const [ref, visibleCount] = useOverflow(text);
-
- const truncatedText = React.useMemo(() => {
- if (visibleCount < text.length) {
- return `${text.substring(
- 0,
- visibleCount - endCharsCount - ELLIPSIS_CHAR.length,
- )}${ELLIPSIS_CHAR}${text.substring(text.length - endCharsCount)}`;
- } else {
- return text;
- }
- }, [endCharsCount, text, visibleCount]);
+ const truncatedText = React.useCallback(
+ (visibleCount: number) => {
+ if (visibleCount < text.length) {
+ return `${text.substring(
+ 0,
+ visibleCount - endCharsCount - ELLIPSIS_CHAR.length,
+ )}${ELLIPSIS_CHAR}${text.substring(text.length - endCharsCount)}`;
+ } else {
+ return text;
+ }
+ },
+ [endCharsCount, text],
+ );
return (
- {
whiteSpace: 'nowrap',
...style,
}}
- ref={ref}
+ itemsLength={text.length}
{...rest}
>
- {textRenderer?.(truncatedText, text) ?? truncatedText}
-
+ {(visibleCount) =>
+ textRenderer?.(truncatedText(visibleCount), text) ??
+ truncatedText(visibleCount)
+ }
+
);
};
if (process.env.NODE_ENV === 'development') {
diff --git a/packages/itwinui-react/src/utils/components/OverflowContainer.tsx b/packages/itwinui-react/src/utils/components/OverflowContainer.tsx
new file mode 100644
index 00000000000..f8714425f55
--- /dev/null
+++ b/packages/itwinui-react/src/utils/components/OverflowContainer.tsx
@@ -0,0 +1,363 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Bentley Systems, Incorporated. All rights reserved.
+ * See LICENSE.md in the project root for license terms and full copyright notice.
+ *--------------------------------------------------------------------------------------------*/
+import React from 'react';
+import { useMergedRefs } from '../hooks/useMergedRefs.js';
+import type { PolymorphicForwardRefComponent } from '../props.js';
+import { Box } from './Box.js';
+import { useResizeObserver } from '../hooks/useResizeObserver.js';
+import { useLatestRef } from '../hooks/useLatestRef.js';
+import { useLayoutEffect } from '../hooks/useIsomorphicLayoutEffect.js';
+
+type OverflowContainerProps = {
+ /**
+ * If the overflow detection is disabled, all items will be displayed.
+ * @default false
+ */
+ overflowDisabled?: boolean;
+ /**
+ * The orientation of the overflow in container.
+ * @default 'horizontal'
+ */
+ overflowOrientation?: 'horizontal' | 'vertical';
+} & (
+ | {
+ children: React.ReactNode[];
+ /**
+ * Number of items to display. Since overflow detection considers *all* children, `itemsLength` may need to
+ * account for the `overflowTag` depending on your implementation to prevent off-by-one errors.
+ *
+ * Required if `children: React.ReactNode[]`.
+ */
+ itemsLength?: undefined;
+ /**
+ * What is rendered at `overflowLocation` when `OverflowContainer` starts overflowing.
+ *
+ * Required if `children: React.ReactNode[]`.
+ */
+ overflowTag: (visibleCount: number) => React.ReactNode;
+ /**
+ * Where the overflowTag is placed. Values:
+ * - start: At the start
+ * - end: At the end
+ * @default 'end'
+ */
+ overflowLocation?: 'start' | 'end';
+ }
+ | {
+ children: (visibleCount: number) => React.ReactNode;
+ itemsLength: number;
+ overflowTag?: undefined;
+ overflowLocation?: undefined;
+ }
+);
+
+/**
+ * Renders fewer children + an `overflowTag` when it starts overflowing. When not overflowing, it renders all children.
+ * This component listens to resize events and updates the rendered content accordingly.
+ *
+ * Two forms of usage:
+ * 1. `children: React.ReactNode[]`: Pass all the children and an `overflowTag` and this component handles when to show
+ * what, depending on whether the component is overflowing.
+ * 2. `children: (visibleCount: number) => React.ReactNode`: For more customization, pass a function to get the
+ * `visibleCount` and then render custom content based on that.
+ *
+ * @example
+ * (
+ * +${tags.length - (visibleCount - 1)} item(s) // -1 to account for the overflowTag
+ * )}
+ * overflowLocation='start'
+ * >
+ * {items}
+ *
+ *
+ * @example
+ *
+ * {(visibleCount) => {
+ * // Custom content dependent on visibleCount
+ * return (
+ * <>
+ * itemsLeft(visibleCount)
+ * overflowButton(visibleCount)
+ * itemsRight(visibleCount)
+ * >
+ * );
+ * }
+ *
+ */
+export const OverflowContainer = React.forwardRef((props, ref) => {
+ const {
+ overflowTag,
+ overflowLocation = 'end',
+ children,
+ itemsLength,
+ overflowDisabled = false,
+ overflowOrientation,
+ ...rest
+ } = props;
+
+ const [containerRef, _visibleCount] = useOverflow(
+ typeof children === 'function' ? itemsLength ?? 0 : children.length + 1,
+ overflowDisabled,
+ overflowOrientation,
+ );
+
+ // Minimum visibleCount of 1 since we always at least show the `overflowTag` in small sizes.
+ const visibleCount = Math.max(_visibleCount, 1);
+
+ /**
+ * - `visibleCount === children.length + 1` means that we show all children and no overflow tag.
+ * - `visibleCount <= children.length` means that we show visibleCount - 1 children and 1 overflow tag.
+ */
+ const visibleItems = React.useMemo(() => {
+ // User wants complete control over what items are rendered.
+ if (typeof children === 'function' || overflowTag == null) {
+ return null;
+ }
+
+ if (visibleCount > children.length) {
+ return children;
+ }
+
+ if (overflowLocation === 'start') {
+ return (
+ <>
+ {overflowTag(visibleCount - 2)}
+ {children.slice(children.length - (visibleCount - 1))}
+ >
+ );
+ }
+
+ return (
+ <>
+ {children.slice(0, visibleCount - 1)}
+ {overflowTag(visibleCount)}
+ >
+ );
+ }, [children, overflowTag, overflowLocation, visibleCount]);
+
+ return (
+
+ {typeof children === 'function' ? children(visibleCount) : visibleItems}
+
+ );
+}) as PolymorphicForwardRefComponent<'div', OverflowContainerProps>;
+
+// ----------------------------------------------------------------------------
+
+/**
+ * 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.
+ *
+ * @private
+ * @param items 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)}
+ *
+ * );
+ */
+const useOverflow = (
+ itemsLength: number,
+ disabled = false,
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
+) => {
+ /** `[number, number]` means that we're still guessing. `null` means that we got the correct `visibleCount`. */
+ type GuessRange = [number, number] | null;
+
+ /** First guess of the number of items that overflows. We refine this guess with subsequent renders. */
+ const STARTING_MAX_ITEMS_COUNT = 32;
+
+ const containerRef = React.useRef(null);
+
+ const initialVisibleCount = React.useMemo(
+ () =>
+ disabled ? itemsLength : Math.min(itemsLength, STARTING_MAX_ITEMS_COUNT),
+ [disabled, itemsLength],
+ );
+ const initialVisibleCountGuessRange = React.useMemo(
+ () => (disabled ? null : ([0, initialVisibleCount] satisfies GuessRange)),
+ [disabled, initialVisibleCount],
+ );
+
+ const [visibleCount, _setVisibleCount] =
+ React.useState(initialVisibleCount);
+ const [visibleCountGuessRange, setVisibleCountGuessRange] =
+ React.useState(initialVisibleCountGuessRange);
+
+ const isStabilized = visibleCountGuessRange == null;
+
+ /**
+ * Ensures that `visibleCount <= itemsLength`
+ */
+ const setVisibleCount = React.useCallback(
+ (setStateAction: React.SetStateAction) => {
+ _setVisibleCount((prev) => {
+ const newVisibleCount =
+ typeof setStateAction === 'function'
+ ? setStateAction(prev)
+ : setStateAction;
+
+ return Math.min(newVisibleCount, itemsLength);
+ });
+ },
+ [itemsLength],
+ );
+
+ const [resizeRef] = useResizeObserver(
+ React.useCallback(() => {
+ setVisibleCount(initialVisibleCount);
+ setVisibleCountGuessRange(initialVisibleCountGuessRange);
+ }, [initialVisibleCount, initialVisibleCountGuessRange, setVisibleCount]),
+ );
+
+ 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.
+ *
+ * Logic:
+ * - Have a guess range for `visibleCount`. e.g. `[0, 32]` (32 is an arbitrary choice).
+ * - Keep doubling the max guess until the container overflows.
+ * i.e. the max guess should always be `≥` the correct `visibleCount`.
+ * - With each such doubling, the new min guess is the current max guess (since underflow = we guessed low).
+ * - Set `visibleCount` to the `maxGuess`.
+ * - Repeat the following by calling `guessVisibleCount()` (keep re-rendering but not painting):
+ * - Each time the container overflows, new max guess is the average of the two guesses.
+ * - Each time the container does not overflow, new min guess is the average of the two 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 disabled or already stabilized
+ if (disabled || isStabilized || isGuessing.current) {
+ return;
+ }
+
+ // We need to wait for the ref to be attached so that we can measure available and required sizes.
+ if (containerRef.current == null) {
+ return;
+ }
+
+ try {
+ isGuessing.current = true;
+
+ const dimension = orientation === 'horizontal' ? 'Width' : 'Height';
+ const availableSize = containerRef.current[`offset${dimension}`];
+ const requiredSize = containerRef.current[`scroll${dimension}`];
+
+ const isOverflowing = availableSize < requiredSize;
+
+ // We have already found the correct visibleCount
+ if (
+ (visibleCount === itemsLength && !isOverflowing) ||
+ // if the new average of visibleCountGuessRange will never change the visibleCount anymore (infinite loop)
+ (visibleCountGuessRange[1] - visibleCountGuessRange[0] === 1 &&
+ visibleCount === visibleCountGuessRange[0])
+ ) {
+ setVisibleCountGuessRange(null);
+ return;
+ }
+
+ // Before the main logic, the max guess MUST be >= the correct visibleCount for the algorithm to work.
+ // If not:
+ // - double the max guess and visibleCount: since we need to overflow.
+ // - set min guess to current visibleCount: since underflow means correct visibleCount >= current visibleCount.
+ if (visibleCountGuessRange[1] === visibleCount && !isOverflowing) {
+ const doubleOfMaxGuess = visibleCountGuessRange[1] * 2;
+
+ setVisibleCountGuessRange([visibleCount, doubleOfMaxGuess]);
+ setVisibleCount(doubleOfMaxGuess);
+
+ return;
+ }
+
+ let newVisibleCountGuessRange = visibleCountGuessRange;
+
+ if (isOverflowing) {
+ // overflowing = we guessed too high. So, new max guess = half the current guess
+ newVisibleCountGuessRange = [visibleCountGuessRange[0], visibleCount];
+ } else {
+ // not overflowing = maybe we guessed too low? So, new min guess = half of current guess
+ newVisibleCountGuessRange = [visibleCount, visibleCountGuessRange[1]];
+ }
+
+ setVisibleCountGuessRange(newVisibleCountGuessRange);
+
+ // Next guess is always the middle of the new guess range
+ setVisibleCount(
+ Math.floor(
+ (newVisibleCountGuessRange[0] + newVisibleCountGuessRange[1]) / 2,
+ ),
+ );
+ } finally {
+ isGuessing.current = false;
+ }
+ }, [
+ containerRef,
+ disabled,
+ isStabilized,
+ itemsLength,
+ orientation,
+ setVisibleCount,
+ visibleCount,
+ visibleCountGuessRange,
+ ]);
+
+ const previousVisibleCount = useLatestRef(visibleCount);
+ const previousVisibleCountGuessRange = useLatestRef(visibleCountGuessRange);
+ const previousContainer = useLatestRef(containerRef.current);
+
+ // Guess each time any of the following changes:
+ // - `visibleCount`
+ // - `visibleCountGuessRange`
+ // - `containerRef`
+ useLayoutEffect(() => {
+ if (disabled || isStabilized) {
+ return;
+ }
+
+ if (
+ visibleCount !== previousVisibleCount.current ||
+ JSON.stringify(visibleCountGuessRange) !==
+ (previousVisibleCountGuessRange.current != null
+ ? JSON.stringify(previousVisibleCountGuessRange.current)
+ : undefined) ||
+ containerRef.current !== previousContainer.current
+ ) {
+ previousVisibleCount.current = visibleCount;
+ previousVisibleCountGuessRange.current = visibleCountGuessRange;
+ previousContainer.current = containerRef.current;
+
+ guessVisibleCount();
+ }
+ }, [
+ disabled,
+ guessVisibleCount,
+ isStabilized,
+ previousContainer,
+ previousVisibleCount,
+ previousVisibleCountGuessRange,
+ visibleCount,
+ visibleCountGuessRange,
+ ]);
+
+ const mergedRefs = useMergedRefs(containerRef, resizeRef);
+ return [mergedRefs, visibleCount] as const;
+};
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 8187717394c..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,
- 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 157571f75c2..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 items 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 = (
- items: React.ReactNode[] | string,
- disabled = false,
- orientation: 'horizontal' | 'vertical' = 'horizontal',
-) => {
- const containerRef = React.useRef(null);
-
- const [visibleCount, setVisibleCount] = React.useState(() =>
- disabled ? items.length : Math.min(items.length, 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(items.length);
- } else {
- setVisibleCount(Math.min(items.length, STARTING_MAX_ITEMS_COUNT));
- needsFullRerender.current = true;
- }
- }, [containerSize, disabled, items]);
-
- 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(items.length, 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(items.length, visibleItems * 2));
- }
- }
- needsFullRerender.current = false;
- }, [containerSize, visibleCount, disabled, items.length, orientation]);
-
- useLayoutEffect(() => {
- previousContainerSize.current = containerSize;
- }, [containerSize]);
-
- return [mergedRefs, visibleCount] as const;
-};
diff --git a/testing/e2e/app/routes/Breadcrumbs/route.tsx b/testing/e2e/app/routes/Breadcrumbs/route.tsx
new file mode 100644
index 00000000000..e5b0ac4ddd5
--- /dev/null
+++ b/testing/e2e/app/routes/Breadcrumbs/route.tsx
@@ -0,0 +1,25 @@
+import { Breadcrumbs, Button } from '@itwin/itwinui-react';
+
+export default function BreadcrumbsTest() {
+ const items = Array(5)
+ .fill(null)
+ .map((_, index) => (
+
+ Item {index}
+
+ ));
+
+ return (
+ <>
+