Skip to content
This repository has been archived by the owner on May 24, 2024. It is now read-only.

[terra-dropdown-button] Add support for icons in split button #4080

Merged
merged 15 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/terra-core-docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

* Added
* Added examples and tests for `terra-dropdown-button` `SplitButton` with icons.

* Changed
* Updated documentation for `terra-signature`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import DefaultSplitButton from './example/DefaultSplitButton?dev-site-example';
import GhostSplitButton from './example/GhostSplitButton?dev-site-example';
import DisabledSplitButton from './example/DisabledSplitButton?dev-site-example';
import BlockSplitButton from './example/BlockSplitButton?dev-site-example';
import IconSplitButton from './example/IconSplitButton?dev-site-example';

import SplitButtonPropsTable from 'terra-dropdown-button/lib/SplitButton?dev-site-props-table';
import ItemPropsTable from 'terra-dropdown-button/lib/Item?dev-site-props-table';
Expand Down Expand Up @@ -46,6 +47,7 @@ import { Item, SplitButton } from 'terra-dropdown-button';
<GhostSplitButton />
<DisabledSplitButton />
<BlockSplitButton />
<IconSplitButton />

## Split Button Props
<SplitButtonPropsTable />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { Item, SplitButton } from 'terra-dropdown-button';
import { IconReply } from 'terra-icon';

import classNames from 'classnames/bind';
import styles from './IconSplitButton.module.scss';

const cx = classNames.bind(styles);

const Example = () => (
<>
<SplitButton
primaryOptionLabel="Reply"
icon={<IconReply />}
onSelect={() => {}}
buttonAttrs={{
'aria-label': 'icon split',
}}
className={cx('icon-button')}
>
<Item label="Reply All" onSelect={() => {}} />
<Item label="Forward" onSelect={() => {}} />
<Item label="Reply in 10 minutes" onSelect={() => {}} />
<Item label="Selective Reply" onSelect={() => {}} />
</SplitButton>
<SplitButton
primaryOptionLabel="Reply"
icon={<IconReply />}
isReversed
onSelect={() => {}}
buttonAttrs={{
'aria-label': 'reverse icon split',
}}
className={cx('icon-button')}
>
<Item label="Reply All" onSelect={() => {}} />
<Item label="Forward" onSelect={() => {}} />
<Item label="Reply in 10 minutes" onSelect={() => {}} />
<Item label="Selective Reply" onSelect={() => {}} />
</SplitButton>
<SplitButton
primaryOptionLabel="Reply"
icon={<IconReply />}
isIconOnly
onSelect={() => {}}
buttonAttrs={{
'aria-label': 'icon only split',
}}
className={cx('icon-button')}
>
<Item label="Reply All" onSelect={() => {}} />
<Item label="Forward" onSelect={() => {}} />
<Item label="Reply in 10 minutes" onSelect={() => {}} />
<Item label="Selective Reply" onSelect={() => {}} />
</SplitButton>
</>
);

