Skip to content

Commit 7f9e5e6

Browse files
committed
feat: add useCollapseChildren hook
1 parent 5ff5047 commit 7f9e5e6

File tree

9 files changed

+462
-102
lines changed

9 files changed

+462
-102
lines changed

src/components/Breadcrumbs/Breadcrumbs.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ $block: '.#{variables.$ns}breadcrumbs';
3737

3838
&_calculating {
3939
overflow: visible;
40+
41+
#{$block}__link {
42+
overflow: visible;
43+
}
4044
}
4145
}
4246

src/components/Breadcrumbs/Breadcrumbs.tsx

Lines changed: 20 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import * as React from 'react';
44

55
import {useForkRef, useResizeObserver} from '../../hooks';
6+
import {useCollapseChildren} from '../../hooks/useCollapseChildren';
67
import type {PopupPlacement} from '../Popup';
78
import type {AriaLabelingProps, DOMProps, Key, QAProps} from '../types';
89
import {filterDOMProps} from '../utils/filterDOMProps';
@@ -35,6 +36,7 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs(
3536
) {
3637
const listRef = React.useRef<HTMLOListElement>(null);
3738
const containerRef = useForkRef(ref, listRef);
39+
const menuRef = React.useRef<HTMLLIElement>(null);
3840
const endContentRef = React.useRef<HTMLLIElement>(null);
3941

4042
const items: React.ReactElement<any>[] = [];
@@ -47,103 +49,33 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs(
4749
}
4850
});
4951

50-
const [visibleItemsCount, setVisibleItemsCount] = React.useState(items.length);
51-
const [calculated, setCalculated] = React.useState(false);
52-
const recalculate = (visibleItems: number) => {
53-
const list = listRef.current;
54-
if (!list) {
55-
return;
56-
}
57-
const listItems = Array.from(list.children) as HTMLElement[];
58-
const endElement = endContentRef.current;
59-
if (endElement) {
60-
listItems.pop();
61-
}
62-
if (listItems.length === 0) {
63-
setCalculated(true);
64-
return;
65-
}
66-
const containerWidth = list.offsetWidth - (endElement?.offsetWidth ?? 0);
67-
let newVisibleItemsCount = 0;
68-
let calculatedWidth = 0;
69-
let maxItems = props.maxItems || Infinity;
70-
71-
let rootWidth = 0;
72-
if (props.showRoot) {
73-
const item = listItems.shift();
74-
if (item) {
75-
rootWidth = item.offsetWidth;
76-
calculatedWidth += rootWidth;
77-
}
78-
newVisibleItemsCount++;
79-
}
80-
81-
const hasMenu = items.length > visibleItems;
82-
if (hasMenu) {
83-
const item = listItems.shift();
84-
if (item) {
85-
calculatedWidth += item.offsetWidth;
86-
}
87-
maxItems--;
88-
}
89-
90-
if (props.showRoot && calculatedWidth >= containerWidth) {
91-
calculatedWidth -= rootWidth;
92-
newVisibleItemsCount--;
93-
}
94-
95-
const lastItem = listItems.pop();
96-
if (lastItem) {
97-
calculatedWidth += Math.min(lastItem.offsetWidth, 200);
98-
if (calculatedWidth < containerWidth) {
99-
newVisibleItemsCount++;
100-
}
101-
}
102-
103-
for (let i = listItems.length - 1; i >= 0; i--) {
104-
const item = listItems[i];
105-
calculatedWidth += item.offsetWidth;
106-
if (calculatedWidth >= containerWidth) {
107-
break;
108-
}
109-
newVisibleItemsCount++;
110-
}
111-
112-
newVisibleItemsCount = Math.max(Math.min(maxItems, newVisibleItemsCount), 1);
113-
if (newVisibleItemsCount === visibleItemsCount) {
114-
setCalculated(true);
115-
} else {
116-
setVisibleItemsCount(newVisibleItemsCount);
117-
}
118-
};
119-
120-
const handleResize = React.useCallback(() => {
121-
setVisibleItemsCount(items.length);
122-
setCalculated(false);
123-
}, [items.length]);
124-
useResizeObserver({
125-
ref: listRef,
126-
onResize: handleResize,
52+
const {
53+
calculated,
54+
recalculate,
55+
visibleCount: visibleItemsCount,
56+
} = useCollapseChildren({
57+
containerRef: listRef,
58+
preservedRefs: [menuRef, endContentRef],
59+
direction: 'start',
60+
minCount: 1,
61+
maxCount:
62+
typeof props.maxItems === 'number' && props.maxItems < items.length
63+
? props.maxItems - 1
64+
: undefined,
12765
});
66+
12867
useResizeObserver({
12968
ref: props.endContent ? endContentRef : undefined,
130-
onResize: handleResize,
69+
onResize: recalculate,
13170
});
13271

13372
const lastChildren = React.useRef<typeof props.children | null>(null);
13473
React.useLayoutEffect(() => {
13574
if (calculated && props.children !== lastChildren.current) {
13675
lastChildren.current = props.children;
137-
setVisibleItemsCount(items.length);
138-
setCalculated(false);
139-
}
140-
}, [calculated, items.length, props.children]);
141-
142-
React.useLayoutEffect(() => {
143-
if (!calculated) {
144-
recalculate(visibleItemsCount);
76+
recalculate();
14577
}
146-
});
78+
}, [calculated, recalculate, props.children]);
14779

