Unverified Commit 87a61388 authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1180 from hypothesis/annotation-publish-control

Convert and apply new menu styles, icons to annotation-publish component
parents 64adde7b a9cb5bd0
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false" class="Icon Icon--groups"><g fill-rule="evenodd"><path fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 15a3 3 0 0 1 6 0m2-4a3 3 0 0 1 6 0M4 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm8-4a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"></path><rect fill="none" stroke="none" x="0" y="0" width="16" height="16"></rect></g></svg>
<svg
xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="120px"
height="120px"
viewBox="0 0 120 120"
version="1.1"
id="svg2"
inkscape:version="0.91 r13725"
sodipodi:docname="groups.svg">
<metadata
id="metadata18">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1080"
inkscape:window-height="607"
id="namedview16"
showgrid="false"
inkscape:zoom="1.9666667"
inkscape:cx="46.892284"
inkscape:cy="60"
inkscape:window-x="520"
inkscape:window-y="123"
inkscape:window-maximized="0"
inkscape:current-layer="svg2" />
<!-- Generator: Sketch 3.3.3 (12072) - http://www.bohemiancoding.com/sketch -->
<title
id="title4">Artboard 1</title>
<desc
id="desc6">Created with Sketch.</desc>
<defs
id="defs8" />
<g
id="Page-1"
sketch:type="MSPage"
transform="translate(0.50847458,7.627119)"
style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1">
<g
id="Artboard-1"
sketch:type="MSArtboardGroup"
style="fill:#000000">
<circle
id="Oval-1"
sketch:type="MSShapeGroup"
cx="36"
cy="41"
r="18" />
<circle
id="Oval-1-Copy"
sketch:type="MSShapeGroup"
cx="84"
cy="41"
r="18" />
<path
d="m 72,97.041748 44,0 L 116,85 c 0,0 0,-19 -32,-19 -9.06502,0 -15.561747,1.524724 -20.217828,3.710318 1.214568,0.986788 2.306029,2.05955 3.277473,3.213139 2.135429,2.535822 3.518643,5.273433 4.291509,8.026767 0.276063,0.983477 0.45577,1.908217 0.557712,2.755611 C 71.975452,84.259334 72,84.696591 72,85 l 0,12.041748 z"
id="Path-1-Copy-2"
sketch:type="MSShapeGroup"
inkscape:connector-curvature="0" />
<path
d="m 4,97.041748 64,0 L 68,85 C 68,85 68,66 36,66 4,66 4.0041989,85 4.0041989,85 L 4,97.041748 Z"
id="Path-1"
sketch:type="MSShapeGroup"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>
...@@ -5,9 +5,9 @@ ...@@ -5,9 +5,9 @@
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<defs></defs> <defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-4-Copy-3" fill="#A6A6A6"> <g id="Group-4-Copy-3" fill="currentColor">
<rect id="Rectangle-34" x="0" y="24" width="48" height="32"></rect> <rect id="Rectangle-34" x="0" y="24" width="48" height="32"></rect>
<path d="M24,0 C24,0 8,0 8,16 L8,32 L16,32 L16,16.0000004 C16,8 24,8 24,8 C24,8 32,8 32,16 L32,32 L40,32 L40,16 C40,0 24,0 24,0 Z" id="Path-52"></path> <path d="M24,0 C24,0 8,0 8,16 L8,32 L16,32 L16,16.0000004 C16,8 24,8 24,8 C24,8 32,8 32,16 L32,32 L40,32 L40,16 C40,0 24,0 24,0 Z" id="Path-52"></path>
</g> </g>
</g> </g>
</svg> </svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="nonzero" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm2.655 11.535c.244-.242.442-.719.442-1.063a1.13 1.13 0 0 0-.288-.696l-.442-.442a1.033 1.033 0 0 0-.73-.302H7.484C7.181 8.88 6.791 8 6.452 8c-.34 0-.674-.08-.978-.231l-.357-.179a.386.386 0 0 1-.213-.345c0-.153.118-.317.263-.366l1.006-.335a.618.618 0 0 1 .163-.026c.106 0 .258.056.338.126l.3.26c.046.04.106.063.169.063h.182a.258.258 0 0 0 .23-.373l-.503-1.006a.306.306 0 0 1-.027-.116c0-.06.035-.143.078-.185l.32-.31a.258.258 0 0 1 .18-.074h.29c.06 0 .141-.034.183-.076l.258-.258c.1-.1.1-.264 0-.364l-.151-.152c-.101-.1-.101-.264 0-.365l.333-.333.151-.151a.516.516 0 0 0 0-.73l-.912-.913a6.45 6.45 0 0 0-.787.078v.365a.516.516 0 0 1-.747.461l-.775-.387a6.487 6.487 0 0 0-3.329 3.287c.32.474.813 1.205 1.116 1.65.138.203.4.503.582.668l.026.023c.308.278.65.516 1.021.702.452.227 1.111.586 1.575.842.328.182.53.527.53.903v1.032c0 .274.11.537.303.73.484.484.785 1.246.73 1.653v.884c.473 0 .932-.055 1.376-.152l.56-1.511c.067-.177.106-.362.155-.544a.771.771 0 0 1 .199-.346l.365-.364zm2.797-2.946l.94.235c.036-.27.06-.544.06-.824a6.4 6.4 0 0 0-.688-2.882l-.419.21a.773.773 0 0 0-.298.263l-.632.947a.908.908 0 0 0-.13.43c0 .13.058.321.13.43l.58.87c.107.16.27.274.457.32z"/>
</svg>
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { applyTheme } = require('../util/theme');
const { withServices } = require('../util/service-context');
const Menu = require('./menu');
const MenuItem = require('./menu-item');
/**
* Render a compound control button for publishing (saving) an annotation:
* - Save the annotation — left side of button
* - Choose sharing/privacy option - drop-down menu on right side of button
*
*/
function AnnotationPublishControl({
group,
isDisabled,
isShared,
onCancel,
onSave,
onSetPrivacy,
settings,
}) {
const publishDestination = isShared ? group.name : 'Only Me';
const themeProps = ['ctaTextColor', 'ctaBackgroundColor'];
const menuLabel = (
<div className="annotation-publish-control__btn-dropdown-arrow">
<div className="annotation-publish-control__btn-dropdown-arrow-separator" />
<div
className="annotation-publish-control__btn-dropdown-arrow-indicator"
style={applyTheme(themeProps, settings)}
>
<div></div>
</div>
</div>
);
return (
<div className="annotation-publish-control">
<div className="annotation-publish-control__btn">
<button
className="annotation-publish-control__btn-primary"
style={applyTheme(themeProps, settings)}
onClick={onSave}
disabled={isDisabled}
title={`Publish this annotation to ${publishDestination}`}
>
Post to {publishDestination}
</button>
<Menu
arrowClass="annotation-publish-control__btn-menu-arrow"
containerPositioned={false}
contentClass="annotation-publish-control__btn-menu-content"
label={menuLabel}
menuIndicator={false}
title="Change annotation sharing setting"
align="left"
>
<MenuItem
icon={group.type === 'open' ? 'public' : 'groups'}
label={group.name}
isSelected={isShared}
onClick={() => onSetPrivacy({ level: 'shared' })}
/>
<MenuItem
icon="lock"
label="Only Me"
isSelected={!isShared}
onClick={() => onSetPrivacy({ level: 'private' })}
/>
</Menu>
</div>
<button
className="annotation-publish-control__cancel-btn btn-clean"
onClick={onCancel}
title="Cancel changes to this annotation"
>
<i className="h-icon-cancel-outline publish-annotation-cancel-btn__icon btn-icon" />{' '}
Cancel
</button>
</div>
);
}
AnnotationPublishControl.propTypes = {
/** The group the annotation is currently associated with */
group: propTypes.object.isRequired,
/**
* Should the save button be disabled?
* Hint: it will be if the annotation has no content
*/
isDisabled: propTypes.bool,
/** The current privacy setting on the annotation. Is it shared to group? */
isShared: propTypes.bool,
/** Callback for cancel button click */
onCancel: propTypes.func.isRequired,
/** Callback for save button click */
onSave: propTypes.func.isRequired,
/** Callback when selecting a privacy option in the menu */
onSetPrivacy: propTypes.func.isRequired,
/** services */
settings: propTypes.object.isRequired,
};
AnnotationPublishControl.injectedProps = ['settings'];
module.exports = withServices(AnnotationPublishControl);
'use strict';
// @ngInject
function DropdownMenuBtnController($timeout) {
const self = this;
this.toggleDropdown = function($event) {
$event.stopPropagation();
$timeout(function() {
self.onToggleDropdown();
}, 0);
};
}
module.exports = {
controller: DropdownMenuBtnController,
controllerAs: 'vm',
bindings: {
isDisabled: '<',
label: '<',
dropdownMenuLabel: '@',
onClick: '&',
onToggleDropdown: '&',
},
template: require('../templates/dropdown-menu-btn.html'),
};
...@@ -11,8 +11,8 @@ const SvgIcon = require('./svg-icon'); ...@@ -11,8 +11,8 @@ const SvgIcon = require('./svg-icon');
// The triangular indicator below the menu toggle button that visually links it // The triangular indicator below the menu toggle button that visually links it
// to the menu content. // to the menu content.
const menuArrow = ( const menuArrow = className => (
<svg className="menu__arrow" width={15} height={8}> <svg className={classnames('menu__arrow', className)} width={15} height={8}>
<path d="M0 8 L7 0 L15 8" stroke="currentColor" strokeWidth="2" /> <path d="M0 8 L7 0 L15 8" stroke="currentColor" strokeWidth="2" />
</svg> </svg>
); );
...@@ -44,7 +44,9 @@ let ignoreNextClick = false; ...@@ -44,7 +44,9 @@ let ignoreNextClick = false;
*/ */
function Menu({ function Menu({
align = 'left', align = 'left',
arrowClass = '',
children, children,
containerPositioned = true,
contentClass, contentClass,
defaultOpen = false, defaultOpen = false,
label, label,
...@@ -136,10 +138,16 @@ function Menu({ ...@@ -136,10 +138,16 @@ function Menu({
} }
}; };
const containerStyle = {
position: containerPositioned ? 'relative' : 'static',
};
return ( return (
<div <div
className="menu" className="menu"
ref={menuRef} ref={menuRef}
// Add inline styles for positioning
style={containerStyle}
// Don't close the menu if the mouse is released over one of the menu // Don't close the menu if the mouse is released over one of the menu
// elements outside the content area (eg. the arrow at the top of the // elements outside the content area (eg. the arrow at the top of the
// content). // content).
...@@ -167,7 +175,7 @@ function Menu({ ...@@ -167,7 +175,7 @@ function Menu({
</button> </button>
{isOpen && ( {isOpen && (
<Fragment> <Fragment>
{menuArrow} {menuArrow(arrowClass)}
<div <div
className={classnames( className={classnames(
'menu__content', 'menu__content',
...@@ -193,6 +201,13 @@ Menu.propTypes = { ...@@ -193,6 +201,13 @@ Menu.propTypes = {
*/ */
align: propTypes.oneOf(['left', 'right']), align: propTypes.oneOf(['left', 'right']),
/**
* Additional CSS class for the arrow caret at the edge of the menu
* content that "points" toward the menu's toggle button. This can be used
* to adjust the position of that caret respective to the toggle button.
*/
arrowClass: propTypes.string,
/** /**
* Label element for the toggle button that hides and shows the menu. * Label element for the toggle button that hides and shows the menu.
*/ */
...@@ -209,6 +224,12 @@ Menu.propTypes = { ...@@ -209,6 +224,12 @@ Menu.propTypes = {
*/ */
children: propTypes.any, children: propTypes.any,
/**
* Whether the menu elements should be positioned relative to the Menu
* container. When `false`, the consumer is responsible for positioning.
*/
containerPositioned: propTypes.bool,
/** /**
* Additional CSS classes to apply to the menu. * Additional CSS classes to apply to the menu.
*/ */
......
'use strict';
/**
* @description Displays a combined privacy/selection post button to post
* a new annotation
*/
// @ngInject
module.exports = {
controller: function() {
this.showDropdown = false;
this.privateLabel = 'Only Me';
this.publishDestination = function() {
return this.isShared ? this.group.name : this.privateLabel;
};
this.groupCategory = function() {
return this.group.type === 'open' ? 'public' : 'group';
};
this.setPrivacy = function(level) {
this.onSetPrivacy({ level: level });
};
},
controllerAs: 'vm',
bindings: {
group: '<',
canPost: '<',
isShared: '<',
onCancel: '&',
onSave: '&',
onSetPrivacy: '&',
},
template: require('../templates/publish-annotation-btn.html'),
};
...@@ -12,8 +12,11 @@ const icons = { ...@@ -12,8 +12,11 @@ const icons = {
copy: require('../../images/icons/copy.svg'), copy: require('../../images/icons/copy.svg'),
cursor: require('../../images/icons/cursor.svg'), cursor: require('../../images/icons/cursor.svg'),
external: require('../../images/icons/external.svg'), external: require('../../images/icons/external.svg'),
groups: require('../../images/icons/groups.svg'),
help: require('../../images/icons/help.svg'), help: require('../../images/icons/help.svg'),
leave: require('../../images/icons/leave.svg'), leave: require('../../images/icons/leave.svg'),
lock: require('../../images/icons/lock.svg'),
public: require('../../images/icons/public.svg'),
refresh: require('../../images/icons/refresh.svg'), refresh: require('../../images/icons/refresh.svg'),
share: require('../../images/icons/share.svg'), share: require('../../images/icons/share.svg'),
}; };
......
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const AnnotationPublishControl = require('../annotation-publish-control');
const MenuItem = require('../menu-item');
describe('AnnotationPublishControl', () => {
let fakeGroup;
let fakeSettings;
let fakeApplyTheme;
const createAnnotationPublishControl = (props = {}) => {
return shallow(
<AnnotationPublishControl
group={fakeGroup}
isDisabled={false}
isShared={true}
onCancel={sinon.stub()}
onSave={sinon.stub()}
onSetPrivacy={sinon.stub()}
settings={fakeSettings}
{...props}
/>
).dive(); // Dive needed because this component uses `withServices`
};
beforeEach(() => {
fakeGroup = {
name: 'Fake Group',
type: 'private',
};
fakeSettings = {
branding: {
ctaTextColor: '#0f0',
ctaBackgroundColor: '#00f',
},
};
fakeApplyTheme = sinon.stub();
AnnotationPublishControl.$imports.$mock({
'../util/theme': {
applyTheme: fakeApplyTheme,
},
});
});
describe('theming', () => {
it('should apply theme styles', () => {
const fakeStyle = { foo: 'bar' };
fakeApplyTheme.returns(fakeStyle);
const wrapper = createAnnotationPublishControl();
const btnPrimary = wrapper.find(
'.annotation-publish-control__btn-primary'
);
assert.calledWith(
fakeApplyTheme,
['ctaTextColor', 'ctaBackgroundColor'],
fakeSettings
);
assert.include(btnPrimary.prop('style'), fakeStyle);
});
});
describe('dropdown menu button (form submit button)', () => {
const btnClass = '.annotation-publish-control__btn-primary';
context('shared annotation', () => {
it('should label the button with the group name', () => {
const wrapper = createAnnotationPublishControl({ isShared: true });
const btn = wrapper.find(btnClass);
assert.equal(
btn.prop('title'),
`Publish this annotation to ${fakeGroup.name}`
);
assert.equal(btn.text(), `Post to ${fakeGroup.name}`);
});
});
context('private annotation', () => {
it('should label the button with "Only Me"', () => {
const wrapper = createAnnotationPublishControl({ isShared: false });
const btn = wrapper.find(btnClass);
assert.equal(btn.prop('title'), 'Publish this annotation to Only Me');
assert.equal(btn.text(), 'Post to Only Me');
});
});
it('should disable the button if `isDisabled`', () => {
const wrapper = createAnnotationPublishControl({ isDisabled: true });
const btn = wrapper.find(btnClass);
assert.isOk(btn.prop('disabled'));
});
it('should enable the button if not `isDisabled`', () => {
const wrapper = createAnnotationPublishControl({ isDisabled: false });
const btn = wrapper.find(btnClass);
assert.isNotOk(btn.prop('disabled'));
});
it('should have a save callback', () => {
const fakeOnSave = sinon.stub();
const wrapper = createAnnotationPublishControl({ onSave: fakeOnSave });
const btn = wrapper.find(btnClass);
assert.equal(btn.prop('onClick'), fakeOnSave);
});
});
describe('menu', () => {
describe('share (to group) menu item', () => {
it('should invoke privacy callback with shared privacy', () => {
const fakeOnSetPrivacy = sinon.stub();
const wrapper = createAnnotationPublishControl({
onSetPrivacy: fakeOnSetPrivacy,
});
const shareMenuItem = wrapper.find(MenuItem).first();
shareMenuItem.prop('onClick')();
assert.calledWith(fakeOnSetPrivacy, { level: 'shared' });
});
it('should have a label that is the name of the group', () => {
const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find(MenuItem).first();
assert.equal(shareMenuItem.prop('label'), fakeGroup.name);
});
context('private group', () => {
it('should have a group icon', () => {
const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find(MenuItem).first();
assert.equal(shareMenuItem.prop('icon'), 'groups');
});
});
context('open group', () => {
beforeEach(() => {
fakeGroup.type = 'open';
});
it('should have a public icon', () => {
const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find(MenuItem).first();
assert.equal(shareMenuItem.prop('icon'), 'public');
});
});
});
describe('private (only me) menu item', () => {
it('should invoke callback with private privacy', () => {
const fakeOnSetPrivacy = sinon.stub();
const wrapper = createAnnotationPublishControl({
onSetPrivacy: fakeOnSetPrivacy,
});
const privateMenuItem = wrapper.find(MenuItem).at(1);
privateMenuItem.prop('onClick')();
assert.calledWith(fakeOnSetPrivacy, { level: 'private' });
});
it('should use a private/lock icon', () => {
const wrapper = createAnnotationPublishControl();
const privateMenuItem = wrapper.find(MenuItem).at(1);
assert.equal(privateMenuItem.prop('icon'), 'lock');
});
it('should have an "Only me" label', () => {
const wrapper = createAnnotationPublishControl();
const privateMenuItem = wrapper.find(MenuItem).at(1);
assert.equal(privateMenuItem.prop('label'), 'Only Me');
});
});
});
describe('cancel button', () => {
it('should have a cancel callback', () => {
const fakeOnCancel = sinon.stub();
const wrapper = createAnnotationPublishControl({
onCancel: fakeOnCancel,
});
const cancelBtn = wrapper.find('.annotation-publish-control__cancel-btn');
cancelBtn.prop('onClick')();
assert.calledOnce(fakeOnCancel);
});
});
});
...@@ -187,4 +187,32 @@ describe('Menu', () => { ...@@ -187,4 +187,32 @@ describe('Menu', () => {
const content = wrapper.find('.menu__content'); const content = wrapper.find('.menu__content');
assert.isTrue(content.hasClass('special-menu')); assert.isTrue(content.hasClass('special-menu'));
}); });
it('applies custom arrow class', () => {
const wrapper = createMenu({
arrowClass: 'my-arrow-class',
defaultOpen: true,
});
const arrow = wrapper.find('.menu__arrow');
assert.isTrue(arrow.hasClass('my-arrow-class'));
});
it('has relative positioning if `containerPositioned` is `true`', () => {
const wrapper = createMenu({
containerPositioned: true, // default
});
const menuContainer = wrapper.find('.menu');
assert.include({ position: 'relative' }, menuContainer.prop('style'));
});
it('has static positioning if `containerPositioned` is `false`', () => {
const wrapper = createMenu({
containerPositioned: false,
});
const menuContainer = wrapper.find('.menu');
assert.include({ position: 'static' }, menuContainer.prop('style'));
});
}); });
'use strict';
const angular = require('angular');
const util = require('../../directive/test/util');
const fakeStorage = {};
const fakeLocalStorage = {
setItem: function(key, value) {
fakeStorage[key] = value;
},
getItem: function(key) {
return fakeStorage[key];
},
};
describe('publishAnnotationBtn', function() {
before(function() {
angular
.module('app', [])
.component('dropdownMenuBtn', require('../dropdown-menu-btn'))
.component('publishAnnotationBtn', require('../publish-annotation-btn'))
.factory('localStorage', function() {
return fakeLocalStorage;
});
});
let element;
beforeEach(function() {
angular.mock.module('app');
// create a new instance of the directive with default
// attributes
element = util.createDirective(document, 'publishAnnotationBtn', {
group: {
name: 'Public',
},
canPost: true,
isShared: false,
onSave: function() {},
onSetPrivacy: function() {},
onCancel: function() {},
});
});
[
{
groupType: 'open',
expectedIcon: 'public',
},
{
groupType: 'restricted',
expectedIcon: 'group',
},
{
groupType: 'private',
expectedIcon: 'group',
},
].forEach(({ groupType, expectedIcon }) => {
it('should set the correct group-type icon class', function() {
element.ctrl.group = {
name: 'My Group',
type: groupType,
};
element.scope.$digest();
const iconElement = element.find('.group-icon-container > i');
assert.isTrue(iconElement.hasClass(`h-icon-${expectedIcon}`));
});
});
it('should display "Post to Only Me"', function() {
const buttons = element.find('button');
assert.equal(buttons.length, 3);
assert.equal(buttons[0].innerHTML, 'Post to Only Me');
});
it('should display "Post to Research Lab"', function() {
element.ctrl.group = {
name: 'Research Lab',
};
element.ctrl.isShared = true;
element.scope.$digest();
const buttons = element.find('button');
assert.equal(buttons[0].innerHTML, 'Post to Research Lab');
});
it('should save when "Post..." is clicked', function() {
const savedSpy = sinon.spy();
element.ctrl.onSave = savedSpy;
assert.ok(!savedSpy.called);
angular.element(element.find('button')[0]).click();
assert.ok(savedSpy.calledOnce);
});
it('should change privacy when privacy option selected', function() {
const privacyChangedSpy = sinon.spy();
// for existing annotations, the privacy should not be changed
// unless the user makes a choice from the list
element.ctrl.onSetPrivacy = privacyChangedSpy;
assert.ok(!privacyChangedSpy.called);
const privateOption = element.find('li')[1];
const sharedOption = element.find('li')[0];
angular.element(privateOption).click();
assert.equal(privacyChangedSpy.callCount, 1);
angular.element(sharedOption).click();
assert.equal(privacyChangedSpy.callCount, 2);
});
it('should disable post buttons when posting is not possible', function() {
element.ctrl.canPost = false;
element.scope.$digest();
let disabledBtns = element.find('button[disabled]');
assert.equal(disabledBtns.length, 1);
// check that buttons are enabled when posting is possible
element.ctrl.canPost = true;
element.scope.$digest();
disabledBtns = element.find('button[disabled]');
assert.equal(disabledBtns.length, 0);
});
it('should revert changes when cancel is clicked', function() {
const cancelSpy = sinon.spy();
element.ctrl.onCancel = cancelSpy;
element.scope.$digest();
const cancelBtn = element.find('.publish-annotation-cancel-btn');
assert.equal(cancelBtn.length, 1);
angular.element(cancelBtn).click();
assert.equal(cancelSpy.callCount, 1);
});
});
...@@ -147,6 +147,10 @@ function startAngularApp(config) { ...@@ -147,6 +147,10 @@ function startAngularApp(config) {
'annotationActionButton', 'annotationActionButton',
wrapReactComponent(require('./components/annotation-action-button')) wrapReactComponent(require('./components/annotation-action-button'))
) )
.component(
'annotationPublishControl',
wrapReactComponent(require('./components/annotation-publish-control'))
)
.component( .component(
'annotationShareDialog', 'annotationShareDialog',
require('./components/annotation-share-dialog') require('./components/annotation-share-dialog')
...@@ -156,7 +160,6 @@ function startAngularApp(config) { ...@@ -156,7 +160,6 @@ function startAngularApp(config) {
'annotationViewerContent', 'annotationViewerContent',
require('./components/annotation-viewer-content') require('./components/annotation-viewer-content')
) )
.component('dropdownMenuBtn', require('./components/dropdown-menu-btn'))
.component('excerpt', require('./components/excerpt')) .component('excerpt', require('./components/excerpt'))
.component('groupList', require('./components/group-list')) .component('groupList', require('./components/group-list'))
.component( .component(
...@@ -173,10 +176,6 @@ function startAngularApp(config) { ...@@ -173,10 +176,6 @@ function startAngularApp(config) {
.component('markdown', require('./components/markdown')) .component('markdown', require('./components/markdown'))
.component('moderationBanner', require('./components/moderation-banner')) .component('moderationBanner', require('./components/moderation-banner'))
.component('newNoteBtn', require('./components/new-note-btn')) .component('newNoteBtn', require('./components/new-note-btn'))
.component(
'publishAnnotationBtn',
require('./components/publish-annotation-btn')
)
.component( .component(
'searchInput', 'searchInput',
wrapReactComponent(require('./components/search-input')) wrapReactComponent(require('./components/search-input'))
......
...@@ -72,14 +72,13 @@ ...@@ -72,14 +72,13 @@
<footer class="annotation-footer"> <footer class="annotation-footer">
<div class="annotation-form-actions" ng-if="vm.editing()"> <div class="annotation-form-actions" ng-if="vm.editing()">
<publish-annotation-btn <annotation-publish-control
class="publish-annotation-btn" group="vm.group()"
group="vm.group()" is-disabled="!vm.hasContent()"
can-post="vm.hasContent()" is-shared="vm.isShared()"
is-shared="vm.isShared()" on-cancel="vm.revert()"
on-cancel="vm.revert()" on-save="vm.save()"
on-save="vm.save()" on-set-privacy="vm.setPrivacy(level)"></annotation-publish-control>
on-set-privacy="vm.setPrivacy(level)"></publish-annotation-btn>
</div> </div>
<div class="annotation-section annotation-license" <div class="annotation-section annotation-license"
......
<div class="dropdown-menu-btn" >
<button
class="dropdown-menu-btn__btn"
ng-bind="vm.label"
ng-click="vm.onClick($event)"
ng-disabled="vm.isDisabled"
h-branding="ctaTextColor, ctaBackgroundColor">
</button>
<button
class="dropdown-menu-btn__dropdown-arrow"
title="{{vm.dropdownMenuLabel}}"
ng-click="vm.toggleDropdown($event)">
<div class="dropdown-menu-btn__dropdown-arrow-separator"></div>
<div
class="dropdown-menu-btn__dropdown-arrow-indicator"
h-branding="ctaTextColor, ctaBackgroundColor">
<div></div>
</div>
</button>
</div>
<div dropdown="" class="publish-annotation-btn__btn" is-open="vm.showDropdown" keyboard-nav>
<dropdown-menu-btn
label="'Post to ' + vm.publishDestination()"
on-click="vm.onSave()"
on-toggle-dropdown="vm.showDropdown = !vm.showDropdown"
title="Publish this annotation to {{vm.publishDestination()}}"
dropdown-menu-label="Change annotation sharing setting"
is-disabled="!vm.canPost">
</dropdown-menu-btn>
<div class="publish-annotation-btn__dropdown-container">
<ul class="dropdown-menu pull-center group-list publish-annotation-btn__dropdown-menu" role="menu">
<li class="dropdown-menu__row" ng-click="vm.setPrivacy('shared')">
<div class="group-item">
<div class="group-icon-container">
<i class="small" ng-class="'h-icon-' + vm.groupCategory()"></i>
</div>
<div class="group-details">
<div class="group-name-container">
<a href="" class="group-name-link" ng-bind="vm.group.name"></a>
</div>
</div>
</div>
</li>
<li class="dropdown-menu__row" ng-click="vm.setPrivacy('private')">
<div class="group-item">
<div class="group-icon-container">
<i class="small h-icon-lock"></i>
</div>
<div class="group-details">
<div class="group-name-container">
<a href="" class="group-name-link" ng-bind="vm.privateLabel"></a>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<button class="publish-annotation-cancel-btn btn-clean"
ng-click="vm.onCancel()"
title="Cancel changes to this annotation"
>
<i class="h-icon-cancel-outline publish-annotation-cancel-btn__icon btn-icon"></i> Cancel
</button>
'use strict';
const { applyTheme } = require('../theme');
describe('sidebar/util/theme/applyTheme', () => {
let fakeSettings;
beforeEach(() => {
fakeSettings = {
branding: {
accentColor: '#f00', // color
appBackgroundColor: '#0f0', // backgroundColor
ctaBackgroundColor: '#00f', // backgroundColor
ctaTextColor: '#00f', // color
selectionFontFamily: 'Times New Roman', // fontFamily
annotationFontFamily: 'Helvetica', // fontFamily
},
};
});
it('populates the style object with values for defined, supported theme props', () => {
const style = applyTheme(
['accentColor', 'appBackgroundColor', 'selectionFontFamily'],
fakeSettings
);
assert.include(style, {
color: '#f00',
backgroundColor: '#0f0',
fontFamily: 'Times New Roman',
});
});
it('overwrites a prop value with one later in the passed properties if conflicting', () => {
const style = applyTheme(['ctaTextColor', 'accentColor'], fakeSettings);
assert.include(style, {
color: '#f00',
});
});
it('does not add style rules for properties not in whitelist', () => {
fakeSettings.branding.foobar = 'left';
const style = applyTheme(['foobar', 'selectionFontFamily'], fakeSettings);
assert.hasAllKeys(style, ['fontFamily']);
});
it('does not add style rules for values not defined in settings', () => {
fakeSettings.branding = {
appBackgroundColor: '#0f0',
};
const style = applyTheme(
['appBackgroundColor', 'ctaTextColor'],
fakeSettings
);
assert.hasAllKeys(style, ['backgroundColor']);
assert.doesNotHaveAnyKeys(style, ['color']);
});
it('does not add any style rules if no branding settings', () => {
fakeSettings = {};
const style = applyTheme(
['appBackgroundColor', 'ctaTextColor'],
fakeSettings
);
assert.isEmpty(style);
});
});
'use strict';
/**
* @const {Object} All supported options for theming and their corresponding
* CSS property names (JS-style)
*/
const supportedThemeProperties = {
accentColor: 'color',
appBackgroundColor: 'backgroundColor',
ctaBackgroundColor: 'backgroundColor',
ctaTextColor: 'color',
selectionFontFamily: 'fontFamily',
annotationFontFamily: 'fontFamily',
};
/**
* Return a React `style` object suitable for use as the value of the `style`
* attr in a React element, with styling rules for the requested set of
* `themeProperties`.
*
* `supportedThemeProperties` defines a whitelist of properties that may be
* set by a partner's configuration for theme customization. For a given theme
* property's styling to be present in the returned style object, all of the
* following must be true:
*
* - The theme property is present in the `supportedThemeProperties` whitelist
* - `settings.branding` (derived from client configuration) has an entry
* for this theme property
*
* See https://reactjs.org/docs/dom-elements.html#style
*
* @param {String[]} themeProperties Which of the supported theme properties
* should have applied rules in the `style`
* object
* @param {Object} settings A settings object, in which any `branding`
* property values are set
* @return {Object} An React-style style object
*
* @example
* let themeProperties = ['accentColor', 'ctaTextColor', 'foo'];
* let settings = { branding: {
* accentColor: '#ffc',
* selectionFontFamily: 'Times New Roman'
* }
* };
* // Only two of the `themeProperties` are whitelisted and
* // only one of those has a value in the `settings` object, so:
* applyTheme(themeProperties, settings); // -> { color: '#ffc '}
*/
function applyTheme(themeProperties, settings) {
const style = {};
if (!settings.branding) {
return style;
}
themeProperties.forEach(themeProp => {
const propertyName = supportedThemeProperties[themeProp];
const propertyValue = settings.branding[themeProp];
if (propertyName && propertyValue) {
style[propertyName] = propertyValue;
}
});
return style;
}
module.exports = {
applyTheme,
};
@import "../base"; @import '../base';
// See http://compass-style.org/reference/compass/utilities/general/clearfix/#mixin-pie-clearfix // See http://compass-style.org/reference/compass/utilities/general/clearfix/#mixin-pie-clearfix
@mixin pie-clearfix { @mixin pie-clearfix {
&:after { &:after {
content: ""; content: '';
display: table; display: table;
clear: both; clear: both;
} }
} }
@mixin focus-outline { @mixin focus-outline {
border-color: #51A7E8; border-color: #51a7e8;
box-shadow: 0px 1px 2px rgba(0, 0, 0, .075) inset, 0px 0px 5px rgba(81, 167, 232, .5); box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.075) inset,
0px 0px 5px rgba(81, 167, 232, 0.5);
} }
@mixin form-input { @mixin form-input {
@include font-normal; @include font-normal;
border: 1px solid $gray-lighter; border: 1px solid $gray-lighter;
border-radius: 2px; border-radius: 2px;
padding: .5em .75em; padding: 0.5em 0.75em;
font-weight: normal; font-weight: normal;
color: $gray; color: $gray;
background-color: #FAFAFA; background-color: #fafafa;
} }
@mixin form-input-focus { @mixin form-input-focus {
outline: none; outline: none;
background-color: #FFF; background-color: #fff;
@include focus-outline; @include focus-outline;
@include placeholder { @include placeholder {
...@@ -45,20 +46,20 @@ ...@@ -45,20 +46,20 @@
} }
@mixin btn { @mixin btn {
box-shadow: 0 1px 0 rgba(0, 0, 0, .15); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15);
background: linear-gradient($button-background-gradient); background: linear-gradient($button-background-gradient);
display: inline-block; display: inline-block;
font-weight: bold; font-weight: bold;
color: $button-text-color; color: $button-text-color;
text-shadow: 0 1px 0 #FFF; text-shadow: 0 1px 0 #fff;
border-radius: 2px; border-radius: 2px;
border: 1px solid $gray-light; border: 1px solid $gray-light;
padding: .5em .9em; padding: 0.5em 0.9em;
} }
@mixin btn-hover { @mixin btn-hover {
box-shadow: 0 1px 0 rgba(0, 0, 0, .05); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
outline: none; outline: none;
color: $button-text-color; color: $button-text-color;
background: $button-background-start; background: $button-background-start;
...@@ -66,7 +67,7 @@ ...@@ -66,7 +67,7 @@
} }
@mixin btn-active { @mixin btn-active {
box-shadow: inset 0 1px 0 rgba(0, 0, 0, .1); box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.1);
background: $button-background-end; background: $button-background-end;
color: #424242; color: #424242;
border-color: #bababa; border-color: #bababa;
...@@ -75,18 +76,41 @@ ...@@ -75,18 +76,41 @@
@mixin btn-disabled { @mixin btn-disabled {
box-shadow: none; box-shadow: none;
cursor: default; cursor: default;
background: #F0F0F0; background: #f0f0f0;
border-color: #CECECE; border-color: #cecece;
color: $gray-light; color: $gray-light;
} }
@mixin primary-action-btn {
// note that there is currently some duplication here between
// the styling for this element and <dropdown-menu-btn>
color: $color-seashell;
background-color: $color-dove-gray;
height: 35px;
border: none;
border-radius: 2px;
font-weight: bold;
font-size: $body1-font-size;
padding-left: 12px;
padding-right: 12px;
&:disabled {
color: $gray-light;
}
&:hover:enabled {
background-color: $color-mine-shaft;
}
}
// Tint and shade functions from // Tint and shade functions from
// https://css-tricks.com/snippets/sass/tint-shade-functions // https://css-tricks.com/snippets/sass/tint-shade-functions
@function tint($color, $percent){ @function tint($color, $percent) {
@return mix(white, $color, $percent); @return mix(white, $color, $percent);
} }
@function shade($color, $percent){ @function shade($color, $percent) {
@return mix(black, $color, $percent); @return mix(black, $color, $percent);
} }
.annotation-publish-control {
display: flex;
&__cancel-btn {
@extend .btn--cancel;
margin-left: 5px;
font-weight: normal;
&__icon {
margin-right: 3px;
transform: translateY(10%);
}
}
// A split button with a primary submit on the left and a drop-down menu
// of related options to the right
.annotation-publish-control__btn {
$text-color: $color-seashell;
$default-background-color: $color-dove-gray;
$hover-background-color: $color-mine-shaft;
$h-padding: 9px;
$height: 35px;
$border-radius: 2px;
$arrow-indicator-width: 26px;
height: $height;
position: relative;
// Align the menu arrow correctly with the ▼ in the toggle
&-menu-arrow {
right: 5px;
}
// Make sure the menu content is wide enough to "reach" to the right-aligned
// menu arrow
&-menu-content {
min-width: 100%;
}
&-primary {
@include primary-action-btn;
// the label occupies the entire space of the button and
// shows a darker state on hover
width: 100%;
height: 100%;
text-align: left;
padding-left: $h-padding;
padding-right: $arrow-indicator-width + 8px;
}
// dropdown arrow which reveals the button's associated menu
// when clicked
&-dropdown-arrow {
position: absolute;
right: 0px;
top: 0px;
height: 100%;
width: $arrow-indicator-width;
padding-left: 0px;
padding-right: $h-padding;
margin-left: 8px;
border: none;
background-color: $color-dove-gray;
border-top-right-radius: $border-radius;
border-bottom-right-radius: $border-radius;
&:hover,
button[aria-expanded='true'] & {
// Show a hover effect on hover or if associated menu is open
background-color: $hover-background-color;
}
&:hover &-separator,
button[aria-expanded='true'] &-separator {
// hide the 1px vertical separator when the dropdown arrow
// is hovered or menu is open
background-color: $color-dove-gray;
}
// 1px vertical separator between label and dropdown arrow
&-separator {
position: absolute;
top: 0px;
bottom: 0px;
margin-top: auto;
margin-bottom: auto;
width: 1px;
height: 15px;
background-color: $color-gray;
}
// the ▼ arrow which reveals the dropdown menu when clicked
&-indicator {
color: $text-color;
position: absolute;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
line-height: $height;
text-align: center;
& > div {
transform: scaleY(0.7);
}
}
}
}
}
@import './primary-action-btn';
// the primary action button for a form
.dropdown-menu-btn {
$text-color: $color-seashell;
$default-background-color: $color-dove-gray;
$hover-background-color: $color-mine-shaft;
$h-padding: 9px;
$height: 35px;
$border-radius: 2px;
$arrow-indicator-width: 26px;
height: $height;
position: relative;
&__btn {
@include primary-action-btn;
// the label occupies the entire space of the button and
// shows a darker state on hover
width: 100%;
height: 100%;
text-align: left;
padding-left: $h-padding;
padding-right: $arrow-indicator-width + 8px;
}
// dropdown arrow which reveals the button's associated menu
// when clicked
&__dropdown-arrow {
position: absolute;
right: 0px;
top: 0px;
height: 100%;
width: $arrow-indicator-width;
padding-left: 0px;
padding-right: $h-padding;
margin-left: 8px;
border: none;
background-color: transparent;
border-top-right-radius: $border-radius;
border-bottom-right-radius: $border-radius;
&:hover {
background-color: $hover-background-color;
}
&:hover &-separator {
// hide the 1px vertical separator when the dropdown arrow
// is hovered
background-color: transparent;
}
// 1px vertical separator between label and dropdown arrow
&-separator {
position: absolute;
top: 0px;
bottom: 0px;
margin-top: auto;
margin-bottom: auto;
width: 1px;
height: 15px;
background-color: $color-gray;
}
// the ▼ arrow which reveals the dropdown menu when clicked
&-indicator {
color: $text-color;
position: absolute;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
line-height: $height;
text-align: center;
& > div {
transform: scaleY(0.7);
}
}
}
}
...@@ -44,9 +44,9 @@ ...@@ -44,9 +44,9 @@
// overlaps the content's border. The effect is that the menu's border is a // overlaps the content's border. The effect is that the menu's border is a
// rounded rect with a notch at the top. // rounded rect with a notch at the top.
position: absolute; position: absolute;
top: calc(100% - 2px); // nb. Adjust this if changing the <svg> size. top: calc(100% - 2px); // nb. Adjust this if changing the <svg> size.
right: 0; right: 0;
z-index: 1; z-index: 2;
} }
// Content area of the menu. // Content area of the menu.
...@@ -57,6 +57,7 @@ ...@@ -57,6 +57,7 @@
font-size: $body2-font-size; font-size: $body2-font-size;
position: absolute; position: absolute;
top: calc(100% + 5px); top: calc(100% + 5px);
z-index: 1;
&--align-left { &--align-left {
left: 0; left: 0;
......
@mixin primary-action-btn {
// note that there is currently some duplication here between
// the styling for this element and <dropdown-menu-btn>
color: $color-seashell;
background-color: $color-dove-gray;
height: 35px;
border: none;
border-radius: 2px;
font-weight: bold;
font-size: $body1-font-size;
padding-left: 12px;
padding-right: 12px;
&:disabled {
color: $gray-light;
}
&:hover:enabled {
background-color: $color-mine-shaft;
}
}
// A dark grey button used for the primary action // A dark grey button used for the primary action
// in a form // in a form
.primary-action-btn { .primary-action-btn {
......
.publish-annotation-btn {
display: flex;
&__btn {
position: relative;
}
// a container which wraps the dropdown menu
// in order to allow it to be positioned
// relative to its own width
&__dropdown-container {
position: absolute;
left: 100%;
}
// the content of the dropdown menu
&__dropdown-menu {
// align the ▼ arrow in the dropdown button
// with the ▲ arrow in the dropdown menu
position: relative;
left: calc(-50% - 6px);
}
}
// .dropdown-menu initially hides the dropdown via
// `visibility`. Show it when the dropdown is open
.open .publish-annotation-btn__dropdown-menu {
visibility: visible;
}
.publish-annotation-cancel-btn {
@extend .btn--cancel;
margin-left: 5px;
font-weight: normal;
&__icon {
margin-right: 3px;
transform: translateY(10%);
}
}
...@@ -20,8 +20,8 @@ $base-line-height: 20px; ...@@ -20,8 +20,8 @@ $base-line-height: 20px;
// ---------- // ----------
@import './components/annotation'; @import './components/annotation';
@import './components/annotation-share-dialog'; @import './components/annotation-share-dialog';
@import './components/annotation-publish-control';
@import './components/annotation-thread'; @import './components/annotation-thread';
@import './components/dropdown-menu-btn';
@import './components/excerpt'; @import './components/excerpt';
@import './components/group-list'; @import './components/group-list';
@import './components/group-list-v2'; @import './components/group-list-v2';
...@@ -37,7 +37,6 @@ $base-line-height: 20px; ...@@ -37,7 +37,6 @@ $base-line-height: 20px;
@import './components/moderation-banner'; @import './components/moderation-banner';
@import './components/new-note'; @import './components/new-note';
@import './components/primary-action-btn'; @import './components/primary-action-btn';
@import './components/publish-annotation-btn';
@import './components/search-status-bar'; @import './components/search-status-bar';
@import './components/selection-tabs'; @import './components/selection-tabs';
@import './components/share-link'; @import './components/share-link';
......
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