Skip to content

Commit

Permalink
feat: Add smooth scrolling to Link component. (#348)
Browse files Browse the repository at this point in the history
* feat: Add smooth scrolling to Link component.

* Add unit tests for onClick and onMouseOver.
  • Loading branch information
Matthias Wagler authored Feb 28, 2020
1 parent 77d5572 commit 28cfb0b
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 40 deletions.
4 changes: 2 additions & 2 deletions lib/components/branding/MadeBy/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ const MadeBy: FunctionComponent<MadeByProps> = ({

&nbsp;

<Link href='https://www.thenativeweb.io' isExternal={ true }>the native web</Link>
<Link href='https://www.thenativeweb.io'>the native web</Link>

{ partners.map((item, index): ReactElement => (
<React.Fragment key={ item.name }>
{ index === partners.length - 1 ? ' and ' : ', ' }
<Link href={ item.href } isExternal={ true }>{ item.name }</Link>
<Link href={ item.href }>{ item.name }</Link>
</React.Fragment>
)) }
</div>
Expand Down
7 changes: 2 additions & 5 deletions lib/components/input/Link/Documentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,13 @@ const Documentation = (): ReactElement => (
<Headline level='2'>External links</Headline>

<Paragraph>
When linking to external websites, make sure to
set <code>isExternal</code> to <code>true</code>. This will
set <code>rel</code> and <code>target</code> attributes
properly.
When linking to external websites, the <code>rel</code> and <code>target</code> attributes
will be set automatically, so that external links will open in a new tab.
</Paragraph>

<ComponentPreview>
<Link
href='https://www.thenativeweb.io'
isExternal={ true }
>
This an external link
</Link>
Expand Down
71 changes: 54 additions & 17 deletions lib/components/input/Link/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import NextLink from 'next/link';
import { scrollToAnchor } from '../../../utils/scrollToAnchor';
import { Theme } from '../../..';
import { classNames, createUseStyles } from '../../../styles';
import { LinkClassNames, styles } from './styles';
import React, { FunctionComponent, MouseEvent, ReactElement } from 'react';
import React, { CSSProperties, FunctionComponent, MouseEvent, ReactElement } from 'react';

interface LinkProps {
className?: string;
isExternal?: boolean;
href?: string;
href: string;
id?: string;
onClick?: (event: MouseEvent) => void;
style?: CSSProperties;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
onMouseOver?: (event: MouseEvent<HTMLAnchorElement>) => void;
}

const useStyles = createUseStyles<Theme, LinkClassNames>(styles);
Expand All @@ -17,27 +20,61 @@ const Link: FunctionComponent<LinkProps> = React.forwardRef(({
id,
className,
children,
isExternal = false,
href,
onClick = (): void => {
// Intentionally left blank.
}
style,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onClick,
onMouseOver
}, ref): ReactElement => {
const classes = useStyles();
const brandClassNames = classNames(classes.Link, className);
const linkClasses = classNames(classes.Link, className);

let rel: string | undefined,
target: string | undefined;

const isAnchorLink = href.startsWith('#'),
isExternalLink = href.startsWith('https://') || href.startsWith('http://'),
isMailToLink = href.startsWith('mailto:');

const anchorProps = {
id,
style,
href,
className: linkClasses,
target,
rel,
ref,
onClick,
onMouseOver
};

if (isAnchorLink || isExternalLink || isMailToLink) {
if (isExternalLink) {
anchorProps.target = '_blank';
anchorProps.rel = 'noopener noreferrer';
}

if (isAnchorLink && !onClick) {
anchorProps.onClick = scrollToAnchor;
}

if (isExternal) {
return (
<a ref={ ref as any } id={ id } className={ brandClassNames } href={ href } onClick={ onClick } rel='noopener noreferrer' target='_blank'>
{ children }
</a>
return React.createElement(
'a',
anchorProps,
children
);
}

return (
<a ref={ ref as any } id={ id } className={ brandClassNames } href={ href } onClick={ onClick }>
{ children }
</a>
<NextLink href={ href }>
{
React.createElement(
'a',
anchorProps,
children
)
}
</NextLink>
);
});

Expand Down
2 changes: 1 addition & 1 deletion lib/components/input/Link/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ export type LinkClassNames = 'Link';
const styles = (theme: Theme): ComponentClassNames<LinkClassNames> => ({
Link: {
color: theme.color.brand.highlight,
textDecoration: 'none',
fontWeight: 400,
fontFamily: theme.font.family.default,
fontSize: 'inherit',
textDecoration: 'none',

'&:hover': {
textDecoration: 'none'
Expand Down
5 changes: 1 addition & 4 deletions lib/components/navigation/PageNavigation/Page/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createUseStyles } from '../../../../styles';
import NextLink from 'next/link';
import { classNames, Link, Theme } from '../../../..';
import { PageClassNames, styles } from './styles';
import React, { FunctionComponent, ReactElement } from 'react';
Expand Down Expand Up @@ -34,9 +33,7 @@ const Page: FunctionComponent<PageProps> = ({
}, `Level${level}`);

return (
<NextLink href={ pagePathWithoutTrailingSlash }>
<Link href={ pagePathWithoutTrailingSlash } className={ componentClasses }>{ title }</Link>
</NextLink>
<Link href={ pagePathWithoutTrailingSlash } className={ componentClasses }>{ title }</Link>
);
};

Expand Down
21 changes: 21 additions & 0 deletions lib/utils/scrollToAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MouseEvent } from 'react';

const scrollToAnchor = function (event: MouseEvent<HTMLElement>): void {
const href = event.currentTarget.getAttribute('href');

if (!href) {
return;
}

const targetToScrollTo = document.querySelector(href);

if (!targetToScrollTo) {
return;
}

event.preventDefault();

targetToScrollTo.scrollIntoView({ behavior: 'smooth' });
};

export { scrollToAnchor };
5 changes: 5 additions & 0 deletions test/shared/eventDispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ const click = (element: HTMLElement): void => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
};

const mouseover = (element: HTMLElement): void => {
element.dispatchEvent(new Event('mouseover', { bubbles: true }));
};

const submit = (element: HTMLElement): void => {
element.dispatchEvent(new Event('submit', { bubbles: true }));
};

export {
click,
mouseover,
submit
};
2 changes: 1 addition & 1 deletion test/shared/sampleApplication/pages/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class TestApp extends React.Component<{}, TestAppState> {
<section>
<Headline level='2'>Link</Headline>
<Link href='/interal'>This is an internal link!</Link>
<Link href='http://www.google.de' isExternal={ true }>This is an external link!</Link>
<Link href='http://www.google.de'>This is an external link!</Link>
</section>
<section>
<Headline level='2'>Message</Headline>
Expand Down
60 changes: 50 additions & 10 deletions test/unit/input/LinkTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act } from '../../shared/act';
import { assert } from 'assertthat';
import React from 'react';
import ReactDOM from 'react-dom';
import { click, mouseover } from '../../shared/eventDispatchers';
import { Link, ThemeProvider } from '../../../lib';

suite('Link', (): void => {
Expand All @@ -20,13 +21,13 @@ suite('Link', (): void => {
act((): void => {
ReactDOM.render(
<ThemeProvider>
<Link href='/linkTo'>This is a link.</Link>
<Link id='link' href='/linkTo'>This is a link.</Link>
</ThemeProvider>,
container
);
});

const link = container.querySelector('a');
const link = container.querySelector<HTMLAnchorElement>('#link');

assert.that(link).is.not.null();
assert.that(link!.className).is.containing('Link');
Expand All @@ -36,34 +37,73 @@ suite('Link', (): void => {
assert.that(link!.target).is.not.equalTo('_blank');
});

test('can be found by defined property id.', async (): Promise<void> => {
test('sets defined properties in DOM Element if href is absolute.', async (): Promise<void> => {
act((): void => {
ReactDOM.render(
<ThemeProvider>
<Link id='some-id' href='/linkTo'>This is a link.</Link>
<Link id='link' href='https://thenativeweb.io'>This is a link.</Link>
</ThemeProvider>,
container
);
});

const link = container.querySelector('#some-id');
const link = container.querySelector<HTMLAnchorElement>('#link');

assert.that(link!.rel).is.equalTo('noopener noreferrer');
assert.that(link!.target).is.equalTo('_blank');
});

test('takes onClick function and runs it if clicked.', async (): Promise<void> => {
let clicked = false;

const onClick = (): void => {
clicked = true;
};

act((): void => {
ReactDOM.render(
<ThemeProvider>
<Link id='link' onClick={ onClick } href='https://thenativeweb.io'>This is a link.</Link>
</ThemeProvider>,
container
);
});

const link = container.querySelector<HTMLAnchorElement>('#link');

assert.that(link).is.not.null();

act((): void => {
click(link!);
});

assert.that(clicked).is.true();
});

test('sets defined properties in DOM Element if property isExternal is set to true.', async (): Promise<void> => {
test('takes onMouseOver function and runs it if hovered.', async (): Promise<void> => {
let hovered = false;

const onMouseOver = (): void => {
hovered = true;
};

act((): void => {
ReactDOM.render(
<ThemeProvider>
<Link href='https://thenativeweb.io' isExternal={ true }>This is a link.</Link>
<Link id='link' onMouseOver={ onMouseOver } href='https://thenativeweb.io'>This is a link.</Link>
</ThemeProvider>,
container
);
});

const link = container.querySelector('a');
const link = container.querySelector<HTMLAnchorElement>('#link');

assert.that(link!.rel).is.equalTo('noopener noreferrer');
assert.that(link!.target).is.equalTo('_blank');
assert.that(link).is.not.null();

act((): void => {
mouseover(link!);
});

assert.that(hovered).is.true();
});
});

0 comments on commit 28cfb0b

Please sign in to comment.