Skip to content

Commit

Permalink
feat: typings for <Hyperlink>, convert it to TypeScript
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed May 17, 2024
1 parent 5096728 commit 2d79395
Show file tree
Hide file tree
Showing 4 changed files with 79 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,48 @@ import userEvent from '@testing-library/user-event';

import Hyperlink from '.';

const content = 'content';
const destination = 'destination';
const content = 'content';
const onClick = jest.fn();
const props = {
content,
destination,
onClick,
};
const externalLinkAlternativeText = 'externalLinkAlternativeText';
const externalLinkTitle = 'externalLinkTitle';
const externalLinkProps = {
target: '_blank',
target: '_blank' as const,
externalLinkAlternativeText,
externalLinkTitle,
...props,
};

describe('correct rendering', () => {
beforeEach(() => {
onClick.mockClear();
});
it('renders with deprecated "content" prop', async () => {
// @ts-expect-error - we don't include 'content' in the types since it's deprecated. Change to 'children'.
const { getByRole } = render(<Hyperlink {...props} content={content} />);
const wrapper = getByRole('link');
expect(wrapper).toBeInTheDocument();

expect(wrapper).toHaveClass('pgn__hyperlink');
expect(wrapper).toHaveTextContent('content');
expect(wrapper).toHaveAttribute('href', destination);
expect(wrapper).toHaveAttribute('target', '_self');

await userEvent.click(wrapper);
expect(onClick).toHaveBeenCalledTimes(1);
});

it('renders Hyperlink', async () => {
const { getByRole } = render(<Hyperlink {...props} />);
const { getByRole } = render(<Hyperlink {...props}>{content}</Hyperlink>);
const wrapper = getByRole('link');
expect(wrapper).toBeInTheDocument();

expect(wrapper).toHaveClass('pgn__hyperlink');
expect(wrapper).toHaveClass('standalone-link');
expect(wrapper).toHaveTextContent(content);
expect(wrapper).toHaveAttribute('href', destination);
expect(wrapper).toHaveAttribute('target', '_self');
Expand All @@ -36,8 +54,17 @@ describe('correct rendering', () => {
expect(onClick).toHaveBeenCalledTimes(1);
});

it('renders an underlined Hyperlink', async () => {
const { getByRole } = render(<Hyperlink isInline {...props}>{content}</Hyperlink>);
const wrapper = getByRole('link');
expect(wrapper).toBeInTheDocument();
expect(wrapper).toHaveClass('pgn__hyperlink');
expect(wrapper).not.toHaveClass('standalone-link');
expect(wrapper).toHaveClass('inline-link');
});

it('renders external Hyperlink', () => {
const { getByRole, getByTestId } = render(<Hyperlink {...externalLinkProps} />);
const { getByRole, getByTestId } = render(<Hyperlink {...externalLinkProps}>{content}</Hyperlink>);
const wrapper = getByRole('link');
const icon = getByTestId('hyperlink-icon');
const iconSvg = icon.querySelector('svg');
Expand All @@ -53,18 +80,16 @@ describe('correct rendering', () => {

describe('security', () => {
it('prevents reverse tabnabbing for links with target="_blank"', () => {
const { getByRole } = render(<Hyperlink {...externalLinkProps} />);
const { getByRole } = render(<Hyperlink {...externalLinkProps}>{content}</Hyperlink>);
const wrapper = getByRole('link');
expect(wrapper).toHaveAttribute('rel', 'noopener noreferrer');
});
});

describe('event handlers are triggered correctly', () => {
let spy;
beforeEach(() => { spy = jest.fn(); });

it('should fire onClick', async () => {
const { getByRole } = render(<Hyperlink {...props} onClick={spy} />);
const spy = jest.fn();
const { getByRole } = render(<Hyperlink {...props} onClick={spy}>{content}</Hyperlink>);
const wrapper = getByRole('link');
expect(spy).toHaveBeenCalledTimes(0);
await userEvent.click(wrapper);
Expand Down
81 changes: 42 additions & 39 deletions src/Hyperlink/index.jsx → src/Hyperlink/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable react/require-default-props */
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import isRequiredIf from 'react-proptype-conditional-require';
import { Launch } from '../../icons';
import Icon from '../Icon';

Expand All @@ -10,20 +10,39 @@ import withDeprecatedProps, { DeprTypes } from '../withDeprecatedProps';
export const HYPER_LINK_EXTERNAL_LINK_ALT_TEXT = 'in a new tab';
export const HYPER_LINK_EXTERNAL_LINK_TITLE = 'Opens in a new tab';

const Hyperlink = React.forwardRef((props, ref) => {
const {
className,
destination,
children,
target,
onClick,
externalLinkAlternativeText,
externalLinkTitle,
variant,
isInline,
showLaunchIcon,
...attrs
} = props;
interface Props extends Omit<React.ComponentPropsWithoutRef<'a'>, 'href' | 'target'> {
/** specifies the URL */
destination: string;
/** Content of the hyperlink */
children: React.ReactNode;
/** Custom class names for the hyperlink */
className?: string;
/** Alt text for the icon indicating that this link opens in a new tab, if target="_blank". e.g. _("in a new tab") */
externalLinkAlternativeText?: string;
/** Tooltip text for the "opens in new tab" icon, if target="_blank". e.g. _("Opens in a new tab"). */
externalLinkTitle?: string;
/** type of hyperlink */
variant?: 'default' | 'muted' | 'brand';
/** Display the link with an underline. By default, it is only underlined on hover. */
isInline?: boolean;
/** specify if we need to show launch Icon. By default, it will be visible. */
showLaunchIcon?: boolean;
target?: '_blank' | '_self';
}

const Hyperlink = React.forwardRef<HTMLAnchorElement, Props>(({
className,
destination,
children,
target = '_self',
onClick = () => {},
externalLinkAlternativeText = HYPER_LINK_EXTERNAL_LINK_ALT_TEXT,
externalLinkTitle = HYPER_LINK_EXTERNAL_LINK_TITLE,
variant = 'default',
isInline = false,
showLaunchIcon = true,
...attrs
}, ref) => {
let externalLinkIcon;

if (target === '_blank') {
Expand Down Expand Up @@ -83,17 +102,6 @@ const Hyperlink = React.forwardRef((props, ref) => {
);
});

Hyperlink.defaultProps = {
className: undefined,
target: '_self',
onClick: () => {},
externalLinkAlternativeText: HYPER_LINK_EXTERNAL_LINK_ALT_TEXT,
externalLinkTitle: HYPER_LINK_EXTERNAL_LINK_TITLE,
variant: 'default',
isInline: false,
showLaunchIcon: true,
};

Hyperlink.propTypes = {
/** specifies the URL */
destination: PropTypes.string.isRequired,
Expand All @@ -105,23 +113,18 @@ Hyperlink.propTypes = {
* loaded into the same browsing context as the current one.
* If the target is `_blank` (opening a new window) `rel='noopener'` will be added to the anchor tag to prevent
* any potential [reverse tabnabbing attack](https://www.owasp.org/index.php/Reverse_Tabnabbing).
*/
target: PropTypes.string,
*/
// @ts-ignore - some magic 'propTypes' decoding is clashing with our other types above, and won't accept '_blank' here
target: PropTypes.oneOf(['_blank', '_self']),
/** specifies the callback function when the link is clicked */
onClick: PropTypes.func,
/** specifies the text for links with a `_blank` target (which loads the URL in a new browsing context). */
externalLinkAlternativeText: isRequiredIf(
PropTypes.string,
props => props.target === '_blank',
),
/** specifies the title for links with a `_blank` target (which loads the URL in a new browsing context). */
externalLinkTitle: isRequiredIf(
PropTypes.string,
props => props.target === '_blank',
),
/** Alt text for the icon indicating that this link opens in a new tab, if target="_blank". e.g. _("in a new tab") */
externalLinkAlternativeText: PropTypes.string,
/** Tooltip text for the "opens in new tab" icon, if target="_blank". e.g. _("Opens in a new tab"). */
externalLinkTitle: PropTypes.string,
/** type of hyperlink */
variant: PropTypes.oneOf(['default', 'muted', 'brand']),
/** specify the link style. By default, it will be underlined. */
/** Display the link with an underline. By default, it is only underlined on hover. */
isInline: PropTypes.bool,
/** specify if we need to show launch Icon. By default, it will be visible. */
showLaunchIcon: PropTypes.bool,
Expand Down
2 changes: 1 addition & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export { default as Bubble } from './Bubble';
export { default as Chip, CHIP_PGN_CLASS } from './Chip';
export { default as ChipCarousel } from './ChipCarousel';
export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink';
export { default as Icon } from './Icon';

// // // // // // // // // // // // // // // // // // // // // // // // // // //
Expand Down Expand Up @@ -72,7 +73,6 @@ export const
FormAutosuggestOption: any,
InputGroup: any;
// from './Form';
export const Hyperlink: any, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT: string, HYPER_LINK_EXTERNAL_LINK_TITLE: string; // from './Hyperlink';
export const IconButton: any, IconButtonWithTooltip: any; // from './IconButton';
export const IconButtonToggle: any; // from './IconButtonToggle';
export const Input: any; // from './Input';
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export { default as Bubble } from './Bubble';
export { default as Chip, CHIP_PGN_CLASS } from './Chip';
export { default as ChipCarousel } from './ChipCarousel';
export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink';
export { default as Icon } from './Icon';

// // // // // // // // // // // // // // // // // // // // // // // // // // //
Expand Down Expand Up @@ -72,7 +73,6 @@ export {
FormAutosuggestOption,
InputGroup,
} from './Form';
export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink';
export { default as IconButton, IconButtonWithTooltip } from './IconButton';
export { default as IconButtonToggle } from './IconButtonToggle';
export { default as Input } from './Input';
Expand Down

0 comments on commit 2d79395

Please sign in to comment.