Skip to content

Commit

Permalink
🎨 improve navigation menu with context management and button componen…
Browse files Browse the repository at this point in the history
…t integration
  • Loading branch information
AntoineKM committed Nov 6, 2024
1 parent a0fb362 commit 0f1b74e
Show file tree
Hide file tree
Showing 6 changed files with 432 additions and 387 deletions.
97 changes: 97 additions & 0 deletions packages/kitchn/src/components/NavigationMenu/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React from "react";
import styled from "styled-components";

import { DecoratorProps } from "../../../hoc";
import useNavigationMenu from "../../../hooks/useNavigationMenu";
import useNavigationMenuItem from "../../../hooks/useNavigationMenuItem";
import { getId } from "../../../utils";
import Button, { ButtonProps } from "../../Button";
import Icon, { IconProps } from "../../Icon";
import Text from "../../Text";

export type NavigationMenuButtonProps = {
active?: boolean;
disabled?: boolean;
id?: string;
children?: React.ReactNode;
} & (
| ({
unstyled?: true;
} & React.PropsWithChildren)
| ({
unstyled?: false;
} & ButtonProps &
DecoratorProps)
);

const NavigationMenuButton = styled(
({ active, disabled, unstyled, id, ...props }: NavigationMenuButtonProps) => {
const { handleMouseOver, setTooltipContent } = useNavigationMenu();
const { id: itemId, hasContent, position } = useNavigationMenuItem();
const buttonId = itemId || id || getId();

const handleHover = (e: React.MouseEvent<HTMLElement>) => {
// Clear tooltip content if this is a simple button without dropdown content
if (!hasContent) {
setTooltipContent(null);
}
handleMouseOver(e, buttonId);
};

if (unstyled && props.children) {
return React.cloneElement(props.children as React.ReactElement, {
...props,
onMouseOver: handleHover,
});
}

return (
<Button
size={"small"}
shape={"round"}
variant={"ghost"}
role={"menuitem"}
active={"foobar"}
data-position={position}
onMouseOver={handleHover}
disabled={disabled}
{...props}
>
<Text size={"inherit"} color={active ? "lightest" : "light"} span>
{props.children}
</Text>
</Button>
);
},
)`
border-width: 0;
&:hover {
border-width: 0;
}
`;

export const NavigationMenuButtonIcon = styled(
({ children, ...props }: IconProps) => {
const { activeId } = useNavigationMenu();
const { id: itemId } = useNavigationMenuItem();
const isActive = activeId === itemId;

return (
<Icon
// TODO: Replace style by styled props
style={{
transform: isActive ? "rotate(180deg)" : "none",
}}
{...props}
>
{children}
</Icon>
);
},
)`
transition: transform 0.3s;
color: ${({ theme }) => theme.colors.text.light};
`;

export default NavigationMenuButton;
244 changes: 244 additions & 0 deletions packages/kitchn/src/components/NavigationMenu/Content/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import React from "react";
import styled, { css, keyframes, RuleSet } from "styled-components";
import { Keyframes } from "styled-components/dist/types";

import { DecoratorProps } from "../../../hoc";
import useNavigationMenu from "../../../hooks/useNavigationMenu";
import useNavigationMenuItem from "../../../hooks/useNavigationMenuItem";
import Container from "../../Container";

const enterFromRight = keyframes`
from {
opacity: 0;
transform: translateX(200px);
}
to {
opacity: 1;
transform: translateX(0);
}
`;

const enterFromLeft = keyframes`
from {
opacity: 0;
transform: translateX(-200px);
}
to {
opacity: 1;
transform: translateX(0);
}
`;

const exitToRight = keyframes`
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(200px);
}
`;

const exitToLeft = keyframes`
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-200px);
}
`;

export const animationConfig = {
duration: "250ms", // Slightly faster for more snappy feel
easing: "cubic-bezier(0.4, 0.0, 0.2, 1)", // Smooth easing
delayBeforeRemove: 200, // Time to wait before removing from DOM
};

// Direction type for animation
export type AnimationDirection =
| "normal"
| "reverse"
| "alternate"
| "alternate-reverse";

// Menu slide direction
export type MenuDirection = "right" | "left" | "none";

// Animation config type
export interface AnimationConfig {
duration: string;
easing: string;
}

// Animation state interface
export interface AnimationState {
isEntering: boolean;
shouldRender: boolean;
}

// Return type for useMenuAnimation hook
export interface MenuAnimationResult extends AnimationState {
animationStyle: RuleSet<object>;
}

export const createAnimationStyle = (
animation: Keyframes,
direction: AnimationDirection = "normal",
) => css`
animation: ${animation} ${animationConfig.duration} ${animationConfig.easing}
${direction} forwards;
`;

