feat(picker): new folder and label picker

Summary:
The new, styled folder and label picker!

Some highlights:
- Folder picker has custom icons and a divider
- Label picker has checkboxes with off, checked, and intermediate state
- Extracted LabelColorizer out of mail-label
- Search will bold the results it found
- Fixes to Tooltip to prevent it displaying at invalid moments
- Keyboard UX improvements to Menu

Test Plan: coming soon

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D1790
This commit is contained in:
Evan Morikawa 2015-07-23 11:47:46 -07:00
parent 0aea79f0ba
commit f22d154486
29 changed files with 356 additions and 278 deletions

View file

@ -4,6 +4,8 @@
GeneratedForm,
GeneratedFieldset} = require ('../src/components/generated-form')
{MailLabel, LabelColorizer} = require '../src/components/mail-label'
module.exports =
# Models
Menu: require '../src/components/menu'
@ -23,7 +25,8 @@ module.exports =
InjectedComponent: require '../src/components/injected-component'
TokenizingTextField: require '../src/components/tokenizing-text-field'
TimeoutTransitionGroup: require '../src/components/timeout-transition-group'
MailLabel: require '../src/components/mail-label'
MailLabel: MailLabel
LabelColorizer: LabelColorizer
FormItem: FormItem
GeneratedForm: GeneratedForm
GeneratedFieldset: GeneratedFieldset

View file

