feat(scrollbars): Custom scrollbars via ScrollRegion

Summary:
ScrollRegion with support for tooltips shown as you scroll, custom tooltips for thread list and message list.

fix(specs): all other scrollbars hidden to prevent incompatibility

fix scrollbar sizing when used in conjunction with resizableregion

Test Plan: Need to write tests and docs for ScrollRegion - no tests yet

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1597
This commit is contained in:
Ben Gotow 2015-06-05 11:50:55 -07:00
parent bc916a2530
commit fb3f7fc410
13 changed files with 341 additions and 32 deletions

View file

@ -16,6 +16,7 @@ module.exports =
MultiselectList: require '../src/components/multiselect-list'
MultiselectActionBar: require '../src/components/multiselect-action-bar'
ResizableRegion: require '../src/components/resizable-region'
ScrollRegion: require '../src/components/scroll-region'
InjectedComponentSet: require '../src/components/injected-component-set'
InjectedComponent: require '../src/components/injected-component'
TokenizingTextField: require '../src/components/tokenizing-text-field'

View file

@ -1,5 +1,6 @@
React = require 'react'
{Actions} = require("nylas-exports")
{ScrollRegion} = require("nylas-component-kit")
SidebarDividerItem = require("./account-sidebar-divider-item")
SidebarTagItem = require("./account-sidebar-tag-item")
SidebarSheetItem = require("./account-sidebar-sheet-item")
@ -26,11 +27,11 @@ class AccountSidebar extends React.Component
@unsubscribe() if @unsubscribe
render: =>
<div id="account-sidebar" className="account-sidebar">
<ScrollRegion id="account-sidebar" className="account-sidebar">
<div className="account-sidebar-sections">
{@_sections()}
</div>
</div>
</ScrollRegion>
_sections: =>
return @state.sections.map (section) =>

View file

