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

[EuiListGroupItem] Add external prop #7352

Merged
2 changes: 2 additions & 0 deletions changelogs/upcoming/7352.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Updated `EuiListGroupItem` to render an external icon and screen reader affordance for links with `target` set to to `_blank`
- Updated `EuiListGroupItem` with a new `external` prop, which allows enabling or disabling the new external link icon
11 changes: 11 additions & 0 deletions src-docs/src/views/list_group/list_group_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ export const ListGroupExample = {
<EuiCode>isActive</EuiCode> and <EuiCode>isDisabled</EuiCode>{' '}
properties.
</p>
<p>
If your link is external or will open in a new tab, you can manually{' '}
set the <EuiCode>external</EuiCode> property. However, just like{' '}
with the{' '}
<Link to="/navigation/link">
<strong>EuiLink</strong>
</Link>{' '}
component, setting{' '}
<EuiCode language="tsx">{'target="_blank"'}</EuiCode> defaults to{' '}
<EuiCode language="tsx">{'external={true}'}</EuiCode>.
</p>
<p>
As is done in this example, the <strong>EuiListGroup</strong>{' '}
component can also accept an array of items via the{' '}
Expand Down
5 changes: 3 additions & 2 deletions src-docs/src/views/list_group/list_group_links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ const myContent = [
iconType: 'copyClipboard',
},
{
label: 'Fifth link',
href: '#/display/list-group',
label: 'Fifth link will open in new tab',
href: 'http://www.elastic.co',
iconType: 'crosshairs',
target: '_blank',
},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ exports[`EuiCollapsibleNavLink renders a link 1`] = `
>
Link
<span
class="emotion-euiLink__externalIcon"
class="emotion-EuiExternalLinkIcon"
data-euiicon-type="popout"
>
External link
</span>
<span
class="emotion-euiScreenReaderOnly-euiLink__screenReaderText"
class="emotion-euiScreenReaderOnly"
>
(opens in a new tab or window)
</span>
Expand Down
21 changes: 3 additions & 18 deletions src/components/link/__snapshots__/link.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,6 @@ exports[`EuiLink accent is rendered 1`] = `
/>
`;

exports[`EuiLink allows for target and external to be controlled independently 1`] = `
<a
class="euiLink emotion-euiLink-primary"
href="#"
rel="noopener noreferrer"
target="_blank"
>
<span
class="emotion-euiScreenReaderOnly-euiLink__screenReaderText"
>
(opens in a new tab or window)
</span>
</a>
`;

exports[`EuiLink button respects the type property 1`] = `
<button
class="euiLink emotion-euiLink-primary"
Expand Down Expand Up @@ -57,7 +42,7 @@ exports[`EuiLink it is an external link 1`] = `
rel="noreferrer"
>
<span
class="emotion-euiLink__externalIcon"
class="emotion-EuiExternalLinkIcon"
data-euiicon-type="popout"
>
External link
Expand Down Expand Up @@ -147,13 +132,13 @@ exports[`EuiLink supports target 1`] = `
target="_blank"
>
<span
class="emotion-euiLink__externalIcon"
class="emotion-EuiExternalLinkIcon"
data-euiicon-type="popout"
>
External link
</span>
<span
class="emotion-euiScreenReaderOnly-euiLink__screenReaderText"
class="emotion-euiScreenReaderOnly"
>
(opens in a new tab or window)
</span>
Expand Down
85 changes: 85 additions & 0 deletions src/components/link/external_link_icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';
import { render } from '../../test/rtl';
import { shouldRenderCustomStyles } from '../../test/internal';

import { EuiExternalLinkIcon } from './external_link_icon';

// Note - the icon is not actually text, but it's mocked as such
describe('EuiExternalLinkIcon', () => {
shouldRenderCustomStyles(<EuiExternalLinkIcon external={true} />);

it('always renders the icon if `external` is true', () => {
const { container } = render(<EuiExternalLinkIcon external={true} />);
expect(container).toMatchInlineSnapshot(`
<div>
<span
class="emotion-EuiExternalLinkIcon"
data-euiicon-type="popout"
>
External link
</span>
</div>
`);
});

describe('target="_blank"', () => {
it('renders the icon by default, along with screen reader text', () => {
const { container } = render(<EuiExternalLinkIcon target="_blank" />);
expect(container).toMatchInlineSnapshot(`
<div>
<span
class="emotion-EuiExternalLinkIcon"
data-euiicon-type="popout"
>
External link
</span>
<span
class="emotion-euiScreenReaderOnly"
>
(opens in a new tab or window)
</span>
</div>
`);
});

it('hides the icon if `external` is false, but still shows the screen reader text', () => {
const { container } = render(
<EuiExternalLinkIcon target="_blank" external={false} />
);
expect(container).toMatchInlineSnapshot(`
<div>
<span
class="emotion-euiScreenReaderOnly"
>
(opens in a new tab or window)
</span>
</div>
`);
});
});

it('renders nothing if neither external nor target="_blank" are set', () => {
const { container } = render(<EuiExternalLinkIcon />);
expect(container).toMatchInlineSnapshot(`<div />`);
});

it('allows configuring the icon props', () => {
const { getByTestSubject } = render(
<EuiExternalLinkIcon
external={true}
data-test-subj="test"
size="xl"
color="text"
/>
);
expect(getByTestSubject('test')).toBeInTheDocument();
});
});
67 changes: 67 additions & 0 deletions src/components/link/external_link_icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { FunctionComponent, AnchorHTMLAttributes } from 'react';

import { useEuiTheme } from '../../services';
import { logicalStyle } from '../../global_styling';
import { EuiIcon, EuiIconProps } from '../icon';
import { EuiI18n, useEuiI18n } from '../i18n';
import { EuiScreenReaderOnly } from '../accessibility';

/**
* DRY util for indicating external links both via icon and to
* screen readers. Used internally by at EuiLink and EuiListGroupItem
*/

export type EuiExternalLinkIconProps = {
target?: AnchorHTMLAttributes<HTMLAnchorElement>['target'];
/**
* Set to true to show an icon indicating that it is an external link;
* Defaults to true if `target="_blank"`
*/
external?: boolean;
};

export const EuiExternalLinkIcon: FunctionComponent<
EuiExternalLinkIconProps & Partial<EuiIconProps>
> = ({ target, external, ...rest }) => {
const { euiTheme } = useEuiTheme();

const showExternalLinkIcon =
(target === '_blank' && external !== false) || external === true;

const iconAriaLabel = useEuiI18n(
'euiExternalLinkIcon.ariaLabel',
'External link'
);

return (
<>
{showExternalLinkIcon && (
<EuiIcon
css={logicalStyle('margin-left', euiTheme.size.xs)}
aria-label={iconAriaLabel}
size="s"
type="popout"
{...rest}
/>
)}
{target === '_blank' && (
<EuiScreenReaderOnly>
<span>
<EuiI18n
token="euiExternalLinkIcon.newTarget.screenReaderOnlyText"
default="(opens in a new tab or window)"
/>
</span>
</EuiScreenReaderOnly>
)}
</>
);
};
14 changes: 1 addition & 13 deletions src/components/link/link.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@

import { css } from '@emotion/react';
import { UseEuiTheme } from '../../services';
import {
euiFocusRing,
logicalCSS,
logicalTextAlignCSS,
} from '../../global_styling';
import { euiFocusRing, logicalTextAlignCSS } from '../../global_styling';

const _colorCSS = (color: string) => {
return `
Expand Down Expand Up @@ -87,13 +83,5 @@ export const euiLinkStyles = (euiThemeContext: UseEuiTheme) => {
warning: css(_colorCSS(euiTheme.colors.warningText)),
ghost: css(_colorCSS(euiTheme.colors.ghost)),
text: css(_colorCSS(euiTheme.colors.text)),

// Children
euiLink__screenReaderText: css`
${logicalCSS('left', '0px')}
`,
euiLink__externalIcon: css`
${logicalCSS('margin-left', euiTheme.size.xs)}
`,
};
};
7 changes: 0 additions & 7 deletions src/components/link/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,6 @@ describe('EuiLink', () => {
expect(container.firstChild).toMatchSnapshot();
});

test('allows for target and external to be controlled independently', () => {
const { container } = render(
<EuiLink href="#" target="_blank" external={false} />
);
expect(container.firstChild).toMatchSnapshot();
});

test('supports rel', () => {
const { container } = render(<EuiLink href="hoi" rel="stylesheet" />);
expect(container.firstChild).toMatchSnapshot();
Expand Down
35 changes: 5 additions & 30 deletions src/components/link/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import React, {
MouseEventHandler,
} from 'react';
import classNames from 'classnames';

import { getSecureRelForTarget, useEuiTheme } from '../../services';
import { euiLinkStyles } from './link.styles';
import { EuiIcon } from '../icon';
import { EuiI18n, useEuiI18n } from '../i18n';
import { CommonProps, ExclusiveUnion } from '../common';
import { EuiScreenReaderOnly } from '../accessibility';
import { validateHref } from '../../services/security/href_validator';

import { EuiExternalLinkIcon } from './external_link_icon';
import { euiLinkStyles } from './link.styles';

export type EuiLinkType = 'button' | 'reset' | 'submit';

export const COLORS = [
Expand Down Expand Up @@ -95,32 +95,10 @@ const EuiLink = forwardRef<HTMLAnchorElement | HTMLButtonElement, EuiLinkProps>(
const euiTheme = useEuiTheme();
const styles = euiLinkStyles(euiTheme);
const cssStyles = [styles.euiLink];
const cssScreenReaderTextStyles = [styles.euiLink__screenReaderText];
const cssExternalLinkIconStyles = [styles.euiLink__externalIcon];

const isHrefValid = !href || validateHref(href);
const disabled = _disabled || !isHrefValid;

const newTargetScreenreaderText = (
<EuiScreenReaderOnly css={cssScreenReaderTextStyles}>
<span>
<EuiI18n
token="euiLink.newTarget.screenReaderOnlyText"
default="(opens in a new tab or window)"
/>
</span>
</EuiScreenReaderOnly>
);

const externalLinkIcon = (
<EuiIcon
aria-label={useEuiI18n('euiLink.external.ariaLabel', 'External link')}
size="s"
css={cssExternalLinkIconStyles}
type="popout"
/>
);

if (href === undefined || !isHrefValid) {
const buttonProps = {
className: classNames('euiLink', className),
Expand Down Expand Up @@ -152,17 +130,14 @@ const EuiLink = forwardRef<HTMLAnchorElement | HTMLButtonElement, EuiLinkProps>(
onClick,
...rest,
};
const showExternalLinkIcon =
(target === '_blank' && external !== false) || external === true;

return (
<a
ref={ref as React.Ref<HTMLAnchorElement>}
{...(anchorProps as EuiLinkAnchorProps)}
>
{children}
{showExternalLinkIcon && externalLinkIcon}
{target === '_blank' && newTargetScreenreaderText}
<EuiExternalLinkIcon external={external} target={target} />
</a>
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/list_group/list_group_item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ export const euiListGroupItemInnerStyles = (euiThemeContext: UseEuiTheme) => {
text-decoration: underline;
}
`,
externalIcon: css`
${logicalCSS('margin-left', euiTheme.size.xs)}
`,
};
};

Expand Down
Loading
Loading