@ -1,43 +1,43 @@
_ = require 'underscore'
React = require 'react'
{Actions,
{Utils,
Thread,
Actions,
TaskQueue,
CategoryStore,
NamespaceStore,
WorkspaceStore,
ChangeLabelsTask,
ChangeFolderTask,
FocusedContentStore} = require 'nylas-exports'
FocusedContentStore,
FocusedCategoryStore} = require 'nylas-exports'
{Menu,
Popover,
RetinaImg} = require 'nylas-component-kit'
RetinaImg,
LabelColorizer} = require 'nylas-component-kit'
# This changes the category on one or more threads.
#
# See internal_packages/thread-list/lib/thread-buttons.cjsx
# See internal_packages/message-list/lib/thread-tags-button.cjsx
# See internal_packages/message-list/lib/thread-archive-button.cjsx
# See internal_packages/message-list/lib/message-toolbar-items.cjsx
class CategoryPicker extends React.Component
@displayName: "CategoryPicker"
@containerRequired: false
@propTypes:
selection: React.PropTypes.object.isRequired
constructor: (@props) ->
@state = _.extend @_recalculateState(@props), searchValue: ""
@contextTypes:
sheetDepth: React.PropTypes.number
componentDidMount: =>
@unsubscribers = []
@unsubscribers.push CategoryStore.listen @_onStoreChanged
@unsubscribers.push NamespaceStore.listen @_onStoreChanged
@unsubscribers.push FocusedContentStore.listen @_onStoreChanged
@unsubscribers.push FocusedCategoryStore.listen @_onStoreChanged
@_commandUnsubscriber = atom.commands.add 'body',
"application:change-category": @_onChangeCategory
"application:change-category": @_onOpenCategoryPopover
# If the threads we're picking categories for change, (like when they
# get their categories updated), we expect our parents to pass us new
@ -52,9 +52,25 @@ class CategoryPicker extends React.Component
@_commandUnsubscriber.dispose()
render: =>
button = <button className="btn btn-toolbar" data-tooltip={@_tooltipLabel()}>
<RetinaImg name="toolbar-tags.png" mode={RetinaImg.Mode.ContentIsMask}/>
<RetinaImg name="toolbar-chevron.png" mode={RetinaImg.Mode.ContentIsMask}/>
return <span></span> unless @_namespace
if @_namespace?.usesLabels()
img = "ic-toolbar-tag.png"
tooltip = "Apply Labels"
placeholder = "Label as"
else if @_namespace?.usesFolders()
img = "ic-toolbar-movetofolder.png"
tooltip = "Move to Folder"
placeholder = "Move to folder"
else
img = ""
tooltip = ""
placeholder = ""
if @state.isPopoverOpen then tooltip = ""
button = <button className="btn btn-toolbar" data-tooltip={tooltip}>
<RetinaImg name={img} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
headerComponents = [
@ -62,39 +78,99 @@ class CategoryPicker extends React.Component
tabIndex="1"
key="textfield"
className="search"
placeholder={placeholder}
value={@state.searchValue}
onChange={@_onSearchValueChange}/>
]
<Popover className="tag-picker"
<Popover className="category-picker"
ref="popover"
onOpened={@_onPopoverOpened}
direction="down"
onClosed={@_onPopoverClosed}
direction="down-align-left"
buttonComponent={button}>
<Menu ref="menu"
headerComponents={headerComponents}
footerComponents={[]}
items={@state.categoryData}
itemKey={ (categoryDatum) -> categoryDatum.id }
itemContent={@_itemContent}
itemChecked={ (categoryDatum) -> categoryDatum.usage > 0 }
itemContent={@_renderItemContent}
onSelect={@_onSelectCategory}
defaultSelectedIndex={if @state.searchValue is "" then -1 else 0}
/>
</Popover>
_tooltipLabel: ->
return "" unless @_namespace
if @_namespace.usesLabels()
return "Apply Labels"
else if @_namespace.usesFolders()
return "Move to Folder"
_onChangeCategory: =>
_onOpenCategoryPopover: =>
return unless @_threads().length > 0
return unless @context.sheetDepth is WorkspaceStore.sheetStack().length - 1
@refs.popover.open()
return
_itemContent: (categoryDatum) =>
<span className="category-item">{categoryDatum.display_name}</span>
_renderItemContent: (categoryDatum) =>
if categoryDatum.divider
return <Menu.Item divider={categoryDatum.divider} />
if @_namespace?.usesLabels()
icon = @_renderCheckbox(categoryDatum)
else if @_namespace?.usesFolders()
icon = @_renderFolderIcon(categoryDatum)
else return <span></span>
<div className="category-item">
{icon}
<div className="category-display-name">
{@_renderBoldedSearchResults(categoryDatum)}
</div>
</div>
_renderCheckbox: (categoryDatum) ->
styles = {}
styles.backgroundColor = categoryDatum.backgroundColor
if categoryDatum.usage is 0
checkStatus = <span></span>
else if categoryDatum.usage < categoryDatum.numThreads
checkStatus = <RetinaImg
className="check-img dash"
name="tagging-conflicted.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={=> @_onSelectCategory(categoryDatum)}/>
else
checkStatus = <RetinaImg
className="check-img check"
name="tagging-checkmark.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={=> @_onSelectCategory(categoryDatum)}/>
<div className="check-wrap" style={styles}>
<RetinaImg
className="check-img check"
name="tagging-checkbox.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={=> @_onSelectCategory(categoryDatum)}/>
{checkStatus}
</div>
_renderFolderIcon: (categoryDatum) ->
<RetinaImg name={"#{categoryDatum.name}.png"} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />
_renderBoldedSearchResults: (categoryDatum) ->
name = categoryDatum.display_name
searchTerm = (@state.searchValue ? "").trim()
return name if searchTerm.length is 0
re = Utils.wordSearchRegExp(searchTerm)
parts = name.split(re).map (part) ->
# The wordSearchRegExp looks for a leading non-word character to
# deterine if it's a valid place to search. As such, we need to not
# include that leading character as part of our match.
if re.test(part)
if /\W/.test(part[0])
return <span>{part[0]}<strong>{part[1..-1]}</strong></span>
else
return <strong>{part}</strong>
else return part
return <span>{parts}</span>
_onSelectCategory: (categoryDatum) =>
return unless @_threads().length > 0
@ -116,11 +192,12 @@ class CategoryPicker extends React.Component
threadIds: @_threadIds()
if @props.thread
Actions.moveThread(@props.thread, task)
else if @props.selection
else if @props.items
Actions.moveThreads(@_threads(), task)
else throw new Error("Invalid organizationUnit")
@refs.popover.close()
TaskQueue.enqueue(task)
_onStoreChanged: =>
@ -131,20 +208,33 @@ class CategoryPicker extends React.Component
_onPopoverOpened: =>
@setState @_recalculateState(@props, searchValue: "")
@setState isPopoverOpen: true
_onPopoverClosed: =>
@setState isPopoverOpen: false
_recalculateState: (props=@props, {searchValue}={}) =>
searchValue = searchValue ? @state?.searchValue ? ""
if @_threads(props).length is 0
numThreads = @_threads(props).length
if numThreads is 0
return {categoryData: [], searchValue}
@_namespace = NamespaceStore.current()
return unless @_namespace
categories = CategoryStore.getCategories()
categories = [].concat(CategoryStore.getStandardCategories())
.concat([{divider: true}])
.concat(CategoryStore.getUserCategories())
usageCount = @_categoryUsageCount(props, categories)
allInInbox = @_allInInbox(usageCount, numThreads)
displayData = {usageCount, numThreads}
categoryData = _.chain(categories)
.filter(@_isUserFacing)
.filter(_.partial(@_isUserFacing, allInInbox))
.filter(_.partial(@_isInSearch, searchValue))
.map(_.partial(@_extendCategoryWithUsage, usageCount))
.map(_.partial(@_extendCategoryWithDisplayData, displayData))
.value()
return {categoryData, searchValue}
@ -157,34 +247,32 @@ class CategoryPicker extends React.Component
return categoryUsageCount
_isInSearch: (searchValue, category) ->
searchTerm = searchValue.trim().toLowerCase()
return true if searchTerm.length is 0
return Utils.wordSearchRegExp(searchValue).test(category.displayName)
catName = category.displayName.trim().toLowerCase()
_isUserFacing: (allInInbox, category) =>
hiddenCategories = []
currentCategoryId = FocusedCategoryStore.categoryId()
if @_namespace?.usesLabels()
hiddenCategories = ["all", "spam", "trash", "drafts", "sent"]
if allInInbox
hiddenCategories.push("inbox")
return false if category.divider
else if @_namespace?.usesFolders()
hiddenCategories = ["drafts", "sent"]
wordIndices = []
# Where a non-word character is followed by a word character
# We don't use \b (word boundary) because we want to split on slashes
# and dashes and other non word things
re = /\W[\w\d]/g
while match = re.exec(catName) then wordIndices.push(match.index)
# To shift to the start of each word.
wordIndices = wordIndices.map (i) -> i += 1
return (category.name not in hiddenCategories) and (category.id isnt currentCategoryId)
# Always include the start
wordIndices.push(0)
_allInInbox: (usageCount, numThreads) ->
inbox = CategoryStore.getStandardCategory("inbox")
return usageCount[inbox.id] is numThreads
return catName.indexOf(searchTerm) in wordIndices
_isUserFacing: (category) =>
hiddenCategories = ["inbox", "all", "archive", "drafts", "sent"]
return category.name not in hiddenCategories
_extendCategoryWithUsage: (usageCount, category, i, categories) ->
_extendCategoryWithDisplayData: ({usageCount, numThreads}, category) ->
return category if category.divider
cat = category.toJSON()
usage = usageCount[cat.id] ? 0
cat.backgroundColor = LabelColorizer.backgroundColorDark(category)
cat.usage = usage
cat.totalUsage = categories.length
cat.numThreads = numThreads
return cat
_threadCategories: (thread) =>
@ -195,7 +283,7 @@ class CategoryPicker extends React.Component
else throw new Error("Invalid organizationUnit")
_threads: (props=@props) =>
if props.selection then return (props.selection.items() ? [])
if props.items then return (props.items ? [])
else if props.thread then return [props.thread]
else return []

