Skip to content

Commit

Permalink
feat: Add TypeScript types for <Hyperlink> (#3077)
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed May 22, 2024
1 parent 9993fb3 commit d4fce25
Show file tree
Hide file tree
Showing 7 changed files with 70 additions and 54 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ module.exports = {
{
devDependencies: [
'**/*.stories.jsx',
'src/setupTest.js',
'src/setupTest.ts',
'**/*.test.jsx',
'**/*.test.js',
'config/*.js',
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
"^.+\\.tsx?$": "ts-jest"
},
"setupFilesAfterEnv": [
"./src/setupTest.js"
"./src/setupTest.ts"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
Expand All @@ -164,7 +164,7 @@
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"src/setupTest.js",
"src/setupTest.ts",
"src/index.js",
"/tests/",
"/www/",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,34 @@ 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 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 +40,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 +66,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
78 changes: 41 additions & 37 deletions src/Hyperlink/index.jsx → src/Hyperlink/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
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';

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.ComponentPropsWithRef<'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,
onClick,
externalLinkAlternativeText,
externalLinkTitle,
variant,
isInline,
showLaunchIcon,
...attrs
}, ref) => {
let externalLinkIcon;

if (target === '_blank') {
Expand Down Expand Up @@ -105,32 +121,20 @@ 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,
*/
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,
};

export default withDeprecatedProps(Hyperlink, 'Hyperlink', {
/** specifies the text or element that a URL should be associated with */
content: {
deprType: DeprTypes.MOVED,
newName: 'children',
},
});
export default Hyperlink;
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
5 changes: 3 additions & 2 deletions src/setupTest.js → src/setupTest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'regenerator-runtime/runtime';

import '@testing-library/jest-dom';
Expand All @@ -20,6 +21,6 @@ class ResizeObserver {

window.ResizeObserver = ResizeObserver;

window.crypto = {
getRandomValues: arr => crypto.randomBytes(arr.length),
(window as any).crypto = {
getRandomValues: (arr: any) => crypto.randomBytes(arr.length),
};

0 comments on commit d4fce25

Please sign in to comment.