Unverified Commit 8377b378 authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1598 from hypothesis/action-button

Add and use new `ActionButton` component
parents 3b8084ef 599e6ec5
'use strict';
const classnames = require('classnames');
const propTypes = require('prop-types');
const { createElement } = require('preact');
const SvgIcon = require('./svg-icon');
/**
* A button, with (required) text label and an (optional) icon
*/
function ActionButton({
icon = '',
isActive = false,
isPrimary = false,
label,
onClick = () => null,
useCompactStyle = false,
}) {
return (
<button
className={classnames('action-button', {
'action-button--compact': useCompactStyle,
'action-button--primary': isPrimary,
'is-active': isActive,
})}
onClick={onClick}
aria-pressed={isActive}
title={label}
>
{icon && (
<SvgIcon
name={icon}
className={classnames('action-button__icon', {
'action-button__icon--compact': useCompactStyle,
'action-button__icon--primary': isPrimary,
})}
/>
)}
<span className="action-button__label">{label}</span>
</button>
);
}
ActionButton.propTypes = {
/** The name of the SVGIcon to render */
icon: propTypes.string,
/** Is this button currently in an "active" or in an "on" state? */
isActive: propTypes.bool,
/**
* Does this button represent the "primary" action available? If so,
* differentiating styles will be applied.
*/
isPrimary: propTypes.bool,
/** a label used for the `title` and `aria-label` attributes */
label: propTypes.string.isRequired,
/** callback for button clicks */
onClick: propTypes.func,
/** Allows a variant of this type of button that takes up less space */
useCompactStyle: propTypes.bool,
};
module.exports = ActionButton;
...@@ -6,9 +6,9 @@ const { createElement } = require('preact'); ...@@ -6,9 +6,9 @@ const { createElement } = require('preact');
const { applyTheme } = require('../util/theme'); const { applyTheme } = require('../util/theme');
const { withServices } = require('../util/service-context'); const { withServices } = require('../util/service-context');
const ActionButton = require('./action-button');
const Menu = require('./menu'); const Menu = require('./menu');
const MenuItem = require('./menu-item'); const MenuItem = require('./menu-item');
const SvgIcon = require('./svg-icon');
/** /**
* Render a compound control button for publishing (saving) an annotation: * Render a compound control button for publishing (saving) an annotation:
...@@ -75,14 +75,12 @@ function AnnotationPublishControl({ ...@@ -75,14 +75,12 @@ function AnnotationPublishControl({
/> />
</Menu> </Menu>
</div> </div>
<button <ActionButton
className="action-button" icon="cancel"
label="Cancel"
onClick={onCancel} onClick={onCancel}
title="Cancel changes to this annotation" useCompactStyle
> />
<SvgIcon name="cancel" className="action-button__icon--compact" />
Cancel
</button>
</div> </div>
); );
} }
......
...@@ -8,6 +8,8 @@ const { withServices } = require('../util/service-context'); ...@@ -8,6 +8,8 @@ const { withServices } = require('../util/service-context');
const uiConstants = require('../ui-constants'); const uiConstants = require('../ui-constants');
const useStore = require('../store/use-store'); const useStore = require('../store/use-store');
const ActionButton = require('./action-button');
/** /**
* Of the annotations in the thread `annThread`, how many * Of the annotations in the thread `annThread`, how many
* are currently `visible` in the browser (sidebar)? * are currently `visible` in the browser (sidebar)?
...@@ -143,22 +145,17 @@ function SearchStatusBar({ rootThread }) { ...@@ -143,22 +145,17 @@ function SearchStatusBar({ rootThread }) {
})(), })(),
}; };
const btnProps = {
className: 'primary-action-btn primary-action-btn--short',
onClick: actions.clearSelection,
};
return ( return (
<div> <div>
{modes.filtered && ( {modes.filtered && (
<div className="search-status-bar"> <div className="search-status-bar">
<button <ActionButton
title="Clear the search filter and show all annotations" icon="cancel"
{...btnProps} label="Clear search"
> onClick={actions.clearSelection}
<i className="primary-action-btn__icon h-icon-close" /> isPrimary
Clear search useCompactStyle
</button> />
<span className="search-status-bar__filtered-text"> <span className="search-status-bar__filtered-text">
{modeText.filtered} {modeText.filtered}
</span> </span>
...@@ -173,14 +170,12 @@ function SearchStatusBar({ rootThread }) { ...@@ -173,14 +170,12 @@ function SearchStatusBar({ rootThread }) {
)} )}
{modes.selected && ( {modes.selected && (
<div className="search-status-bar"> <div className="search-status-bar">
<button <ActionButton
title="Clear the selection and show all annotations" label={modeText.selected}
{...btnProps} onClick={actions.clearSelection}
> isPrimary
<span className="search-status-bar__selected-text"> useCompactStyle
{modeText.selected} />
</span>
</button>
</div> </div>
)} )}
</div> </div>
......
...@@ -7,8 +7,8 @@ const scrollIntoView = require('scroll-into-view'); ...@@ -7,8 +7,8 @@ const scrollIntoView = require('scroll-into-view');
const useStore = require('../store/use-store'); const useStore = require('../store/use-store');
const ActionButton = require('./action-button');
const Slider = require('./slider'); const Slider = require('./slider');
const SvgIcon = require('./svg-icon');
/** /**
* Base component for a sidebar panel. * Base component for a sidebar panel.
...@@ -47,14 +47,12 @@ function SidebarPanel({ children, panelName, title, onActiveChanged }) { ...@@ -47,14 +47,12 @@ function SidebarPanel({ children, panelName, title, onActiveChanged }) {
<div className="sidebar-panel__header"> <div className="sidebar-panel__header">
<div className="sidebar-panel__title u-stretch">{title}</div> <div className="sidebar-panel__title u-stretch">{title}</div>
<div> <div>
<button <ActionButton
className="sidebar-panel__close-btn" icon="cancel"
label="Close"
onClick={closePanel} onClick={closePanel}
aria-label="close panel" useCompactStyle
> />
<SvgIcon name="cancel" className="action-button__icon--compact" />
Close
</button>
</div> </div>
</div> </div>
<div className="sidebar-panel__content">{children}</div> <div className="sidebar-panel__content">{children}</div>
......
'use strict';
const { createElement } = require('preact');
const { mount } = require('enzyme');
const ActionButton = require('../action-button');
const mockImportedComponents = require('./mock-imported-components');
describe('ActionButton', () => {
let fakeOnClick;
function createComponent(props = {}) {
return mount(
<ActionButton
icon="fakeIcon"
label="My Action"
onClick={fakeOnClick}
{...props}
/>
);
}
beforeEach(() => {
fakeOnClick = sinon.stub();
ActionButton.$imports.$mock(mockImportedComponents());
});
afterEach(() => {
ActionButton.$imports.$restore();
});
it('renders `SvgIcon` if icon property set', () => {
const wrapper = createComponent();
assert.equal(wrapper.find('SvgIcon').prop('name'), 'fakeIcon');
});
it('does not render `SvgIcon` if no icon property set', () => {
const wrapper = createComponent({ icon: undefined });
assert.isFalse(wrapper.find('SvgIcon').exists());
});
it('invokes `onClick` callback when pressed', () => {
const wrapper = createComponent();
wrapper.find('button').simulate('click');
assert.calledOnce(fakeOnClick);
});
it('adds compact styles if `useCompactStyle` is `true`', () => {
const wrapper = createComponent({ useCompactStyle: true });
assert.isTrue(wrapper.find('button').hasClass('action-button--compact'));
});
context('in active state (`isActive` is `true`)', () => {
it('adds `is-active` className', () => {
const wrapper = createComponent({ isActive: true });
assert.isTrue(wrapper.find('button').hasClass('is-active'));
});
it('sets `aria-pressed` attribute on button', () => {
const wrapper = createComponent({ isActive: true });
assert.isTrue(wrapper.find('button').prop('aria-pressed'));
});
});
});
...@@ -191,10 +191,9 @@ describe('AnnotationPublishControl', () => { ...@@ -191,10 +191,9 @@ describe('AnnotationPublishControl', () => {
const wrapper = createAnnotationPublishControl({ const wrapper = createAnnotationPublishControl({
onCancel: fakeOnCancel, onCancel: fakeOnCancel,
}); });
const cancelBtn = wrapper.find( const cancelBtn = wrapper.find('ActionButton');
'[title="Cancel changes to this annotation"]'
); cancelBtn.props().onClick();
cancelBtn.prop('onClick')();
assert.calledOnce(fakeOnCancel); assert.calledOnce(fakeOnCancel);
}); });
......
...@@ -113,8 +113,8 @@ describe('SearchStatusBar', () => { ...@@ -113,8 +113,8 @@ describe('SearchStatusBar', () => {
const wrapper = createComponent({}); const wrapper = createComponent({});
const buttonText = wrapper.find('button').text(); const button = wrapper.find('ActionButton');
assert.equal(buttonText, 'Clear search'); assert.equal(button.props().label, 'Clear search');
const searchResultsText = wrapper.find('span').text(); const searchResultsText = wrapper.find('span').text();
assert.equal(searchResultsText, test.expectedText); assert.equal(searchResultsText, test.expectedText);
...@@ -251,14 +251,10 @@ describe('SearchStatusBar', () => { ...@@ -251,14 +251,10 @@ describe('SearchStatusBar', () => {
const wrapper = createComponent({}); const wrapper = createComponent({});
const button = wrapper.find('button'); const button = wrapper.find('ActionButton');
const selectedText = wrapper.find(
'.search-status-bar__selected-text'
);
assert.isTrue(button.exists()); assert.isTrue(button.exists());
assert.equal(button.text(), test.buttonText); assert.equal(button.props().label, test.buttonText);
assert.isTrue(selectedText.exists());
}); });
}); });
}); });
...@@ -285,14 +281,10 @@ describe('SearchStatusBar', () => { ...@@ -285,14 +281,10 @@ describe('SearchStatusBar', () => {
const wrapper = createComponent({}); const wrapper = createComponent({});
const button = wrapper.find('button'); const button = wrapper.find('ActionButton');
const selectedText = wrapper.find(
'.search-status-bar__selected-text'
);
assert.isTrue(button.exists()); assert.isTrue(button.exists());
assert.equal(button.text(), test.buttonText); assert.equal(button.props().label, test.buttonText);
assert.isTrue(selectedText.exists());
}); });
}); });
}); });
...@@ -315,16 +307,11 @@ describe('SearchStatusBar', () => { ...@@ -315,16 +307,11 @@ describe('SearchStatusBar', () => {
const wrapper = createComponent({}); const wrapper = createComponent({});
const button = wrapper.find('button'); const button = wrapper.find('ActionButton');
const selectedText = wrapper.find('.search-status-bar__selected-text');
const filteredText = wrapper.find('.search-status-bar__filtered-text');
assert.isTrue(button.exists()); assert.isTrue(button.exists());
assert.isFalse(selectedText.exists()); assert.equal(button.props().label, 'Clear search');
assert.isTrue(filteredText.exists());
}); });
}); });
}); });
// });
}); });
...@@ -45,7 +45,11 @@ describe('SidebarPanel', () => { ...@@ -45,7 +45,11 @@ describe('SidebarPanel', () => {
it('closes the panel when close button is clicked', () => { it('closes the panel when close button is clicked', () => {
const wrapper = createSidebarPanel({ panelName: 'flibberty' }); const wrapper = createSidebarPanel({ panelName: 'flibberty' });
wrapper.find('button').simulate('click');
wrapper
.find('ActionButton')
.props()
.onClick();
assert.calledWith(fakeStore.toggleSidebarPanel, 'flibberty', false); assert.calledWith(fakeStore.toggleSidebarPanel, 'flibberty', false);
}); });
......
...@@ -6,7 +6,7 @@ const { createElement } = require('preact'); ...@@ -6,7 +6,7 @@ const { createElement } = require('preact');
const { copyText } = require('../util/copy-to-clipboard'); const { copyText } = require('../util/copy-to-clipboard');
const { withServices } = require('../util/service-context'); const { withServices } = require('../util/service-context');
const SvgIcon = require('./svg-icon'); const ActionButton = require('./action-button');
/** /**
* Display current client version info * Display current client version info
...@@ -38,13 +38,11 @@ function VersionInfo({ flash, versionData }) { ...@@ -38,13 +38,11 @@ function VersionInfo({ flash, versionData }) {
<dd className="version-info__value">{versionData.timestamp}</dd> <dd className="version-info__value">{versionData.timestamp}</dd>
</dl> </dl>
<div className="version-info__actions"> <div className="version-info__actions">
<button <ActionButton
className="version-info__copy-btn action-button" label="Copy version details"
onClick={copyVersionData} onClick={copyVersionData}
> icon="copy"
<SvgIcon name="copy" className="action-button__icon" /> />
Copy version details
</button>
</div> </div>
</div> </div>
); );
......
...@@ -43,13 +43,36 @@ ...@@ -43,13 +43,36 @@
color: var.$grey-5; color: var.$grey-5;
font-weight: 700; font-weight: 700;
&:hover {
background-color: var.$grey-2;
}
&__label {
padding-left: 0.25em;
padding-right: 0.25em;
}
&__icon { &__icon {
color: var.$grey-5; color: var.$grey-5;
margin: $icon-margin; margin: $icon-margin;
&--compact {
margin: 0;
}
&--primary {
color: var.$grey-1;
}
} }
&:hover { &--primary {
background-color: var.$grey-2; color: var.$grey-1;
background-color: var.$grey-mid;
&:hover {
color: var.$grey-1;
background-color: var.$grey-6;
}
} }
} }
......
...@@ -2,8 +2,4 @@ ...@@ -2,8 +2,4 @@
.action-button { .action-button {
@include buttons.action-button; @include buttons.action-button;
&__icon--compact {
margin: 0;
}
} }
.search-status-bar { .search-status-bar {
display: flex;
align-items: center;
margin-bottom: 10px; margin-bottom: 10px;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment