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

[Tech debt] Create new internal util for rendering semantic <a>/<button> elements #7533

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions changelogs/upcoming/7533.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Breaking changes**

- Removed `EuiLinkType`, `EuiLinkAnchorProps`, and `EuiLinkButtonProps` types. Use `EuiLinkProps` instead
- Removed the `element` prop from `EuiButton`. All button components will automatically detect whether it should render a link or button based on the `href` prop.
2 changes: 1 addition & 1 deletion src/components/badge/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import {
useEuiTheme,
getSecureRelForTarget,
wcagContrastMin,
validateHref,
} from '../../services';
import { EuiInnerText } from '../inner_text';
import { EuiIcon, IconType } from '../icon';
import { validateHref } from '../../services/security/href_validator';

import { getTextColor, getColorContrast, getIsValidColor } from './color_utils';
import { euiBadgeStyles } from './badge.styles';
Expand Down
1 change: 0 additions & 1 deletion src/components/button/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const meta: Meta<EuiButtonProps> = {
},
args: {
// Component defaults
element: 'button',
type: 'button',
color: 'primary',
size: 'm',
Expand Down
133 changes: 9 additions & 124 deletions src/components/button/button_display/_button_display.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,45 +54,6 @@ describe('EuiButtonDisplay', () => {
});
});

describe('element', () => {
const elements = ['a', 'button', 'span'] as const;

const getButtonElement = (container: HTMLElement) =>
container.firstChild!.nodeName.toLowerCase();

elements.forEach((element) => {
test(element, () => {
const { container } = render(
<EuiButtonDisplay element={element} className="testing">
Content
</EuiButtonDisplay>
);

expect(getButtonElement(container)).toEqual(element);
});
});

it('always renders a `button` element if disabled', () => {
const { container } = render(
<EuiButtonDisplay element="a" isDisabled>
Content
</EuiButtonDisplay>
);

expect(getButtonElement(container)).toEqual('button');
});

it('always renders an `a` element if a href is passed', () => {
const { container } = render(
<EuiButtonDisplay element="span" href="#">
Content
</EuiButtonDisplay>
);

expect(getButtonElement(container)).toEqual('a');
});
});