14880
let contents = items;
14981
if (items.length > visibleItemsCount) {
@@ -220,6 +152,7 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs(
220152
}
221153
return (
222154
<li
155+
ref={isMenu ? menuRef : undefined}
223156
key={isMenu ? 'menu' : `item-${key}`}
224157
className={b('item', {calculating: isCurrent && !calculated, current: isCurrent})}
225158
>

src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import {BreadcrumbsItem} from '../BreadcrumbsItem';
88
import type {BreadcrumbsItemProps} from '../BreadcrumbsItem';
99

1010
beforeEach(() => {
11-
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
11+
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
1212
this: Element,
1313
) {
1414
if (this instanceof HTMLOListElement) {
15-
return 500;
15+
return {width: 499} as DOMRect;
1616
}
17-
return 100;
17+
return {width: 100} as DOMRect;
1818
});
1919
});
2020

@@ -105,14 +105,14 @@ it('shows a maximum of 3 items with showRoot', () => {
105105
});
106106

107107
it('shows less than 4 items if they do not fit', () => {
108-
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
108+
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
109109
this: Element,
110110
) {
111111
if (this instanceof HTMLOListElement) {
112-
return 300;
112+
return {width: 299} as DOMRect;
113113
}
114114

115-
return 100;
115+
return {width: 100} as DOMRect;
116116
});
117117

118118
render(
@@ -135,17 +135,17 @@ it('shows less than 4 items if they do not fit', () => {
135135
});
136136

137137
it('shows other items if the last item is too long', () => {
138-
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
138+
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
139139
this: Element,
140140
) {
141141
if (this instanceof HTMLOListElement) {
142-
return 401;
142+
return {width: 401} as DOMRect;
143143
}
144144

145145
if (this.getAttribute('class')?.includes('__item_current')) {
146-
return 300;
146+
return {width: 300} as DOMRect;
147147
}
148-
return 100;
148+
return {width: 100} as DOMRect;
149149
});
150150

151151
render(
@@ -168,14 +168,14 @@ it('shows other items if the last item is too long', () => {
168168
});
169169

170170
it('collapses root item if it does not fit', () => {
171-
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
171+
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
172172
this: Element,
173173
) {
174174
if (this instanceof HTMLOListElement) {
175-
return 300;
175+
return {width: 299} as DOMRect;
176176
}
177177

178-
return 100;
178+
return {width: 100} as DOMRect;
179179
});
180180

181181
render(

src/demo/ShowcaseItem/ShowcaseItem.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import './ShowcaseItem.scss';
77
interface ShowcaseItemProps {
88
title: string;
99
children: React.ReactNode;
10+
className?: string;
1011
}
1112

1213
const b = cn('showcase-item');
1314

14-
export function ShowcaseItem({title, children}: ShowcaseItemProps) {
15+
export function ShowcaseItem({title, children, className}: ShowcaseItemProps) {
1516
return (
16-
<div className={b()}>
17+
<div className={b(null, className)}>
1718
<div className={b('title')}>{title}</div>
1819
<div className={b('content')}>{children}</div>
1920
</div>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!--GITHUB_BLOCK-->
2+
3+
# useCollapseChildren
4+
5+
<!--/GITHUB_BLOCK-->
6+
7+
```tsx
8+
import {useCollapseChildren} from '@gravity-ui/uikit';
9+
```
10+
11+
The `useCollapseChildren` hook calculates visible children count for a specified container element.
12+
13+
## Properties
14+
15+
| Name | Description | Type | Default |
16+
| :------------ | :------------------------------------------------------------------ | :------------------------------: | :--------: |
17+
| enabled | Whether or not the hook is enabled. | `boolean` | `true` |
18+
| containerRef | React ref for the container element. | `React.RefObject` | |
19+
| preservedRefs | React refs for elements that should not participate in calculation. | `React.RefObject[]` | |
20+
| minCount | The minimum count of items to be visible. | `number` | `0` |
21+
| maxCount | The maximum count of items to be visible. | `number` | `Infinity` |
22+
| direction | Collapse direction of items. | `"start"` `"end"` | `"end"` |
23+
| gap | The distance between items. | `number` | `0` |
24+
| childSelector | CSS-selector to pick child items in the container. | `string` | `"*"` |
25+
| getChildWidth | Custom measure function of item's width. | `(child: HTMLElement) => number` | |
26+
27+
## Result
28+
29+
```ts
30+
interface UseCollapseChildrenResult {
31+
/**
32+
* Whether or not calulation is complete.
33+
* Your items should be in measurable state when it's not calculated.
34+
*/
35+
calculated: boolean;
36+
/**
37+
* Trigger recalculation manually.
38+
*/
39+
recalculate: () => void;
40+
/**
41+
* Nubmer of items that can be visible in the container, excluding preserved items.
42+
*/
43+
visibleCount: number;
44+
}
45+
```
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
.use-collapse-children-story {
2+
&__showcase {
3+
width: 100%;
4+
}
5+
6+
&__list {
7+
display: flex;
8+
overflow: hidden;
9+
}
10+
11+
&__item {
12+
box-sizing: border-box;
13+
flex: 0 0 auto;
14+
padding: 5px;
15+
border: 2px dashed rgb(188 143 143);
16+
background-color: rgb(221 190 225);
17+
white-space: nowrap;
18+
text-align: center;
19+
20+
&_root {
21+
border-color: rgb(164, 63, 63);
22+
background-color: rgb(236, 112, 112);
23+
}
24+
25+
&_active {
26+
border-color: rgb(63, 164, 74);
27+
background-color: rgb(112, 236, 124);
28+
}
29+
30+
&_more {
31+
border-color: rgb(63, 107, 164);
32+
background-color: rgb(112, 193, 236);
33+
}
34+
35+
&_current:not(&_calculating) {
36+
flex-shrink: 1;
37+
overflow: hidden;
38+
text-overflow: ellipsis;
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)