View file

@ -1,4 +1,72 @@
@import "ui-variables";
.category-picker {
.popover {
}
.menu {
background: @background-secondary;
.header-container {
border-bottom: 0;
}
.item.selected:hover {
background-color: @accent-primary;
color: @text-color-inverse;
}
.item:hover {
background-color: transparent;
color: inherit;
.primary, .secondary {
color: inherit;
}
}
.item.divider {
background-color: #e0e0e0;
margin: 4px 0;
height: 2px;
padding: 0;
}
}
.btn.btn-toolbar {
margin-left: 0;
margin-right: 0;
}
.check-wrap {
width: 14px;
height: 14px;
border-radius: 2px;
display: inline-block;
position: relative;
top: 2px;
}
img.content-mask {
background-color: @text-color-subtle;
position: relative;
top: -1px;
}
.item.selected img.content-mask {
background-color: @text-color-inverse;
}
img.check-img {
position: absolute;
}
.category-item {
font-size: 14px;
}
.category-display-name {
display: inline-block;
margin-left: 10px;
}
}

View file

@ -5,6 +5,7 @@ React = require 'react'
class ThreadArchiveButton extends React.Component
@displayName: "ThreadArchiveButton"
@containerRequired: false
render: =>
<button className="btn btn-toolbar btn-archive"

