Skip to content

Commit

Permalink
Merge pull request #22 from hypothesis/update-sass-add-buttons
Browse files Browse the repository at this point in the history
Add shared button components
  • Loading branch information
lyzadanger authored Apr 15, 2021
2 parents 3ce1d79 + 0f60c64 commit b3317d5
Show file tree
Hide file tree
Showing 12 changed files with 861 additions and 2 deletions.
131 changes: 131 additions & 0 deletions src/components/buttons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import classnames from 'classnames';

import { SvgIcon } from './SvgIcon';

/**
* @typedef ButtonProps
* @prop {import("preact").ComponentChildren} [children]
* @prop {string} [className]
* @prop {string} [icon] - Name of `SvgIcon` to render in the button
* @prop {'left'|'right'} [iconPosition] - Icon positioned to left or to
* right of button text
* @prop {boolean} [disabled]
* @prop {boolean} [expanded] - Is the element associated with this button
* expanded (set `aria-expanded`)
* @prop {boolean} [pressed] - Is this button currently "active?" (set
* `aria-pressed`)
* @prop {() => any} [onClick]
* @prop {'small'|'medium'|'large'} [size='medium'] - Relative button size:
* affects padding
* @prop {Object} [style] - Optional inline styles
* @prop {string} [title] - Button title; used for `aria-label` attribute
* @prop {'normal'|'primary'|'light'|'dark'} [variant='normal'] - For styling: element variant
*/

/**
* @typedef IconButtonBaseProps
* @prop {string} icon - Icon is required for icon buttons
* @prop {string} title - Title is required for icon buttons
*/

/**
* @typedef {ButtonProps & IconButtonBaseProps} IconButtonProps
*/

/**
* @param {ButtonProps} props
*/
function ButtonBase({
children,
className,
icon,
iconPosition = 'left',
disabled,
expanded,
pressed,
onClick = () => {},
size = 'medium',
style = {},
title,
variant = 'normal',
}) {
const otherAttributes = {};
if (typeof disabled === 'boolean') {
otherAttributes.disabled = disabled;
}
if (typeof title !== 'undefined') {
otherAttributes.title = title;
otherAttributes['aria-label'] = title;
}

if (typeof expanded === 'boolean') {
otherAttributes['aria-expanded'] = expanded;
}
if (typeof pressed === 'boolean') {
otherAttributes['aria-pressed'] = pressed;
}

return (
<button
className={classnames(
className,
`${className}--${size}`,
`${className}--${variant}`,
{
[`${className}--icon-${iconPosition}`]: icon,
}
)}
onClick={onClick}
{...otherAttributes}
style={style}
>
{children}
</button>
);
}

/**
* An icon-only button
*
* @param {IconButtonProps} props
*/
export function IconButton(props) {
const { className = 'IconButton', ...restProps } = props;
const { icon } = props;
return (
<ButtonBase className={className} {...restProps}>
<SvgIcon name={icon} />
</ButtonBase>
);
}

/**
* A labeled button, with or without an icon
*
* @param {ButtonProps} props
*/
export function LabeledButton(props) {
const { icon, iconPosition = 'left' } = props;
const { children, className = 'LabeledButton', ...restProps } = props;
return (
<ButtonBase className={className} {...restProps}>
{icon && iconPosition === 'left' && <SvgIcon name={icon} />}
{children}
{icon && iconPosition === 'right' && <SvgIcon name={icon} />}
</ButtonBase>
);
}

/**
* A button styled to appear as an HTML link (<a>)
*
* @param {ButtonProps} props
*/
export function LinkButton(props) {
const { children } = props;
return (
<ButtonBase className="LinkButton" {...props}>
{children}
</ButtonBase>
);
}
257 changes: 257 additions & 0 deletions src/components/test/buttons-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { mount } from 'enzyme';

import { IconButton, LabeledButton, LinkButton } from '../buttons.js';
import { $imports } from '../buttons.js';

import { checkAccessibility } from '../../../test/util/accessibility';
import mockImportedComponents from '../../../test/util/mock-imported-components';

