Skip to content

Commit

Permalink
add render prop to NavigationItem (#3279)
Browse files Browse the repository at this point in the history
- the `render` prop enables the substitution of the default anchor tag with an alternate link, such as React Router, facilitating integration with routing libraries.
  • Loading branch information
mark-tate committed Jun 18, 2024
1 parent f89189d commit 492fdd4
Show file tree
Hide file tree
Showing 10 changed files with 477 additions and 76 deletions.
7 changes: 7 additions & 0 deletions .changeset/tender-avocados-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@salt-ds/core": minor
---

add `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
@@ -1,4 +1,4 @@
import { NavigationItem } from "@salt-ds/core";
import { NavigationItem, NavigationItemRenderProps } from "@salt-ds/core";
import { NotificationIcon } from "@salt-ds/icons";

describe("GIVEN a NavItem", () => {
Expand Down Expand Up @@ -100,4 +100,87 @@ describe("GIVEN a NavItem", () => {
});
});
});

describe("AND `render` is passed a render function", () => {
it("should call `render` to create parent item", () => {
const mockRender = cy.stub().as("render");
cy.mount(
<NavigationItem
active={true}
expanded={true}
href="https://www.saltdesignsystem.com"
level={2}
parent={true}
orientation="vertical"
render={mockRender}
>
Navigation Item
</NavigationItem>
);
cy.get("@render").should("have.been.calledWithMatch", {
isParent: true,
active: true,
elementProps: {
"aria-expanded": true,
"aria-label": "expand",
className: Cypress.sinon.match.string,
children: Cypress.sinon.match.any,
},
expanded: true,
href: "https://www.saltdesignsystem.com",
level: 2,
orientation: "vertical",
});
});
it("should call `render` to create child item", () => {
const mockRender = cy.stub().as("render");
cy.mount(
<NavigationItem
active={true}
expanded={true}
href="https://www.saltdesignsystem.com"
level={2}
parent={false}
orientation="vertical"
render={mockRender}
>
Navigation Item
</NavigationItem>
);
cy.get("@render").should("have.been.calledWithMatch", {
isParent: false,
active: true,
elementProps: {
"aria-current": "page",
"aria-label": "change page",
className: Cypress.sinon.match.string,
children: Cypress.sinon.match.any,
},
expanded: true,
href: "https://www.saltdesignsystem.com",
level: 2,
orientation: "vertical",
});
});
});

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 id={"button"}>Button Children</button>}
>
Navigation Item
</NavigationItem>
);
cy.findByRole("button", { name: "expand" }).should("exist");
cy.findByRole("button", { name: "expand" }).should(
"have.attr",
"aria-expanded",
"false"
);
cy.findByText("Button Children").should("exist");
});
});
});
45 changes: 0 additions & 45 deletions packages/core/src/navigation-item/ConditionalWrapper.tsx

This file was deleted.

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

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

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

export const ExpansionIcon: FC<ExpansionIconProps> = ({
expanded = false,
orientation = "horizontal",
}: Pick<NavigationItemProps, "expanded" | "orientation" | "className">) {
}) => {
const Icon =
iconExpansionMap[orientation][expanded ? "expanded" : "collapsed"];
return <Icon aria-hidden="true" />;
}
};
137 changes: 112 additions & 25 deletions packages/core/src/navigation-item/NavigationItem.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
import { ComponentPropsWithoutRef, forwardRef, MouseEventHandler } from "react";
import { makePrefixer } from "../utils";
import React, {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ComponentPropsWithoutRef,
forwardRef,
MouseEvent,
MouseEventHandler,
ReactElement,
ReactNode,
} from "react";
import { makePrefixer, mergeProps } from "../utils";
import { clsx } from "clsx";
import { ExpansionIcon } from "./ExpansionIcon";
import { ConditionalWrapper } from "./ConditionalWrapper";

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

import navigationItemCss from "./NavigationItem.css";

type OptionalPartial<T, K extends keyof T> = Partial<Pick<T, K>>;

type RenderProp<P = React.HTMLAttributes<any>> = (props: P) => ReactNode;

export interface NavigationItemRenderProps
extends OptionalPartial<
NavigationItemProps,
"active" | "expanded" | "level" | "parent" | "orientation"
> {
/**
* Props to apply to the chld row to render a link
*/
linkProps?: AnchorHTMLAttributes<HTMLAnchorElement>;
/**
* Props to apply to the parent row to open and close the row
*/
parentProps?: ButtonHTMLAttributes<HTMLButtonElement>;
}

export interface NavigationItemProps extends ComponentPropsWithoutRef<"div"> {
/**
* Whether the navigation item is active.
Expand All @@ -34,6 +61,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?: RenderProp<NavigationItemRenderProps> | ReactElement;
/**
* Action to be triggered when the navigation item is expanded.
*/
Expand All @@ -46,19 +77,41 @@ export interface NavigationItemProps extends ComponentPropsWithoutRef<"div"> {

const withBaseName = makePrefixer("saltNavigationItem");

type CreateElementProps = NavigationItemRenderProps &
OptionalPartial<NavigationItemProps, "render">;

function createElement(Type: React.ElementType, props: CreateElementProps) {
const { render, ...rest } = props;
const elementProps = props.parent ? props.parentProps : props.linkProps;
let element: ReactElement;
if (React.isValidElement<any>(render)) {
const renderProps = render.props;
element = React.cloneElement(
render,
mergeProps(elementProps as Record<string, unknown>, renderProps)
);
} else if (render) {
element = render(rest) as ReactElement;
} else {
element = <Type {...elementProps} />;
}
return element;
}

export const NavigationItem = forwardRef<HTMLDivElement, NavigationItemProps>(
function NavigationItem(props, ref) {
const {
active,
blurActive,
render,
children,
className,
expanded = false,
href,
orientation = "horizontal",
parent,
level = 0,
onExpand,
href,
style: styleProp,
...rest
} = props;
Expand All @@ -75,34 +128,68 @@ export const NavigationItem = forwardRef<HTMLDivElement, NavigationItemProps>(
"--saltNavigationItem-level": `${level}`,
};

const elementProps = {
className: clsx(
withBaseName("wrapper"),
{
[withBaseName("active")]: active || blurActive,
[withBaseName("blurActive")]: blurActive,
[withBaseName("rootItem")]: level === 0,
},
withBaseName(orientation)
),
children: (
<>
<span className={withBaseName("label")}>{children}</span>
{parent ? (
<ExpansionIcon expanded={expanded} orientation={orientation} />
) : null}
</>
),
};
let parentProps: Partial<ButtonHTMLAttributes<HTMLButtonElement>> = {};
let linkProps: Partial<AnchorHTMLAttributes<HTMLAnchorElement>> = {};
if (parent) {
const handleExpand = onExpand
? (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onExpand(event);
}
: undefined;
parentProps = {
...elementProps,
"aria-label": "expand",
"aria-expanded": expanded,
onClick: handleExpand,
};
} else {
linkProps = {
...elementProps,
href,
"aria-label": "change page",
"aria-current": active ? "page" : undefined,
};
}

const defaultElementType = parent ? "button" : "a";
const renderedElement = createElement(defaultElementType, {
active,
expanded,
parent,
level,
orientation,
render,
linkProps: !parent ? linkProps : undefined,
parentProps: parent ? parentProps : undefined,
});
return (
<div
ref={ref}
className={clsx(withBaseName(), className)}
style={style}
{...rest}
>
<ConditionalWrapper
className={clsx(
withBaseName("wrapper"),
{
[withBaseName("active")]: active || blurActive,
[withBaseName("blurActive")]: blurActive,
[withBaseName("rootItem")]: level === 0,
},
withBaseName(orientation)
)}
parent={parent}
expanded={expanded}
onExpand={onExpand}
active={active}
href={href}
>
<span className={withBaseName("label")}>{children}</span>
{parent && (
<ExpansionIcon expanded={expanded} orientation={orientation} />
)}
</ConditionalWrapper>
{renderedElement}
</div>
);
}
Expand Down
Loading

0 comments on commit 492fdd4

Please sign in to comment.