-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #22 from hypothesis/update-sass-add-buttons
Add shared button components
- Loading branch information
Showing
12 changed files
with
861 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}) | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.