// Add common tests for a button component for stuff provided by `ButtonBase`
function addCommonTests({ componentName, createComponentFn, withIcon = true }) {
describe(`${componentName} common support`, () => {
if (withIcon) {
it('renders the indicated icon', () => {
const wrapper = createComponentFn({ icon: 'fakeIcon' });
const button = wrapper.find('button');
const icon = wrapper.find('SvgIcon');
assert.equal(icon.prop('name'), 'fakeIcon');
// Icon is positioned "left" even if it is the only element in the <button>
assert.isTrue(button.hasClass(`${componentName}--icon-left`));
});
}

it('invokes callback on click', () => {
const onClick = sinon.stub();
const wrapper = createComponentFn({ onClick });

wrapper.find('button').simulate('click');
assert.calledOnce(onClick);
});

it('uses an internal no-op callback if no `onClick` is provided', () => {
// This test merely exercises the `onClick` prop default-value branch
// in the code
const wrapper = createComponentFn({ onClick: undefined });
wrapper.find('button').simulate('click');
});

it('uses a default className', () => {
const wrapper = createComponentFn();

assert.isTrue(wrapper.find('button').hasClass(componentName));
});

['primary', 'light', 'dark'].forEach(variant => {
it('renders a valid variant', () => {
const wrapper = createComponentFn({ variant });

assert.isTrue(
wrapper.find('button').hasClass(`${componentName}--${variant}`)
);
});
});

it('sets a `normal` variant modifier class by default', () => {
const wrapper = createComponentFn();

assert.isTrue(
wrapper.find('button').hasClass(`${componentName}--normal`)
);
});

['small', 'medium', 'large'].forEach(size => {
it('renders a valid size', () => {
const wrapper = createComponentFn({ size });

assert.isTrue(
wrapper.find('button').hasClass(`${componentName}--${size}`)
);
});
});

it('sets a `medium` size modifier class by default', () => {
const wrapper = createComponentFn();

assert.isTrue(
wrapper.find('button').hasClass(`${componentName}--medium`)
);
});

it('overrides className when provided', () => {
const wrapper = createComponentFn({
className: 'CustomClassName',
variant: 'primary',
});

assert.isTrue(wrapper.find('button').hasClass('CustomClassName'));
assert.isTrue(
wrapper.find('button').hasClass('CustomClassName--primary')
);
assert.isFalse(wrapper.find('button').hasClass(componentName));
});

it('adds inline styles when provided', () => {
const wrapper = createComponentFn({ style: { backgroundColor: 'pink' } });

assert.equal(
wrapper.getDOMNode().getAttribute('style'),
'background-color: pink;'
);
});

[
{
propName: 'expanded',
propValue: true,
attributeName: 'aria-expanded',
attributeValue: 'true',
},
{
propName: 'pressed',
propValue: true,
attributeName: 'aria-pressed',
attributeValue: 'true',
},
{
propName: 'title',
propValue: 'Click here',
attributeName: 'aria-label',
attributeValue: 'Click here',
},
{
propName: 'disabled',
propValue: true,
attributeName: 'disabled',
attributeValue: '',
},
].forEach(testCase => {
it('sets attributes on the button element', () => {
const wrapper = createComponentFn({
[testCase.propName]: testCase.propValue,
});

const element = wrapper.find('button').getDOMNode();

assert.equal(
element.getAttribute(testCase.attributeName),
testCase.attributeValue
);
});
});
});
}

describe('buttons', () => {
let fakeOnClick;

beforeEach(() => {
fakeOnClick = sinon.stub();
$imports.$mock(mockImportedComponents());
});

afterEach(() => {
$imports.$restore();
});

describe('IconButton', () => {
function createComponent(props = {}) {
return mount(
<IconButton
icon="fakeIcon"
title="My Action"
onClick={fakeOnClick}
{...props}
/>
);
}

addCommonTests({
componentName: 'IconButton',
createComponentFn: createComponent,
});

it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent(),
})
);
});

describe('LabeledButton', () => {
function createComponent(props = {}) {
return mount(
<LabeledButton title="My Action" onClick={fakeOnClick} {...props}>
Do this
</LabeledButton>
);
}

addCommonTests({
componentName: 'LabeledButton',
createComponentFn: createComponent,
});

it('renders the indicated icon on the right if `iconPosition` is `right`', () => {
const wrapper = createComponent({
icon: 'fakeIcon',
iconPosition: 'right',
});

const icon = wrapper.find('SvgIcon');
const button = wrapper.find('button');
assert.equal(icon.prop('name'), 'fakeIcon');
assert.isTrue(button.hasClass('LabeledButton--icon-right'));
});

it('does not render an icon if none indicated', () => {
// Icon not required for `LabeledButton`
const wrapper = createComponent();

const icon = wrapper.find('SvgIcon');
assert.isFalse(icon.exists());
});

it('renders children', () => {
const wrapper = createComponent();

assert.equal(wrapper.text(), 'Do this');
});

it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent(),
})
);
});

describe('LinkButton', () => {
function createComponent(props = {}) {
return mount(
<LinkButton title="My Action" onClick={fakeOnClick} {...props}>
Do this
</LinkButton>
);
}

addCommonTests({
componentName: 'LinkButton',
createComponentFn: createComponent,
withIcon: false,
});

it('renders children', () => {
const wrapper = createComponent();

assert.equal(wrapper.text(), 'Do this');
});

it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent(),
})
);
});
});
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Components
export { IconButton, LabeledButton, LinkButton } from './components/buttons';
export { SvgIcon, registerIcons } from './components/SvgIcon';

// Hooks
Expand Down
Loading

0 comments on commit b3317d5

Please sign in to comment.