diff --git a/.changeset/light-poems-trade.md b/.changeset/light-poems-trade.md new file mode 100644 index 00000000000..25cf62fb663 --- /dev/null +++ b/.changeset/light-poems-trade.md @@ -0,0 +1,5 @@ +--- +'@itwin/itwinui-css': patch +--- + +`iui-breadcrumbs-list` now stretches to the width of `iui-breadcrumbs`. diff --git a/packages/itwinui-css/src/breadcrumbs/breadcrumbs.scss b/packages/itwinui-css/src/breadcrumbs/breadcrumbs.scss index 64004a580bc..bce71c12a7c 100644 --- a/packages/itwinui-css/src/breadcrumbs/breadcrumbs.scss +++ b/packages/itwinui-css/src/breadcrumbs/breadcrumbs.scss @@ -20,6 +20,7 @@ list-style-type: none; user-select: none; block-size: 100%; + inline-size: 100%; } .iui-breadcrumbs-item { diff --git a/packages/itwinui-react/src/core/Breadcrumbs/Breadcrumbs.tsx b/packages/itwinui-react/src/core/Breadcrumbs/Breadcrumbs.tsx index 74f49e4501b..1c09a82d759 100644 --- a/packages/itwinui-react/src/core/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/itwinui-react/src/core/Breadcrumbs/Breadcrumbs.tsx @@ -5,10 +5,9 @@ import * as React from 'react'; import cx from 'classnames'; import { - useMergedRefs, - useOverflow, SvgChevronRight, Box, + OverflowContainer, useWarningLogger, } from '../../utils/index.js'; import type { PolymorphicForwardRefComponent } from '../../utils/index.js'; @@ -114,70 +113,40 @@ type BreadcrumbsProps = { */ const BreadcrumbsComponent = React.forwardRef((props, ref) => { const { - children: items, - currentIndex = items.length - 1, + children: childrenProp, + currentIndex = React.Children.count(childrenProp) - 1, separator, overflowButton, className, ...rest } = props; - const [overflowRef, visibleCount] = useOverflow(items.length); - const refs = useMergedRefs(overflowRef, ref); + const items = React.useMemo( + () => React.Children.toArray(childrenProp), + [childrenProp], + ); return ( - - {visibleCount > 1 && ( - <> - - - - )} - {items.length - visibleCount > 0 && ( - <> - - {overflowButton ? ( - overflowButton(visibleCount) - ) : ( - - … - - )} - - - - )} - {items - .slice( - visibleCount > 1 - ? items.length - visibleCount + 1 - : items.length - 1, - ) - .map((_, _index) => { - const index = - visibleCount > 1 - ? 1 + (items.length - visibleCount) + _index - : items.length - 1; - return ( - - - {index < items.length - 1 && ( - - )} - - ); - })} - + + + {items} + + ); }) as PolymorphicForwardRefComponent<'nav', BreadcrumbsProps>; @@ -187,6 +156,58 @@ if (process.env.NODE_ENV === 'development') { // ---------------------------------------------------------------------------- +type BreadcrumbContentProps = Omit & { + currentIndex: NonNullable; +}; + +const BreadcrumbContent = (props: BreadcrumbContentProps) => { + const { children: items, currentIndex, overflowButton, separator } = props; + const { visibleCount } = OverflowContainer.useContext(); + + return ( + <> + {visibleCount > 1 && ( + <> + + + + )} + {items.length - visibleCount > 0 && ( + <> + + {overflowButton ? ( + overflowButton(visibleCount) + ) : ( + + … + + )} + + + + )} + {items + .slice( + visibleCount > 1 ? items.length - visibleCount + 1 : items.length - 1, + ) + .map((_, _index) => { + const index = + visibleCount > 1 + ? 1 + (items.length - visibleCount) + _index + : items.length - 1; + return ( + + + {index < items.length - 1 && } + + ); + })} + + ); +}; + +// ---------------------------------------------------------------------------- + const ListItem = ({ item, isActive, diff --git a/packages/itwinui-react/src/core/ButtonGroup/ButtonGroup.tsx b/packages/itwinui-react/src/core/ButtonGroup/ButtonGroup.tsx index 1594117dad8..188653dcde2 100644 --- a/packages/itwinui-react/src/core/ButtonGroup/ButtonGroup.tsx +++ b/packages/itwinui-react/src/core/ButtonGroup/ButtonGroup.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; import cx from 'classnames'; -import { useOverflow, useMergedRefs, Box } from '../../utils/index.js'; +import { Box, OverflowContainer } from '../../utils/index.js'; import type { AnyString, PolymorphicForwardRefComponent, @@ -172,6 +172,12 @@ const BaseGroup = React.forwardRef((props, forwardedRef) => { // ---------------------------------------------------------------------------- +type OverflowGroupProps = Pick< + ButtonGroupProps, + 'children' | 'orientation' | 'overflowPlacement' +> & + Required>; + const OverflowGroup = React.forwardRef((props, forwardedRef) => { const { children: childrenProp, @@ -186,14 +192,11 @@ const OverflowGroup = React.forwardRef((props, forwardedRef) => { [childrenProp], ); - const [overflowRef, visibleCount] = useOverflow( - items.length, - !overflowButton, - orientation, - ); - return ( - { }, props.className, )} - ref={useMergedRefs(forwardedRef, overflowRef)} + ref={forwardedRef} > - {(() => { - if (!(visibleCount < items.length)) { - return items; - } - - const overflowStart = - overflowPlacement === 'start' - ? items.length - visibleCount - : visibleCount - 1; - - return ( - <> - {overflowButton && - overflowPlacement === 'start' && - overflowButton(overflowStart)} - - {overflowPlacement === 'start' - ? items.slice(overflowStart + 1) - : items.slice(0, Math.max(0, overflowStart))} - - {overflowButton && - overflowPlacement === 'end' && - overflowButton(overflowStart)} - - ); - })()} - + + ); -}) as PolymorphicForwardRefComponent< - 'div', - Pick< - ButtonGroupProps, - 'children' | 'orientation' | 'overflowButton' | 'overflowPlacement' - > ->; +}) as PolymorphicForwardRefComponent<'div', OverflowGroupProps>; + +// ---------------------------------------------------------------------------- + +type OverflowGroupContentProps = Pick< + OverflowGroupProps, + 'overflowButton' | 'overflowPlacement' +> & { + items: ReturnType; +}; + +const OverflowGroupContent = (props: OverflowGroupContentProps) => { + const { overflowButton, overflowPlacement, items } = props; + const { visibleCount } = OverflowContainer.useContext(); + + const overflowStart = + overflowPlacement === 'start' + ? items.length - visibleCount + : visibleCount - 1; + + if (!(visibleCount < items.length)) { + return items; + } + + return ( + <> + {overflowButton && + overflowPlacement === 'start' && + overflowButton(overflowStart)} + + {overflowPlacement === 'start' + ? items.slice(overflowStart + 1) + : items.slice(0, Math.max(0, overflowStart))} + + {overflowButton && + overflowPlacement === 'end' && + overflowButton(overflowStart)} + + ); +}; diff --git a/packages/itwinui-react/src/core/Select/SelectTagContainer.tsx b/packages/itwinui-react/src/core/Select/SelectTagContainer.tsx index 00336400ff4..8311ab26465 100644 --- a/packages/itwinui-react/src/core/Select/SelectTagContainer.tsx +++ b/packages/itwinui-react/src/core/Select/SelectTagContainer.tsx @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; import cx from 'classnames'; -import { useOverflow, useMergedRefs, Box } from '../../utils/index.js'; import type { PolymorphicForwardRefComponent } from '../../utils/index.js'; import { SelectTag } from './SelectTag.js'; +import { OverflowContainer } from '../../utils/index.js'; type SelectTagContainerProps = { /** @@ -18,23 +18,42 @@ type SelectTagContainerProps = { /** */ export const SelectTagContainer = React.forwardRef((props, ref) => { - const { tags, className, ...rest } = props; + const { tags: tagsProp, className, ...rest } = props; - const [containerRef, visibleCount] = useOverflow(tags.length); - const refs = useMergedRefs(ref, containerRef); + const tags = React.useMemo( + () => React.Children.toArray(tagsProp), + [tagsProp], + ); return ( - - <> - {visibleCount < tags.length ? tags.slice(0, visibleCount - 1) : tags} - {visibleCount < tags.length && ( - - )} - - + + ); }) as PolymorphicForwardRefComponent<'div', SelectTagContainerProps>; + +// ---------------------------------------------------------------------------- + +type SelectTagContainerContentProps = { + tags: ReturnType; +}; + +const SelectTagContainerContent = (props: SelectTagContainerContentProps) => { + const { tags } = props; + const { visibleCount } = OverflowContainer.useContext(); + + return ( + <> + {visibleCount < tags.length ? tags.slice(0, visibleCount - 1) : tags} + + + + + + ); +}; diff --git a/packages/itwinui-react/src/core/Table/TablePaginator.tsx b/packages/itwinui-react/src/core/Table/TablePaginator.tsx index fa0ac1ad2e2..6f5a26e66ff 100644 --- a/packages/itwinui-react/src/core/Table/TablePaginator.tsx +++ b/packages/itwinui-react/src/core/Table/TablePaginator.tsx @@ -12,11 +12,11 @@ import { MenuItem } from '../Menu/MenuItem.js'; import { getBoundedValue, useGlobals, - useOverflow, useContainerWidth, SvgChevronLeft, SvgChevronRight, Box, + OverflowContainer, } from '../../utils/index.js'; import type { CommonProps } from '../../utils/index.js'; import type { TablePaginatorRendererProps } from './Table.js'; @@ -148,7 +148,7 @@ export const TablePaginator = (props: TablePaginatorProps) => { const pageListRef = React.useRef(null); const [focusedIndex, setFocusedIndex] = React.useState(currentPage); - React.useEffect(() => { + React.useLayoutEffect(() => { setFocusedIndex(currentPage); }, [currentPage]); @@ -170,37 +170,7 @@ export const TablePaginator = (props: TablePaginatorProps) => { }, [focusedIndex]); const buttonSize = size != 'default' ? 'small' : undefined; - - const pageButton = React.useCallback( - (index: number, tabIndex = index === focusedIndex ? 0 : -1) => ( - - ), - [focusedIndex, currentPage, localization, buttonSize, onPageChange], - ); - const totalPagesCount = Math.ceil(totalRowsCount / pageSize); - const pageList = React.useMemo( - () => - new Array(totalPagesCount) - .fill(null) - .map((_, index) => pageButton(index)), - [pageButton, totalPagesCount], - ); - const [overflowRef, visibleCount] = useOverflow(pageList.length); - - const [paginatorResizeRef, paginatorWidth] = useContainerWidth(); const onKeyDown = (event: React.KeyboardEvent) => { // alt + arrow keys are used by browser/assistive technologies @@ -247,38 +217,13 @@ export const TablePaginator = (props: TablePaginatorProps) => { } }; - const halfVisibleCount = Math.floor(visibleCount / 2); - let startPage = focusedIndex - halfVisibleCount; - let endPage = focusedIndex + halfVisibleCount + 1; - if (startPage < 0) { - endPage = Math.min(totalPagesCount, endPage + Math.abs(startPage)); // If no room at the beginning, show extra pages at the end - startPage = 0; - } - if (endPage > totalPagesCount) { - startPage = Math.max(0, startPage - (endPage - totalPagesCount)); // If no room at the end, show extra pages at the beginning - endPage = totalPagesCount; - } - - // Show ellipsis only if there is a gap between the extremities and the middle pages - const showStartEllipsis = startPage > 1; - const showEndEllipsis = endPage < totalPagesCount - 1; + const [paginatorResizeRef, paginatorWidth] = useContainerWidth(); - const hasNoRows = totalPagesCount === 0; const showPagesList = totalPagesCount > 1 || isLoading; const showPageSizeList = pageSizeList && !!onPageSizeChange && !!totalRowsCount; - const ellipsis = ( - - … - - ); - + const hasNoRows = totalPagesCount === 0; const noRowsContent = ( <> {isLoading ? ( @@ -307,7 +252,7 @@ export const TablePaginator = (props: TablePaginatorProps) => { )} {showPagesList && ( - + { onKeyDown={onKeyDown} ref={pageListRef} > - {(() => { - if (hasNoRows) { - return noRowsContent; - } - if (visibleCount === 1) { - return pageButton(focusedIndex); - } - - return ( - <> - {startPage !== 0 && ( - <> - {pageButton(0, 0)} - {showStartEllipsis ? ellipsis : null} - - )} - {pageList.slice(startPage, endPage)} - {endPage !== totalPagesCount && !isLoading && ( - <> - {showEndEllipsis ? ellipsis : null} - {pageButton(totalPagesCount - 1, 0)} - - )} - {isLoading && ( - <> - {ellipsis} - - - )} - - ); - })()} + {hasNoRows ? ( + noRowsContent + ) : ( + + )} { > - + )} {showPageSizeList && ( @@ -407,3 +333,113 @@ export const TablePaginator = (props: TablePaginatorProps) => { ); }; + +// ---------------------------------------------------------------------------- + +type TablePaginatorPageButtonsProps = Pick< + TablePaginatorProps, + 'onPageChange' +> & + Required> & { + focusedIndex: number; + totalPagesCount: number; + currentPage: number; + }; + +const TablePaginatorPageButtons = (props: TablePaginatorPageButtonsProps) => { + const { + focusedIndex, + totalPagesCount, + onPageChange, + currentPage, + localization, + isLoading, + size, + } = props; + + const { visibleCount } = OverflowContainer.useContext(); + + const buttonSize = size != 'default' ? 'small' : undefined; + + const pageButton = React.useCallback( + (index: number, tabIndex = index === focusedIndex ? 0 : -1) => ( + + ), + [focusedIndex, currentPage, localization, buttonSize, onPageChange], + ); + + const pageList = React.useMemo( + () => + new Array(totalPagesCount) + .fill(null) + .map((_, index) => pageButton(index)), + [pageButton, totalPagesCount], + ); + + const halfVisibleCount = Math.floor(visibleCount / 2); + let startPage = focusedIndex - halfVisibleCount; + let endPage = focusedIndex + halfVisibleCount + 1; + if (startPage < 0) { + endPage = Math.min(totalPagesCount, endPage + Math.abs(startPage)); // If no room at the beginning, show extra pages at the end + startPage = 0; + } + if (endPage > totalPagesCount) { + startPage = Math.max(0, startPage - (endPage - totalPagesCount)); // If no room at the end, show extra pages at the beginning + endPage = totalPagesCount; + } + + const ellipsis = ( + + … + + ); + + if (visibleCount === 1) { + return pageButton(focusedIndex); + } + + // Show ellipsis only if there is a gap between the extremities and the middle pages + const showStartEllipsis = startPage > 1; + const showEndEllipsis = endPage < totalPagesCount - 1; + + return ( + <> + {startPage !== 0 && ( + <> + {pageButton(0, 0)} + {showStartEllipsis ? ellipsis : null} + + )} + {pageList.slice(startPage, endPage)} + {endPage !== totalPagesCount && !isLoading && ( + <> + {showEndEllipsis ? ellipsis : null} + {pageButton(totalPagesCount - 1, 0)} + + )} + {isLoading && ( + <> + {ellipsis} + + + )} + + ); +}; diff --git a/packages/itwinui-react/src/utils/components/MiddleTextTruncation.tsx b/packages/itwinui-react/src/utils/components/MiddleTextTruncation.tsx index ece5ac57d56..ec22e27733e 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 = '…'; @@ -44,23 +44,11 @@ export type MiddleTextTruncationProps = { * /> */ export const MiddleTextTruncation = (props: MiddleTextTruncationProps) => { - const { text, endCharsCount = 6, textRenderer, style, ...rest } = props; - - const [ref, visibleCount] = useOverflow(text.length); - - 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 { text, style, ...rest } = props; return ( - { whiteSpace: 'nowrap', ...style, }} - ref={ref} + itemsCount={text.length} {...rest} > - {textRenderer?.(truncatedText, text) ?? truncatedText} - + + ); }; if (process.env.NODE_ENV === 'development') { MiddleTextTruncation.displayName = 'MiddleTextTruncation'; } + +// ---------------------------------------------------------------------------- + +const MiddleTextTruncationContent = (props: MiddleTextTruncationProps) => { + const { text, endCharsCount = 6, textRenderer } = props; + const { visibleCount } = OverflowContainer.useContext(); + + 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]); + + return textRenderer?.(truncatedText, text) ?? truncatedText; +}; 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..548b586195b --- /dev/null +++ b/packages/itwinui-react/src/utils/components/OverflowContainer.tsx @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { useOverflow } from '../hooks/useOverflow.js'; +import { useSafeContext } from '../hooks/useSafeContext.js'; + +type OverflowContainerProps = { + /** + * The orientation of the overflow in container. + * @default 'horizontal' + */ + overflowOrientation?: 'horizontal' | 'vertical'; + /** + * Count of the *original* items (i.e. when sufficient space is available). + */ + itemsCount: number; +}; + +const OverflowContainerComponent = React.forwardRef((props, ref) => { + const { itemsCount, children, overflowOrientation, ...rest } = props; + + const [containerRef, visibleCount] = useOverflow( + itemsCount, + false, + overflowOrientation, + ); + + const overflowContainerContextValue = React.useMemo( + () => ({ visibleCount, itemsCount }), + [itemsCount, visibleCount], + ); + + return ( + + + {children} + + + ); +}) as PolymorphicForwardRefComponent<'div', OverflowContainerProps>; + +// ---------------------------------------------------------------------------- + +type OverflowContainerOverflowNodeProps = { + children: React.ReactNode; +}; + +/** + * Shows the content only when the container is overflowing. + */ +const OverflowContainerOverflowNode = ( + props: OverflowContainerOverflowNodeProps, +) => { + const { children } = props; + + const { visibleCount, itemsCount } = useOverflowContainerContext(); + const isOverflowing = visibleCount < itemsCount; + + return isOverflowing ? children : null; +}; + +// ---------------------------------------------------------------------------- + +/** + * Wrapper over `useOverflow`. + * + * - Use `OverflowContainer.useContext()` to get overflow related properties. + * - Wrap overflow content in `OverflowContainer.OverflowNode` to conditionally render it when overflowing. + */ +export const OverflowContainer = Object.assign(OverflowContainerComponent, { + /** + * Wrap overflow content in this component to conditionally render it when overflowing. + */ + OverflowNode: OverflowContainerOverflowNode, + /** + * Get overflow related properties of the nearest `OverflowContainer` ancestor. + */ + useContext: useOverflowContainerContext, +}); + +// ---------------------------------------------------------------------------- + +const OverflowContainerContext = React.createContext< + | { + visibleCount: number; + itemsCount: number; + } + | undefined +>(undefined); +if (process.env.NODE_ENV === 'development') { + OverflowContainerContext.displayName = 'OverflowContainerContext'; +} + +function useOverflowContainerContext() { + const overflowContainerContext = useSafeContext(OverflowContainerContext); + return overflowContainerContext; +} diff --git a/packages/itwinui-react/src/utils/components/index.ts b/packages/itwinui-react/src/utils/components/index.ts index 07a621438b0..d537eaaf939 100644 --- a/packages/itwinui-react/src/utils/components/index.ts +++ b/packages/itwinui-react/src/utils/components/index.ts @@ -16,3 +16,4 @@ export * from './Portal.js'; export * from './ShadowRoot.js'; export * from './LineClamp.js'; export * from './FieldsetBase.js'; +export * from './OverflowContainer.js';