Skip to content

Commit

Permalink
feat(action-popover): align component with design system
Browse files Browse the repository at this point in the history
aligns component with the sage design system, to ensure consumers can use the component with
expected styling and functionality. Nine different styling variations have been added
which change the alignment and padding of each child to ensure designs are correct. Also,
support for submenu's opening on the right has been included with the addition of the
`submenuPosition` prop. When set to "right" the menu-item chevron will appear on the right
of the action popover menu, and any submenu's will open from the right-hand side. Also,
when the `submenuPosition` prop is "left", either by default, or set by the consumer and
a content is re-sized the `submenuPosition` will be temporarily set to "right" if the
window is deemed to be too small. Finally, icon's are now supported in submenu's and can
be included by passing the `icon` prop to the submenu's menu item.

fix #5801
  • Loading branch information
tomdavies73 committed Jul 12, 2023
1 parent 6a62b3f commit ea9e867
Show file tree
Hide file tree
Showing 12 changed files with 1,703 additions and 164 deletions.
399 changes: 389 additions & 10 deletions cypress/components/action-popover/action-popover.cy.tsx

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions cypress/locators/action-popover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {
ACTION_POPOVER_BUTTON,
ACTION_POPOVER_DATA_COMPONENT,
ACTION_POPOVER_SUBMENU,
ACTION_POPOVER_MENU_ITEM_ICON,
ACTION_POPOVER_MENU_ITEM_INNER_TEXT,
ACTION_POPOVER_MENU_ITEM_CHEVRON,
ACTION_POPOVER_WRAPPER,
} from "./locators";

Expand All @@ -18,6 +21,12 @@ export const actionPopoverInnerItem = (index) =>
.first();
export const actionPopoverSubmenu = (index) =>
cy.get(ACTION_POPOVER_SUBMENU).eq(1).children().eq(index).find("button");
export const actionPopoverMenuItemIcon = () =>
cy.get(ACTION_POPOVER_MENU_ITEM_ICON);
export const actionPopoverMenuItemInnerText = () =>
cy.get(ACTION_POPOVER_MENU_ITEM_INNER_TEXT);
export const actionPopoverMenuItemChevron = () =>
cy.get(ACTION_POPOVER_MENU_ITEM_CHEVRON);
export const actionPopoverSubmenuByIndex = () =>
cy.get(ACTION_POPOVER_SUBMENU).eq(1);
export const actionPopoverWrapper = () => cy.get(ACTION_POPOVER_WRAPPER);
6 changes: 6 additions & 0 deletions cypress/locators/action-popover/locators.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@ export const ACTION_POPOVER_BUTTON = '[data-element="action-popover-button"]';
export const ACTION_POPOVER_DATA_COMPONENT =
'[data-component="action-popover"]';
export const ACTION_POPOVER_SUBMENU = '[data-element="action-popover-submenu"]';
export const ACTION_POPOVER_MENU_ITEM_ICON =
'[data-element="action-popover-menu-item-icon"]';
export const ACTION_POPOVER_MENU_ITEM_INNER_TEXT =
'[data-element="action-popover-menu-item-inner-text"]';
export const ACTION_POPOVER_MENU_ITEM_CHEVRON =
'[data-element="action-popover-menu-item-chevron"]';
export const ACTION_POPOVER_WRAPPER =
'[data-component="action-popover-wrapper"]';
3 changes: 3 additions & 0 deletions src/components/action-popover/action-popover-context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React from "react";

export type Alignment = "left" | "right";

type ActionPopoverContextType = {
setOpenPopover: (isOpen: boolean) => void;
focusButton: () => void;
submenuPosition: Alignment;
isOpenPopover: boolean;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
MenuItemIcon,
SubMenuItemIcon,
StyledMenuItem,
StyledMenuItemInnerText,
StyledMenuItemOuterContainer,
StyledMenuItemWrapper,
} from "../action-popover.style";
import Events from "../../../__internal__/utils/helpers/events";
import createGuid from "../../../__internal__/utils/helpers/guid";
import ActionPopoverContext from "../action-popover-context";
import ActionPopoverContext, { Alignment } from "../action-popover-context";
import useLocale from "../../../hooks/__internal__/useLocale";

import { IconType } from "../../icon";
Expand Down Expand Up @@ -47,55 +49,54 @@ export interface ActionPopoverItemProps {
/** @ignore @private */
focusItem?: boolean;
/** @ignore @private */
horizontalAlignment?: "left" | "right";
horizontalAlignment?: Alignment;
/** @ignore @private */
childHasSubmenu?: boolean;
/** @ignore @private */
childHasIcon?: boolean;
/** @ignore @private */
currentSubmenuPosition?: Alignment;
/** @ignore @private */
setChildHasSubmenu?: (value: boolean) => void;
/** @ignore @private */
setChildHasIcon?: (value: boolean) => void;
/** @ignore @private */
setCurrentSubmenuPosition?: (value: Alignment) => void;
/** @ignore @private */
isASubmenu?: boolean;
}