export const useMenuAnimation = (
isVisible: boolean,
direction: MenuDirection = "right",
): {
shouldRender: boolean;
isEntering: boolean;
direction: MenuDirection;
} => {
const [animationState, setAnimationState] = React.useState<AnimationState>({
isEntering: false,
shouldRender: isVisible,
});

React.useEffect(() => {
let timer: number;

if (isVisible) {
// Immediately show and start enter animation
setAnimationState({
isEntering: true,
shouldRender: true,
});
} else {
// Start exit animation but keep rendered
setAnimationState({
isEntering: false,
shouldRender: true,
});

// Remove from DOM after animation completes
timer = window.setTimeout(() => {
setAnimationState({
isEntering: false,
shouldRender: false,
});
}, animationConfig.delayBeforeRemove);
}

return () => {
if (timer) {
window.clearTimeout(timer);
}
};
}, [isVisible]);

return {
...animationState,
direction,
};
};

// StyledContent component using the animations
export const StyledContent = styled.ul<{
isEntering: boolean;
direction: MenuDirection;
}>`
pointer-events: all;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.gap.normal};
opacity: 0;
transform: translateX(
${({ direction }) =>
direction === "right" ? "100px" : direction === "left" ? "-100px" : "0"}
);
${({ isEntering, direction }) =>
direction !== "none"
? isEntering
? css`
animation: ${direction === "right" ? enterFromRight : enterFromLeft}
${animationConfig.duration} ${animationConfig.easing} forwards;
`
: css`
animation: ${direction === "right" ? exitToRight : exitToLeft}
${animationConfig.duration} ${animationConfig.easing} forwards;
`
: css`
opacity: 1;
transform: translateX(0);
`}
`;

type NavigationMenuContentProps = {
children: React.ReactNode;
id?: string;
} & DecoratorProps;

const NavigationMenuContent = ({
children,
id,
...props
}: NavigationMenuContentProps) => {
const { activeId, previousId, setTooltipContent, menuItems } =
useNavigationMenu();
const { id: itemId } = useNavigationMenuItem();
const contentId = itemId || id;
const isActive = activeId === contentId;

// Determine animation direction based on IDs
const getAnimationDirection = (): MenuDirection => {
if (!previousId || !activeId) return "none";

let previousIndex = -1;
let currentIndex = -1;

menuItems.forEach((item, index) => {
if (item === previousId) {
previousIndex = index;
}
if (item === activeId) {
currentIndex = index;
}
});

return previousIndex < currentIndex ? "right" : "left";
};

const { shouldRender, isEntering, direction } = useMenuAnimation(
isActive,
getAnimationDirection(),
);

const content = (shouldRender: boolean) => {
if (shouldRender) {
return (
<Container px={12} py={8} overflow={"hidden"}>
<StyledContent
{...props}
isEntering={isEntering}
direction={direction}
>
{children}
</StyledContent>
</Container>
);
}

return null;
};

React.useEffect(() => {
if (contentId && isActive) {
setTooltipContent(content(shouldRender));
}
}, [shouldRender, contentId, isActive]);

return null;
};

export default NavigationMenuContent;
33 changes: 27 additions & 6 deletions packages/kitchn/src/components/NavigationMenu/Item/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from "react";
import styled from "styled-components";

import { NavigationMenuContent } from "..";
import { NavigationMenuItemContext } from "../../../contexts/NavigationMenuItem";
import useNavigationMenu from "../../../hooks/useNavigationMenu";
import { getId } from "../../../utils";
import NavigationMenuContent from "../Content";

type NavigationMenuItemProps = {
children: React.ReactNode;
Expand All @@ -21,17 +22,37 @@ const NavigationMenuItemWithContext = ({
children,
...props
}: NavigationMenuItemProps) => {
const itemId = getId();
const { menuItems, setMenuItems } = useNavigationMenu();
const [itemId] = React.useState(getId());

// Check if this menu item has a NavigationMenu.Content child
const hasContent = React.Children.toArray(children).some(
(child) =>
React.isValidElement(child) &&
(child.type as any)?.name === NavigationMenuContent.name,
React.isValidElement(child) && child.type === NavigationMenuContent,
);

React.useEffect(() => {
if (menuItems.includes(itemId)) return;
setMenuItems((prev) => {
const newItems = [...prev];
if (!newItems.includes(itemId)) {
newItems.push(itemId);
}
return newItems;
});

return () => {
setMenuItems((prev) => prev.filter((id) => id !== itemId));
};
}, []);

return (
<NavigationMenuItemContext.Provider value={{ id: itemId, hasContent }}>
<NavigationMenuItemContext.Provider
value={{
id: itemId,
hasContent,
position: menuItems.indexOf(itemId),
}}
>
<NavigationMenuItem data-id={itemId} {...props}>
{children}
</NavigationMenuItem>
Expand Down
Loading

0 comments on commit 0f1b74e

Please sign in to comment.