View file

@ -5,6 +5,7 @@ React = require 'react'
class StarButton extends React.Component
@displayName: "StarButton"
@containerRequired: false
@propTypes:
thread: React.PropTypes.object

View file

@ -1,150 +0,0 @@
_ = require 'underscore'
React = require 'react'
Reflux = require 'reflux'
classNames = require 'classnames'
{Actions,
Utils,
FocusedContentStore,
FocusedContentStore,
DatabaseStore,
Tag,
Thread,
TaskQueue} = require 'nylas-exports'
{RetinaImg,
Popover,
Menu} = require 'nylas-component-kit'
TagsStore = Reflux.createStore
init: ->
@_setStoreDefaults()
@_registerListeners()
@_fetch()
items: ->
@_items
_setStoreDefaults: ->
@_items = []
_registerListeners: ->
@listenTo DatabaseStore, @_onDataChanged
_onDataChanged: (change) ->
if change and change.objectClass is Tag.name
@_fetch()
_fetch: ->
DatabaseStore.findAll(Tag).then (tags) =>
@_items = tags
@trigger()
# Note
class ThreadTagsButton extends React.Component
@displayName: 'ThreadTagsButton'
constructor: (@props) ->
@state = @_getStateForSearch('')
@
componentDidMount: ->
@unsubscribers = []
@unsubscribers.push TagsStore.listen @_onStoreChange
@unsubscribers.push FocusedContentStore.listen @_onFocusChange
componentWillUnmount: =>
return unless @unsubscribers
unsubscribe() for unsubscribe in @unsubscribers
render: =>
button = <button className="btn btn-toolbar">
<RetinaImg name="toolbar-tags.png" mode={RetinaImg.Mode.ContentIsMask}/>
<RetinaImg name="toolbar-chevron.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
headerComponents = [
<input type="text"
tabIndex="1"
key="textfield"
className="search"
value={@state.searchValue}
onChange={@_onSearchValueChange}/>
]
<Popover className="tag-picker"
direction="down"
onOpened={@_onShowTags}
buttonComponent={button}>
<Menu ref="menu"
headerComponents={headerComponents}
footerComponents={[]}
items={@state.tags}
itemKey={ (item) -> item.id }
itemContent={@_itemContent}
itemChecked={@_itemChecked}
onSelect={@_onToggleTag}
/>
</Popover>
_itemContent: (tag) =>
if tag.id is 'divider'
<Menu.Item divider={tag.name} />
else
tag.name.charAt(0).toUpperCase() + tag.name.slice(1)
_itemChecked: (tag) =>
return false unless @state.thread
@state.thread.hasCategoryId(tag.id)
_onShowTags: =>
# Always reset search state when the popover is shown
if @state.searchValue.length > 0
@setState @_getStateForSearch('')
_onToggleTag: (tag) =>
return unless @state.thread
if @state.thread.hasCategoryId(tag.id)
task = new AddRemoveTagsTask(@state.thread, [], [tag.id])
else
task = new AddRemoveTagsTask(@state.thread, [tag.id], [])
@refs.menu.setSelectedItem(null)
TaskQueue.enqueue(task)
_onFocusChange: (change) =>
if change.impactsCollection('thread')
@_onStoreChange()
_onStoreChange: =>
@setState @_getStateForSearch(@state.searchValue)
_onSearchValueChange: (event) =>
@setState @_getStateForSearch(event.target.value)
_getStateForSearch: (searchValue = '') =>
searchTerm = searchValue.toLowerCase()
thread = FocusedContentStore.focused('thread')
return [] unless thread
tags = _.filter TagsStore.items(), (tag) -> tag.name.toLowerCase().indexOf(searchTerm) is 0
# Some tags are "magic" state and can't be added/removed
tags = _.filter tags, (tag) -> not (tag.id in ['unseen', 'attachment', 'sending', 'drafts', 'sent'])
# Some tags are readonly
tags = _.filter tags, (tag) -> not tag.readonly
# Organize tags into currently applied / not applied
active = []
inactive = []
for tag in tags
if thread.hasCategoryId(tag.id)
active.push(tag)
else
inactive.push(tag)
{searchValue, thread, tags: [].concat(active, inactive)}
module.exports = ThreadTagsButton