@ -4,8 +4,6 @@
#account-sidebar {
order: 1;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background-color: @source-list-bg;
section {

View file

@ -4,11 +4,46 @@ classNames = require 'classnames'
MessageItem = require "./message-item"
{Utils, Actions, MessageStore, ComponentRegistry} = require("nylas-exports")
{Spinner,
ScrollRegion,
ResizableRegion,
RetinaImg,
InjectedComponentSet,
InjectedComponent} = require('nylas-component-kit')
class MessageListScrollTooltip extends React.Component
@displayName: 'MessageListScrollTooltip'
@propTypes:
viewportCenter: React.PropTypes.number.isRequired
totalHeight: React.PropTypes.number.isRequired
componentWillMount: =>
@setupForProps(@props)
componentWillReceiveProps: (newProps) =>
@setupForProps(newProps)
shouldComponentUpdate: (newProps, newState) =>
not _.isEqual(@state,newState)
setupForProps: (props) ->
# Technically, we could have MessageList provide the currently visible
# item index, but the DOM approach is simple and self-contained.
#
els = document.querySelectorAll('.message-item-wrap')
idx = _.findIndex els, (el) -> el.offsetTop > props.viewportCenter
if idx is -1
idx = els.length
@setState
idx: idx
count: els.length
render: ->
<div className="scroll-tooltip">
{@state.idx} of {@state.count}
</div>
class MessageList extends React.Component
@displayName: 'MessageList'
@containerRequired: false
@ -74,22 +109,23 @@ class MessageList extends React.Component
"ready": @state.ready
<div className="message-list" id="message-list">
<div tabIndex="-1"
<ScrollRegion tabIndex="-1"
className={wrapClass}
scrollTooltipComponent={MessageListScrollTooltip}
onScroll={_.debounce(@_cacheScrollPos, 100)}
ref="messageWrap">
<InjectedComponentSet
className="message-list-notification-bars"
matching={role:"MessageListNotificationBar"}
exposedProps={thread: @state.currentThread}/>
<InjectedComponentSet
className="message-list-headers"
matching={role:"MessageListHeaders"}
exposedProps={thread: @state.currentThread}/>
<div className="headers" style={position:'relative'}>
<InjectedComponentSet
className="message-list-notification-bars"
matching={role:"MessageListNotificationBar"}
exposedProps={thread: @state.currentThread}/>
<InjectedComponentSet
className="message-list-headers"
matching={role:"MessageListHeaders"}
exposedProps={thread: @state.currentThread}/>
</div>
{@_messageComponents()}
</div>
</ScrollRegion>
{@_renderReplyArea()}
<Spinner visible={!@state.ready} />
</div>

View file

@ -12,6 +12,32 @@ classNames = require 'classnames'
ThreadListParticipants = require './thread-list-participants'
ThreadListStore = require './thread-list-store'
class ThreadListScrollTooltip extends React.Component
@displayName: 'ThreadListScrollTooltip'
@propTypes:
viewportCenter: React.PropTypes.number.isRequired
totalHeight: React.PropTypes.number.isRequired
componentWillMount: =>
@setupForProps(@props)
componentWillReceiveProps: (newProps) =>
@setupForProps(newProps)
shouldComponentUpdate: (newProps, newState) =>
@state?.idx isnt newState.idx
setupForProps: (props) ->
idx = Math.floor(ThreadListStore.view().count() / @props.totalHeight * @props.viewportCenter)
@setState
idx: idx
item: ThreadListStore.view().get(idx)
render: ->
<div className="scroll-tooltip">
{timestamp(@state.item?.lastMessageTimestamp)}
</div>
class ThreadList extends React.Component
@displayName: 'ThreadList'
@ -99,6 +125,7 @@ class ThreadList extends React.Component
commands={@commands}
itemPropsProvider={@itemPropsProvider}
className="thread-list"
scrollTooltipComponent={ThreadListScrollTooltip}
collection="thread" />
# Additional Commands

View file

@ -8,7 +8,9 @@
.thread-list, .draft-list {
order: 3;
flex: 1;
position:relative;
position:absolute;
width:100%;
height:100%;
-webkit-font-smoothing: subpixel-antialiased;
.list-item {

View file

@ -1,5 +1,6 @@
_ = require 'underscore'
React = require 'react/addons'
ScrollRegion = require './scroll-region'
RangeChunkSize = 10
@ -85,14 +86,11 @@ class ListTabular extends React.Component
# If our view has been swapped out for an entirely different one,
# reset our scroll position to the top.
if prevProps.dataView isnt @props.dataView
container = React.findDOMNode(@refs.container)
container.scrollTop = 0
@refs.container.scrollTop = 0
@updateRangeState()
updateScrollState: =>
window.requestAnimationFrame =>
container = React.findDOMNode(@refs.container)
# Create an event that fires when we stop receiving scroll events.
# There is no "scrollend" event, but we really need one.
clearTimeout(@_scrollTimer) if @_scrollTimer
@ -104,7 +102,7 @@ class ListTabular extends React.Component
# If we've shifted enough pixels from our previous scrollTop to require
# new rows to be rendered, update our state!
if Math.abs(@state.scrollTop - container.scrollTop) >= @_rowHeight() * RangeChunkSize
if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @_rowHeight() * RangeChunkSize
@updateRangeState()
onDoneReceivingScrollEvents: =>
@ -113,8 +111,7 @@ class ListTabular extends React.Component
@updateRangeState()
updateRangeState: =>
container = @refs.container
scrollTop = React.findDOMNode(container)?.scrollTop
scrollTop = @refs.container.scrollTop
rowHeight = @_rowHeight()
@ -159,12 +156,12 @@ class ListTabular extends React.Component
height: @props.dataView.count() * @_rowHeight()
pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
<div ref="container" onScroll={@updateScrollState} tabIndex="-1" className="list-container list-tabular">
<ScrollRegion ref="container" onScroll={@updateScrollState} tabIndex="-1" className="list-container list-tabular" scrollTooltipComponent={@props.scrollTooltipComponent} >
{@_headers()}
<div className="list-rows" style={innerStyles}>
{@_rows()}
</div>
</div>
</ScrollRegion>
_rowHeight: =>
39

View file

@ -32,6 +32,7 @@ class MultiselectList extends React.Component
columns: React.PropTypes.array.isRequired
dataStore: React.PropTypes.object.isRequired
itemPropsProvider: React.PropTypes.func.isRequired
scrollTooltipComponent: React.PropTypes.func
constructor: (@props) ->
@state = @_getStateFromStores()
@ -120,6 +121,7 @@ class MultiselectList extends React.Component
<ListTabular
ref="list"
columns={@props.columns}
scrollTooltipComponent={@props.scrollTooltipComponent}
dataView={@state.dataView}
itemPropsProvider={@itemPropsProvider}
onSelect={@_onClickItem}

View file

@ -0,0 +1,144 @@
_ = require 'underscore'
React = require 'react/addons'
{Utils} = require 'nylas-exports'
classNames = require 'classnames'
###
The ScrollRegion component attaches a custom scrollbar.
###
class ScrollRegion extends React.Component
@displayName: "ScrollRegion"
@propTypes:
onScroll: React.PropTypes.func
className: React.PropTypes.string
scrollTooltipComponent: React.PropTypes.func
constructor: (@props) ->
@state =
totalHeight:0
viewportHeight: 0
viewportOffset: 0
dragging: false
scrolling: false
Object.defineProperty(@, 'scrollTop', {
get: -> React.findDOMNode(@refs.content).scrollTop
set: (val) -> React.findDOMNode(@refs.content).scrollTop = val
})
componentDidMount: =>
@_recomputeDimensions()
componentDidUpdate: =>
@_recomputeDimensions()
componentWillUnmount: =>
@_onHandleUp()
shouldComponentUpdate: (newProps, newState) =>
not Utils.isEqualReact(newProps, @props) or not Utils.isEqualReact(newState, @state)
render: =>
containerClasses = "#{@props.className ? ''} " + classNames
'scroll-region': true
'dragging': @state.dragging
'scrolling': @state.scrolling
otherProps = _.omit(@props, _.keys(@constructor.propTypes))
tooltip = []
if @props.scrollTooltipComponent
tooltip = <@props.scrollTooltipComponent viewportCenter={@state.viewportOffset + @state.viewportHeight / 2} totalHeight={@state.totalHeight} />
<div className={containerClasses} {...otherProps}>
<div className="scrollbar-track" style={@_scrollbarWrapStyles()} onMouseEnter={@_recomputeDimensions}>
<div className="scrollbar-track-inner" ref="track" onClick={@_onScrollJump}>
<div className="scrollbar-handle" onMouseDown={@_onHandleDown} style={@_scrollbarHandleStyles()} ref="handle" onClick={@_onHandleClick} >
<div className="tooltip">{tooltip}</div>
</div>
</div>
</div>
<div className="scroll-region-content" onScroll={@_onScroll} ref="content">
{@props.children}
</div>
</div>
_scrollbarWrapStyles: =>
position:'absolute'
top: 0
bottom: 0
right: 0
zIndex: 2
_scrollbarHandleStyles: =>
handleHeight = @_getHandleHeight()
handleTop = (@state.viewportOffset / (@state.totalHeight - @state.viewportHeight)) * (@state.trackHeight - handleHeight)
position:'relative'
height: handleHeight
top: handleTop
_getHandleHeight: =>
Math.min(@state.totalHeight, Math.max(40, (@state.trackHeight / @state.totalHeight) * @state.trackHeight))
_recomputeDimensions: =>
return unless @refs.content
contentNode = React.findDOMNode(@refs.content)
trackNode = React.findDOMNode(@refs.track)
totalHeight = contentNode.scrollHeight
trackHeight = trackNode.clientHeight
viewportHeight = contentNode.clientHeight
viewportOffset = contentNode.scrollTop
if @state.totalHeight != totalHeight or
@state.trackHeight != trackHeight or
@state.viewportOffset != viewportOffset or
@state.viewportHeight != viewportHeight
@setState({totalHeight, trackHeight, viewportOffset, viewportHeight})
_onHandleDown: (event) =>
handleNode = React.findDOMNode(@refs.handle)
@_trackOffset = React.findDOMNode(@refs.track).getBoundingClientRect().top
@_mouseOffsetWithinHandle = event.pageY - handleNode.getBoundingClientRect().top
window.addEventListener("mousemove", @_onHandleMove)
window.addEventListener("mouseup", @_onHandleUp)
@setState(dragging: true)
_onHandleMove: (event) =>
trackY = event.pageY - @_trackOffset - @_mouseOffsetWithinHandle
trackPxToViewportPx = (@state.totalHeight - @state.viewportHeight) / (@state.trackHeight - @_getHandleHeight())
contentNode = React.findDOMNode(@refs.content)
contentNode.scrollTop = trackY * trackPxToViewportPx
_onHandleUp: (event) =>
window.removeEventListener("mousemove", @_onHandleMove)
window.removeEventListener("mouseup", @_onHandleUp)
@setState(dragging: false)
_onHandleClick: (event) =>
# Avoid event propogating up to track
event.stopPropagation()
_onScrollJump: (event) =>
@_mouseOffsetWithinHandle = @_getHandleHeight() / 2
@_onHandleMove(event)
_onScroll: (event) =>
@_recomputeDimensions()
@props.onScroll?(event)
if not @state.scrolling
@setState(scrolling: true)
@_onStoppedScroll ?= _.debounce =>
@setState(scrolling: false)
, 250
@_onStoppedScroll()
module.exports = ScrollRegion

View file

@ -71,11 +71,6 @@
.list-container {
position: absolute;
height: 100%;
width: 100%;
overflow-y: scroll;
.list-item {
font-size: @font-size-base;
line-height: @line-height-computed;
@ -107,6 +102,7 @@
.list-tabular {
flex: 1;
width: 100%;
height: 100%;
.list-tabular-item {
position: relative;

View file

@ -0,0 +1,104 @@
@import "ui-variables";
@tooltipBorderColor: rgba(54, 56, 57, 0.9);
@tooltipBackground: -webkit-gradient(linear, left top, left bottom, from(rgba(99, 102, 103, 0.9)), to(rgba(82, 85, 86, 0.9)));
.scroll-tooltip {
background: @tooltipBackground;
color: white;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.25);
padding: @padding-base-vertical @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
border: 1px solid @tooltipBorderColor;
border-radius: @border-radius-base;
transform: translate(-15px, 0);
position: relative;
white-space:nowrap;
}
.scroll-tooltip:after, .scroll-tooltip:before {
left: 100%;
top: 50%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
.scroll-tooltip:after {
border-color: transparent;
border-left-color: lighten(rgba(99, 102, 103, 1), 3%);
border-width: 8px;
margin-top: -8px;
}
.scroll-tooltip:before {
border-color: transparent;
border-left-color: darken(@tooltipBorderColor, 20%);
border-width: 9px;
margin-top: -9px;
}
::-webkit-scrollbar {
display: none;
}
.scroll-region {
position:relative;
.scroll-region-content {
position: absolute;
height: 100%;
width: 100%;
overflow-y: scroll;
}
.scrollbar-track {
opacity: 0;
transition: opacity 0.3s;
transition-delay: 0.5s;
padding:3px;
width:17px;
background: @list-bg;
border-left: 1px solid @border-color-divider;
&:hover {
opacity: 1;
transition-delay: 0s;
}
/* Used to read the track height with padding applied. */
.scrollbar-track-inner {
height:100%;
}
.scrollbar-handle {
background-color: lighten(@gray, 40%);
border:1px solid lighten(@gray, 30%);
border-radius:8px;
.tooltip {
opacity: 0;
transition: opacity 0.3s;
top: 50%;
transform: translate(-100%, -50%);
position: absolute;
}
}
}
}
.scroll-region.scrolling {
.scrollbar-track {
opacity: 1;
transition-delay: 0s;
}
}
.scroll-region.dragging {
.scrollbar-track {
opacity: 1;
.scrollbar-handle {
cursor: default;
background-color: lighten(@gray, 30%);
border:1px solid lighten(@gray, 20%);
.tooltip {
opacity: 1;
}
}
}
}

View file

@ -18,6 +18,7 @@
@import "components/tokenizing-text-field";
@import "components/extra";
@import "components/list-tabular";
@import "components/scroll-region";
@import "components/spinner";
@import "components/generated-form";
@import "components/empty-state";

View file

@ -198,7 +198,7 @@ body.is-blurred {
}
&.flexbox-handle-right {
right:-3px;
right:-4px;
padding-right:3px;
}
&.flexbox-handle-left {