Skip to content

Commit

Permalink
Add render to NavigationItem (#3709)
Browse files Browse the repository at this point in the history
Co-authored-by: mark-tate <[email protected]>
  • Loading branch information
joshwooding and mark-tate authored Jul 12, 2024
1 parent 83a2a7f commit 34e8c9c
Show file tree
Hide file tree
Showing 15 changed files with 480 additions and 88 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-avocados-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@salt-ds/core": minor
---

Added `render` prop to `NavigationItem`. The `render` prop enables the substitution of the default anchor tag with an alternate link, such as React Router, facilitating integration with routing libraries.
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,8 @@ describe("GIVEN a NavItem", () => {
describe("AND it is a parent", () => {
it("should render an expansion button", () => {
cy.mount(<NavigationItem parent={true}>Navigation Item</NavigationItem>);
cy.findByRole("button", { name: "expand" }).should("exist");
cy.findByRole("button", { name: "expand" }).should(
"have.attr",
"aria-expanded",
"false",
);
cy.findByRole("button").should("exist");
cy.findByRole("button").should("have.attr", "aria-expanded", "false");
});

it("should call `onExpand` when the expansion button is clicked", () => {
Expand All @@ -81,7 +77,7 @@ describe("GIVEN a NavItem", () => {
Navigation Item
</NavigationItem>,
);
cy.findByRole("button", { name: "expand" }).realClick();
cy.findByRole("button").realClick();
cy.get("@expandSpy").should("have.been.calledOnce");
});

Expand All @@ -92,12 +88,76 @@ describe("GIVEN a NavItem", () => {
Navigation Item
</NavigationItem>,
);
cy.findByRole("button", { name: "expand" }).should(
"have.attr",
"aria-expanded",
"true",
);
cy.findByRole("button").should("have.attr", "aria-expanded", "true");
});
});
});

describe("AND `render` is passed a render function", () => {
it("should call `render` to create parent item", () => {
const mockRender = cy
.stub()
.as("render")
.returns(<button>Parent Button</button>);
cy.mount(
<NavigationItem
active={true}
expanded={true}
href="https://www.saltdesignsystem.com"
level={2}
parent={true}
orientation="vertical"
render={mockRender}
>
Navigation Item
</NavigationItem>,
);
cy.findByText("Parent Button").should("exist");
cy.get("@render").should("have.been.calledWithMatch", {
"aria-expanded": true,
className: Cypress.sinon.match.string,
children: Cypress.sinon.match.any,
});
});
it("should call `render` to create child item", () => {
const mockRender = cy
.stub()
.as("render")
// biome-ignore lint/a11y/useValidAnchor: <explanation>
.returns(<a>Navigation Link</a>);
cy.mount(
<NavigationItem
active={true}
expanded={true}
href="https://www.saltdesignsystem.com"
level={2}
parent={false}
orientation="vertical"
render={mockRender}
>
Navigation Item
</NavigationItem>,
);
cy.findByText("Navigation Link").should("exist");
cy.get("@render").should("have.been.calledWithMatch", {
"aria-current": "page",
className: Cypress.sinon.match.string,
children: Cypress.sinon.match.any,
href: "https://www.saltdesignsystem.com",
});
});
});

describe("AND `render` is given a JSX element", () => {
it("should merge the props and render the JSX element ", () => {
cy.mount(
<NavigationItem parent={true} render={<button>Button Children</button>}>
Navigation Item
</NavigationItem>,
);
cy.findByRole("button").should("exist");
cy.findByRole("button").should("have.attr", "aria-expanded", "false");
cy.findByText("Button Children").should("exist");
});
});
});
54 changes: 54 additions & 0 deletions packages/core/src/__tests__/__e2e__/utils/renderProps.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { renderProps } from "@salt-ds/core";
import { mount } from "cypress/react18";
import type { ComponentPropsWithoutRef } from "react";

describe("renderProps function", () => {
const Button = (props: ComponentPropsWithoutRef<"button">) => (
<button {...props} />
);

it("should merge the props and render the JSX element when `render` is a valid React element", () => {
const props = {
render: <Button>Button Children</Button>,
className: "test-class",
};

mount(renderProps(Button, props));
cy.findByRole("button", { name: "Button Children" }).should("exist");
cy.findByRole("button", { name: "Button Children" }).should(
"have.class",
"test-class",
);
});

it("should call the function with the rest of the props and render the returned element when `render` is a function", () => {
const renderFunction = (props: { className: string }) => (
<Button className={props.className}>Button Children</Button>
);
const props = {
render: renderFunction,
className: "test-class",
};

mount(renderProps(Button, props));
cy.findByRole("button", { name: "Button Children" }).should("exist");
cy.findByRole("button", { name: "Button Children" }).should(
"have.class",
"test-class",
);
});

it("should render the Type component with the rest of the props when `render` is not provided", () => {
const props = {
className: "test-class",
children: "Button Children",
};

mount(renderProps("button", props));
cy.findByRole("button", { name: "Button Children" }).should("exist");
cy.findByRole("button", { name: "Button Children" }).should(
"have.class",
"test-class",
);
});
});
46 changes: 0 additions & 46 deletions packages/core/src/navigation-item/ConditionalWrapper.tsx

This file was deleted.