View file

@ -35,6 +35,7 @@ class Tooltip extends React.Component
componentWillUnmount: =>
clearTimeout @_showTimeout
clearTimeout @_showDelayTimeout
@_mutationObserver?.disconnect()
render: =>
<div className="tooltip-wrap #{@state.pos}" style={@_positionStyles()}>
@ -72,6 +73,11 @@ class Tooltip extends React.Component
return target
_onTooltipEnter: (target) =>
# https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
@_mutationObserver?.disconnect()
@_mutationObserver = new MutationObserver @_onTooltipLeave
@_mutationObserver.observe(target.parentNode, attributes: true, subtree: true, childList: true)
@_lastTarget = target
@_enteredTooltip = true
clearTimeout(@_showTimeout)
@ -82,6 +88,7 @@ class Tooltip extends React.Component
_onTooltipLeave: =>
return unless @_enteredTooltip
@_mutationObserver?.disconnect()
@_enteredTooltip = false
clearTimeout(@_showTimeout)
@_hideTooltip()

View file

@ -206,13 +206,12 @@
'tab': 'native!'
'shift-tab': 'native!'
# For menus
'body .menu, body .menu, body .menu input':
'up': 'menu:move-up'
'down': 'menu:move-down'
'enter': 'menu:enter'
# and by "native!" I actually mean for it to just let React deal with
# it.
'tab': 'native!'
'shift-tab': 'native!'
# For Popover Component
'body .popover-container, body .popover-container input':

View file

@ -2,19 +2,25 @@ React = require 'react'
RetinaImg = require './retina-img'
CategoryStore = require '../flux/stores/category-store'
LabelColorizer =
color: (label) -> "hsl(#{label.hue()}, 50%, 34%)"
backgroundColor: (label) -> "hsl(#{label.hue()}, 62%, 87%)"
backgroundColorDark: (label) -> "hsl(#{label.hue()}, 62%, 57%)"
styles: (label) ->
color: LabelColorizer.color(label)
backgroundColor: LabelColorizer.backgroundColor(label)
boxShadow: "inset 0 0 1px hsl(#{label.hue()}, 62%, 47%), inset 0 1px 1px rgba(255,255,255,0.5), 0 0.5px 0 rgba(255,255,255,0.5)"
backgroundImage: 'linear-gradient(rgba(255,255,255, 0.4), rgba(255,255,255,0))'
class MailLabel extends React.Component
@propTypes:
label: React.PropTypes.object.isRequired
onRemove: React.PropTypes.function
render: ->
hue = @props.label.hue()
style =
backgroundColor: "hsl(#{hue}, 62%, 87%)"
color: "hsl(#{hue}, 50%, 34%)"
boxShadow: "inset 0 0 1px hsl(#{hue}, 62%, 47%), inset 0 1px 1px rgba(255,255,255,0.5), 0 0.5px 0 rgba(255,255,255,0.5)"
backgroundImage: 'linear-gradient(rgba(255,255,255, 0.4), rgba(255,255,255,0))'
classname = 'mail-label'
content = @props.label.displayName
@ -25,14 +31,14 @@ class MailLabel extends React.Component
x = <RetinaImg
className="x"
name="label-x.png"
style={backgroundColor: "hsl(#{hue}, 50%, 34%)"}
style={backgroundColor: LabelColorizer.color(@props.label)}
mode={RetinaImg.Mode.ContentIsMask}
onClick={@props.onRemove}/>
<div className={classname} style={style}>{content}{x}</div>
<div className={classname} style={LabelColorizer.styles(@props.label)}>{content}{x}</div>
_removable: ->
isLockedLabel = @props.label.name in CategoryStore.LockedCategoryNames
return @props.onRemove and not isLockedLabel
module.exports = MailLabel
module.exports = {MailLabel, LabelColorizer}

View file

