Commit a3f910d3 authored by Robert Knight's avatar Robert Knight Committed by Sheetal Umesh Kumar

Implement new design for hovered conversation threads (#3376)

* Implement new design for hovered conversation threads

Implement the new design for hovered replies from
https://trello.com/c/aXCXxzx2 .

The most visible effect is that conversation threads have a grey
background when hovered.

In the process of implementing the new styling, there is some cleanup
of the CSS:

 * Use `--reply`/`--top-reply` modifier classes on <annotation> and
   <annotation-thread> elements to style annotations, top-level replies
   and nested replies differently. This makes the CSS simpler and
   reduces the risk of unexpected side effects that come with descendant
   selectors.

 * Rename `thread` CSS classes to match the name of the component
   that they are used in, `annotation-thread`.

* Move 'annotation-unavailable-message' styling to app.scss

This class is used in the root template (viewer.html) not the
<annotation-thread> component.

* Darken expand/collapse toggle arrow only when annotation itself is hovered

Darken the expand/collapse arrow when an annotation is hovered but not
when its replies are hovered.

* Remove no-op CSS class

The `clear: both` styling had no effect because <annotation-thread>
is now using flexbox rather than floats for layout.

* Do not show 'Hide replies' link for replies

For replies there were two different ways to collapse the annotation
card, the expand/collapse toggle arrow and the 'Hide replies' link.

Removing the 'Hide replies' link avoids having two ways to do the same
thing and makes the cards look cleaner.

* Remove the light grey background for hovered replies

Following design review, remove the grey background for hovered replies.

* Make rendering of dashed lines to the left of replies better in Chrome

Previously the dashed line started at the top of the <annotation-thread>
component and the top part was covered up by the thread expand/collapse
toggle.

In Chrome the alignment of dashes within a dashed border varies as the
height of the element changes [1]. Therefore depending on the height of
the reply, this could result in the visible part of the line below the
collapse/expand toggle starting at either a gap or dash in the line.

By instead moving the dashed line to a separate element which is
positioned beneath the expand/collapse toggle, the first visible dash in
the reply line always appears in the same place and is aligned correctly
with the annotation content to its right.

[1] See http://www.impressivewebs.com/comparison-css-border-style/
    for a visual representation of why this is done.
parent d6f1a7ec
...@@ -191,12 +191,16 @@ function sortThread(thread, compareFn, replyCompareFn) { ...@@ -191,12 +191,16 @@ function sortThread(thread, compareFn, replyCompareFn) {
} }
/** /**
* Return a copy of @p thread with the replyCount property updated. * Return a copy of @p thread with the `replyCount` and `depth` properties
* updated.
*/ */
function countReplies(thread) { function countRepliesAndDepth(thread, depth) {
var children = thread.children.map(countReplies); var children = thread.children.map(function (c) {
return countRepliesAndDepth(c, depth + 1);
});
return Object.assign({}, thread, { return Object.assign({}, thread, {
children: children, children: children,
depth: depth,
replyCount: children.reduce(function (total, child) { replyCount: children.reduce(function (total, child) {
return total + 1 + child.replyCount; return total + 1 + child.replyCount;
}, 0), }, 0),
...@@ -324,8 +328,8 @@ function buildThread(annotations, opts) { ...@@ -324,8 +328,8 @@ function buildThread(annotations, opts) {
// Sort the root thread according to the current search criteria // Sort the root thread according to the current search criteria
thread = sortThread(thread, opts.sortCompareFn, opts.replySortCompareFn); thread = sortThread(thread, opts.sortCompareFn, opts.replySortCompareFn);
// Update reply counts // Update `replyCount` and `depth` properties
thread = countReplies(thread); thread = countRepliesAndDepth(thread, -1);
return thread; return thread;
} }
......
...@@ -30,6 +30,10 @@ function showAllParents(thread, showFn) { ...@@ -30,6 +30,10 @@ function showAllParents(thread, showFn) {
// @ngInject // @ngInject
function AnnotationThreadController() { function AnnotationThreadController() {
// Flag that tracks whether the content of the annotation is hovered,
// excluding any replies.
this.annotationHovered = false;
this.toggleCollapsed = function () { this.toggleCollapsed = function () {
this.onChangeCollapsed({ this.onChangeCollapsed({
id: this.thread.id, id: this.thread.id,
...@@ -37,6 +41,30 @@ function AnnotationThreadController() { ...@@ -37,6 +41,30 @@ function AnnotationThreadController() {
}); });
}; };
this.threadClasses = function () {
return {
'annotation-thread': true,
'annotation-thread--reply': this.thread.depth > 0,
'annotation-thread--top-reply': this.thread.depth === 1,
};
};
this.threadToggleClasses = function () {
return {
'annotation-thread__collapse-toggle': true,
'is-open': !this.thread.collapsed,
'is-hovered': this.annotationHovered,
};
};
this.annotationClasses = function () {
return {
annotation: true,
'annotation--reply': this.thread.depth > 0,
'is-collapsed': this.thread.collapsed,
};
};
/** /**
* Show this thread and any of its children * Show this thread and any of its children
*/ */
...@@ -46,6 +74,10 @@ function AnnotationThreadController() { ...@@ -46,6 +74,10 @@ function AnnotationThreadController() {
showAllChildren(this.thread, this.onForceVisible); showAllChildren(this.thread, this.onForceVisible);
}; };
this.isTopLevelThread = function () {
return !this.thread.parent;
};
/** /**
* Return the total number of annotations in the current * Return the total number of annotations in the current
* thread which have been hidden because they do not match the current * thread which have been hidden because they do not match the current
......
...@@ -690,6 +690,10 @@ function AnnotationController( ...@@ -690,6 +690,10 @@ function AnnotationController(
return persona.username(domainModel.user); return persona.username(domainModel.user);
}; };
vm.isReply = function () {
return isReply(domainModel);
};
/** /**
* Sets whether or not the controls for expanding/collapsing the body of * Sets whether or not the controls for expanding/collapsing the body of
* lengthy annotations should be shown. * lengthy annotations should be shown.
......
...@@ -10,10 +10,12 @@ function PageObject(element) { ...@@ -10,10 +10,12 @@ function PageObject(element) {
return Array.from(element[0].querySelectorAll('annotation')); return Array.from(element[0].querySelectorAll('annotation'));
}; };
this.visibleReplies = function () { this.visibleReplies = function () {
return Array.from(element[0].querySelectorAll('.thread:not(.ng-hide)')); return Array.from(element[0].querySelectorAll(
'.annotation-thread__content > ul > li:not(.ng-hide)'
));
}; };
this.replyList = function () { this.replyList = function () {
return element[0].querySelector('.thread-replies'); return element[0].querySelector('.annotation-thread__content > ul');
}; };
this.isHidden = function (element) { this.isHidden = function (element) {
return element.classList.contains('ng-hide'); return element.classList.contains('ng-hide');
......
...@@ -327,4 +327,16 @@ describe('build-thread', function () { ...@@ -327,4 +327,16 @@ describe('build-thread', function () {
}]); }]);
}); });
}); });
describe('depth', function () {
it('is 0 for annotations', function () {
var thread = createThread(SIMPLE_FIXTURE, {}, ['depth']);
assert.deepEqual(thread[0].depth, 0);
});
it('is 1 for top-level replies', function () {
var thread = createThread(SIMPLE_FIXTURE, {}, ['depth']);
assert.deepEqual(thread[0].children[0].depth, 1);
});
});
}); });
.annotation-thread {
display: flex;
flex-direction: row;
}
// Direct or nested reply to an annotation
.annotation-thread--reply {
// Left margin is set so that left edge of collapse toggle arrow
// for the reply is aligned with the left edge of the parent annotation's
// content.
margin-left: -5px;
}
// Top-level reply to an annotation
.annotation-thread--top-reply {
padding-top: 5px;
padding-bottom: 5px;
}
li:first-child .annotation-thread--top-reply {
// Gap between baseline of 'Hide/Show Replies' for annotation and top
// of first reply should be ~15px
margin-top: 5px;
}
// Container for the toggle arrow and dashed line at the left edge of replies.
.annotation-thread__thread-edge {
display: flex;
flex-direction: column;
width: 8px;
margin-right: 13px;
}
// The dashed line at the left edge of replies
.annotation-thread__thread-line {
border-right: 1px dashed $grey-3;
flex-grow: 1;
}
.annotation-thread__content {
flex-grow: 1;
}
// Darken expand/collapse toggle when an annotation is hovered. This is only
// when the annotation itself is hovered, not the replies.
.annotation-thread__collapse-toggle:hover,
.annotation-thread__collapse-toggle.is-hovered {
color: $grey-7;
}
// Toggle arrow which expands and collapses threads.
// This is aligned so that it appears above a dashed line which appears
// to the left of the threads.
.annotation-thread__collapse-toggle {
width: 10px;
color: $grey-4;
display: block;
text-align: center;
font-size: 15px;
line-height: 22px;
height: 100%;
&.is-open {
// When the thread is expanded, the top of the dashed line is should be
// aligned with the top of the privacy indicator ("Only me") if present
height: 24px;
}
}
...@@ -10,7 +10,7 @@ $annotation-card-left-padding: 10px; ...@@ -10,7 +10,7 @@ $annotation-card-left-padding: 10px;
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.10); box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.10);
border-radius: 2px; border-radius: 2px;
padding: $layout-h-margin; padding: $layout-h-margin;
background: $white; background-color: $white;
} }
.annotation-card:hover { .annotation-card:hover {
...@@ -211,22 +211,20 @@ $annotation-card-left-padding: 10px; ...@@ -211,22 +211,20 @@ $annotation-card-left-padding: 10px;
display: none; display: none;
} }
.thread-replies { .annotation--reply.is-collapsed {
.annotation.collapsed { margin-bottom: 0;
margin-bottom: 0;
.annotation-header { .annotation-header {
margin: 0; margin: 0;
} }
.annotation-body, .annotation-footer { .annotation-body, .annotation-footer {
display: none; display: none;
} }
.annotation-collapsed-replies { .annotation-collapsed-replies {
display: inline; display: inline;
margin-left: .25em; margin-left: .25em;
}
} }
} }
...@@ -241,3 +239,31 @@ $annotation-card-left-padding: 10px; ...@@ -241,3 +239,31 @@ $annotation-card-left-padding: 10px;
flex-direction: row; flex-direction: row;
margin-bottom: 10px; margin-bottom: 10px;
} }
// Style adjustments for annotation cards that are replies
.annotation--reply {
.annotation-action-btn {
color: $grey-4;
}
.annotation-footer {
// Margin between bottom of ascent of annotation body and
// top of annotation footer should be ~15px
margin-top: $layout-h-margin - 8px;
}
.annotation-header {
// Margin between bottom of ascent of annotation card footer labels
// and top of ascent of username should be ~20px
margin-top: 0px;
}
.annotation-body {
// Margin between top of ascent of annotation body and
// bottom of ascent of username should be ~15px
margin-top: $layout-h-margin - 8px;
// Margin between bottom of ascent of annotation body and
// top of annotation footer labels should be ~15px
margin-bottom: $layout-h-margin - 3px;
}
}
...@@ -11,6 +11,7 @@ $base-line-height: 20px; ...@@ -11,6 +11,7 @@ $base-line-height: 20px;
@import './about-this-version-dialog'; @import './about-this-version-dialog';
@import './annotation'; @import './annotation';
@import './annotation-share-dialog'; @import './annotation-share-dialog';
@import './annotation-thread';
@import './api-token-input'; @import './api-token-input';
@import './dropdown-menu-btn'; @import './dropdown-menu-btn';
@import './excerpt'; @import './excerpt';
...@@ -26,7 +27,6 @@ $base-line-height: 20px; ...@@ -26,7 +27,6 @@ $base-line-height: 20px;
@import './simple-search'; @import './simple-search';
@import './spinner'; @import './spinner';
@import './tags-input'; @import './tags-input';
@import './thread';
@import './tooltip'; @import './tooltip';
@import './top-bar'; @import './top-bar';
...@@ -63,6 +63,9 @@ body { ...@@ -63,6 +63,9 @@ body {
} }
} }
// Elements in root template (viewer.html)
// ---------------------------------------
.create-account-banner { .create-account-banner {
background-color: $gray-dark; background-color: $gray-dark;
border-radius: 2px; border-radius: 2px;
...@@ -104,3 +107,38 @@ body { ...@@ -104,3 +107,38 @@ body {
top: 1em; top: 1em;
} }
} }
.thread-list {
& > * {
// Default spacing between items in the annotation card list
margin-bottom: .72em;
}
}
.thread-list__spacer {
// This is a hidden element which is used to reserve space for off-screen
// threads, so it should not occupy any space other than that set via its
// 'height' inline style property.
margin: 0;
}
.annotation-unavailable-message {
display: flex;
flex-direction: column;
border: 1px solid $gray-lighter;
padding-top: 30px;
padding-bottom: 30px;
border-radius: 3px;
align-items: center;
&__label {
text-align: center;
}
&__icon {
background-image: url(../images/icons/lock.svg);
background-repeat: no-repeat;
width: 56px;
height: 48px;
}
}
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
} }
.markdown-tools { .markdown-tools {
background-color: $white;
border-top: .1em solid #D3D3D3; border-top: .1em solid #D3D3D3;
border-left: .1em solid #D3D3D3; border-left: .1em solid #D3D3D3;
border-right: .1em solid #D3D3D3; border-right: .1em solid #D3D3D3;
......
$thread-padding: $annotation-card-left-padding;
.thread-list {
& > * {
margin-bottom: .72em;
}
& > li.thread > .threadexp {
display: none;
}
}
.thread-list__spacer {
// This is a hidden element which is used to reserve space for off-screen
// threads, so it should not occupy any space other than that set via its
// 'height' inline style property.
margin: 0;
}
.annotation-unavailable-message {
display: flex;
flex-direction: column;
border: 1px solid $gray-lighter;
padding-top: 30px;
padding-bottom: 30px;
border-radius: 3px;
align-items: center;
&__label {
text-align: center;
}
&__icon {
background-image: url(../images/icons/lock.svg);
background-repeat: no-repeat;
width: 56px;
height: 48px;
}
}
.thread-replies {
.annotation-action-btn {
color: $grey-4;
}
.annotation-footer {
// Margin between bottom of ascent of annotation body and
// top of annotation footer should be ~15px
margin-top: $layout-h-margin - 8px;
// Margin between bottom of ascent of annotation footer labels
// and top of ascent of username should be ~20px
margin-bottom: $layout-h-margin - 3px;
}
.annotation-header {
// Margin between bottom of ascent of annotation card footer labels
// and top of ascent of username should be ~20px
margin-top: $layout-h-margin - 2px;
}
.annotation-body {
// Margin between top of ascent of annotation body and
// bottom of ascent of username should be ~15px
margin-top: $layout-h-margin - 8px;
// Margin between bottom of ascent of annotation body and
// top of annotation footer labels should be ~15px
margin-bottom: $layout-h-margin - 3px;
}
}
.thread {
cursor: pointer;
position: relative;
& > ul {
padding-left: $thread-padding + 3px;
padding-right: $thread-padding + 3px;
margin-left: -$thread-padding;
margin-right: -$thread-padding;
}
// nested threads for annotation replies
.thread {
border-left: 1px dotted $grey-3;
padding: 0;
padding-left: $thread-padding;
&:hover {
& > .threadexp > span {
color: $grey-7;
}
}
}
.threadexp {
position: absolute;
left: -.7em;
width: 1.4em;
height: 1.4em;
font-size: 1.1em;
span {
background: $white;
color: $grey-4;
display: block;
line-height: inherit;
text-align: center;
}
}
}
.thread-load-more {
clear: both;
}
...@@ -140,7 +140,7 @@ ...@@ -140,7 +140,7 @@
</a> </a>
</div> </div>
<div class="annotation-replies" ng-if="vm.replyCount > 0"> <div class="annotation-replies" ng-if="!vm.isReply() && vm.replyCount > 0">
<a href="" <a href=""
ng-click="vm.onReplyCountClick()"> ng-click="vm.onReplyCountClick()">
<span class="annotation-replies__link">{{ vm.isCollapsed ? 'Show replies' : 'Hide replies' }}</span> <span class="annotation-replies__link">{{ vm.isCollapsed ? 'Show replies' : 'Hide replies' }}</span>
......
<a href="" <div ng-class="vm.threadClasses()">
class="threadexp" <div class="annotation-thread__thread-edge" ng-if="!vm.isTopLevelThread()">
title="{{vm.thread.collapsed && 'Expand' || 'Collapse'}}" <a href=""
ng-click="vm.toggleCollapsed()" ng-class="vm.threadToggleClasses()"
ng-if="vm.thread.parent"> title="{{vm.thread.collapsed && 'Expand' || 'Collapse'}}"
<span ng-class="{'h-icon-arrow-right': vm.thread.collapsed, ng-click="vm.toggleCollapsed()">
'h-icon-arrow-drop-down': !vm.thread.collapsed}"></span> <span ng-class="{'h-icon-arrow-right': vm.thread.collapsed,
</a> 'h-icon-arrow-drop-down': !vm.thread.collapsed}"></span>
</a>
<div class="annotation-thread__thread-line"></div>
</div>
<div class="annotation-thread__content">
<annotation ng-class="vm.annotationClasses()"
annotation="vm.thread.annotation"
is-collapsed="vm.thread.collapsed"
is-last-reply="$last"
is-sidebar="::vm.isSidebar"
name="annotation"
ng-mouseenter="vm.annotationHovered = true"
ng-mouseleave="vm.annotationHovered = false"
ng-if="vm.thread.annotation"
ng-show="vm.thread.visible"
show-document-info="vm.showDocumentInfo"
on-reply-count-click="vm.toggleCollapsed()"
reply-count="vm.thread.replyCount">
</annotation>
<!-- Annotation --> <div ng-if="!vm.thread.annotation" class="thread-deleted">
<annotation class="annotation thread-message {{vm.thread.collapsed && 'collapsed'}}" <p><em>Message not available.</em></p>
annotation="vm.thread.annotation" </div>
is-collapsed="vm.thread.collapsed"
is-last-reply="$last"
is-sidebar="::vm.isSidebar"
name="annotation"
ng-if="vm.thread.annotation"
ng-show="vm.thread.visible"
show-document-info="vm.showDocumentInfo"
on-reply-count-click="vm.toggleCollapsed()"
reply-count="vm.thread.replyCount">
</annotation>
<div ng-if="!vm.thread.annotation" class="thread-deleted"> <div ng-if="vm.hiddenCount() > 0">
<p><em>Message not available.</em></p> <a class="small"
</div> href=""
ng-click="vm.showThreadAndReplies()"
ng-pluralize
count="vm.hiddenCount()"
when="{'0': '',
one: 'View one more in conversation',
other: 'View {} more in conversation'}"
></a>
</div>
<div class="thread-load-more" ng-if="vm.hiddenCount() > 0"> <!-- Replies -->
<a class="load-more small" <ul ng-show="!vm.thread.collapsed">
href="" <li ng-repeat="child in vm.thread.children track by child.id"
ng-click="vm.showThreadAndReplies()" ng-show="vm.shouldShowReply(child)">
ng-pluralize <annotation-thread
count="vm.hiddenCount()" show-document-info="false"
when="{'0': '', thread="child"
one: 'View one more in conversation', on-change-collapsed="vm.onChangeCollapsed({id:id, collapsed:collapsed})"
other: 'View {} more in conversation'}" on-force-visible="vm.onForceVisible({thread:thread})">
></a> </annotation-thread>
</li>
</ul>
</div>
</div> </div>
<!-- Replies -->
<ul class="thread-replies" ng-show="!vm.thread.collapsed">
<li class="thread"
ng-repeat="child in vm.thread.children track by child.id"
ng-show="vm.shouldShowReply(child)">
<annotation-thread
show-document-info="false"
thread="child"
on-change-collapsed="vm.onChangeCollapsed({id:id, collapsed:collapsed})"
on-force-visible="vm.onForceVisible({thread:thread})">
</annotation-thread>
</li>
</ul>
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
<li class="thread-list__spacer" <li class="thread-list__spacer"
ng-style="{height: virtualThreadList.offscreenUpperHeight}"></li> ng-style="{height: virtualThreadList.offscreenUpperHeight}"></li>
<li id="{{child.id}}" <li id="{{child.id}}"
class="annotation-card thread" class="annotation-card"
ng-class="{'js-hover': hasFocus(child.annotation)}" ng-class="{'js-hover': hasFocus(child.annotation)}"
ng-mouseenter="focus(child.annotation)" ng-mouseenter="focus(child.annotation)"
ng-click="scrollTo(child.annotation)" ng-click="scrollTo(child.annotation)"
......
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