18 changes: 14 additions & 4 deletions packages/core/src/navigation-item/ExpansionIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ChevronDownIcon, ChevronRightIcon } from "@salt-ds/icons";
import type { NavigationItemProps } from "./NavigationItem";

const iconExpansionMap = {
vertical: {
Expand All @@ -12,11 +11,22 @@ const iconExpansionMap = {
},
};

export function ExpansionIcon({
interface ExpansionIconProps {
/**
* Whether the navigation item is expanded.
*/
expanded?: boolean;
/**
* The orientation of the navigation item.
*/
orientation?: "horizontal" | "vertical";
}

export const ExpansionIcon = ({
expanded = false,
orientation = "horizontal",
}: Pick<NavigationItemProps, "expanded" | "orientation" | "className">) {
}: ExpansionIconProps) => {
const Icon =
iconExpansionMap[orientation][expanded ? "expanded" : "collapsed"];
return <Icon aria-hidden="true" />;
}
};
34 changes: 24 additions & 10 deletions packages/core/src/navigation-item/NavigationItem.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { clsx } from "clsx";
import {
type ComponentPropsWithoutRef,
type MouseEvent,
type MouseEventHandler,
forwardRef,
} from "react";
import { makePrefixer } from "../utils";
import { ConditionalWrapper } from "./ConditionalWrapper";
import { ExpansionIcon } from "./ExpansionIcon";

import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";

import type { RenderPropsType } from "../utils";
import navigationItemCss from "./NavigationItem.css";
import { NavigationItemAction } from "./NavigationItemAction";

export interface NavigationItemProps extends ComponentPropsWithoutRef<"div"> {
/**
Expand All @@ -38,6 +40,10 @@ export interface NavigationItemProps extends ComponentPropsWithoutRef<"div"> {
* Whether the navigation item is a parent with nested items.
*/
parent?: boolean;
/**
* Render prop to enable customisation of navigation item element.
*/
render?: RenderPropsType["render"];
/**
* Action to be triggered when the navigation item is expanded.
*/
Expand All @@ -55,14 +61,15 @@ export const NavigationItem = forwardRef<HTMLDivElement, NavigationItemProps>(
const {
active,
blurActive,
render,
children,
className,
expanded = false,
href,
orientation = "horizontal",
parent,
level = 0,
onExpand,
href,
style: styleProp,
...rest
} = props;
Expand All @@ -79,14 +86,21 @@ export const NavigationItem = forwardRef<HTMLDivElement, NavigationItemProps>(
"--saltNavigationItem-level": `${level}`,
};

const isParent = parent || href === undefined;

const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onExpand?.(event);
};

return (
<div
ref={ref}
className={clsx(withBaseName(), className)}
style={style}
{...rest}
>
<ConditionalWrapper
<NavigationItemAction
className={clsx(
withBaseName("wrapper"),
{
Expand All @@ -96,17 +110,17 @@ export const NavigationItem = forwardRef<HTMLDivElement, NavigationItemProps>(
},
withBaseName(orientation),
)}
parent={parent}
expanded={expanded}
onExpand={onExpand}
active={active}
render={render ?? (isParent ? <button type="button" /> : undefined)}
aria-expanded={isParent ? expanded : undefined}
onClick={handleClick}
aria-current={!isParent && active ? "page" : undefined}
href={href}
>
<span className={withBaseName("label")}>{children}</span>
{parent && (
{isParent ? (
<ExpansionIcon expanded={expanded} orientation={orientation} />
)}
</ConditionalWrapper>
) : null}
</NavigationItemAction>
</div>
);
},
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/navigation-item/NavigationItemAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ComponentPropsWithoutRef } from "react";
import { renderProps } from "../utils";

interface NavigationItemActionProps extends ComponentPropsWithoutRef<any> {}

export function NavigationItemAction(props: NavigationItemActionProps) {
return renderProps("a", props);
}
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from "./marginMiddleware";
export * from "./ownerDocument";
export * from "./ownerWindow";
export * from "./polymorphicTypes";
export * from "./renderProps";
export * from "./setRef";
export * from "./useControlled";
export * from "./useFloatingUI";
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/utils/renderProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
type ElementType,
type ReactElement,
cloneElement,
isValidElement,
} from "react";
import { mergeProps } from "./mergeProps";

export interface RenderPropsType {
render?: ReactElement | ((props: any) => ReactElement);
}

export function renderProps<Type extends ElementType>(
Type: Type,
props: RenderPropsType & React.ComponentProps<Type>,
): ReactElement {
const { render, ...rest } = props;
// Case 1: If render is a valid React element, clone it with merged props
if (isValidElement(render)) {
const renderProps = render.props as React.ComponentProps<Type>;
return cloneElement(render, mergeProps(rest, renderProps));
}

const restProps = rest as React.ComponentProps<Type>;

// Case 2: If render is a function, call it with the rest of the props
if (typeof render === "function") {
const renderedElement = render(restProps);
if (isValidElement(renderedElement)) {
return renderedElement;
}
throw new Error("Render function did not return a valid React element");
}

// Case 3: If render is not provided, render the Type component with the rest of the props
return <Type {...restProps} />;
}
Loading

0 comments on commit 34e8c9c

Please sign in to comment.