Skip to content

Commit

Permalink
Add render prop to Link (#4325)
Browse files Browse the repository at this point in the history
Co-authored-by: Josh Wooding <[email protected]>
Co-authored-by: mark-tate <[email protected]>
  • Loading branch information
3 people authored Jan 20, 2025
1 parent 569f8ac commit 225a61b
Show file tree
Hide file tree
Showing 10 changed files with 146 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/silent-dots-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@salt-ds/core": minor
---

Added `render` prop to `Link`. The `render` prop enables the substitution of the default anchor tag with an alternate link, such as React Router, facilitating integration with routing libraries.
36 changes: 36 additions & 0 deletions packages/core/src/__tests__/__e2e__/link/Link.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,40 @@ describe("GIVEN a link", () => {

cy.findByTestId(/TearOutIcon/i).should("not.exist");
});

it("WHEN `render` is passed a render function, THEN should call `render` to create the element", () => {
const testId = "link-testid";

const mockRender = cy
.stub()
.as("render")
.returns(
<a href="#root" data-testid={testId}>
Action
</a>,
);

cy.mount(<Link href="#root" render={mockRender} />);

cy.findByTestId(testId).should("exist");

cy.get("@render").should("have.been.calledWithMatch", {
className: Cypress.sinon.match.string,
children: Cypress.sinon.match.any,
});
});

it("WHEN `render` is given a JSX element, THEN should merge the props and render the JSX element", () => {
const testId = "link-testid";

const mockRender = (
<a href="#root" data-testid={testId}>
Action
</a>
);

cy.mount(<Link href="#root" render={mockRender} />);

cy.findByTestId(testId).should("exist");
});
});
20 changes: 16 additions & 4 deletions packages/core/src/link/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import type { IconProps } from "@salt-ds/icons";
import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";
import { clsx } from "clsx";
import { type ComponentType, type ReactElement, forwardRef } from "react";
import {
type ComponentPropsWithoutRef,
type ComponentType,
type ReactElement,
forwardRef,
} from "react";
import { useIcon } from "../semantic-icon-provider";
import { Text, type TextProps } from "../text";
import { makePrefixer } from "../utils";
import { type RenderPropsType, makePrefixer } from "../utils";
import linkCss from "./Link.css";
import { LinkAction } from "./LinkAction";

const withBaseName = makePrefixer("saltLink");

Expand All @@ -16,8 +22,14 @@ const withBaseName = makePrefixer("saltLink");
* @example
* <LinkExample to="#link">Action</LinkExample>
*/
export interface LinkProps extends Omit<TextProps<"a">, "as" | "disabled"> {
export interface LinkProps
extends Omit<ComponentPropsWithoutRef<"a">, "color">,
Pick<TextProps<"a">, "maxRows" | "styleAs" | "color" | "variant"> {
IconComponent?: ComponentType<IconProps> | null;
/**
* Render prop to enable customisation of anchor element.
*/
render?: RenderPropsType["render"];
}

export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
Expand Down Expand Up @@ -47,7 +59,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(

return (
<Text
as="a"
as={LinkAction}
className={clsx(withBaseName(), className)}
href={href}
ref={ref}
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/link/LinkAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { renderProps } from "../utils";

interface LinkActionProps extends ComponentPropsWithoutRef<"a"> {}

export const LinkAction = forwardRef<HTMLAnchorElement, LinkActionProps>(
function LinkAction(props, ref) {
return renderProps("a", { ...props, ref });
},
);
19 changes: 19 additions & 0 deletions packages/core/stories/link/link.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,22 @@ export const Truncation: StoryFn<typeof Link> = () => {
// </div>
// );
// };

const CustomLinkImplementation = (props: any) => (
<a href="#root" aria-label={"overridden-label"} {...props}>
Your own Link implementation
</a>
);

export const RenderElement: StoryFn<typeof Link> = () => {
return <Link href="#root" render={<CustomLinkImplementation />} />;
};

export const RenderProp: StoryFn<typeof Link> = () => {
return (
<Link
href="#root"
render={(props) => <CustomLinkImplementation {...props} />}
/>
);
};
18 changes: 18 additions & 0 deletions site/docs/components/link/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,22 @@ The default variant is `primary`.

</LivePreview>

<LivePreview componentName="link" exampleName="RenderElement" displayName="Render prop - element">

## Render prop - element

Using the `render` prop, you can customize the element rendered by the Link. Props defined on the JSX element will be merged with props from the Link.

</LivePreview>

<LivePreview componentName="link" exampleName="RenderProp" displayName="Render prop - callback">

## Render prop - callback

The `render` prop can also accept a function. This approach allows more control over how props are merged, allowing for more precise customization of the component's behavior.

When a function is passed to the `render` prop, it's your responsibility to merge props within the function itself.

</LivePreview>

</LivePreviewControls>
4 changes: 2 additions & 2 deletions site/docs/components/navigation-item/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ will be created in the DOM as an `<a>` with merged props

Using the `render` prop, you can customize the element rendered by the `NavigationItem`.

The render prop can also accept a function. This approach allows more control over how props are merged, allowing for more precise customization of the component's behavior.
The `render` prop can also accept a function. This approach allows more control over how props are merged, allowing for more precise customization of the component's behavior.

When a function is passed to the render prop, it's your responsibility to merge props within the function itself.
When a function is passed to the `render` prop, it's your responsibility to merge props within the function itself.

</LivePreview>

Expand Down
19 changes: 19 additions & 0 deletions site/src/examples/link/RenderElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Link, Text } from "@salt-ds/core";
import type { ReactElement } from "react";
import styles from "./index.module.css";

const CustomLinkImplementation = (props: any) => (
<a {...props}>
<Text>Your own Link implementation</Text>
</a>
);

export const RenderElement = (): ReactElement => {
return (
<Link
href="#"
className={styles.linkExample}
render={<CustomLinkImplementation />}
/>
);
};
19 changes: 19 additions & 0 deletions site/src/examples/link/RenderProp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Link, Text } from "@salt-ds/core";
import type { ReactElement } from "react";
import styles from "./index.module.css";

const CustomLinkImplementation = (props: any) => (
<a {...props}>
<Text>Your own Link implementation</Text>
</a>
);

export const RenderProp = (): ReactElement => {
return (
<Link
href="#"
className={styles.linkExample}
render={(props) => <CustomLinkImplementation {...props} />}
/>
);
};
2 changes: 2 additions & 0 deletions site/src/examples/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from "./OpenInANewTab";
export * from "./Variant";
export * from "./Color";
export * from "./Visited";
export * from "./RenderElement";
export * from "./RenderProp";

0 comments on commit 225a61b

Please sign in to comment.