diff --git a/src/Hyperlink/Hyperlink.test.jsx b/src/Hyperlink/Hyperlink.test.tsx similarity index 60% rename from src/Hyperlink/Hyperlink.test.jsx rename to src/Hyperlink/Hyperlink.test.tsx index 2d5ffd3c5e..597ba8e5c4 100644 --- a/src/Hyperlink/Hyperlink.test.jsx +++ b/src/Hyperlink/Hyperlink.test.tsx @@ -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(); + 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(); + const { getByRole } = render({content}); 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'); @@ -36,8 +54,17 @@ describe('correct rendering', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + it('renders an underlined Hyperlink', async () => { + const { getByRole } = render({content}); + 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(); + const { getByRole, getByTestId } = render({content}); const wrapper = getByRole('link'); const icon = getByTestId('hyperlink-icon'); const iconSvg = icon.querySelector('svg'); @@ -53,18 +80,16 @@ describe('correct rendering', () => { describe('security', () => { it('prevents reverse tabnabbing for links with target="_blank"', () => { - const { getByRole } = render(); + const { getByRole } = render({content}); 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(); + const spy = jest.fn(); + const { getByRole } = render({content}); const wrapper = getByRole('link'); expect(spy).toHaveBeenCalledTimes(0); await userEvent.click(wrapper); diff --git a/src/Hyperlink/index.jsx b/src/Hyperlink/index.tsx similarity index 59% rename from src/Hyperlink/index.jsx rename to src/Hyperlink/index.tsx index 7c4a61f882..efe7fd3b92 100644 --- a/src/Hyperlink/index.jsx +++ b/src/Hyperlink/index.tsx @@ -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'; @@ -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, '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(({ + 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') { @@ -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, @@ -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, diff --git a/src/index.d.ts b/src/index.d.ts index 9d71f85477..e4050de325 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -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'; // // // // // // // // // // // // // // // // // // // // // // // // // // // @@ -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'; diff --git a/src/index.js b/src/index.js index 6e8b9294c5..2f1b794be4 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; // // // // // // // // // // // // // // // // // // // // // // // // // // // @@ -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';