export default Example;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:local {
.icon-button {
margin: 5px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import classnames from 'classnames/bind';
import { SplitButton, Item } from 'terra-dropdown-button';
import { IconFeaturedOutlineYellow } from 'terra-icon';
import styles from './ExtraSpacing.module.scss';

const cx = classnames.bind(styles);

const RightIconSplitButton = () => (
<div className={cx('container-spacing-wrapper')}>
<h3>Icon Left</h3>
<div className={cx('button-spacing-wrapper')}>
<SplitButton
primaryOptionLabel="Split"
icon={<IconFeaturedOutlineYellow />}
metaData={{ key: 'primary-button' }}
onSelect={() => {}}
id="left-icon"
>
<Item id="opt1" label="1st" metaData={{ key: '1st Option' }} onSelect={() => {}} />
<Item id="opt2" label="2nd" metaData={{ key: '2nd Option' }} onSelect={() => {}} />
<Item id="opt3" label="3rd" metaData={{ key: '3rd Option' }} onSelect={() => {}} />
</SplitButton>
</div>
<h3>Icon Right</h3>
<div className={cx('button-spacing-wrapper')}>
<SplitButton
primaryOptionLabel="Split"
icon={<IconFeaturedOutlineYellow />}
isReversed
metaData={{ key: 'primary-button' }}
onSelect={() => {}}
id="right-icon"
>
<Item id="opt1" label="1st" metaData={{ key: '1st Option' }} onSelect={() => {}} />
<Item id="opt2" label="2nd" metaData={{ key: '2nd Option' }} onSelect={() => {}} />
<Item id="opt3" label="3rd" metaData={{ key: '3rd Option' }} onSelect={() => {}} />
</SplitButton>
</div>
<h3>Icon Only</h3>
<div className={cx('button-spacing-wrapper')}>
<SplitButton
primaryOptionLabel="Split"
icon={<IconFeaturedOutlineYellow />}
isIconOnly
metaData={{ key: 'primary-button' }}
onSelect={() => {}}
id="icon-only"
>
<Item id="opt1" label="1st" metaData={{ key: '1st Option' }} onSelect={() => {}} />
<Item id="opt2" label="2nd" metaData={{ key: '2nd Option' }} onSelect={() => {}} />
<Item id="opt3" label="3rd" metaData={{ key: '3rd Option' }} onSelect={() => {}} />
</SplitButton>
</div>
</div>
);

export default RightIconSplitButton;
3 changes: 3 additions & 0 deletions packages/terra-dropdown-button/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

* Added
* Added support for icons in `SplitButton`.

## 1.40.0 - (February 15, 2024)

* Changed
Expand Down
5 changes: 4 additions & 1 deletion packages/terra-dropdown-button/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,8 @@
"LICENSE",
"NOTICE",
"README.md"
]
],
"devDependencies": {
"terra-icon": "^3.60.0"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this as a devDependency for use in Jest tests

}
}
42 changes: 41 additions & 1 deletion packages/terra-dropdown-button/src/SplitButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const propTypes = {
* The options to display in the dropdown. Should be comprised of the subcomponent `Item`.
*/
children: PropTypes.node.isRequired,
/**
* An optional icon. Nested inline with the text when provided.
*/
icon: PropTypes.element,
/**
* Determines whether the component should have block styles applied. The dropdown will match the component's width.
*/
Expand All @@ -35,6 +39,14 @@ const propTypes = {
* Determines whether the primary button and expanding the dropdown should be disabled.
*/
isDisabled: PropTypes.bool,
/**
* Whether or not the button should only display as an icon.
*/
isIconOnly: PropTypes.bool,
/**
* Reverses the position of the icon and text.
*/
isReversed: PropTypes.bool,
/**
* Sets the text that will be shown on the primary button which is outside the dropdown.
*/
Expand Down Expand Up @@ -190,9 +202,12 @@ class SplitButton extends React.Component {
render() {
const {
children,
isReversed,
icon,
isBlock,
isCompact,
isDisabled,
isIconOnly,
primaryOptionLabel,
onSelect,
variant,
Expand Down Expand Up @@ -235,6 +250,30 @@ class SplitButton extends React.Component {
theme.className,
);

const buttonTextClassnames = cx([
{ 'text-first': icon && isReversed },
]);

const iconClassnames = cx([
{ 'icon-first': (!isIconOnly) && !isReversed },
]);

const buttonText = !isIconOnly ? <span className={buttonTextClassnames}>{primaryOptionLabel}</span> : null;

let buttonIcon = null;
if (icon) {
const iconSvgClasses = icon.props.className ? `${icon.props.className} ${cx('icon-svg')}` : cx('icon-svg');
const cloneIcon = React.cloneElement(icon, { className: iconSvgClasses });
buttonIcon = <span className={iconClassnames}>{cloneIcon}</span>;
}

const buttonLabel = (
<>
{isReversed ? buttonText : buttonIcon}
{isReversed ? buttonIcon : buttonText}
</>
);

let buttonAriaLabel = '';
const modifiedButtonAttrs = { ...buttonAttrs };
if (modifiedButtonAttrs && modifiedButtonAttrs['aria-label']) {
Expand Down Expand Up @@ -270,8 +309,9 @@ class SplitButton extends React.Component {
disabled={isDisabled}
tabIndex={isDisabled ? '-1' : undefined}
aria-disabled={isDisabled}
aria-label={isIconOnly ? primaryOptionLabel : undefined}
>
{primaryOptionLabel}
{buttonLabel}
</button>
<button
{...modifiedButtonAttrs}
Expand Down
13 changes: 13 additions & 0 deletions packages/terra-dropdown-button/src/SplitButton.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,17 @@
top: var(--terra-dropdown-button-split-type-caret-top, 0.04em);
width: var(--terra-dropdown-button-split-type-caret-width, 1em);
}

.text-first {
margin-right: var(--terra-dropdown-button-split-type-text-first-margin-right, 0.3571rem);
}

.icon-first {
margin-right: var(--terra-dropdown-button-split-type-icon-first-margin-right, 0.3571rem);
}

.icon-svg {
height: var(--terra-dropdown-button-split-type-icon-height, 1rem);
width: var(--terra-dropdown-button-split-type-icon-width, 1rem);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
--terra-dropdown-button-split-type-caret-focus-outline-ghost: 2px dashed #b2b5b6;
--terra-dropdown-button-split-type-caret-hover-box-shadow-ghost: none;

--terra-dropdown-button-split-type-text-first-margin-right: 0.3571rem;
--terra-dropdown-button-split-type-icon-first-margin-right: 0.3571rem;
--terra-dropdown-button-split-type-icon-height: 1rem;
--terra-dropdown-button-split-type-icon-width: 1rem;

/* Icons */
@include terra-inline-svg-var('--terra-dropdown-button-caret-active-background-image-neutral','<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="is-bidi"><path fill="#b2b5b6" d="M48 12L24 36 0 12h48z"/></svg>');
@include terra-inline-svg-var('--terra-dropdown-button-caret-background-image-neutral', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="is-bidi"><path fill="#b2b5b6" d="M48 12L24 36 0 12h48z"/></svg>');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
--terra-dropdown-button-split-type-caret-focus-outline-ghost: 2px dashed #000;
--terra-dropdown-button-split-type-caret-hover-box-shadow-ghost: none;

--terra-dropdown-button-split-type-text-first-margin-right: 0.41667rem;
--terra-dropdown-button-split-type-icon-first-margin-right: 0.41667rem;
--terra-dropdown-button-split-type-icon-height: 1rem;
--terra-dropdown-button-split-type-icon-width: 1rem;

/* Icons */
@include terra-inline-svg-var('--terra-dropdown-button-caret-background-image-neutral', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="is-bidi"><path fill="currentColor" d="M24,37.7,0,14.2l3.8-3.9L24,30,44.2,10.3,48,14.2Z"/></svg>');
@include terra-inline-svg-var('--terra-dropdown-button-caret-active-background-image-neutral','<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="is-bidi"><path fill="currentColor" d="M24,37.7,0,14.2l3.8-3.9L24,30,44.2,10.3,48,14.2Z"/></svg>');
Expand Down
76 changes: 76 additions & 0 deletions packages/terra-dropdown-button/tests/jest/SplitButton.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import ThemeContextProvider from 'terra-theme-context/lib/ThemeContextProvider';
import { IntlProvider } from 'react-intl';
import { v4 as uuidv4 } from 'uuid';
import { IconFeaturedOutlineYellow } from 'terra-icon';
import translationsFile from '../../translations/en.json';

import SplitButton, { Item } from '../../src/SplitButton';
Expand Down Expand Up @@ -74,6 +75,81 @@ describe('Dropdown Button', () => {
expect(wrapper.dive()).toMatchSnapshot();
});

it('renders a split button with icon', () => {
const wrapper = enzymeIntl.shallowWithIntl(
<SplitButton
primaryOptionLabel="Primary Option"
icon={<IconFeaturedOutlineYellow />}
onSelect={() => {}}
>
<Item label="1st Option" onSelect={() => {}} />
</SplitButton>,
);

const primaryButton = wrapper.dive().find('button.split-button-primary');

expect(primaryButton.prop('aria-label')).toBeUndefined();
expect(primaryButton.children()).toHaveLength(2);

// expect icon to render first
const firstChild = primaryButton.childAt(0);
expect(firstChild.prop('className')).toBe('icon-first');
expect(firstChild.childAt(0).type()).toBe(IconFeaturedOutlineYellow);

// expect text to render second
const secondChild = primaryButton.childAt(1);
expect(secondChild.text()).toBe('Primary Option');
});

it('renders a split button with text first', () => {
const wrapper = enzymeIntl.shallowWithIntl(
<SplitButton
primaryOptionLabel="Primary Option"
icon={<IconFeaturedOutlineYellow />}
isReversed
onSelect={() => {}}
>
<Item label="1st Option" onSelect={() => {}} />
</SplitButton>,
);

const primaryButton = wrapper.dive().find('button.split-button-primary');

expect(primaryButton.prop('aria-label')).toBeUndefined();
expect(primaryButton.children()).toHaveLength(2);

// expect text to render first
const firstChild = primaryButton.childAt(0);
expect(firstChild.prop('className')).toBe('text-first');
expect(firstChild.text()).toBe('Primary Option');

// expect icon to render second
const secondChild = primaryButton.childAt(1);
expect(secondChild.childAt(0).type()).toBe(IconFeaturedOutlineYellow);
});

it('renders a split button with icon only', () => {
const wrapper = enzymeIntl.shallowWithIntl(
<SplitButton
primaryOptionLabel="Primary Option"
icon={<IconFeaturedOutlineYellow />}
isIconOnly
onSelect={() => {}}
>
<Item label="1st Option" onSelect={() => {}} />
</SplitButton>,
);

const primaryButton = wrapper.dive().find('button.split-button-primary');

expect(primaryButton.prop('aria-label')).toBe('Primary Option');
expect(primaryButton.children()).toHaveLength(1);

// expect icon to render
const buttonChild = primaryButton.childAt(0);
expect(buttonChild.childAt(0).type()).toBe(IconFeaturedOutlineYellow);
});

it('should render a split type with custom attributes', () => {
const wrapper = enzymeIntl.shallowWithIntl(
<SplitButton primaryOptionLabel="Primary Option" test-custom-attribute other-custom-attribute="purple" onSelect={() => {}}>
Expand Down
Loading
Loading