@ -126,6 +126,10 @@ class Menu extends React.Component
- `onSelect` A {Function} called with the selected item when the user clicks
an item in the menu or confirms their selection with the Enter key.
- `defaultSelectedIndex` The index of the item first selected if there
was no other previous index. Defaults to 0. Set to -1 if you want
nothing selected.
###
@propTypes:
className: React.PropTypes.string,
@ -139,9 +143,11 @@ class Menu extends React.Component
onSelect: React.PropTypes.func.isRequired,
defaultSelectedIndex: React.PropTypes.number
constructor: (@props) ->
@state =
selectedIndex: 0
selectedIndex: @props.defaultSelectedIndex ? 0
# Public: Returns the currently selected item.
#
@ -152,16 +158,7 @@ class Menu extends React.Component
# null to remove the selection
#
setSelectedItem: (item) =>
@setState
selectedIndex: @props.items.indexOf(item)
componentDidMount: =>
@subscriptions = new CompositeDisposable()
@subscriptions.add atom.commands.add '.menu', {
'menu:move-up': => @_onShiftSelectedIndex(-1)
'menu:move-down': => @_onShiftSelectedIndex(1)
'menu:enter': => @_onEnter()
}
@setState selectedIndex: @props.items.indexOf(item)
componentWillReceiveProps: (newProps) =>
# Attempt to preserve selection across props.items changes by
@ -171,7 +168,7 @@ class Menu extends React.Component
selection = @props.items[@state.selectedIndex]
newSelectionIndex = 0
else
newSelectionIndex = -1
newSelectionIndex = newProps.defaultSelectedIndex ? -1
if selection?
selectionKey = @props.itemKey(selection)
@ -188,15 +185,14 @@ class Menu extends React.Component
if adjustment isnt 0
container.scrollTop += adjustment
componentWillUnmount: =>
@subscriptions?.dispose()
render: =>
hc = @props.headerComponents ? []
if hc.length is 0 then hc = <span></span>
fc = @props.footerComponents ? []
if fc.length is 0 then fc = <span></span>
<div className={"menu " + @props.className} tabIndex="-1">
<div onKeyDown={@_onKeyDown}
className={"native-key-bindings menu " + @props.className}
tabIndex="-1">
<div className="header-container">
{hc}
</div>
@ -206,6 +202,18 @@ class Menu extends React.Component
</div>
</div>
_onKeyDown: (event) =>
if event.key is "Enter"
@_onEnter()
else if event.key is "ArrowUp" or (event.key is "Tab" and event.shiftKey)
@_onShiftSelectedIndex(-1)
event.preventDefault()
else if event.key is "ArrowDown" or event.key is "Tab"
@_onShiftSelectedIndex(1)
event.preventDefault()
return
_contentContainer: =>
items = @props.items.map(@_itemComponentForItem) ? []
contentClass = classNames
@ -234,18 +242,22 @@ class Menu extends React.Component
_onShiftSelectedIndex: (delta) =>
return if @props.items.length is 0
index = @state.selectedIndex + delta
isDivider = true
while isDivider
item = @props.items[index]
break unless item
if @props.itemContent(item).props?.divider
if delta > 0 then index += 1
else if delta < 0 then index -= 1
else isDivider = false
index = Math.max(0, Math.min(@props.items.length-1, index))
# Update the selected index
@setState
selectedIndex: index
# Fire the shift method again to move selection past the divider
# if the new selected item is a divider.
itemContent = @props.itemContent(@props.items[index])
isDivider = itemContent.props?.divider
@_onShiftSelectedIndex(delta) if isDivider
@setState selectedIndex: index
_onEnter: =>
item = @props.items[@state.selectedIndex]

View file

