Commit 12d49902 authored by Sean Hammond's avatar Sean Hammond Committed by GitHub

Merge pull request #307 from hypothesis/excerpt-component

Convert `<excerpt>` to a component
parents ce2509ba 45f09724
......@@ -140,6 +140,7 @@ module.exports = angular.module('h', [
.component('annotationShareDialog', require('./components/annotation-share-dialog'))
.component('annotationThread', require('./components/annotation-thread'))
.component('dropdownMenuBtn', require('./components/dropdown-menu-btn'))
.component('excerpt', require('./components/excerpt'))
.component('groupList', require('./components/group-list'))
.component('helpLink', require('./components/help-link'))
.component('helpPanel', require('./components/help-panel'))
......@@ -163,7 +164,6 @@ module.exports = angular.module('h', [
.directive('markdown', require('./directive/markdown'))
.directive('topBar', require('./directive/top-bar'))
.directive('excerpt', require('./directive/excerpt').directive)
.directive('formInput', require('./directive/form-input'))
.directive('formValidate', require('./directive/form-validate'))
.directive('hAutofocus', require('./directive/h-autofocus'))
......@@ -202,7 +202,7 @@ module.exports = angular.module('h', [
.factory('store', require('./store'))
.value('Discovery', require('../shared/discovery'))
.value('ExcerptOverflowMonitor', require('./directive/excerpt-overflow-monitor'))
.value('ExcerptOverflowMonitor', require('./util/excerpt-overflow-monitor'))
.value('VirtualThreadList', require('./virtual-thread-list'))
.value('raven', require('./raven'))
.value('settings', settings)
......
'use strict';
// @ngInject
function ExcerptController($element, $scope, ExcerptOverflowMonitor) {
var self = this;
if (this.collapse === undefined) {
this.collapse = true;
}
if (this.animate === undefined) {
this.animate = true;
}
if (this.enabled === undefined) {
this.enabled = true;
}
this.isExpandable = function () {
return this.overflowing && this.collapse;
};
this.isCollapsible = function () {
return this.overflowing && !this.collapse;
};
this.toggle = function (event) {
// When the user clicks a link explicitly to toggle the collapsed state,
// the event is not propagated.
event.stopPropagation();
this.collapse = !this.collapse;
};
this.expand = function () {
// When the user expands the excerpt 'implicitly' by clicking at the bottom
// of the collapsed excerpt, the event is allowed to propagate. For
// annotation cards, this causes clicking on a quote to scroll the view to
// the selected annotation.
this.collapse = false;
};
this.showInlineControls = function () {
return this.overflowing && this.inlineControls;
};
this.bottomShadowStyles = function () {
return {
'excerpt__shadow': true,
'excerpt__shadow--transparent': this.inlineControls,
'is-hidden': !this.isExpandable(),
};
};
// Test if the element or any of its parents have been hidden by
// an 'ng-show' directive
function isElementHidden() {
var el = $element[0];
while (el) {
if (el.classList.contains('ng-hide')) {
return true;
}
el = el.parentElement;
}
return false;
}
var overflowMonitor = new ExcerptOverflowMonitor({
getState: function () {
return {
enabled: self.enabled,
animate: self.animate,
collapsedHeight: self.collapsedHeight,
collapse: self.collapse,
overflowHysteresis: self.overflowHysteresis,
};
},
contentHeight: function () {
var contentElem = $element[0].querySelector('.excerpt');
if (!contentElem) {
return null;
}
return contentElem.scrollHeight;
},
onOverflowChanged: function (overflowing) {
self.overflowing = overflowing;
if (self.onCollapsibleChanged) {
self.onCollapsibleChanged({collapsible: overflowing});
}
// Even though this change happens outside the framework, we use
// $digest() rather than $apply() here to avoid a large number of full
// digest cycles if many excerpts update their overflow state at the
// same time. The onCollapsibleChanged() handler, if any, is
// responsible for triggering any necessary digests in parent scopes.
$scope.$digest();
},
}, window.requestAnimationFrame);
this.contentStyle = overflowMonitor.contentStyle;
// Listen for document events which might affect whether the excerpt
// is overflowing, even if its content has not changed.
$element[0].addEventListener('load', overflowMonitor.check, false /* capture */);
window.addEventListener('resize', overflowMonitor.check);
$scope.$on('$destroy', function () {
window.removeEventListener('resize', overflowMonitor.check);
});
// Watch for changes to the visibility of the excerpt.
// Unfortunately there is no DOM API for this, so we rely on a digest
// being triggered after the visibility changes.
$scope.$watch(isElementHidden, function (hidden) {
if (!hidden) {
overflowMonitor.check();
}
});
// Watch input properties which may affect the overflow state
$scope.$watch('vm.contentData', overflowMonitor.check);
$scope.$watch('vm.enabled', overflowMonitor.check);
// Trigger an initial calculation of the overflow state.
//
// This is performed asynchronously so that the content of the <excerpt>
// has settled - ie. all Angular directives have been fully applied and
// the DOM has stopped changing. This may take several $digest cycles.
overflowMonitor.check();
}
/**
* @description This component truncates the height of its contents to a
* specified number of lines and provides controls for expanding
* and collapsing the resulting truncated element.
*/
module.exports = {
controller: ExcerptController,
controllerAs: 'vm',
bindings: {
/** Whether or not expansion should be animated. Defaults to true. */
animate: '<?',
/**
* The data which is used to generate the excerpt's content.
* When this changes, the excerpt will recompute whether the content
* is overflowing.
*/
contentData: '<',
/** Whether or not truncation should be enabled */
enabled: '<?',
/**
* Specifies whether controls to expand and collapse
* the excerpt should be shown inside the <excerpt> component.
* If false, external controls can expand/collapse the excerpt by
* setting the 'collapse' property.
*/
inlineControls: '<',
/** Sets whether or not the excerpt is collapsed. */
collapse: '=?',
/**
* Called when the collapsibility of the excerpt (that is, whether or
* not the content height exceeds the collapsed height), changes.
*
* Note: This function is *not* called from inside a digest cycle,
* the caller is responsible for triggering any necessary digests.
*/
onCollapsibleChanged: '&?',
/** The height of this container in pixels when collapsed.
*/
collapsedHeight: '<',
/**
* The number of pixels by which the height of the excerpt's content
* must extend beyond the collapsed height in order for truncation to
* be activated. This prevents the 'More' link from being shown to expand
* the excerpt if it has only been truncated by a very small amount, such
* that expanding the excerpt would reveal no extra lines of text.
*/
overflowHysteresis: '<?',
},
transclude: true,
template: require('../templates/excerpt.html'),
};
......@@ -2,17 +2,17 @@
var angular = require('angular');
var util = require('./util');
var util = require('../../directive/test/util');
var excerpt = require('../excerpt');
describe('excerpt directive', function () {
describe('excerpt', function () {
// ExcerptOverflowMonitor fake instance created by the current test
var fakeOverflowMonitor;
var SHORT_DIV = '<div id="foo" style="height:5px;"></div>';
var TALL_DIV = '<div id="foo" style="height:200px;">foo bar</div>';
function excerptDirective(attrs, content) {
function excerptComponent(attrs, content) {
var defaultAttrs = {
enabled: true,
contentData: 'the content',
......@@ -25,7 +25,7 @@ describe('excerpt directive', function () {
before(function () {
angular.module('app', [])
.directive('excerpt', excerpt.directive);
.component('excerpt', excerpt);
});
beforeEach(function () {
......@@ -45,7 +45,7 @@ describe('excerpt directive', function () {
context('when created', function () {
it('schedules an overflow state recalculation', function () {
excerptDirective({}, '<span id="foo"></span>');
excerptComponent({}, '<span id="foo"></span>');
assert.called(fakeOverflowMonitor.check);
});
......@@ -57,7 +57,7 @@ describe('excerpt directive', function () {
inlineControls: false,
overflowHysteresis: 20,
};
excerptDirective(attrs, '<span></span>');
excerptComponent(attrs, '<span></span>');
assert.deepEqual(fakeOverflowMonitor.ctrl.getState(), {
animate: attrs.animate,
enabled: attrs.enabled,
......@@ -68,14 +68,14 @@ describe('excerpt directive', function () {
});
it('reports the content height to ExcerptOverflowMonitor', function () {
excerptDirective({}, TALL_DIV);
excerptComponent({}, TALL_DIV);
assert.deepEqual(fakeOverflowMonitor.ctrl.contentHeight(), 200);
});
});
context('input changes', function () {
it('schedules an overflow state check when inputs change', function () {
var element = excerptDirective({}, '<span></span>');
var element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
element.scope.contentData = 'new-content';
element.scope.$digest();
......@@ -83,7 +83,7 @@ describe('excerpt directive', function () {
});
it('does not schedule a state check if inputs are unchanged', function () {
var element = excerptDirective({}, '<span></span>');
var element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
element.scope.$digest();
assert.notCalled(fakeOverflowMonitor.check);
......@@ -92,14 +92,14 @@ describe('excerpt directive', function () {
context('document events', function () {
it('schedules an overflow check when media loads', function () {
var element = excerptDirective({}, '<img src="https://example.com/foo.jpg">');
var element = excerptComponent({}, '<img src="https://example.com/foo.jpg">');
fakeOverflowMonitor.check.reset();
util.sendEvent(element[0], 'load');
assert.called(fakeOverflowMonitor.check);
});
it('schedules an overflow check when the window is resized', function () {
var element = excerptDirective({}, '<span></span>');
var element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
util.sendEvent(element[0].ownerDocument.defaultView, 'resize');
assert.called(fakeOverflowMonitor.check);
......@@ -108,7 +108,7 @@ describe('excerpt directive', function () {
context('visibility changes', function () {
it('schedules an overflow check when shown', function () {
var element = excerptDirective({}, '<span></span>');
var element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
// ng-hide is the class used by the ngShow and ngHide directives
......@@ -126,7 +126,7 @@ describe('excerpt directive', function () {
context('excerpt content style', function () {
it('sets the content style using ExcerptOverflowMonitor#contentStyle()', function () {
var element = excerptDirective({}, '<span></span>');
var element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.contentStyle.returns({'max-height': '52px'});
element.scope.$digest();
var content = element[0].querySelector('.excerpt');
......@@ -136,19 +136,19 @@ describe('excerpt directive', function () {
describe('enabled state', function () {
it('renders its contents in a .excerpt element by default', function () {
var element = excerptDirective({}, '<span id="foo"></span>');
var element = excerptComponent({}, '<span id="foo"></span>');
assert.equal(element.find('.excerpt #foo').length, 1);
});
it('when enabled, renders its contents in a .excerpt element', function () {
var element = excerptDirective({enabled: true}, '<span id="foo"></span>');
var element = excerptComponent({enabled: true}, '<span id="foo"></span>');
assert.equal(element.find('.excerpt #foo').length, 1);
});
it('when disabled, renders its contents but not in a .excerpt element', function () {
var element = excerptDirective({enabled: false}, '<span id="foo"></span>');
var element = excerptComponent({enabled: false}, '<span id="foo"></span>');
assert.equal(element.find('.excerpt #foo').length, 0);
assert.equal(element.find('#foo').length, 1);
......@@ -175,7 +175,7 @@ describe('excerpt directive', function () {
}
it('displays inline controls if collapsed', function () {
var element = excerptDirective({inlineControls: true},
var element = excerptComponent({inlineControls: true},
TALL_DIV);
fakeOverflowMonitor.ctrl.onOverflowChanged(true);
var expandLink = findInlineControl(element[0]);
......@@ -184,13 +184,13 @@ describe('excerpt directive', function () {
});
it('does not display inline controls if not collapsed', function () {
var element = excerptDirective({inlineControls: true}, SHORT_DIV);
var element = excerptComponent({inlineControls: true}, SHORT_DIV);
var expandLink = findInlineControl(element[0]);
assert.notOk(expandLink);
});
it('toggles the expanded state when clicked', function () {
var element = excerptDirective({inlineControls: true}, TALL_DIV);
var element = excerptComponent({inlineControls: true}, TALL_DIV);
fakeOverflowMonitor.ctrl.onOverflowChanged(true);
var expandLink = findInlineControl(element[0]);
angular.element(expandLink.querySelector('a')).click();
......@@ -202,7 +202,7 @@ describe('excerpt directive', function () {
describe('bottom area', function () {
it('expands the excerpt when clicking at the bottom if collapsed', function () {
var element = excerptDirective({inlineControls: true},
var element = excerptComponent({inlineControls: true},
TALL_DIV);
element.scope.$digest();
assert.isTrue(element.ctrl.collapse);
......@@ -215,7 +215,7 @@ describe('excerpt directive', function () {
describe('#onCollapsibleChanged', function () {
it('is called when overflow state changes', function () {
var callback = sinon.stub();
excerptDirective({
excerptComponent({
onCollapsibleChanged: {
args: ['collapsible'],
callback: callback,
......
'use strict';
function ExcerptController() {
if (this.collapse === undefined) {
this.collapse = true;
}
if (this.animate === undefined) {
this.animate = true;
}
if (this.enabled === undefined) {
this.enabled = true;
}
this.isExpandable = function () {
return this.overflowing && this.collapse;
};
this.isCollapsible = function () {
return this.overflowing && !this.collapse;
};
this.toggle = function (event) {
// When the user clicks a link explicitly to toggle the collapsed state,
// the event is not propagated.
event.stopPropagation();
this.collapse = !this.collapse;
};
this.expand = function () {
// When the user expands the excerpt 'implicitly' by clicking at the bottom
// of the collapsed excerpt, the event is allowed to propagate. For
// annotation cards, this causes clicking on a quote to scroll the view to
// the selected annotation.
this.collapse = false;
};
this.showInlineControls = function () {
return this.overflowing && this.inlineControls;
};
this.bottomShadowStyles = function () {
return {
'excerpt__shadow': true,
'excerpt__shadow--transparent': this.inlineControls,
'is-hidden': !this.isExpandable(),
};
};
}
/**
* @ngdoc directive
* @name excerpt
* @restrict E
* @description This directive truncates the height of its contents to a
* specified number of lines and provides controls for expanding
* and collapsing the resulting truncated element.
*/
// @ngInject
function excerpt(ExcerptOverflowMonitor) {
return {
bindToController: true,
controller: ExcerptController,
controllerAs: 'vm',
link: function (scope, elem, attrs, ctrl) {
// Test if the element or any of its parents have been hidden by
// an 'ng-show' directive
function isElementHidden() {
var el = elem[0];
while (el) {
if (el.classList.contains('ng-hide')) {
return true;
}
el = el.parentElement;
}
return false;
}
var overflowMonitor = new ExcerptOverflowMonitor({
getState: function () {
return {
enabled: ctrl.enabled,
animate: ctrl.animate,
collapsedHeight: ctrl.collapsedHeight,
collapse: ctrl.collapse,
overflowHysteresis: ctrl.overflowHysteresis,
};
},
contentHeight: function () {
var contentElem = elem[0].querySelector('.excerpt');
if (!contentElem) {
return null;
}
return contentElem.scrollHeight;
},
onOverflowChanged: function (overflowing) {
ctrl.overflowing = overflowing;
if (ctrl.onCollapsibleChanged) {
ctrl.onCollapsibleChanged({collapsible: overflowing});
}
// Even though this change happens outside the framework, we use
// $digest() rather than $apply() here to avoid a large number of full
// digest cycles if many excerpts update their overflow state at the
// same time. The onCollapsibleChanged() handler, if any, is
// responsible for triggering any necessary digests in parent scopes.
scope.$digest();
},
}, window.requestAnimationFrame);
ctrl.contentStyle = overflowMonitor.contentStyle;
// Listen for document events which might affect whether the excerpt
// is overflowing, even if its content has not changed.
elem[0].addEventListener('load', overflowMonitor.check, false /* capture */);
window.addEventListener('resize', overflowMonitor.check);
scope.$on('$destroy', function () {
window.removeEventListener('resize', overflowMonitor.check);
});
// Watch for changes to the visibility of the excerpt.
// Unfortunately there is no DOM API for this, so we rely on a digest
// being triggered after the visibility changes.
scope.$watch(isElementHidden, function (hidden) {
if (!hidden) {
overflowMonitor.check();
}
});
// Watch input properties which may affect the overflow state
scope.$watch('vm.contentData', overflowMonitor.check);
scope.$watch('vm.enabled', overflowMonitor.check);
// Trigger an initial calculation of the overflow state.
//
// This is performed asynchronously so that the content of the <excerpt>
// has settled - ie. all Angular directives have been fully applied and
// the DOM has stopped changing. This may take several $digest cycles.
overflowMonitor.check();
},
scope: {
/** Whether or not expansion should be animated. Defaults to true. */
animate: '<?',
/**
* The data which is used to generate the excerpt's content.
* When this changes, the excerpt will recompute whether the content
* is overflowing.
*/
contentData: '<',
/** Whether or not truncation should be enabled */
enabled: '<?',
/**
* Specifies whether controls to expand and collapse
* the excerpt should be shown inside the <excerpt> component.
* If false, external controls can expand/collapse the excerpt by
* setting the 'collapse' property.
*/
inlineControls: '<',
/** Sets whether or not the excerpt is collapsed. */
collapse: '=?',
/**
* Called when the collapsibility of the excerpt (that is, whether or
* not the content height exceeds the collapsed height), changes.
*
* Note: This function is *not* called from inside a digest cycle,
* the caller is responsible for triggering any necessary digests.
*/
onCollapsibleChanged: '&?',
/** The height of this container in pixels when collapsed.
*/
collapsedHeight: '<',
/**
* The number of pixels by which the height of the excerpt's content
* must extend beyond the collapsed height in order for truncation to
* be activated. This prevents the 'More' link from being shown to expand
* the excerpt if it has only been truncated by a very small amount, such
* that expanding the excerpt would reveal no extra lines of text.
*/
overflowHysteresis: '<?',
},
restrict: 'E',
transclude: true,
template: require('../templates/excerpt.html'),
};
}
module.exports = {
directive: excerpt,
Controller: ExcerptController,
};
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