Skip to content

Commit

Permalink
[EuiListGroupItem] Add external prop (#7352)
Browse files Browse the repository at this point in the history
Co-authored-by: Cee Chen <constance.chen@elastic.co>
  • Loading branch information
Heenawter and cee-chen authored Nov 15, 2023
1 parent aca8043 commit 7740aa3
Show file tree
Hide file tree
Showing 13 changed files with 209 additions and 76 deletions.
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

0 comments on commit 7740aa3

Please sign in to comment.