describe('disabled behavior', () => {
it('disables hrefs with javascript in them and renders a button instead of a link', () => {
const { container } = render(
Expand All @@ -118,90 +79,14 @@ describe('EuiButtonDisplay', () => {
});
});

describe('link vs button behavior', () => {
describe('links', () => {
it('renders valid links as <a> tags', () => {
const { container } = render(<EuiButtonDisplay href="#" />);
expect(container.querySelector('a')).toBeTruthy();
expect(container.querySelector('button')).toBeFalsy();
});

it('removes the `type` prop from links', () => {
const { container } = render(
<EuiButtonDisplay href="#" type="button" />
);
expect(container.querySelector('a')?.getAttribute('type')).toBeNull();
});

it('inserts `rel=noreferrer`', () => {
const { queryByTestSubject } = render(
<>
<EuiButtonDisplay
href="https://elastic.co"
data-test-subj="rel"
rel="noreferrer"
/>
<EuiButtonDisplay
href="https://elastic.co"
data-test-subj="norel"
/>
</>
);

expect(queryByTestSubject('rel')?.getAttribute('rel')).toEqual(
'noreferrer'
);
expect(queryByTestSubject('norel')?.getAttribute('rel')).toEqual(
'noreferrer'
);
});

it('inserts `rel=noopener` for all target=_blank links', () => {
const { queryByTestSubject } = render(
<>
<EuiButtonDisplay
href="https://elastic.co"
target="_blank"
data-test-subj="blank"
/>
</>
);

expect(queryByTestSubject('blank')?.getAttribute('rel')).toEqual(
'noopener noreferrer'
);
});
});

describe('buttons', () => {
it('removes the `href`, `rel` and `target` props from buttons', () => {
const { container } = render(
<EuiButtonDisplay
isDisabled
href="#"
rel="noopener"
target="_blank"
/>
);
expect(container.querySelector('a')).toBeFalsy();
const button = container.querySelector('button')!;

expect(button).toBeTruthy();
expect(button.getAttribute('href')).toBeNull();
expect(button.getAttribute('rel')).toBeNull();
expect(button.getAttribute('target')).toBeNull();
});

it('only sets [disabled] and [aria-pressed] on buttons', () => {
const { container } = render(
<>
<EuiButtonDisplay isDisabled isSelected />
<EuiButtonDisplay href="#" isSelected />
</>
);
expect(container.querySelectorAll('[disabled]')).toHaveLength(1);
expect(container.querySelectorAll('[aria-pressed]')).toHaveLength(1);
});
});
it('only sets [aria-pressed] and [type] on buttons', () => {
const { container } = render(
<>
<EuiButtonDisplay isSelected type="submit" />
<EuiButtonDisplay href="#" isSelected type="submit" />
</>
);
expect(container.querySelectorAll('[aria-pressed]')).toHaveLength(1);
expect(container.querySelectorAll('[type="submit"]')).toHaveLength(1);
});
});
95 changes: 37 additions & 58 deletions src/components/button/button_display/_button_display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import React, {
ButtonHTMLAttributes,
} from 'react';

// @ts-ignore module doesn't export `createElement`
import { createElement } from '@emotion/react';
import { getSecureRelForTarget, useEuiTheme } from '../../../services';
import {
RenderLinkOrButton,
validateHref,
useEuiTheme,
} from '../../../services';

import {
CommonProps,
Expand All @@ -31,7 +33,6 @@ import {
EuiButtonDisplayContentProps,
EuiButtonDisplayContentType,
} from './_button_display_content';
import { validateHref } from '../../../services/security/href_validator';

const SIZES = ['xs', 's', 'm'] as const;
export type EuiButtonDisplaySizes = (typeof SIZES)[number];
Expand All @@ -43,7 +44,6 @@ export type EuiButtonDisplaySizes = (typeof SIZES)[number];
export interface EuiButtonDisplayCommonProps
extends EuiButtonDisplayContentProps,
CommonProps {
element?: 'a' | 'button' | 'span';
children?: ReactNode;
size?: EuiButtonDisplaySizes;
/**
Expand Down Expand Up @@ -107,10 +107,12 @@ export function isButtonDisabled({
* EuiButtonDisplay is an internal-only component used for displaying
* any element as a button.
*/
export const EuiButtonDisplay = forwardRef<HTMLElement, EuiButtonDisplayProps>(
export const EuiButtonDisplay = forwardRef<
HTMLButtonElement | HTMLAnchorElement,
EuiButtonDisplayProps
>(
(
{
element: _element = 'button',
type = 'button',
children,
iconType,
Expand Down Expand Up @@ -147,60 +149,37 @@ export const EuiButtonDisplay = forwardRef<HTMLElement, EuiButtonDisplayProps>(
styles[size],
fullWidth && styles.fullWidth,
minWidth == null && styles.defaultMinWidth,
buttonIsDisabled && styles.isDisabled,
];

const innerNode = (
<EuiButtonDisplayContent
isLoading={isLoading}
isDisabled={buttonIsDisabled}
iconType={iconType}
iconSide={iconSide}
iconSize={iconSize}
textProps={textProps}
{...contentProps}
return (
<RenderLinkOrButton
componentCss={cssStyles}
style={minWidth ? { ...style, minInlineSize: minWidth } : style}
fallbackElement="button"
elementRef={ref}
href={href}
target={target}
rel={rel}
isDisabled={disabled || isDisabled || isLoading}
buttonProps={{
type,
'aria-pressed': isSelected,
css: buttonIsDisabled && styles.isDisabled,
}}
{...rest}
>
{children}
</EuiButtonDisplayContent>
);

const element = buttonIsDisabled ? 'button' : href ? 'a' : _element;
let elementProps = {};
// Element-specific attributes
if (element === 'button') {
elementProps = {
...elementProps,
disabled: buttonIsDisabled,
'aria-pressed': isSelected,
};
}

const relObj: {
rel?: string;
href?: string;
type?: ButtonHTMLAttributes<HTMLButtonElement>['type'];
target?: string;
} = {};

if (href && !buttonIsDisabled) {
relObj.href = href;
relObj.rel = getSecureRelForTarget({ href, target, rel });
relObj.target = target;
} else {
relObj.type = type as ButtonHTMLAttributes<HTMLButtonElement>['type'];
}

return createElement(
element,
{
css: cssStyles,
style: minWidth ? { ...style, minInlineSize: minWidth } : style,
ref,
...elementProps,
...relObj,
...rest,
},
innerNode
<EuiButtonDisplayContent
isLoading={isLoading}
isDisabled={buttonIsDisabled}
iconType={iconType}
iconSide={iconSide}
iconSize={iconSize}
textProps={textProps}
{...contentProps}
>
{children}
</EuiButtonDisplayContent>
</RenderLinkOrButton>
);
}
);
Expand Down
5 changes: 2 additions & 3 deletions src/components/card/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,11 @@ import classNames from 'classnames';

import {
getSecureRelForTarget,
validateHref,
useEuiTheme,
useGeneratedHtmlId,
cloneElementWithCss,
} from '../../services';
import { useGeneratedHtmlId } from '../../services/accessibility';
import { validateHref } from '../../services/security/href_validator';

import { CommonProps, ExclusiveUnion } from '../common';
import { EuiText } from '../text';
import { EuiTitle } from '../title';
Expand Down
2 changes: 1 addition & 1 deletion src/components/context_menu/context_menu_item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import classNames from 'classnames';
import {
useEuiTheme,
getSecureRelForTarget,
validateHref,
cloneElementWithCss,
} from '../../services';
import { validateHref } from '../../services/security/href_validator';
import { CommonProps, keysOf } from '../common';
import { EuiIcon } from '../icon';
import { EuiToolTip, EuiToolTipProps } from '../tool_tip';
Expand Down
1 change: 0 additions & 1 deletion src/components/facet/facet_button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const meta: Meta<EuiFacetButtonProps> = {
},
args: {
// Component defaults
element: 'button',
type: 'button',
size: 's',
iconSide: 'left',
Expand Down
7 changes: 5 additions & 2 deletions src/components/header/header_logo/header_logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import React, {
} from 'react';
import classNames from 'classnames';

import { useEuiTheme, getSecureRelForTarget } from '../../../services';
import { validateHref } from '../../../services/security/href_validator';
import {
useEuiTheme,
getSecureRelForTarget,
validateHref,
} from '../../../services';
import { EuiIcon, IconType } from '../../icon';
import { CommonProps } from '../../common';

Expand Down
2 changes: 1 addition & 1 deletion src/components/key_pad_menu/key_pad_menu_item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import classNames from 'classnames';
import {
useEuiTheme,
getSecureRelForTarget,
validateHref,
useGeneratedHtmlId,
} from '../../services';

Expand All @@ -32,7 +33,6 @@ import {
import { EuiBetaBadge } from '../badge/beta_badge';
import { IconType } from '../icon';
import { EuiRadio, EuiCheckbox } from '../form';
import { validateHref } from '../../services/security/href_validator';
import { EuiToolTip, EuiToolTipProps } from '../tool_tip';

import {
Expand Down
Loading
Loading