@ -2,7 +2,8 @@ React = require "react/addons"
classNames = require 'classnames'
_ = require 'underscore'
{Actions,
{Utils,
Actions,
WorkspaceStore} = require "nylas-exports"
InjectedComponentSet = require './injected-component-set'
RetinaImg = require './retina-img'
@ -75,10 +76,8 @@ class MultiselectActionBar extends React.Component
@unsubscribers.push WorkspaceStore.listen @_onChange
shouldComponentUpdate: (nextProps, nextState) =>
@props.collection isnt nextProps.collection or
@state.count isnt nextState.count or
@state.view isnt nextState.view or
@state.type isnt nextState.type
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
render: =>
<div className={@_classSet()}><div className="absolute"><div className="inner">
@ -98,27 +97,30 @@ class MultiselectActionBar extends React.Component
_renderActions: =>
return <div></div> unless @state.view
<InjectedComponentSet matching={role:"#{@props.collection}:BulkAction"}
exposedProps={selection: @state.view.selection} />
exposedProps={selection: @state.view.selection, items: @state.items} />
_label: =>
if @state.count > 1
"#{@state.count} #{@props.collection}s selected"
else if @state.count is 1
"#{@state.count} #{@props.collection} selected"
if @state.items.length > 1
"#{@state.items.length} #{@props.collection}s selected"
else if @state.items.length is 1
"#{@state.items.length} #{@props.collection} selected"
else
""
_classSet: =>
classNames
"selection-bar": true
"enabled": @state.count > 0
"enabled": @state.items.length > 0
_getStateFromStores: (props) =>
props ?= @props
view = props.dataStore.view()
items = view?.selection.items() ? []
view: view
count: view?.selection.items().length
return {
view: view
items: items
}
_onChange: =>
@setState(@_getStateFromStores())

View file

@ -87,12 +87,12 @@ class Popover extends React.Component
@_focusOnOpen = true
@setState
showing: true
if @props.onOpened?
@props.onOpened()
@props.onOpened?()
close: =>
@setState
showing: false
@props.onClosed?()
_focusImportantElement: =>
# Automatically focus the element inside us with the lowest tab index
@ -143,6 +143,14 @@ class Popover extends React.Component
'top': -10
'left':-12
if @props.direction is "down-align-left"
popoverStyle = _.extend popoverStyle,
'transform': 'translate(0, 2px)'
'top': '100%'
'left': 0
pointerStyle = _.extend pointerStyle,
'display': 'none'
popoverComponent = <div ref="popover" className={"popover popover-"+@props.direction} style={popoverStyle}>
{@props.children}
<div className="popover-pointer" style={pointerStyle}></div>

View file

@ -11,6 +11,17 @@ Utils =
isHash: (object) ->
_.isObject(object) and not _.isFunction(object) and not _.isArray(object)
escapeRegExp: (str) ->
str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")
# Generates a new RegExp that is great for basic search fields. It
# checks if the test string is at the start of words
#
# See regex explanation and test here:
# https://regex101.com/r/zG7aW4/2
wordSearchRegExp: (str="") ->
new RegExp("((?:^|\\W|$)#{Utils.escapeRegExp(str.trim())})", "ig")
# Takes an optional customizer. The customizer is passed the key and the
# new cloned value for that key. The customizer is expected to either
# modify the value and return it or simply be the identity function.

View file

@ -14,8 +14,7 @@ class ModelViewSelection
ids: ->
_.pluck(@_items, 'id')
items: ->
@_items
items: -> _.clone(@_items)
top: ->
@_items[@_items.length - 1]

View file

@ -18,7 +18,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
constructor: ({@labelsToAdd, @labelsToRemove, @threadIds, @messageIds}={}) ->
@threadIds ?= []; @messageIds ?= []
@objectIds = @threadIds.concat(@messageIds)
@_newLabels = []
@_newLabels = {}
super
label: -> "Applying labels…"
@ -46,7 +46,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
return Promise.resolve(Task.Status.Finished)
requestBody: (id) ->
labels: @_newLabels.map (l) -> l.id
labels: @_newLabels[id].map (l) -> l.id
createUndoTask: ->
labelsToAdd = @labelsToRemove
@ -59,7 +59,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
# Called from super-class's `performLocal`
localUpdateThread: (thread, categories) ->
newLabels = @_newLabelSet(thread, categories)
@_newLabels = newLabels
@_newLabels[thread.id] = newLabels
messageQuery = DatabaseStore.findAll(Message, threadId: thread.id)
childSavePromise = messageQuery.then (messages) ->
@ -79,7 +79,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
# Called from super-class's `performLocal`
localUpdateMessage: (message, categories) ->
message.labels = @_newLabelSet(message, categories)
@_newLabels = message.labels
@_newLabels[message.id] = message.labels
return DatabaseStore.persistModel(message)
# Returns a new set of {Label} objects that incoprates the existing,

View file

@ -84,6 +84,11 @@ class Toolbar extends React.Component
data: React.PropTypes.object
depth: React.PropTypes.number
@childContextTypes:
sheetDepth: React.PropTypes.number
getChildContext: =>
sheetDepth: @props.depth
constructor: (@props) ->
@state = @_getStateFromStores()

View file

@ -16,6 +16,11 @@ class Sheet extends React.Component
depth: React.PropTypes.number.isRequired
onColumnSizeChanged: React.PropTypes.func
@childContextTypes:
sheetDepth: React.PropTypes.number
getChildContext: =>
sheetDepth: @props.depth
constructor: (@props) ->
@state = @_getStateFromStores()

View file

@ -121,7 +121,6 @@ class WindowEventHandler
onKeydown: (event) ->
atom.keymaps.handleKeyboardEvent(event)
event.stopImmediatePropagation()
# Important: even though we don't do anything here, we need to catch the
# drop event to prevent the browser from navigating the to the "url" of the

View file

@ -1,28 +1,39 @@
@import "ui-variables";
.menu {
flex: 1;
display: flex;
flex-direction: column;
.header-container {
flex-shrink: 0; // Don't squish the header! There may be a search box here
background-color: @background-color-secondary;
border-bottom:1px solid @base-border-color;
padding:8px;
border-bottom: 1px solid @base-border-color;
padding: 3px;
position: relative;
input.search {
border:1px solid darken(@background-color-secondary, 10%);
border: 1px solid darken(@background-color-secondary, 10%);
border-radius: 3px;
padding-left: 0;
background-color: white;
border-radius:12px;
padding-left: 10px;
background-repeat: no-repeat;
background-image: url("../static/images/search/searchloupe@2x.png");
background-size: 15px 15px;
background-position: 7px 4px;
text-indent: 31px;
box-shadow: inset 0 1px 0 rgba(0,0,0,0.05), 0 1px 0 rgba(0,0,0,0.05)
}
}
.footer-container {
position: relative;
border-top:1px solid @base-border-color;
}
.content-container {
background: @background-color-secondary;
width: 100%;
margin-top: -1px;
overflow: auto;
}
.item {
@ -30,7 +41,7 @@
padding-left: @padding-base-horizontal;
padding-top: @padding-base-vertical;
padding-bottom: @padding-base-vertical;
cursor: pointer;
cursor: default;
color: @text-color;
width: 100%;
overflow: hidden;
@ -74,7 +85,7 @@
background-image:url(./images/menu/checked-selected@2x.png);
}
.item.selected, .item:hover {
.item.selected, .item:hover, .item:active {
text-decoration: none;
background-color: @accent-primary;
color: @text-color-inverse;

View file

@ -6,11 +6,14 @@
}
.popover {
display: flex;
flex-direction: column;
width: 250px;
max-height:400px;
background-color: @background-color;
border-radius: @border-radius-base;
box-shadow: 0 4px 30px rgba(0,0,0,0.19), inset 0 0 1px rgba(0,0,0,0.5);
overflow: hidden;
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.15), 0 -0.5px 0 rgba(0, 0, 0, 0.15), 0.5px 0 0 rgba(0, 0, 0, 0.15), -0.5px 0 0 rgba(0, 0, 0, 0.15), 0 4px 7px rgba(0,0,0,0.15), inset 0 0 1px rgba(0,0,0,0.5);
.menu {
z-index:1;

Binary file not shown.

After

(image error) Size: 1.2 KiB

Binary file not shown.

After

(image error) Size: 1.5 KiB

Binary file not shown.

After

(image error) Size: 1.2 KiB

Binary file not shown.

After

(image error) Size: 1 KiB

Binary file not shown.

After

(image error) Size: 14 KiB

Binary file not shown.

After

(image error) Size: 15 KiB

Binary file not shown.

Before

(image error) Size: 1.5 KiB

After

(image error) Size: 1.4 KiB

Binary file not shown.

Before

(image error) Size: 1.7 KiB

After

(image error) Size: 1.7 KiB

Binary file not shown.

Before

(image error) Size: 1.1 KiB