const INTERVAL = 150;

type ContainerPosition = {
left: number;
left: string | number;
top?: string;
bottom?: string;
right: "auto";
right: string | number;
};

function checkRef(ref: React.RefObject<HTMLElement>) {
return Boolean(ref && ref.current);
}

function leftAlignSubmenu(
function calculateSubmenuPosition(
ref: React.RefObject<HTMLElement>,
submenuRef: React.RefObject<HTMLElement>
submenuRef: React.RefObject<HTMLElement>,
submenuPosition: Alignment,
currentSubmenuPosition?: Alignment,
) {
/* istanbul ignore if */
if (!ref.current || !submenuRef.current) return true;

const { left } = ref.current.getBoundingClientRect();
const { offsetWidth } = submenuRef.current;
if (!ref.current || !submenuRef.current) return currentSubmenuPosition || submenuPosition;

return left >= offsetWidth;
}
const { left, right } = ref.current.getBoundingClientRect();
const { offsetWidth } = submenuRef.current;
const windowWidth = document.body.clientWidth;

function getContainerPosition(
itemRef: React.RefObject<HTMLElement>,
submenuRef: React.RefObject<HTMLElement>,
placement: "bottom" | "top"
): ContainerPosition | undefined {
/* istanbul ignore if */
if (!itemRef.current || !submenuRef.current) return undefined;

const { offsetWidth: parentWidth } = itemRef.current;
const { offsetWidth: submenuWidth } = submenuRef.current;
const xPositionValue = leftAlignSubmenu(itemRef, submenuRef)
? -submenuWidth
: parentWidth;
const yPositionName = placement === "top" ? "bottom" : "top";

return {
left: xPositionValue,
[yPositionName]: "calc(-1 * var(--spacing100))",
right: "auto",
};
if (submenuPosition === "left") {
return left >= offsetWidth ? "left" : "right";
}
return windowWidth >= right + offsetWidth ? "right" : "left";
}

export const ActionPopoverItem = ({
Expand All @@ -109,6 +110,13 @@ export const ActionPopoverItem = ({
download,
href,
horizontalAlignment,
childHasSubmenu,
childHasIcon,
currentSubmenuPosition,
setChildHasSubmenu,
setChildHasIcon,
setCurrentSubmenuPosition,
isASubmenu = false,
...rest
}: ActionPopoverItemProps) => {
const l = useLocale();
Expand All @@ -124,42 +132,84 @@ export const ActionPopoverItem = ({
"ActionPopoverItem only accepts submenu of type `ActionPopoverMenu`"
);

const { setOpenPopover, isOpenPopover, focusButton } = context;
const { setOpenPopover, isOpenPopover, focusButton, submenuPosition } = context;
const isHref = !!href;
const [containerPosition, setContainerPosition] = useState<
ContainerPosition | undefined
>(undefined);
const [guid] = useState(createGuid());
const [isOpen, setOpen] = useState(false);
const [focusIndex, setFocusIndex] = useState<number>(0);
const [isLeftAligned, setIsLeftAligned] = useState(true);

const submenuRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLButtonElement>(null);
const mouseEnterTimer = useRef<NodeJS.Timeout | null>(null);
const mouseLeaveTimer = useRef<NodeJS.Timeout | null>(null);
const hasSubmenuAligned = useRef(false);

useEffect(() => {
if (!isOpenPopover) {
setOpen(false);
}
}, [isOpenPopover]);

useEffect(() => {
if (icon) {
setChildHasIcon?.(true);
}
if (submenu) {
setChildHasSubmenu?.(true);
}
}, [icon, setChildHasSubmenu, setChildHasIcon, submenu]);


const alignSubmenu = useCallback(() => {
if (checkRef(ref) && checkRef(submenuRef) && submenu) {
const align = leftAlignSubmenu(ref, submenuRef);
setIsLeftAligned(align);
const checkCalculatedSubmenuPosition = calculateSubmenuPosition(ref, submenuRef, submenuPosition, currentSubmenuPosition)

setCurrentSubmenuPosition?.(checkCalculatedSubmenuPosition)

return checkRef(ref) && checkRef(submenuRef) && submenu;
}, [submenu, setCurrentSubmenuPosition, submenuPosition, currentSubmenuPosition]);

useEffect(() => {
function getContainerPosition(
itemRef: React.RefObject<HTMLElement>,
containerSubmenuRef: React.RefObject<HTMLElement>,
containerPlacement: "bottom" | "top"
): ContainerPosition | undefined {
/* istanbul ignore if */
if (!itemRef.current || !containerSubmenuRef.current) return undefined;

const { offsetWidth: submenuWidth } = containerSubmenuRef.current;

const leftAlignedSubmenu = currentSubmenuPosition === "left";
const leftValue = leftAlignedSubmenu ? -submenuWidth : "auto";
const rightValue = leftAlignedSubmenu ? "auto" : -submenuWidth;
const yPositionName = containerPlacement === "top" ? "bottom" : "top";

return {
left: leftValue,
[yPositionName]: "calc(-1 * var(--spacing100))",
right: rightValue,
};
}
setContainerPosition(getContainerPosition(ref, submenuRef, placement));

}, [submenu, placement, currentSubmenuPosition]);

useEffect(() => {
if (submenu && !hasSubmenuAligned.current) {
alignSubmenu();
hasSubmenuAligned.current = true;
}
}, [submenu, placement]);
});

useEffect(() => {
alignSubmenu();

if (focusItem) {
ref.current?.focus();
}
}, [alignSubmenu, focusItem]);
}, [focusItem]);

useEffect(() => {
return function cleanup() {
Expand Down Expand Up @@ -205,7 +255,7 @@ export const ActionPopoverItem = ({
e.stopPropagation();
} else if (!disabled) {
if (submenu) {
if (isLeftAligned) {
if (currentSubmenuPosition === "left") {
// LEFT: open if has submenu and left aligned otherwise close submenu
if (Events.isLeftKey(e) || Events.isEnterKey(e)) {
setOpen(true);
Expand Down Expand Up @@ -242,7 +292,7 @@ export const ActionPopoverItem = ({
e.stopPropagation();
}
},
[disabled, download, isHref, isLeftAligned, onClick, submenu]
[disabled, download, isHref, onClick, submenu, currentSubmenuPosition]
);

const itemSubmenuProps = {
Expand Down Expand Up @@ -283,7 +333,21 @@ export const ActionPopoverItem = ({
};

const renderMenuItemIcon = () => {
return icon && <MenuItemIcon as={undefined} type={icon} />;
return (
icon && (
<MenuItemIcon
type={icon}
data-element="action-popover-menu-item-icon"
horizontalAlignment={horizontalAlignment}
submenuPosition={currentSubmenuPosition}
childHasIcon={childHasIcon}
childHasSubmenu={childHasSubmenu}
hasIcon={!!icon}
hasSubmenu={!!submenu}
isASubmenu={isASubmenu}
/>
)
);
};

return (
Expand All @@ -298,18 +362,40 @@ export const ActionPopoverItem = ({
tabIndex={0}
isDisabled={disabled}
horizontalAlignment={horizontalAlignment}
submenuPosition={currentSubmenuPosition}
hasSubmenu={!!submenu}
childHasSubmenu={childHasSubmenu}
{...(disabled && { "aria-disabled": true })}
{...(isHref && { as: ("a" as unknown) as undefined, download, href })}
{...(submenu && itemSubmenuProps)}
>
{submenu && checkRef(ref) && isLeftAligned ? (
<SubMenuItemIcon type="chevron_left" />
{submenu && checkRef(ref) && currentSubmenuPosition === "left" ? (
<SubMenuItemIcon
data-element="action-popover-menu-item-chevron"
type="chevron_left_thick"
/>
) : null}
{horizontalAlignment === "left" ? renderMenuItemIcon() : null}
{children}
{horizontalAlignment === "right" ? renderMenuItemIcon() : null}
{submenu && checkRef(ref) && !isLeftAligned ? (
<SubMenuItemIcon type="chevron_right" />
<StyledMenuItemOuterContainer>
{horizontalAlignment === "left" ? renderMenuItemIcon() : null}
<StyledMenuItemInnerText
data-element="action-popover-menu-item-inner-text"
horizontalAlignment={horizontalAlignment}
submenuPosition={currentSubmenuPosition}
isASubmenu={isASubmenu}
childHasSubmenu={childHasSubmenu}
childHasIcon={childHasIcon}
hasIcon={!!icon}
hasSubmenu={!!submenu}
>
{children}
</StyledMenuItemInnerText>
{horizontalAlignment === "right" ? renderMenuItemIcon() : null}
</StyledMenuItemOuterContainer>
{submenu && checkRef(ref) && currentSubmenuPosition === "right" ? (
<SubMenuItemIcon
data-element="action-popover-menu-item-chevron"
type="chevron_right_thick"
/>
) : null}
</StyledMenuItem>
{React.isValidElement(submenu)
Expand All @@ -325,6 +411,8 @@ export const ActionPopoverItem = ({
setOpen,
setFocusIndex,
focusIndex,
isASubmenu: true,
horizontalAlignment,
}
)
: null}
Expand Down
Loading

0 comments on commit ea9e867

Please sign in to comment.