Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/Breadcrumbs/Breadcrumbs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ $block: '.#{variables.$ns}breadcrumbs';

&_calculating {
overflow: visible;

#{$block}__link {
overflow: visible;
}
}
}

Expand Down
113 changes: 26 additions & 87 deletions src/components/Breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,115 +36,51 @@
) {
const listRef = React.useRef<HTMLOListElement>(null);
const containerRef = useForkRef(ref, listRef);
const menuRef = React.useRef<HTMLLIElement>(null);
const endContentRef = React.useRef<HTMLLIElement>(null);

const items: React.ReactElement<any>[] = [];

Check warning on line 42 in src/components/Breadcrumbs/Breadcrumbs.tsx

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected any. Specify a different type
React.Children.forEach(props.children, (child, index) => {
if (React.isValidElement(child)) {
if (child.key === undefined || child.key === null) {
child = React.cloneElement(child, {key: index});

Check warning on line 46 in src/components/Breadcrumbs/Breadcrumbs.tsx

View workflow job for this annotation

GitHub Actions / Verify Files

Assignment to function parameter 'child'
}
items.push(child);
}
});

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<typeof props.children | null>(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) {
Expand Down Expand Up @@ -220,8 +157,10 @@
}
return (
<li
ref={isMenu ? menuRef : undefined}
key={isMenu ? 'menu' : `item-${key}`}
className={b('item', {calculating: isCurrent && !calculated, current: isCurrent})}
data-current={isCurrent ? isCurrent : undefined}
>
{item}
{isCurrent ? null : <BreadcrumbsSeparator separator={props.separator} />}
Expand Down
26 changes: 13 additions & 13 deletions src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
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;
});
});

Expand All @@ -39,7 +39,7 @@
});

it('should handle forward ref', function () {
let ref: React.RefObject<any> | undefined;

Check warning on line 42 in src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected any. Specify a different type
const Component = () => {
ref = React.useRef(null);
return (
Expand Down Expand Up @@ -105,14 +105,14 @@
});

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(
Expand All @@ -135,17 +135,17 @@
});

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(
Expand All @@ -168,14 +168,14 @@
});

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(
Expand Down
5 changes: 3 additions & 2 deletions src/demo/ShowcaseItem/ShowcaseItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={b()}>
<div className={b(null, className)}>
<div className={b('title')}>{title}</div>
<div className={b('content')}>{children}</div>
</div>
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/useCollapseChildren/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!--GITHUB_BLOCK-->

# useCollapseChildren

<!--/GITHUB_BLOCK-->

```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;
}
```
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading
Loading