feat(navbar): open/close animation implemented in framer, tests added

This commit is contained in:
Junior Garcia 2023-04-19 23:37:16 -03:00
parent 20ffb55b23
commit adf1bf6caa
11 changed files with 472 additions and 28 deletions

View File

@ -1,7 +1,15 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {act, render} from "@testing-library/react";
import {Navbar} from "../src";
import {
Navbar,
NavbarBrand,
NavbarItem,
NavbarContent,
NavbarMenuToggle,
NavbarMenu,
NavbarMenuItem,
} from "../src";
describe("Navbar", () => {
it("should render correctly", () => {
@ -16,4 +24,108 @@ describe("Navbar", () => {
render(<Navbar ref={ref} />);
expect(ref.current).not.toBeNull();
});
it("should render correctly with brand", () => {
const wrapper = render(
<Navbar>
<NavbarBrand data-testid="navbar-test">ACME</NavbarBrand>
</Navbar>,
);
expect(wrapper.getByTestId("navbar-test")).toBeInTheDocument();
});
it("should render correctly content children", () => {
const wrapper = render(
<Navbar>
<NavbarContent data-testid="navbar-content-test">
<NavbarItem>Dashboard</NavbarItem>
<NavbarItem>Team</NavbarItem>
<NavbarItem>Deployments</NavbarItem>
<NavbarItem>Activity</NavbarItem>
<NavbarItem>Settings</NavbarItem>
</NavbarContent>
</Navbar>,
);
const navbarContent = wrapper.getByTestId("navbar-content-test");
expect(navbarContent.children.length).toBe(5);
});
it("should render correctly with menu", () => {
const items = ["item1", "item2", "item3", "item4", "item5"];
const wrapper = render(
<Navbar data-testid="navbar-test">
<NavbarMenuToggle data-testid="navbar-toggle-test" />
<NavbarContent data-testid="navbar-content-test">
<NavbarItem>Dashboard</NavbarItem>
<NavbarItem>Team</NavbarItem>
<NavbarItem>Deployments</NavbarItem>
<NavbarItem>Activity</NavbarItem>
<NavbarItem>Settings</NavbarItem>
</NavbarContent>
<NavbarMenu data-testid="navbar-menu-test">
{items.map((item, index) => (
<NavbarMenuItem key={`${item}-${index}`}>{item}</NavbarMenuItem>
))}
</NavbarMenu>
</Navbar>,
);
const toggle = wrapper.getByTestId("navbar-toggle-test");
act(() => {
toggle.click();
});
const menu = wrapper.getByTestId("navbar-menu-test");
expect(menu.children.length).toBe(items.length);
});
it("should call on onChange when toggle is clicked", () => {
const onChange = jest.fn();
const wrapper = render(
<Navbar data-testid="navbar-test">
<NavbarMenuToggle data-testid="navbar-toggle-test" onChange={onChange} />
<NavbarContent data-testid="navbar-content-test">
<NavbarItem>Dashboard</NavbarItem>
<NavbarItem>Team</NavbarItem>
<NavbarItem>Deployments</NavbarItem>
<NavbarItem>Activity</NavbarItem>
<NavbarItem>Settings</NavbarItem>
</NavbarContent>
</Navbar>,
);
const toggle = wrapper.getByTestId("navbar-toggle-test");
act(() => {
toggle.click();
});
expect(onChange).toHaveBeenCalled();
});
it("should render correctly with custom toggle icon", () => {
const wrapper = render(
<Navbar data-testid="navbar-test">
<NavbarMenuToggle data-testid="navbar-toggle-test" icon={<span>test</span>} />
<NavbarContent data-testid="navbar-content-test">
<NavbarItem>Dashboard</NavbarItem>
<NavbarItem>Team</NavbarItem>
<NavbarItem>Deployments</NavbarItem>
<NavbarItem>Activity</NavbarItem>
<NavbarItem>Settings</NavbarItem>
</NavbarContent>
</Navbar>,
);
const toggle = wrapper.getByTestId("navbar-toggle-test");
expect(toggle).toHaveTextContent("test");
});
});

View File

@ -42,6 +42,7 @@
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/framer-transitions": "workspace:*",
"@nextui-org/use-aria-toggle-button": "workspace:*",
"@nextui-org/use-scroll-position": "workspace:*",
"@react-aria/focus": "^3.12.0",

View File

@ -49,7 +49,7 @@ const NavbarMenuToggle = forwardRef<NavbarMenuToggleProps, "button">((props, ref
const Component = as || "button";
const domRef = useDOMRef(ref);
const {slots, classNames, setIsMenuOpen} = useNavbarContext();
const {slots, classNames, isMenuOpen, setIsMenuOpen} = useNavbarContext();
const handleChange = (isOpen: boolean) => {
onChange?.(isOpen);
@ -66,7 +66,7 @@ const NavbarMenuToggle = forwardRef<NavbarMenuToggleProps, "button">((props, ref
const child = useMemo(() => {
if (typeof icon === "function") {
return icon({isOpen: state.isSelected});
return icon({isOpen: isMenuOpen});
}
return icon || <span className={slots.toggleIcon({class: classNames?.toggleIcon})} />;
@ -78,7 +78,7 @@ const NavbarMenuToggle = forwardRef<NavbarMenuToggleProps, "button">((props, ref
}
return state.isSelected ? "close navigation menu" : "open navigation menu";
}, [srOnlyTextProp, state.isSelected]);
}, [srOnlyTextProp, isMenuOpen]);
return (
<Component
@ -86,7 +86,7 @@ const NavbarMenuToggle = forwardRef<NavbarMenuToggleProps, "button">((props, ref
className={slots.toggle?.({class: toggleStyles})}
data-focus-visible={dataAttr(isFocusVisible)}
data-hover={dataAttr(isHovered)}
data-open={dataAttr(state.isSelected)}
data-open={dataAttr(isMenuOpen)}
data-pressed={dataAttr(isPressed)}
{...mergeProps(buttonProps, focusProps, hoverProps, otherProps)}
>

View File

@ -0,0 +1,17 @@
import {TRANSITION_EASINGS} from "@nextui-org/framer-transitions";
import {Variants} from "framer-motion";
export const variants: Variants = {
visible: {
y: 0,
transition: {
ease: TRANSITION_EASINGS.easeOut,
},
},
hidden: {
y: "-100%",
transition: {
ease: TRANSITION_EASINGS.easeIn,
},
},
};

View File

@ -1,6 +1,9 @@
import {forwardRef} from "@nextui-org/system";
import {pickChildren} from "@nextui-org/shared-utils";
import {motion} from "framer-motion";
import {mergeProps} from "@react-aria/utils";
import {variants} from "./navbar-transitions";
import {UseNavbarProps, useNavbar} from "./use-navbar";
import {NavbarProvider} from "./navbar-context";
import NavbarMenu from "./navbar-menu";
@ -18,12 +21,27 @@ const Navbar = forwardRef<NavbarProps, "div">((props, ref) => {
const [childrenWithoutMenu, menu] = pickChildren(children, NavbarMenu);
const content = (
<>
<header {...context.getWrapperProps()}>{childrenWithoutMenu}</header>
{menu}
</>
);
return (
<NavbarProvider value={context}>
<Component {...context.getBaseProps()}>
<header {...context.getWrapperProps()}>{childrenWithoutMenu}</header>
{menu}
</Component>
{context.shouldHideOnScroll ? (
<motion.nav
animate={context.isHidden ? "hidden" : "visible"}
initial={false}
variants={variants}
{...mergeProps(context.getBaseProps(), context.motionProps)}
>
{content}
</motion.nav>
) : (
<Component {...context.getBaseProps()}>{content}</Component>
)}
</NavbarProvider>
);
});

View File

@ -4,10 +4,11 @@ import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system"
import {navbar} from "@nextui-org/theme";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, dataAttr, ReactRef} from "@nextui-org/shared-utils";
import {useMemo, useState} from "react";
import {mergeProps} from "@react-aria/utils";
import {useEffect, useMemo, useRef, useState} from "react";
import {mergeProps, useResizeObserver} from "@react-aria/utils";
import {useScrollPosition} from "@nextui-org/use-scroll-position";
import {useControlledState} from "@react-stately/utils";
import {HTMLMotionProps} from "framer-motion";
export interface UseNavbarProps extends HTMLNextUIProps<"nav", NavbarVariantProps> {
/**
@ -45,6 +46,11 @@ export interface UseNavbarProps extends HTMLNextUIProps<"nav", NavbarVariantProp
* @default false
*/
disableScrollHandler?: boolean;
/**
* The props to modify the framer motion animation. Use the `variants` API to create your own animation.
* This motion is only available if the `shouldHideOnScroll` prop is set to `true`.
*/
motionProps?: HTMLMotionProps<"nav">;
/**
* The event handler for the menu open state.
* @param isOpen boolean
@ -56,6 +62,7 @@ export interface UseNavbarProps extends HTMLNextUIProps<"nav", NavbarVariantProp
* it only works if `disableScrollHandler` is set to `false` or `shouldHideOnScroll` is set to `true`.
*/
onScrollPositionChange?: (scrollPosition: number) => void;
/**
* Classname or List of classes to change the classNames of the element.
* if `className` is passed, it will be added to the base slot.
@ -90,6 +97,7 @@ export function useNavbar(originalProps: UseNavbarProps) {
isMenuOpen: isMenuOpenProp,
isMenuDefaultOpen = false,
onMenuOpenChange = () => {},
motionProps,
className,
classNames,
...otherProps
@ -99,6 +107,9 @@ export function useNavbar(originalProps: UseNavbarProps) {
const domRef = useDOMRef(ref);
const prevWidth = useRef(0);
const navHeight = useRef(0);
const [isHidden, setIsHidden] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useControlledState<boolean | undefined>(
@ -107,6 +118,34 @@ export function useNavbar(originalProps: UseNavbarProps) {
onMenuOpenChange,
);
const updateWidth = () => {
if (domRef.current) {
const width = domRef.current.offsetWidth;
if (width !== prevWidth.current) {
prevWidth.current = width;
}
}
};
useResizeObserver({
ref: domRef,
onResize: () => {
const currentWidth = domRef.current?.offsetWidth;
if (currentWidth !== prevWidth.current) {
updateWidth();
setIsMenuOpen(false);
}
},
});
useEffect(() => {
updateWidth();
navHeight.current = domRef.current?.offsetHeight || 0;
}, []);
const slots = useMemo(
() =>
navbar({
@ -125,7 +164,7 @@ export function useNavbar(originalProps: UseNavbarProps) {
onScrollPositionChange?.(currPos.y);
if (shouldHideOnScroll) {
setIsHidden((prev) => {
const next = currPos.y > prevPos.y;
const next = currPos.y > prevPos.y && currPos.y > navHeight.current;
return next !== prev ? next : prev;
});
@ -135,7 +174,7 @@ export function useNavbar(originalProps: UseNavbarProps) {
const getBaseProps: PropGetter = (props = {}) => ({
...mergeProps(otherProps, props),
"data-hide": dataAttr(isHidden),
"data-hidden": dataAttr(isHidden),
"data-menu-open": dataAttr(isMenuOpen),
ref: domRef,
className: slots.base({class: clsx(baseStyles, props?.className)}),
@ -153,9 +192,12 @@ export function useNavbar(originalProps: UseNavbarProps) {
return {
Component,
motionProps,
isHidden,
slots,
domRef,
classNames,
shouldHideOnScroll,
isMenuOpen,
setIsMenuOpen,
getBaseProps,

View File

@ -3,9 +3,20 @@ import {ComponentStory, ComponentMeta} from "@storybook/react";
import {navbar} from "@nextui-org/theme";
import {Link} from "@nextui-org/link";
import {Button} from "@nextui-org/button";
import {Avatar} from "@nextui-org/avatar";
import {Input} from "@nextui-org/input";
import Lorem from "react-lorem-component";
import {Dropdown, DropdownTrigger, DropdownMenu, DropdownItem} from "@nextui-org/dropdown";
import {ChevronDown, Lock, Activity, Flash, Server, TagUser, Scale} from "@nextui-org/shared-icons";
import {
ChevronDown,
Lock,
Activity,
Flash,
Server,
TagUser,
Scale,
SearchIcon,
} from "@nextui-org/shared-icons";
import {
Navbar,
@ -223,7 +234,7 @@ const WithDropdownTemplate: ComponentStory<typeof Navbar> = (args: NavbarProps)
return (
<App>
<Navbar isBordered position="sticky" {...args}>
<Navbar {...args}>
<NavbarBrand>
<AcmeLogo />
<p className="font-bold hidden sm:block text-inherit">ACME</p>
@ -317,6 +328,212 @@ const WithDropdownTemplate: ComponentStory<typeof Navbar> = (args: NavbarProps)
);
};
const WithAvatarUserTemplate: ComponentStory<typeof Navbar> = (args: NavbarProps) => {
const menuItems = [
"Profile",
"Dashboard",
"Activity",
"Analytics",
"System",
"Deployments",
"My Settings",
"Team Settings",
"Help & Feedback",
"Logout",
];
return (
<App>
<Navbar {...args}>
<NavbarMenuToggle className="sm:hidden" />
<NavbarMenu>
{menuItems.map((item, index) => (
<NavbarMenuItem key={`${item}-${index}`}>
<Link
color={
index === 2 ? "primary" : index === menuItems.length - 1 ? "danger" : "foreground"
}
href="#"
size="lg"
>
{item}
</Link>
</NavbarMenuItem>
))}
</NavbarMenu>
<NavbarBrand>
<AcmeLogo />
<p className="font-bold text-inherit">ACME</p>
</NavbarBrand>
<NavbarContent className="hidden gap-3 md:flex">
<NavbarItem as={Link} color="foreground" href="#">
Dashboard
</NavbarItem>
<NavbarItem isActive as={Link} color="secondary" href="#">
Team
</NavbarItem>
<NavbarItem as={Link} color="foreground" href="#">
Deployments
</NavbarItem>
<NavbarItem as={Link} color="foreground" href="#">
Activity
</NavbarItem>
<NavbarItem as={Link} color="foreground" href="#">
Settings
</NavbarItem>
</NavbarContent>
<NavbarContent>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<NavbarItem>
<Avatar
isBordered
as="button"
className="transition-transform"
color="secondary"
size="sm"
src="https://i.pravatar.cc/150?u=a042581f4e29026704d"
/>
</NavbarItem>
</DropdownTrigger>
<DropdownMenu aria-label="Profile Actions" color="secondary">
<DropdownItem key="profile" className="h-14 gap-2">
<p className="font-semibold">Signed in as</p>
<p className="font-semibold">zoey@example.com</p>
</DropdownItem>
<DropdownItem key="settings" showDivider>
My Settings
</DropdownItem>
<DropdownItem key="team_settings">Team Settings</DropdownItem>
<DropdownItem key="analytics" showDivider>
Analytics
</DropdownItem>
<DropdownItem key="system">System</DropdownItem>
<DropdownItem key="configurations">Configurations</DropdownItem>
<DropdownItem key="help_and_feedback" showDivider>
Help & Feedback
</DropdownItem>
<DropdownItem key="logout" showDivider color="danger">
Log Out
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarContent>
</Navbar>
</App>
);
};
const WithSearchInputTemplate: ComponentStory<typeof Navbar> = (args: NavbarProps) => {
const menuItems = [
"Profile",
"Dashboard",
"Activity",
"Analytics",
"System",
"Deployments",
"My Settings",
"Team Settings",
"Help & Feedback",
"Logout",
];
return (
<App>
<Navbar {...args}>
<NavbarMenuToggle className="sm:hidden" />
<NavbarMenu>
{menuItems.map((item, index) => (
<NavbarMenuItem key={`${item}-${index}`}>
<Link
color={
index === 2 ? "primary" : index === menuItems.length - 1 ? "danger" : "foreground"
}
href="#"
size="lg"
>
{item}
</Link>
</NavbarMenuItem>
))}
</NavbarMenu>
<NavbarBrand>
<AcmeLogo />
<p className="font-bold text-inherit">ACME</p>
</NavbarBrand>
<NavbarContent className="hidden gap-3 md:flex">
<NavbarItem as={Link} color="foreground" href="#">
Dashboard
</NavbarItem>
<NavbarItem isActive as={Link} color="secondary" href="#">
Team
</NavbarItem>
<NavbarItem as={Link} color="foreground" href="#">
Deployments
</NavbarItem>
<NavbarItem as={Link} color="foreground" href="#">
Activity
</NavbarItem>
<NavbarItem as={Link} color="foreground" href="#">
Settings
</NavbarItem>
</NavbarContent>
<NavbarContent>
<NavbarItem className="hidden md:flex">
<Input
classNames={{
input: "text-base",
}}
placeholder="Search..."
size="xs"
startContent={<SearchIcon className="text-base pointer-events-none flex-shrink-0" />}
onClear={() => {
// eslint-disable-next-line no-console
console.log("clear");
}}
/>
</NavbarItem>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<NavbarItem>
<Avatar
isBordered
as="button"
className="transition-transform"
color="secondary"
size="sm"
src="https://i.pravatar.cc/150?u=a042581f4e29026704d"
/>
</NavbarItem>
</DropdownTrigger>
<DropdownMenu aria-label="Profile Actions" color="secondary">
<DropdownItem key="profile" className="h-14 gap-2">
<p className="font-semibold">Signed in as</p>
<p className="font-semibold">zoey@example.com</p>
</DropdownItem>
<DropdownItem key="settings" showDivider>
My Settings
</DropdownItem>
<DropdownItem key="team_settings">Team Settings</DropdownItem>
<DropdownItem key="analytics" showDivider>
Analytics
</DropdownItem>
<DropdownItem key="system">System</DropdownItem>
<DropdownItem key="configurations">Configurations</DropdownItem>
<DropdownItem key="help_and_feedback" showDivider>
Help & Feedback
</DropdownItem>
<DropdownItem key="logout" showDivider color="danger">
Log Out
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarContent>
</Navbar>
</App>
);
};
export const Static = Template.bind({});
Static.args = {
...defaultProps,
@ -348,7 +565,16 @@ WithMenu.args = {
};
export const WithDropdown = WithDropdownTemplate.bind({});
WithDropdown.args = {
...defaultProps,
};
export const WithAvatarUser = WithAvatarUserTemplate.bind({});
WithAvatarUser.args = {
...defaultProps,
};
export const WithSearchInput = WithSearchInputTemplate.bind({});
WithSearchInput.args = {
...defaultProps,
};

View File

@ -1,8 +1,8 @@
import type {PopoverVariantProps, SlotsToClasses, PopoverSlots} from "@nextui-org/theme";
import type {HTMLMotionProps} from "framer-motion";
import type {RefObject, Ref} from "react";
import type {OverlayPlacement} from "@nextui-org/aria-utils";
import {OverlayPlacement, toOverlayPlacement} from "@nextui-org/aria-utils";
import {OverlayTriggerState, useOverlayTriggerState} from "@react-stately/overlays";
import {useFocusRing} from "@react-aria/focus";
import {
@ -13,7 +13,11 @@ import {
} from "@react-aria/overlays";
import {OverlayTriggerProps} from "@react-types/overlays";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {toReactAriaPlacement, getArrowPlacement} from "@nextui-org/aria-utils";
import {
toReactAriaPlacement,
getArrowPlacement,
getShouldUseAxisPlacement,
} from "@nextui-org/aria-utils";
import {popover} from "@nextui-org/theme";
import {mergeProps, mergeRefs} from "@react-aria/utils";
import {createDOMRef} from "@nextui-org/dom-utils";
@ -148,7 +152,7 @@ export function usePopover(originalProps: UsePopoverProps) {
isDisabled: !state.isOpen || !shouldBlockScroll,
});
const {popoverProps, underlayProps, arrowProps, placement} = useReactAriaPopover(
const {popoverProps, underlayProps, arrowProps, placement: ariaPlacement} = useReactAriaPopover(
{
triggerRef,
popoverRef,
@ -195,6 +199,11 @@ export function usePopover(originalProps: UsePopoverProps) {
},
});
const placement = useMemo(
() => (getShouldUseAxisPlacement(ariaPlacement, placementProp) ? ariaPlacement : placementProp),
[ariaPlacement, placementProp],
);
const getTriggerProps = useCallback<PropGetter>(
(props = {}, _ref: Ref<any> | null | undefined = null) => {
return {
@ -221,10 +230,10 @@ export function usePopover(originalProps: UsePopoverProps) {
const getArrowProps = useCallback<PropGetter>(
() => ({
className: slots.arrow({class: classNames?.arrow}),
"data-placement": getArrowPlacement(placement, placementProp),
"data-placement": getArrowPlacement(ariaPlacement, placementProp),
...arrowProps,
}),
[arrowProps, placement, placementProp, slots, classNames],
[arrowProps, ariaPlacement, placementProp, slots, classNames],
);
return {
@ -233,7 +242,7 @@ export function usePopover(originalProps: UsePopoverProps) {
classNames,
showArrow,
triggerRef,
placement: placement ? toOverlayPlacement(placement) : placementProp,
placement,
isOpen: state.isOpen,
onClose: state.close,
disableAnimation,

View File

@ -49,7 +49,7 @@ const navbar = tv({
slots: {
base: [
"relative",
"z-20",
"z-50",
"w-full",
"h-auto",
"flex",
@ -144,6 +144,7 @@ const navbar = tv({
"data-[active=true]:font-semibold",
],
menu: [
"z-50",
"hidden",
"px-6",
"pt-4",
@ -200,10 +201,10 @@ const navbar = tv({
"sticky",
"top-0",
"inset-x-0",
"transition-transform",
"!duration-400",
"translate-y-0",
"data-[hide=true]:-translate-y-full",
// "transition-transform",
// "!duration-400",
// "translate-y-0",
// "data-[hide=true]:-translate-y-full",
],
},
},

View File

@ -90,6 +90,21 @@ export const toOverlayPlacement = (placement: PlacementAxis) => {
return mapPositions[placement];
};
export const getShouldUseAxisPlacement = (
axisPlacement: PlacementAxis,
overlayPlacement: OverlayPlacement,
) => {
if (overlayPlacement.includes("-")) {
const [position] = overlayPlacement.split("-");
if (position.includes(axisPlacement)) {
return false;
}
}
return true;
};
export const getArrowPlacement = (dynamicPlacement: PlacementAxis, placement: OverlayPlacement) => {
if (placement.includes("-")) {
const [, position] = placement.split("-");

3
pnpm-lock.yaml generated
View File

@ -1082,6 +1082,9 @@ importers:
'@nextui-org/dom-utils':
specifier: workspace:*
version: link:../../utilities/dom-utils
'@nextui-org/framer-transitions':
specifier: workspace:*
version: link:../../utilities/framer-transitions
'@nextui-org/shared-utils':
specifier: workspace:*
version: link:../../utilities/shared-utils