Skip to content
Closed
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
33 changes: 23 additions & 10 deletions packages/lib/src/contextual-menu/ContextualMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,41 @@ import scrollbarStyles from "../styles/scroll";
import { addIdToItems, isSection } from "./utils";
import SubMenu from "./SubMenu";

const ContextualMenu = styled.div`
const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>`
box-sizing: border-box;
margin: 0;
border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
border-radius: var(--border-radius-s);
padding: var(--spacing-padding-m) var(--spacing-padding-xs);
display: grid;
gap: var(--spacing-gap-xs);
min-width: 248px;
/* min-width: 248px; */
max-height: 100%;
background-color: var(--color-bg-neutral-lightest);
overflow-y: auto;
overflow-x: hidden;
${scrollbarStyles}
${scrollbarStyles};
${({ displayBorder }) =>
displayBorder &&
`
border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
border-radius: var(--border-radius-s);
padding: var(--spacing-padding-m) var(--spacing-padding-xs);
`}
`;

export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
export default function DxcContextualMenu({
items,
displayBorder = true,
displayGroupLines = false,
displayControlsAfter = false,
responsiveView = false,
}: ContextualMenuPropsType) {
const [firstUpdate, setFirstUpdate] = useState(true);
const [selectedItemId, setSelectedItemId] = useState(-1);
const contextualMenuRef = useRef<HTMLDivElement | null>(null);
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]);
const contextValue = useMemo(
() => ({ selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView }),
[selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView]
);

useLayoutEffect(() => {
if (selectedItemId !== -1 && firstUpdate) {
Expand All @@ -45,7 +58,7 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
}, [firstUpdate, selectedItemId]);

return (
<ContextualMenu ref={contextualMenuRef}>
<ContextualMenuContainer displayBorder={displayBorder} ref={contextualMenuRef}>
<ContextualMenuContext.Provider value={contextValue}>
{itemsWithId[0] && isSection(itemsWithId[0]) ? (
(itemsWithId as SectionWithId[]).map((item, index) => (
Expand All @@ -59,6 +72,6 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
</SubMenu>
)}
</ContextualMenuContext.Provider>
</ContextualMenu>
</ContextualMenuContainer>
);
}
56 changes: 53 additions & 3 deletions packages/lib/src/contextual-menu/GroupItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,64 @@ import MenuItem from "./MenuItem";
import { GroupItemProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";
import { isGroupSelected } from "./utils";
import * as Popover from "@radix-ui/react-popover";

const GroupItem = ({ items, ...props }: GroupItemProps) => {
const groupMenuId = `group-menu-${useId()}`;
const { selectedItemId } = useContext(ContextualMenuContext) ?? {};
const { selectedItemId, responsiveView } = useContext(ContextualMenuContext) ?? {};
const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]);
const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1);

return (
const contextualMenuId = `sidenav-${useId()}`;

const contextValue = useContext(ContextualMenuContext) ?? {};

return responsiveView ? (
<>
<Popover.Root open={isOpen}>
<Popover.Trigger
aria-controls={undefined}
aria-expanded={undefined}
aria-haspopup={undefined}
asChild
type={undefined}
>
<ItemAction
aria-controls={isOpen ? groupMenuId : undefined}
aria-expanded={isOpen ? true : undefined}
aria-pressed={groupSelected && !isOpen}
collapseIcon={isOpen ? <DxcIcon icon="filled_expand_less" /> : <DxcIcon icon="filled_expand_more" />}
onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)}
selected={groupSelected && !isOpen}
{...props}
/>
</Popover.Trigger>
<Popover.Portal container={document.getElementById(`${contextualMenuId}-portal`)}>
<ContextualMenuContext.Provider value={{ ...contextValue, displayGroupLines: false, responsiveView: false }}>
<Popover.Content
aria-label="Group details"
onCloseAutoFocus={(event) => {
event.preventDefault();
}}
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
align="start"
side="right"
style={{ zIndex: "var(--z-contextualmenu)" }}
>
<SubMenu id={groupMenuId} depthLevel={props.depthLevel}>
{items.map((item, index) => (
<MenuItem item={item} depthLevel={props.depthLevel + 1} key={`${item.label}-${index}`} />
))}
</SubMenu>
</Popover.Content>
</ContextualMenuContext.Provider>
</Popover.Portal>
</Popover.Root>
<div id={`${contextualMenuId}-portal`} style={{ position: "absolute" }} />
</>
) : (
<>
<ItemAction
aria-controls={isOpen ? groupMenuId : undefined}
Expand All @@ -25,7 +75,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => {
{...props}
/>
{isOpen && (
<SubMenu id={groupMenuId}>
<SubMenu id={groupMenuId} depthLevel={props.depthLevel}>
{items.map((item, index) => (
<MenuItem item={item} depthLevel={props.depthLevel + 1} key={`${item.label}-${index}`} />
))}
Expand Down
88 changes: 60 additions & 28 deletions packages/lib/src/contextual-menu/ItemAction.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { cloneElement, memo, MouseEvent, useState } from "react";
import { cloneElement, forwardRef, memo, MouseEvent, useContext, useState } from "react";
import styled from "@emotion/styled";
import { ItemActionProps } from "./types";
import DxcIcon from "../icon/Icon";
import { TooltipWrapper } from "../tooltip/Tooltip";
import ContextualMenuContext from "./ContextualMenuContext";

const Action = styled.button<{
depthLevel: ItemActionProps["depthLevel"];
selected: ItemActionProps["selected"];
displayGroupLines: boolean;
responsiveView?: boolean;
}>`
box-sizing: content-box;
border: none;
border-radius: var(--border-radius-s);
padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs)
${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`};
${({ displayGroupLines, depthLevel, responsiveView }) => `
${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupLines ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"};
${displayGroupLines && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""}
`}
display: flex;
align-items: center;
gap: var(--spacing-gap-m);
justify-content: space-between;
justify-content: ${({ responsiveView }) => (responsiveView ? "center" : "space-between")};
background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")};
height: var(--height-s);
cursor: pointer;
Expand Down Expand Up @@ -63,31 +68,58 @@ const Text = styled.span<{ selected: ItemActionProps["selected"] }>`
overflow: hidden;
`;

const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => {
const [hasTooltip, setHasTooltip] = useState(false);
const modifiedBadge = badge && cloneElement(badge, { size: "small" });
const Control = styled.span`
display: flex;
align-items: center;
padding: var(--spacing-padding-none);
justify-content: flex-end;
align-items: center;
gap: var(--spacing-gap-s);
`;

const ItemAction = memo(
forwardRef<HTMLButtonElement, ItemActionProps>(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => {
const [hasTooltip, setHasTooltip] = useState(false);
const modifiedBadge = badge && cloneElement(badge, { size: "small" });
const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(ContextualMenuContext) ?? {};

return (
<TooltipWrapper condition={hasTooltip} label={label}>
<Action depthLevel={depthLevel} {...props}>
<Label>
{collapseIcon && <Icon>{collapseIcon}</Icon>}
{icon && depthLevel === 0 && <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon>}
<Text
selected={props.selected}
onMouseEnter={(event: MouseEvent<HTMLSpanElement>) => {
const text = event.currentTarget;
setHasTooltip(text.scrollWidth > text.clientWidth);
}}
>
{label}
</Text>
</Label>
{modifiedBadge}
</Action>
</TooltipWrapper>
);
});
return (
<TooltipWrapper condition={hasTooltip} label={label}>
<Action
ref={ref}
depthLevel={depthLevel}
displayGroupLines={!!displayGroupLines}
responsiveView={responsiveView}
{...props}
>
<Label>
{!displayControlsAfter && <Control>{collapseIcon && <Icon>{collapseIcon}</Icon>}</Control>}
<TooltipWrapper condition={responsiveView} label={label}>
<Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon>
</TooltipWrapper>
{!responsiveView && (
<Text
selected={props.selected}
onMouseEnter={(event: MouseEvent<HTMLSpanElement>) => {
const text = event.currentTarget;
setHasTooltip(text.scrollWidth > text.clientWidth);
}}
>
{label}
</Text>
)}
</Label>
{!responsiveView && (
<Control>
{modifiedBadge}
{displayControlsAfter && collapseIcon && <Icon>{collapseIcon}</Icon>}
</Control>
)}
</Action>
</TooltipWrapper>
);
})
);

ItemAction.displayName = "ItemAction";

Expand Down
15 changes: 11 additions & 4 deletions packages/lib/src/contextual-menu/Section.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useId } from "react";
import { useContext, useId } from "react";
import styled from "@emotion/styled";
import { DxcInset } from "..";
import DxcDivider from "../divider/Divider";
import SubMenu from "./SubMenu";
import MenuItem from "./MenuItem";
import { SectionProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";

const SectionContainer = styled.section`
display: grid;
Expand All @@ -22,11 +23,11 @@ const Title = styled.h2`

export default function Section({ index, length, section }: SectionProps) {
const id = `section-${useId()}`;

return (
const { responsiveView } = useContext(ContextualMenuContext) ?? {};
return !responsiveView ? (
<SectionContainer aria-label={section.title ?? id} aria-labelledby={id}>
{section.title && <Title id={id}>{section.title}</Title>}
<SubMenu>
<SubMenu depthLevel={-1}>
{section.items.map((item, i) => (
<MenuItem item={item} key={`${item.label}-${i}`} />
))}
Expand All @@ -37,5 +38,11 @@ export default function Section({ index, length, section }: SectionProps) {
</DxcInset>
)}
</SectionContainer>
) : (
<SubMenu depthLevel={-1}>
{section.items.map((item, i) => (
<MenuItem item={item} key={`${item.label}-${i}`} />
))}
</SubMenu>
);
}
17 changes: 14 additions & 3 deletions packages/lib/src/contextual-menu/SubMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import styled from "@emotion/styled";
import { SubMenuProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";
import { useContext } from "react";

const SubMenuContainer = styled.ul`
const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boolean }>`
margin: 0;
padding: 0;
display: grid;
gap: var(--spacing-gap-xs);
list-style: none;

${({ depthLevel, displayGroupLines }) =>
displayGroupLines &&
depthLevel >= 0 &&
`
margin-left: calc(var(--spacing-padding-m) + ${depthLevel} * var(--spacing-padding-xs));
border-left: var(--border-width-s) solid var(--border-color-neutral-lighter);
`}
`;

export default function SubMenu({ children, id }: SubMenuProps) {
export default function SubMenu({ children, id, depthLevel = 0 }: SubMenuProps) {
const { displayGroupLines } = useContext(ContextualMenuContext) ?? {};
return (
<SubMenuContainer id={id} role="menu">
<SubMenuContainer id={id} role="menu" depthLevel={depthLevel} displayGroupLines={displayGroupLines}>
{children}
</SubMenuContainer>
);
Expand Down
42 changes: 36 additions & 6 deletions packages/lib/src/contextual-menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ type Props = {
* Each item can be a single/simple item, a group item or a section.
*/
items: (Item | GroupItem)[] | Section[];
/**
* If true the contextual menu will be displayed with a border.
* @private
*/
displayBorder?: boolean;
/**
* If true the contextual menu will have lines marking the groups.
* @private
*/
displayGroupLines?: boolean;
/**
* If true the contextual menu will have controls at the end.
* @private
*/
displayControlsAfter?: boolean;
/**
* If true the contextual menu will be icons only and display a popover on click.
* @private
*/
responsiveView?: boolean;
};

type ItemWithId = Item & { id: number };
Expand All @@ -31,9 +51,16 @@ type GroupItemWithId = {
};
type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string };

type SingleItemProps = ItemWithId & { depthLevel: number };
type GroupItemProps = GroupItemWithId & { depthLevel: number };
type MenuItemProps = { item: ItemWithId | GroupItemWithId; depthLevel?: number };
type SingleItemProps = ItemWithId & {
depthLevel: number;
};
type GroupItemProps = GroupItemWithId & {
depthLevel: number;
};
type MenuItemProps = {
item: ItemWithId | GroupItemWithId;
depthLevel?: number;
};
type ItemActionProps = ButtonHTMLAttributes<HTMLButtonElement> & {
badge?: Item["badge"];
collapseIcon?: ReactNode;
Expand All @@ -47,10 +74,13 @@ type SectionProps = {
index: number;
length: number;
};
type SubMenuProps = { children: ReactNode; id?: string };
type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number };
type ContextualMenuContextProps = {
selectedItemId: number;
setSelectedItemId: Dispatch<SetStateAction<number>>;
selectedItemId?: number;
setSelectedItemId?: Dispatch<SetStateAction<number>>;
displayGroupLines?: boolean;
displayControlsAfter?: boolean;
responsiveView?: boolean;
};

export type {
Expand Down
Loading
Loading