Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add render prop to Link #4325

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`.
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");
});
});
14 changes: 11 additions & 3 deletions packages/core/src/link/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { useWindow } from "@salt-ds/window";
import { clsx } from "clsx";
import { type ComponentType, type ReactElement, forwardRef } from "react";
import { useIcon } from "../semantic-icon-provider";
import { Text, type TextProps } from "../text";
import type { TextProps } from "../text";
import { makePrefixer } from "../utils";
import type { RenderPropsType } from "../utils";
import linkCss from "./Link.css";
import { LinkAction } from "./LinkAction";

const withBaseName = makePrefixer("saltLink");

Expand All @@ -18,6 +20,10 @@ const withBaseName = makePrefixer("saltLink");
*/
export interface LinkProps extends Omit<TextProps<"a">, "as" | "disabled"> {
IconComponent?: ComponentType<IconProps> | null;
/**
* Render prop to enable customisation of link element.
*/
render?: RenderPropsType["render"];
}

export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
Expand All @@ -29,6 +35,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
color: colorProp,
variant,
target = "_self",
render,
...rest
},
ref,
Expand All @@ -46,13 +53,14 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
IconComponent === undefined ? ExternalIcon : IconComponent;

return (
<Text
<LinkAction
as="a"
className={clsx(withBaseName(), className)}
href={href}
ref={ref}
target={target}
color={color}
render={render}
{...rest}
>
{children}
Expand All @@ -64,6 +72,6 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
<span className={withBaseName("externalLinkADA")}>External</span>
</>
)}
</Text>
</LinkAction>
);
});
15 changes: 15 additions & 0 deletions packages/core/src/link/LinkAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ComponentPropsWithoutRef } from "react";
import { Text } from "../text";
import { renderProps } from "../utils";

interface LinkActionProps extends ComponentPropsWithoutRef<any> {}

export function LinkAction(props: LinkActionProps) {
const { render, ...rest } = props;

if (render) {
return renderProps("a", props);
}

return <Text {...rest} />;
}
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} />}
/>
);
};
16 changes: 16 additions & 0 deletions site/docs/components/link/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,20 @@ 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.

</LivePreview>

</LivePreviewControls>
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 aria-label={"overridden-label"} {...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 aria-label={"overridden-label"} {...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";
Loading