Skip to content

Commit

Permalink
refactor(ActionBar): implement useOverflowItems hook in ActionBar (#6775
Browse files Browse the repository at this point in the history
)

* refactor(useFocus): refactor repeated useEffect code

* refactor(ActionBar): implement useOverflowItems hook

* fix(PageHeader): resolve ActionBar min and max width

* refactor(ActionBar): refactor onChange callback

* refactor: move few looping into the return method

* fix(ActionBar): provide a dummy element to get offset width

* chore: remove useEffect from story

* refactor: rename maxwidth to remainingWidth

* fix: resolve flickering issue

* fix: resolve overflow issue with maxItems

* test: include id attribute for actionBarItem

* test: correct test cases

* test(PageHeader): correct the test case

* test: remove console.log

* chore: remove commented code

* refactor: implement offsetRefHandler callback

* chore: manually run yarn format

* chore: resolve format issue

* chore: resolve format issue

* chore: resolve format issue

* refactor: remove unwanted refs and effects

* refactor: refactor hiddenItems return
  • Loading branch information
makafsal authored Feb 12, 2025
1 parent 018e69f commit f7609f5
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 235 deletions.
68 changes: 36 additions & 32 deletions packages/ibm-products/src/components/ActionBar/ActionBar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import { render, screen, act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ActionBar } from '.';
import { Lightning, Bee } from '@carbon/react/icons';
Expand Down Expand Up @@ -65,14 +65,14 @@ const mockSizes = () => {
};

