diff --git a/src/components/Breadcrumbs/Breadcrumbs.scss b/src/components/Breadcrumbs/Breadcrumbs.scss index bbe3f3a42a..c9e553fe46 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.scss +++ b/src/components/Breadcrumbs/Breadcrumbs.scss @@ -37,6 +37,10 @@ $block: '.#{variables.$ns}breadcrumbs'; &_calculating { overflow: visible; + + #{$block}__link { + overflow: visible; + } } } diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index 70a8122e1c..de58e67eab 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import {useForkRef, useResizeObserver} from '../../hooks'; +import {useCollapseChildren} from '../../hooks/useCollapseChildren'; import type {PopupPlacement} from '../Popup'; import type {AriaLabelingProps, DOMProps, Key, QAProps} from '../types'; import {filterDOMProps} from '../utils/filterDOMProps'; @@ -35,6 +36,7 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( ) { const listRef = React.useRef(null); const containerRef = useForkRef(ref, listRef); + const menuRef = React.useRef(null); const endContentRef = React.useRef(null); const items: React.ReactElement[] = []; @@ -47,103 +49,38 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } }); - const [visibleItemsCount, setVisibleItemsCount] = React.useState(items.length); - const [calculated, setCalculated] = React.useState(false); - const recalculate = (visibleItems: number) => { - const list = listRef.current; - if (!list) { - return; - } - const listItems = Array.from(list.children) as HTMLElement[]; - const endElement = endContentRef.current; - if (endElement) { - listItems.pop(); - } - if (listItems.length === 0) { - setCalculated(true); - return; - } - const containerWidth = list.offsetWidth - (endElement?.offsetWidth ?? 0); - let newVisibleItemsCount = 0; - let calculatedWidth = 0; - let maxItems = props.maxItems || Infinity; - - let rootWidth = 0; - if (props.showRoot) { - const item = listItems.shift(); - if (item) { - rootWidth = item.offsetWidth; - calculatedWidth += rootWidth; - } - newVisibleItemsCount++; - } - - const hasMenu = items.length > visibleItems; - if (hasMenu) { - const item = listItems.shift(); - if (item) { - calculatedWidth += item.offsetWidth; - } - maxItems--; - } - - if (props.showRoot && calculatedWidth >= containerWidth) { - calculatedWidth -= rootWidth; - newVisibleItemsCount--; - } - - const lastItem = listItems.pop(); - if (lastItem) { - calculatedWidth += Math.min(lastItem.offsetWidth, 200); - if (calculatedWidth < containerWidth) { - newVisibleItemsCount++; - } - } - - for (let i = listItems.length - 1; i >= 0; i--) { - const item = listItems[i]; - calculatedWidth += item.offsetWidth; - if (calculatedWidth >= containerWidth) { - break; - } - newVisibleItemsCount++; - } - - newVisibleItemsCount = Math.max(Math.min(maxItems, newVisibleItemsCount), 1); - if (newVisibleItemsCount === visibleItemsCount) { - setCalculated(true); - } else { - setVisibleItemsCount(newVisibleItemsCount); - } - }; - - const handleResize = React.useCallback(() => { - setVisibleItemsCount(items.length); - setCalculated(false); - }, [items.length]); - useResizeObserver({ - ref: listRef, - onResize: handleResize, + const { + calculated, + recalculate, + visibleCount: visibleItemsCount, + } = useCollapseChildren({ + containerRef: listRef, + preservedRefs: [menuRef, endContentRef], + direction: 'start', + minCount: 1, + maxCount: + typeof props.maxItems === 'number' && props.maxItems < items.length + ? props.maxItems - 1 + : undefined, + getChildWidth: (child) => { + const width = child.getBoundingClientRect().width; + const maxWidth = child.dataset.current ? 200 : Infinity; + return Math.min(maxWidth, width); + }, }); + useResizeObserver({ ref: props.endContent ? endContentRef : undefined, - onResize: handleResize, + onResize: recalculate, }); const lastChildren = React.useRef(null); React.useLayoutEffect(() => { if (calculated && props.children !== lastChildren.current) { lastChildren.current = props.children; - setVisibleItemsCount(items.length); - setCalculated(false); - } - }, [calculated, items.length, props.children]); - - React.useLayoutEffect(() => { - if (!calculated) { - recalculate(visibleItemsCount); + recalculate(); } - }); + }, [calculated, recalculate, props.children]); let contents = items; if (items.length > visibleItemsCount) { @@ -220,8 +157,10 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } return (
  • {item} {isCurrent ? null : } diff --git a/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx index 78b7707486..0c64f7a370 100644 --- a/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -8,13 +8,13 @@ import {BreadcrumbsItem} from '../BreadcrumbsItem'; import type {BreadcrumbsItemProps} from '../BreadcrumbsItem'; beforeEach(() => { - jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: Element, ) { if (this instanceof HTMLOListElement) { - return 500; + return {width: 499} as DOMRect; } - return 100; + return {width: 100} as DOMRect; }); }); @@ -105,14 +105,14 @@ it('shows a maximum of 3 items with showRoot', () => { }); it('shows less than 4 items if they do not fit', () => { - jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: Element, ) { if (this instanceof HTMLOListElement) { - return 300; + return {width: 299} as DOMRect; } - return 100; + return {width: 100} as DOMRect; }); render( @@ -135,17 +135,17 @@ it('shows less than 4 items if they do not fit', () => { }); it('shows other items if the last item is too long', () => { - jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: Element, ) { if (this instanceof HTMLOListElement) { - return 401; + return {width: 401} as DOMRect; } if (this.getAttribute('class')?.includes('__item_current')) { - return 300; + return {width: 300} as DOMRect; } - return 100; + return {width: 100} as DOMRect; }); render( @@ -168,14 +168,14 @@ it('shows other items if the last item is too long', () => { }); it('collapses root item if it does not fit', () => { - jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( this: Element, ) { if (this instanceof HTMLOListElement) { - return 300; + return {width: 299} as DOMRect; } - return 100; + return {width: 100} as DOMRect; }); render( diff --git a/src/demo/ShowcaseItem/ShowcaseItem.tsx b/src/demo/ShowcaseItem/ShowcaseItem.tsx index bf346c8505..b7ca71af8a 100644 --- a/src/demo/ShowcaseItem/ShowcaseItem.tsx +++ b/src/demo/ShowcaseItem/ShowcaseItem.tsx @@ -7,13 +7,14 @@ import './ShowcaseItem.scss'; interface ShowcaseItemProps { title: string; children: React.ReactNode; + className?: string; } const b = cn('showcase-item'); -export function ShowcaseItem({title, children}: ShowcaseItemProps) { +export function ShowcaseItem({title, children, className}: ShowcaseItemProps) { return ( -
    +
    {title}
    {children}
    diff --git a/src/hooks/useCollapseChildren/README.md b/src/hooks/useCollapseChildren/README.md new file mode 100644 index 0000000000..c4f959c636 --- /dev/null +++ b/src/hooks/useCollapseChildren/README.md @@ -0,0 +1,45 @@ + + +# useCollapseChildren + + + +```tsx +import {useCollapseChildren} from '@gravity-ui/uikit'; +``` + +The `useCollapseChildren` hook calculates visible children count for a specified container element. + +## Properties + +| Name | Description | Type | Default | +| :------------ | :------------------------------------------------------------------ | :------------------------------: | :--------: | +| enabled | Whether or not the hook is enabled. | `boolean` | `true` | +| containerRef | React ref for the container element. | `React.RefObject` | | +| preservedRefs | React refs for elements that should not participate in calculation. | `React.RefObject[]` | | +| minCount | The minimum count of items to be visible. | `number` | `0` | +| maxCount | The maximum count of items to be visible. | `number` | `Infinity` | +| direction | Collapse direction of items. | `"start"` `"end"` | `"end"` | +| gap | The distance between items. | `number` | `0` | +| childSelector | CSS-selector to pick child items in the container. | `string` | `"*"` | +| getChildWidth | Custom measure function of item's width. | `(child: HTMLElement) => number` | | + +## Result + +```ts +interface UseCollapseChildrenResult { + /** + * Whether or not calulation is complete. + * Your items should be in measurable state when it's not calculated. + */ + calculated: boolean; + /** + * Trigger recalculation manually. + */ + recalculate: () => void; + /** + * Nubmer of items that can be visible in the container, excluding preserved items. + */ + visibleCount: number; +} +``` diff --git a/src/hooks/useCollapseChildren/__stories__/UseCollapseChildren.stories.scss b/src/hooks/useCollapseChildren/__stories__/UseCollapseChildren.stories.scss new file mode 100644 index 0000000000..3e6719d225 --- /dev/null +++ b/src/hooks/useCollapseChildren/__stories__/UseCollapseChildren.stories.scss @@ -0,0 +1,41 @@ +.use-collapse-children-story { + &__showcase { + width: 100%; + } + + &__list { + display: flex; + overflow: hidden; + } + + &__item { + box-sizing: border-box; + flex: 0 0 auto; + padding: 5px; + border: 2px dashed rgb(188 143 143); + background-color: rgb(221 190 225); + white-space: nowrap; + text-align: center; + + &_root { + border-color: rgb(164, 63, 63); + background-color: rgb(236, 112, 112); + } + + &_active { + border-color: rgb(63, 164, 74); + background-color: rgb(112, 236, 124); + } + + &_more { + border-color: rgb(63, 107, 164); + background-color: rgb(112, 193, 236); + } + + &_current:not(&_calculating) { + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + } + } +} diff --git a/src/hooks/useCollapseChildren/__stories__/UseCollapseChildren.stories.tsx b/src/hooks/useCollapseChildren/__stories__/UseCollapseChildren.stories.tsx new file mode 100644 index 0000000000..f369b6a0b8 --- /dev/null +++ b/src/hooks/useCollapseChildren/__stories__/UseCollapseChildren.stories.tsx @@ -0,0 +1,218 @@ +import * as React from 'react'; + +import type {Meta, StoryObj} from '@storybook/react-webpack5'; + +import {cn} from '../../../components/utils/cn'; +import {Showcase} from '../../../demo/Showcase'; +import {ShowcaseItem} from '../../../demo/ShowcaseItem'; +import type {UseCollapseChildrenProps} from '../useCollapseChildren'; +import {useCollapseChildren} from '../useCollapseChildren'; + +import './UseCollapseChildren.stories.scss'; + +const b = cn('use-collapse-children-story'); + +const meta: Meta = { + title: 'Hooks/useCollapseChildren', + argTypes: { + enabled: { + type: 'boolean', + }, + minCount: { + type: 'number', + }, + maxCount: { + type: 'number', + }, + direction: { + control: 'radio', + options: ['start', 'end'], + }, + gap: { + type: 'number', + }, + childSelector: { + type: 'string', + }, + }, + args: { + enabled: true, + minCount: 1, + direction: 'end', + gap: 0, + childSelector: '*', + }, +}; + +export default meta; + +type Story = StoryObj; + +const EXAMPLE_ITEMS = [ + { + width: 100, + }, + { + width: 120, + }, + { + width: 160, + }, + { + width: 40, + }, + { + width: 100, + }, + { + width: 180, + text: 'Long Long Long Long Long Long Long Text', + }, +]; +const ACTIVE_INDEX = 3; + +export const Default: Story = { + render: (args) => { + const listRef = React.useRef(null); + + const {visibleCount} = useCollapseChildren({ + ...args, + containerRef: listRef, + }); + + const items = EXAMPLE_ITEMS.map(({width, text}, i) => ( +
    + {text || width} +
    + )); + + return ( +
    + {args.direction === 'start' + ? items.slice(EXAMPLE_ITEMS.length - visibleCount) + : items.slice(0, visibleCount)} +
    + ); + }, +}; + +function BreadcrumbsShowcase({args}: {args: UseCollapseChildrenProps}) { + const listRef = React.useRef(null); + const rootRef = React.useRef(null); + const moreRef = React.useRef(null); + const currentRef = React.useRef(null); + + const {visibleCount, calculated} = useCollapseChildren({ + ...args, + minCount: 1, + containerRef: listRef, + preservedRefs: [rootRef, moreRef], + getChildWidth: (child) => { + const width = child.getBoundingClientRect().width; + return child === currentRef.current ? Math.min(width, 200) : width; + }, + }); + + let items = EXAMPLE_ITEMS.map(({width, text}, i) => ( +
    + {text || width} +
    + )); + + if (visibleCount < EXAMPLE_ITEMS.length) { + items = items.slice(EXAMPLE_ITEMS.length - visibleCount); + items.unshift( +
    + {EXAMPLE_ITEMS.slice(visibleCount).length} more +
    , + ); + } + + items.unshift( +
    + Root +
    , + ); + + return ( + +
    + {items} +
    +
    + ); +} + +function TabsShowcase({args}: {args: UseCollapseChildrenProps}) { + const listRef = React.useRef(null); + const moreRef = React.useRef(null); + const activeRef = React.useRef(null); + + const {visibleCount} = useCollapseChildren({ + ...args, + minCount: 0, + direction: 'end', + containerRef: listRef, + preservedRefs: [activeRef, moreRef], + }); + const visibleCountIncludingActive = visibleCount + 1; + + let content = EXAMPLE_ITEMS.map(({width}, i) => ( +
    + {i === ACTIVE_INDEX ? 'active' : width} +
    + )); + if (visibleCountIncludingActive < EXAMPLE_ITEMS.length) { + const activeItem = content[ACTIVE_INDEX]; + content = content.slice(0, visibleCountIncludingActive); + + if (!content.includes(activeItem)) { + content.splice(-1, 1, activeItem); + } + + content.push( +
    + {EXAMPLE_ITEMS.length - visibleCountIncludingActive} more +
    , + ); + } + + return ( + +
    + {content} +
    +
    + ); +} + +export const ShowcaseStory: Story = { + name: 'Showcase', + render(args) { + return ( + + + + + ); + }, +}; diff --git a/src/hooks/useCollapseChildren/index.ts b/src/hooks/useCollapseChildren/index.ts new file mode 100644 index 0000000000..e6016cabc4 --- /dev/null +++ b/src/hooks/useCollapseChildren/index.ts @@ -0,0 +1 @@ +export * from './useCollapseChildren'; diff --git a/src/hooks/useCollapseChildren/useCollapseChildren.ts b/src/hooks/useCollapseChildren/useCollapseChildren.ts new file mode 100644 index 0000000000..2f03a6548f --- /dev/null +++ b/src/hooks/useCollapseChildren/useCollapseChildren.ts @@ -0,0 +1,117 @@ +import * as React from 'react'; + +import {useResizeObserver} from '../useResizeObserver'; + +export interface UseCollapseChildrenProps { + enabled?: boolean; + containerRef: React.RefObject; + preservedRefs?: Array>; + minCount?: number; + maxCount?: number; + direction?: 'start' | 'end'; + gap?: number; + childSelector?: string; + getChildWidth?: (child: HTMLElement) => number; +} + +export interface UseCollapseChildrenResult { + calculated: boolean; + recalculate: () => void; + visibleCount: number; +} + +export function useCollapseChildren({ + enabled = true, + containerRef, + preservedRefs = [], + minCount = 0, + maxCount = Infinity, + direction = 'end', + gap = 0, + childSelector = '*', + getChildWidth = (child) => child.getBoundingClientRect().width, +}: UseCollapseChildrenProps): UseCollapseChildrenResult { + const [calculated, setCalculated] = React.useState(false); + const [visibleCount, setVisibleCount] = React.useState(maxCount); + + const calculate = (desiredVisibleCount: number) => { + const container = containerRef.current; + if (!container) return; + + // Batch elements measurement to optimize performance + const containerChildren = Array.from( + container.querySelectorAll(`:scope > ${childSelector}`), + ); + const containerWidth = container.getBoundingClientRect().width; + const itemWidthMap = new WeakMap( + containerChildren.map((child) => [child, getChildWidth(child)]), + ); + + const preservedItems = preservedRefs.flatMap((ref) => ref.current ?? []); + const allItems = direction === 'start' ? containerChildren.reverse() : containerChildren; + + // Place preserved items at the beginning to calculate them first + allItems.sort((itemA, itemB) => { + const valueA = preservedItems.includes(itemA) ? 1 : 0; + const valueB = preservedItems.includes(itemB) ? 1 : 0; + return valueB - valueA; + }); + + let availableWidth = containerWidth; + let newVisibleCount = 0; + + for (const item of allItems) { + if (newVisibleCount >= maxCount) { + break; + } + + availableWidth -= itemWidthMap.get(item) ?? 0; + if (availableWidth < 0) break; + availableWidth -= gap; + + if (!preservedItems.includes(item)) { + newVisibleCount++; + } + } + + const minNewVisibleCount = Math.max(newVisibleCount, minCount); + if (minNewVisibleCount === desiredVisibleCount) { + setCalculated(true); + } else { + setVisibleCount(minNewVisibleCount); + } + }; + + const recalculate = React.useCallback(() => { + if (enabled) { + setVisibleCount(Infinity); + setCalculated(false); + } + }, [enabled]); + + useResizeObserver({ + ref: containerRef, + onResize: recalculate, + }); + + React.useLayoutEffect(() => { + if (enabled && !calculated) { + calculate(visibleCount); + } + + if (!enabled && !calculated) { + setVisibleCount(Infinity); + setCalculated(true); + } + }); + + React.useLayoutEffect(() => { + if (calculated) { + setVisibleCount(Infinity); + setCalculated(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, minCount, maxCount, direction, gap]); + + return {calculated, recalculate, visibleCount}; +}