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.
- parent items can now be modified to act as hyperlinks to specific pages while still retaining the ability to expand and collapse rows
  • Loading branch information
mark-tate committed May 15, 2024
1 parent e4b57a9 commit 60c3bf4
Show file tree
Hide file tree
Showing 8 changed files with 352 additions and 106 deletions.
8 changes: 8 additions & 0 deletions .changeset/tender-avocados-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@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.
- parent items can now be modified to act as hyperlinks to specific pages while still retaining the ability to expand and collapse rows
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 @@ -99,5 +99,68 @@ describe("GIVEN a NavItem", () => {
);
});
});

describe("AND `render` is passed", () => {
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",
});
});
});
});
});
45 changes: 0 additions & 45 deletions packages/core/src/navigation-item/ConditionalWrapper.tsx

This file was deleted.

216 changes: 156 additions & 60 deletions packages/core/src/navigation-item/NavigationItem.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,60 @@
import React, {
AriaAttributes,
ForwardedRef,
MouseEvent,
ReactElement,
} from "react";

Check warning on line 6 in packages/core/src/navigation-item/NavigationItem.tsx

View workflow job for this annotation

GitHub Actions / lint

'/home/runner/work/salt-ds/salt-ds/node_modules/react/index.js' imported multiple times
import { ComponentPropsWithoutRef, forwardRef, MouseEventHandler } from "react";

Check warning on line 7 in packages/core/src/navigation-item/NavigationItem.tsx

View workflow job for this annotation

GitHub Actions / lint

'/home/runner/work/salt-ds/salt-ds/node_modules/react/index.js' imported multiple times
import { makePrefixer } 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>>;

export interface NavigationItemElementProps<
T extends HTMLAnchorElement | HTMLButtonElement
> extends Pick<
AriaAttributes,
"aria-label" | "aria-expanded" | "aria-current"
> {
/**
* Item's children.
*/
children: ReactElement;
/**
* Item's className.
*/
className: string;
/**
* Handler to expand groups.
*/
onClick?: MouseEventHandler<T>;
}

export interface NavigationItemRenderProps<
T extends HTMLAnchorElement | HTMLButtonElement
> extends OptionalPartial<
NavigationItemProps,
"active" | "expanded" | "level" | "orientation"
> {
/**
* If the item to render is expandable when clicked.
*/
isParent: boolean;
/**
* Props to apply to the row's element.
*/
elementProps: NavigationItemElementProps<T>;
/**
* If row should load page when clicked, the corresponding href.
*/
href?: string;
}
export interface NavigationItemProps extends ComponentPropsWithoutRef<"div"> {
/**
* Whether the navigation item is active.
Expand All @@ -34,76 +80,126 @@ 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?: React.FC<
NavigationItemRenderProps<HTMLAnchorElement | HTMLButtonElement>
>;
/**
* Action to be triggered when the navigation item is expanded.
*/
onExpand?: MouseEventHandler<HTMLButtonElement>;
onExpand?: MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
/**
* Href to be passed to the Link element.
* Page to load, when this navigation item is selected.
*/
href?: string;
}

const withBaseName = makePrefixer("saltNavigationItem");

export const NavigationItem = forwardRef<HTMLDivElement, NavigationItemProps>(
function NavigationItem(props, ref) {
const {
active,
blurActive,
children,
className,
expanded = false,
orientation = "horizontal",
parent,
level = 0,
onExpand,
href,
style: styleProp,
...rest
} = props;
export const NavigationItem = forwardRef(function NavigationItem(
props: NavigationItemProps,
ref: ForwardedRef<HTMLDivElement>
) {
const defaultRender: React.FC<
NavigationItemRenderProps<HTMLAnchorElement | HTMLButtonElement>
> = (props) => {
const { isParent, elementProps, href } = props;
/** Parent rows just expand and collapse and are not links */
if (isParent || !href) {
return <button {...elementProps} />;
}
return <a {...elementProps} href={href} />;
};

const targetWindow = useWindow();
useComponentCssInjection({
testId: "salt-navigation-item",
css: navigationItemCss,
window: targetWindow,
});
const {
active,
blurActive,
render = defaultRender,
children,
className,
expanded = false,
orientation = "horizontal",
parent,
level = 0,
onExpand,
href,
style: styleProp,
...rest
} = props;

const style = {
...styleProp,
"--saltNavigationItem-level": `${level}`,
};
const targetWindow = useWindow();
useComponentCssInjection({
testId: "salt-navigation-item",
css: navigationItemCss,
window: targetWindow,
});

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>
</div>
);
const style = {
...styleProp,
"--saltNavigationItem-level": `${level}`,
};

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

return (
<div
ref={ref}
className={clsx(withBaseName(), className)}
style={style}
{...rest}
>
{render({
active,
isParent,
href,
expanded,
level,
orientation,
elementProps,
})}
</div>
);
});
Loading

0 comments on commit 60c3bf4

Please sign in to comment.