const testSizes = (el, property) => {
const classes = el.getAttribute('class').split(' ');
const classes = el.getAttribute('class')?.split(' ');
const container = el.closest('.test-container');

// if no container we are looking at the popup, assign something more than big enough e.g. 1001
const base = container ? parseInt(container.style.width, 10) : 1001;
const propSizes = sizes(base)[property];

if (propSizes) {
if (propSizes && classes) {
for (let cls of classes) {
const val = propSizes[cls] ? propSizes[cls] : -1;
if (val >= 0) {
Expand All @@ -83,8 +83,7 @@ const testSizes = (el, property) => {
}

// The test should never get here as all cases should be catered for in setup.
// eslint-disable-next-line
console.log('testSizes found nothing.', property, el.outerHTML);

return base;
};

Expand Down Expand Up @@ -133,33 +132,38 @@ describe(ActionBar.displayName, () => {
/>
);

expect(
screen.queryByText(/Action 10/, {
selector: `.${blockClass}__displayed-items .${carbon.prefix}--popover-content.${carbon.prefix}--tooltip-content`,
})
).toBeNull();

const menuItemNotSeen = document.querySelector('[role="menuitem"]', {
name: 'Action 10',
});
expect(menuItemNotSeen).toBeNull();

// Click overflow button and check for last action
const ofBtn = screen.getByLabelText(overflowAriaLabel, {
selector: `.${blockClass}-overflow-items`,
});
await act(() => userEvent.click(ofBtn));

// <ul role='menu' /> but default <ul> role of list used for query
// see https://testing-library.com/docs/queries/byrole/#api
// const om = screen.getByRole('list');
// const menuItems = screen.getAllByRole('menuitem');
// use querySelectorAll rather that getAllByRole because the drop-down
// never fully appears in jsdom (requires resize handler mocking)
const menuItemSeen = document.querySelector('[role="menuitem"]', {
name: 'Action 10',
});
expect(menuItemSeen).not.toBeNull();
waitFor(
async () => {
expect(
screen.queryByText(/Action 10/, {
selector: `.${blockClass}__displayed-items .${carbon.prefix}--popover-content.${carbon.prefix}--tooltip-content`,
})
).toBeNull();

const menuItemNotSeen = document.querySelector('[role="menuitem"]', {
name: 'Action 10',
});
expect(menuItemNotSeen).toBeNull();

// Click overflow button and check for last action
const ofBtn = screen.getByLabelText(overflowAriaLabel, {
selector: `.${blockClass}-overflow-items`,
});
await act(() => userEvent.click(ofBtn));

// <ul role='menu' /> but default <ul> role of list used for query
// see https://testing-library.com/docs/queries/byrole/#api
// const om = screen.getByRole('list');
// const menuItems = screen.getAllByRole('menuitem');
// use querySelectorAll rather that getAllByRole because the drop-down
// never fully appears in jsdom (requires resize handler mocking)
const menuItemSeen = document.querySelector('[role="menuitem"]', {
name: 'Action 10',
});
expect(menuItemSeen).not.toBeNull();
},
{ timeout: 0 }
);
});

it('Does not duplicate action IDs', async () => {
Expand Down
209 changes: 37 additions & 172 deletions packages/ibm-products/src/components/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,17 @@

// Import portions of React that are needed.
import React, {
useEffect,
useState,
useRef,
PropsWithChildren,
Ref,
ForwardedRef,
JSX,
RefObject,
} from 'react';

// Other standard imports.
import PropTypes from 'prop-types';
import cx from 'classnames';
import { pkg } from '../../settings';
import { useResizeObserver } from '../../global/js/hooks/useResizeObserver';

// Carbon and package components we use.
import { Button, ButtonProps } from '@carbon/react';
Expand All @@ -29,6 +26,7 @@ import { prepareProps } from '../../global/js/utils/props-helper';
import { ActionBarItem } from './ActionBarItem';
import { ActionBarOverflowItems } from './ActionBarOverflowItems';
import { CarbonIconType } from '@carbon/icons-react/lib/CarbonIcon';
import { useOverflowItems } from '../../global/js/hooks/useOverflowItems';

// The block part of our conventional BEM class names (blockClass__E--M).
const blockClass = `${pkg.prefix}--action-bar`;
Expand Down Expand Up @@ -81,7 +79,7 @@ interface ActionBarProps extends PropsWithChildren {
/**
* onItemCountChange - event reporting maxWidth
*/
onWidthChange?: (sizes: { minWidth: number; maxWidth: number }) => void;
onWidthChange?: (sizes?: { minWidth?: number; maxWidth?: number }) => void;
/**
* overflowAriaLabel label for open close button overflow used for action bar items that do nto fit.
*/
Expand Down Expand Up @@ -120,186 +118,53 @@ export let ActionBar = React.forwardRef(
}: ActionBarProps,
ref: Ref<HTMLDivElement>
) => {
const [displayCount, setDisplayCount] = useState(0);
const [displayedItems, setDisplayedItems] = useState<JSX.Element[]>([]);
const [hiddenSizingItems, setHiddenSizingItems] =
useState<JSX.Element | null>(null);
const internalId = useRef(uuidv4());
const refDisplayedItems = useRef<HTMLDivElement>(null);
const sizingRef = useRef<HTMLDivElement>(null);
const sizes = useRef<{ minWidth?: number; maxWidth?: number }>({});

const backupRef = useRef<HTMLDivElement>(null);
const localRef = ref || backupRef;

// create hidden sizing items
useEffect(() => {
// Hidden action bar and items used to calculate sizes
setHiddenSizingItems(
<div
className={`${blockClass}__hidden-sizing-items`}
aria-hidden={true}
ref={sizingRef}
>
<span>
<ActionBarOverflowItems
className={`${blockClass}__hidden-sizing-item`}
overflowAriaLabel="hidden sizing overflow items"
overflowMenuRef={overflowMenuRef}
overflowItems={[]}
key="hidden-overflow-menu"
tabIndex={-1}
/>
{actions.map(({ key, id, ...rest }) => (
<ActionBarItem
{...rest}
// ensure id is not duplicated
data-original-id={id}
key={`hidden-item-${key}`}
className={`${blockClass}__hidden-sizing-item`}
tabIndex={-1}
/>
))}
</span>
</div>
);
}, [actions, overflowMenuRef]);

// creates displayed items based on actions, displayCount and alignment
useEffect(() => {
// Calculate the displayed items
const newDisplayedItems = actions.map(({ key, ...rest }) => (
<ActionBarItem {...rest} key={key} />
));

// extract any there is not enough room for into newOverflowItems
const newOverflowItems = newDisplayedItems.splice(displayCount);

// add overflow menu if needed
if (newOverflowItems.length) {
newDisplayedItems.push(
<ActionBarOverflowItems
menuOptionsClass={menuOptionsClass}
overflowAriaLabel={overflowAriaLabel}
overflowMenuRef={overflowMenuRef}
overflowItems={newOverflowItems}
key={`overflow-menu-${internalId.current}`}
/>
);
}

setDisplayedItems(newDisplayedItems);
}, [
actions,
displayCount,
overflowAriaLabel,
menuOptionsClass,
overflowMenuRef,
]);

// determine display count based on space available and width of pageActions
const checkFullyVisibleItems = () => {
/* istanbul ignore if */
if (sizingRef.current) {
const sizingItems = Array.from(
sizingRef.current.querySelectorAll<HTMLElement>(
`.${blockClass}__hidden-sizing-item`
)
);

// first item is always the overflow even if nothing else is rendered
const overflowItem = sizingItems.shift();

// determine how many will fit
let spaceAvailable = refDisplayedItems.current?.offsetWidth;
let willFit = 0;
let maxVisibleWidth = 0;
const fitLimit = maxVisible
? Math.min(maxVisible, sizingItems.length)
: sizingItems.length;

// loop checking the space available
for (let i = 0; i < fitLimit; i++) {
const newSpaceAvailable =
spaceAvailable && spaceAvailable - sizingItems[i].offsetWidth;

// update maxVisibleWidth for later use by onWidthChange
maxVisibleWidth += sizingItems[i].offsetWidth;

if (newSpaceAvailable && newSpaceAvailable >= 0) {
spaceAvailable = newSpaceAvailable;
willFit += 1;
}
}

// if not enough space for all items then make room for the overflow
const overflowWidth = overflowItem!.offsetWidth;
if (willFit < sizingItems.length) {
// Check space for overflow
while (
willFit > 0 &&
spaceAvailable &&
spaceAvailable < overflowWidth
) {
willFit -= 1;

// Highly unlikely that any action bar item is narrower than the overflow item

// Make sure by removing items in reverse order
spaceAvailable += sizingItems[willFit].offsetWidth;
}
}

if (
onWidthChange &&
(sizes.current.minWidth !== overflowWidth ||
sizes.current.maxWidth !== maxVisibleWidth)
) {
sizes.current.minWidth = overflowWidth;
sizes.current.maxWidth = maxVisibleWidth;
// emit onWidthChange
onWidthChange({
minWidth: overflowWidth,
maxWidth: maxVisibleWidth,
});
}
if (willFit < 1) {
setDisplayCount(0);
} else {
setDisplayCount(willFit);
}
}
};

useEffect(() => {
checkFullyVisibleItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxVisible, hiddenSizingItems]);

// /* istanbul ignore next */ // not sure how to fake window resize
const handleResize = () => {
// when the hidden sizing items change size
/* istanbul ignore next */
// not sure how to fake window resize
checkFullyVisibleItems();
};

// // resize of the items
useResizeObserver(sizingRef, handleResize);
useResizeObserver(localRef, handleResize);
const localRef = (ref || backupRef) as RefObject<HTMLDivElement>;
const _offsetRef = useRef<HTMLElement>(null);
const offsetRef = (overflowMenuRef ||
_offsetRef) as RefObject<HTMLElement | null>;
const _items = actions.map((action) => ({ id: action?.key, ...action }));
const { visibleItems, hiddenItems, itemRefHandler, offsetRefHandler } =
useOverflowItems(_items, localRef, offsetRef, maxVisible, onWidthChange);

const overflowMenuItems = hiddenItems?.map(({ id: key, ...rest }) => (
<ActionBarItem {...rest} key={key} />
));

return (
<div {...rest} className={cx([blockClass, className])} ref={localRef}>
{hiddenSizingItems}

<div
ref={refDisplayedItems}
className={cx([
`${blockClass}__displayed-items`,
{ [`${blockClass}__displayed-items--right`]: rightAlign },
])}
>
{displayedItems}
{visibleItems.map(({ key, id, ...rest }) => (
<ActionBarItem
{...{
id,
...rest,
}}
key={key}
ref={(node) => {
itemRefHandler(id, node);
}}
/>
))}
{overflowMenuItems?.length > 0 && (
<ActionBarOverflowItems
menuOptionsClass={menuOptionsClass}
overflowAriaLabel={overflowAriaLabel}
overflowMenuRef={(node) =>
(offsetRef.current = offsetRefHandler(node))
}
overflowItems={overflowMenuItems}
key={`overflow-menu-${internalId.current}`}
/>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ ActionBarOverflowItems.propTypes = {
overflowMenuRef: PropTypes.oneOfType([
PropTypes.shape({ current: PropTypes.elementType }),
PropTypes.object,
PropTypes.func,
]),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,11 @@ const initSizes = () => ({
},
});
const testSizes = (el, property, _default) => {
const classes = el.getAttribute('class').split(' ');
const classes = el.getAttribute('class')?.split(' ');
const sizes = initSizes();

const propSizes = sizes[property];
if (propSizes) {
if (propSizes && classes) {
for (let cls of classes) {
// see if any class we check for is catered for.
const val = propSizes[cls] ? propSizes[cls] : -1;
Expand Down
Loading

0 comments on commit f7609f5

Please sign in to comment.