diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -7,8 +7,8 @@ */ return array( 'names' => array( - 'core.pkg.css' => 'b9e2e1e5', - 'core.pkg.js' => '80f86a0a', + 'core.pkg.css' => 'f577cd20', + 'core.pkg.js' => 'f2139810', 'darkconsole.pkg.js' => 'e7393ebb', 'differential.pkg.css' => 'b3eea3f5', 'differential.pkg.js' => '4b7d8f19', @@ -124,7 +124,7 @@ 'rsrc/css/phui/phui-badge.css' => '3baef8db', 'rsrc/css/phui/phui-big-info-view.css' => 'bd903741', 'rsrc/css/phui/phui-box.css' => '5c8387cf', - 'rsrc/css/phui/phui-button.css' => 'a64a8de6', + 'rsrc/css/phui/phui-button.css' => 'e266e0bc', 'rsrc/css/phui/phui-chart.css' => '6bf6f78e', 'rsrc/css/phui/phui-crumbs-view.css' => '6b813619', 'rsrc/css/phui/phui-curtain-view.css' => '7148ae25', @@ -516,14 +516,15 @@ 'rsrc/js/core/behavior-watch-anchor.js' => '9f36c42d', 'rsrc/js/core/behavior-workflow.js' => '0a3f3021', 'rsrc/js/core/phtize.js' => 'd254d646', - 'rsrc/js/phui/behavior-phui-dropdown-menu.js' => '54733475', + 'rsrc/js/phui/behavior-phui-dropdown-menu.js' => '1aa4c968', 'rsrc/js/phui/behavior-phui-file-upload.js' => 'b003d4fb', 'rsrc/js/phui/behavior-phui-object-box-tabs.js' => '2bfa2836', 'rsrc/js/phui/behavior-phui-profile-menu.js' => '12884df9', + 'rsrc/js/phui/behavior-phui-submenu.js' => 'a6f7a73b', 'rsrc/js/phuix/PHUIXActionListView.js' => 'b5c256b8', 'rsrc/js/phuix/PHUIXActionView.js' => '8cf6d262', 'rsrc/js/phuix/PHUIXAutocomplete.js' => '9196fb06', - 'rsrc/js/phuix/PHUIXDropdownMenu.js' => 'bd4c8dca', + 'rsrc/js/phuix/PHUIXDropdownMenu.js' => '82e270da', 'rsrc/js/phuix/PHUIXFormControl.js' => 'e15869a8', 'rsrc/js/phuix/PHUIXIconView.js' => 'bff6884b', ), @@ -669,11 +670,12 @@ 'javelin-behavior-phabricator-watch-anchor' => '9f36c42d', 'javelin-behavior-pholio-mock-edit' => 'bee502c8', 'javelin-behavior-pholio-mock-view' => 'fbe497e7', - 'javelin-behavior-phui-dropdown-menu' => '54733475', + 'javelin-behavior-phui-dropdown-menu' => '1aa4c968', 'javelin-behavior-phui-file-upload' => 'b003d4fb', 'javelin-behavior-phui-hovercards' => 'bcaccd64', 'javelin-behavior-phui-object-box-tabs' => '2bfa2836', 'javelin-behavior-phui-profile-menu' => '12884df9', + 'javelin-behavior-phui-submenu' => 'a6f7a73b', 'javelin-behavior-policy-control' => 'd0c516d5', 'javelin-behavior-policy-rule-editor' => '5e9f347c', 'javelin-behavior-project-boards' => '14a1faae', @@ -822,7 +824,7 @@ 'phui-badge-view-css' => '3baef8db', 'phui-big-info-view-css' => 'bd903741', 'phui-box-css' => '5c8387cf', - 'phui-button-css' => 'a64a8de6', + 'phui-button-css' => 'e266e0bc', 'phui-calendar-css' => 'ccabe893', 'phui-calendar-day-css' => 'd1cf6f93', 'phui-calendar-list-css' => '56e6381a', @@ -870,7 +872,7 @@ 'phuix-action-list-view' => 'b5c256b8', 'phuix-action-view' => '8cf6d262', 'phuix-autocomplete' => '9196fb06', - 'phuix-dropdown-menu' => 'bd4c8dca', + 'phuix-dropdown-menu' => '82e270da', 'phuix-form-control-view' => 'e15869a8', 'phuix-icon-view' => 'bff6884b', 'policy-css' => '957ea14c', @@ -1024,6 +1026,12 @@ 'javelin-workflow', 'javelin-workboard-controller', ), + '1aa4c968' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + 'phuix-dropdown-menu', + ), '1ad0a787' => array( 'javelin-install', 'javelin-reactor', @@ -1275,12 +1283,6 @@ 'javelin-leader', 'javelin-json', ), - 54733475 => array( - 'javelin-behavior', - 'javelin-stratcom', - 'javelin-dom', - 'phuix-dropdown-menu', - ), '54b612ba' => array( 'javelin-color', 'javelin-install', @@ -1529,6 +1531,13 @@ 'javelin-vector', 'javelin-stratcom', ), + '82e270da' => array( + 'javelin-install', + 'javelin-util', + 'javelin-dom', + 'javelin-vector', + 'javelin-stratcom', + ), '834a1173' => array( 'javelin-behavior', 'javelin-scrollbar', @@ -1693,6 +1702,11 @@ 'javelin-uri', 'phabricator-notification', ), + 'a6f7a73b' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), 'a80d0378' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1846,13 +1860,6 @@ 'javelin-vector', 'phui-hovercard', ), - 'bd4c8dca' => array( - 'javelin-install', - 'javelin-util', - 'javelin-dom', - 'javelin-vector', - 'javelin-stratcom', - ), 'bdaf4d04' => array( 'javelin-behavior', 'javelin-dom', diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php --- a/src/applications/base/controller/PhabricatorController.php +++ b/src/applications/base/controller/PhabricatorController.php @@ -474,8 +474,11 @@ public function newCurtainView($object) { $viewer = $this->getViewer(); + $action_id = celerity_generate_unique_node_id(); + $action_list = id(new PhabricatorActionListView()) - ->setViewer($viewer); + ->setViewer($viewer) + ->setID($action_id); // NOTE: Applications (objects of class PhabricatorApplication) can't // currently be set here, although they don't need any of the extensions diff --git a/src/applications/maniphest/controller/ManiphestTaskDetailController.php b/src/applications/maniphest/controller/ManiphestTaskDetailController.php --- a/src/applications/maniphest/controller/ManiphestTaskDetailController.php +++ b/src/applications/maniphest/controller/ManiphestTaskDetailController.php @@ -166,15 +166,6 @@ ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); - $curtain->addAction( - id(new PhabricatorActionView()) - ->setName(pht('Merge Duplicates In')) - ->setHref("/search/attach/{$phid}/TASK/merge/") - ->setWorkflow(true) - ->setIcon('fa-compress') - ->setDisabled(!$can_edit) - ->setWorkflow(true)); - $edit_config = $edit_engine->loadDefaultEditConfiguration(); $can_create = (bool)$edit_config; @@ -195,23 +186,36 @@ $edit_uri = $this->getApplicationURI($edit_uri); } - $curtain->addAction( - id(new PhabricatorActionView()) - ->setName(pht('Create Subtask')) - ->setHref($edit_uri) - ->setIcon('fa-level-down') - ->setDisabled(!$can_create) - ->setWorkflow(!$can_create)); + $task_submenu = array(); + + $task_submenu[] = id(new PhabricatorActionView()) + ->setName(pht('Create Subtask')) + ->setHref($edit_uri) + ->setIcon('fa-level-down') + ->setDisabled(!$can_create) + ->setWorkflow(!$can_create); + + $task_submenu[] = id(new PhabricatorActionView()) + ->setName(pht('Edit Blocking Tasks')) + ->setHref("/search/attach/{$phid}/TASK/blocks/") + ->setWorkflow(true) + ->setIcon('fa-link') + ->setDisabled(!$can_edit) + ->setWorkflow(true); + + $task_submenu[] = id(new PhabricatorActionView()) + ->setName(pht('Merge Duplicates In')) + ->setHref("/search/attach/{$phid}/TASK/merge/") + ->setWorkflow(true) + ->setIcon('fa-compress') + ->setDisabled(!$can_edit) + ->setWorkflow(true); $curtain->addAction( id(new PhabricatorActionView()) - ->setName(pht('Edit Blocking Tasks')) - ->setHref("/search/attach/{$phid}/TASK/blocks/") - ->setWorkflow(true) - ->setIcon('fa-link') - ->setDisabled(!$can_edit) - ->setWorkflow(true)); - + ->setName(pht('Edit Related Tasks...')) + ->setIcon('fa-anchor') + ->setSubmenu($task_submenu)); $owner_phid = $task->getOwnerPHID(); $author_phid = $task->getAuthorPHID(); diff --git a/src/view/layout/PhabricatorActionListView.php b/src/view/layout/PhabricatorActionListView.php --- a/src/view/layout/PhabricatorActionListView.php +++ b/src/view/layout/PhabricatorActionListView.php @@ -21,6 +21,10 @@ return $this; } + public function getID() { + return $this->id; + } + public function render() { $viewer = $this->getViewer(); @@ -44,13 +48,20 @@ require_celerity_resource('phabricator-action-list-view-css'); + $items = array(); + foreach ($actions as $action) { + foreach ($action->getItems() as $item) { + $items[] = $item; + } + } + return phutil_tag( 'ul', array( 'class' => 'phabricator-action-list-view', 'id' => $this->id, ), - $actions); + $items); } public function getDropdownMenuMetadata() { diff --git a/src/view/layout/PhabricatorActionView.php b/src/view/layout/PhabricatorActionView.php --- a/src/view/layout/PhabricatorActionView.php +++ b/src/view/layout/PhabricatorActionView.php @@ -14,6 +14,10 @@ private $metadata; private $selected; private $openInNewWindow; + private $submenu = array(); + private $hidden; + private $depth; + private $id; public function setSelected($selected) { $this->selected = $selected; @@ -95,7 +99,60 @@ return $this->openInNewWindow; } + public function getID() { + if (!$this->id) { + $this->id = celerity_generate_unique_node_id(); + } + return $this->id; + } + + public function setSubmenu(array $submenu) { + $this->submenu = $submenu; + + if (!$this->getHref()) { + $this->setHref('#'); + } + + return $this; + } + + public function getItems($depth = 0) { + $items = array(); + + $items[] = $this; + foreach ($this->submenu as $action) { + foreach ($action->getItems($depth + 1) as $item) { + $item + ->setHidden(true) + ->setDepth($depth + 1); + + $items[] = $item; + } + } + + return $items; + } + + public function setHidden($hidden) { + $this->hidden = $hidden; + return $this; + } + + public function getHidden() { + return $this->hidden; + } + + public function setDepth($depth) { + $this->depth = $depth; + return $this; + } + + public function getDepth() { + return $this->depth; + } + public function render() { + $caret_id = celerity_generate_unique_node_id(); $icon = null; if ($this->icon) { @@ -155,6 +212,18 @@ $target = null; } + if ($this->submenu) { + $caret = javelin_tag( + 'span', + array( + 'class' => 'caret-right', + 'id' => $caret_id, + ), + ''); + } else { + $caret = null; + } + $item = javelin_tag( 'a', array( @@ -164,7 +233,7 @@ 'sigil' => $sigils, 'meta' => $this->metadata, ), - array($icon, $this->name)); + array($icon, $this->name, $caret)); } } else { $item = phutil_tag( @@ -190,10 +259,47 @@ $classes[] = 'phabricator-action-view-selected'; } - return phutil_tag( + if ($this->submenu) { + $classes[] = 'phabricator-action-view-submenu'; + } + + $style = array(); + + if ($this->hidden) { + $style[] = 'display: none;'; + } + + if ($this->depth) { + $indent = ($this->depth * 16); + $style[] = "margin-left: {$indent}px;"; + } + + $sigil = null; + $meta = null; + + if ($this->submenu) { + Javelin::initBehavior('phui-submenu'); + $sigil = 'phui-submenu'; + + $item_ids = array(); + foreach ($this->submenu as $subitem) { + $item_ids[] = $subitem->getID(); + } + + $meta = array( + 'itemIDs' => $item_ids, + 'caretID' => $caret_id, + ); + } + + return javelin_tag( 'li', array( + 'id' => $this->getID(), 'class' => implode(' ', $classes), + 'style' => implode(' ', $style), + 'sigil' => $sigil, + 'meta' => $meta, ), $item); } diff --git a/src/view/phui/PHUIButtonView.php b/src/view/phui/PHUIButtonView.php --- a/src/view/phui/PHUIButtonView.php +++ b/src/view/phui/PHUIButtonView.php @@ -110,6 +110,18 @@ return $this; } + public function setDropdownMenuID($id) { + Javelin::initBehavior('phui-dropdown-menu'); + + $this->addSigil('phui-dropdown-menu'); + $this->setMetadata( + array( + 'menuID' => $id, + )); + + return $this; + } + protected function getTagAttributes() { require_celerity_resource('phui-button-css'); diff --git a/src/view/phui/PHUIHeaderView.php b/src/view/phui/PHUIHeaderView.php --- a/src/view/phui/PHUIHeaderView.php +++ b/src/view/phui/PHUIHeaderView.php @@ -24,6 +24,7 @@ private $badges = array(); private $href; private $actionList; + private $actionListID; public function setHeader($header) { $this->header = $header; @@ -90,6 +91,11 @@ return $this; } + public function setActionListID($action_list_id) { + $this->actionListID = $action_list_id; + return $this; + } + public function setPolicyObject(PhabricatorPolicyInterface $object) { $this->policyObject = $object; return $this; @@ -189,14 +195,20 @@ protected function getTagContent() { - if ($this->actionList) { + if ($this->actionList || $this->actionListID) { $action_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('Actions')) ->setHref('#') ->setIcon('fa-bars') - ->addClass('phui-mobile-menu') - ->setDropdownMenu($this->actionList); + ->addClass('phui-mobile-menu'); + + if ($this->actionList) { + $action_button->setDropdownMenu($this->actionList); + } else if ($this->actionListID) { + $action_button->setDropdownMenuID($this->actionListID); + } + $this->addActionLink($action_button); } diff --git a/src/view/phui/PHUITimelineEventView.php b/src/view/phui/PHUITimelineEventView.php --- a/src/view/phui/PHUITimelineEventView.php +++ b/src/view/phui/PHUITimelineEventView.php @@ -327,9 +327,7 @@ 'sigil' => $sigil, 'aria-haspopup' => 'true', 'aria-expanded' => 'false', - 'meta' => array( - 'items' => hsprintf('%s', $action_list), - ), + 'meta' => $action_list->getDropdownMenuMetadata(), ), array( $aural, diff --git a/src/view/phui/PHUITwoColumnView.php b/src/view/phui/PHUITwoColumnView.php --- a/src/view/phui/PHUITwoColumnView.php +++ b/src/view/phui/PHUITwoColumnView.php @@ -114,7 +114,7 @@ $curtain = $this->getCurtain(); if ($curtain) { $action_list = $curtain->getActionList(); - $this->header->setActionList($action_list); + $this->header->setActionListID($action_list->getID()); } $header = phutil_tag_div( diff --git a/webroot/rsrc/css/phui/phui-button.css b/webroot/rsrc/css/phui/phui-button.css --- a/webroot/rsrc/css/phui/phui-button.css +++ b/webroot/rsrc/css/phui/phui-button.css @@ -264,6 +264,42 @@ border-top-color: #000; } +.phabricator-action-view-submenu .caret-right { + float: right; + margin-top: 4px; + margin-right: 6px; + border-left-color: {$lightgreytext}; +} + +.phabricator-action-view-submenu .caret { + float: right; + margin-top: 5px; + margin-right: 4px; + border-top: 7px solid {$lightgreytext}; +} + +.phabricator-action-view-submenu.phui-submenu-open { + background: {$greybackground}; +} + +.phui-submenu-animate { + animation: phui-submenu-summon 0.25s; +} + +@keyframes phui-submenu-summon { + 0% { + color: {$lightgreytext}; + margin-left: 0; + transform: rotate(12deg); + } + 60% { + margin-left: 24px; + transform: rotate(-5deg); + margin-top: 18px; + } +} + + /* Icons */ .button.has-icon { position: relative; diff --git a/webroot/rsrc/js/phui/behavior-phui-dropdown-menu.js b/webroot/rsrc/js/phui/behavior-phui-dropdown-menu.js --- a/webroot/rsrc/js/phui/behavior-phui-dropdown-menu.js +++ b/webroot/rsrc/js/phui/behavior-phui-dropdown-menu.js @@ -16,17 +16,42 @@ e.kill(); - var list = JX.$H(data.items).getFragment().firstChild; + var list; + var placeholder; + if (data.items) { + list = JX.$H(data.items).getFragment().firstChild; + } else { + list = JX.$(data.menuID); + placeholder = JX.$N('span'); + } var icon = e.getNode('phui-dropdown-menu'); data.menu = new JX.PHUIXDropdownMenu(icon); - data.menu.setContent(list); + + data.menu.listen('open', function() { + if (placeholder) { + JX.DOM.replace(list, placeholder); + } + data.menu.setContent(list); + }); + + data.menu.listen('close', function() { + if (placeholder) { + JX.DOM.replace(placeholder, list); + } + }); + data.menu.open(); JX.DOM.listen(list, 'click', 'tag:a', function(e) { if (!e.isNormalClick()) { return; } + + if (JX.Stratcom.pass()) { + return; + } + data.menu.close(); }); }); diff --git a/webroot/rsrc/js/phui/behavior-phui-submenu.js b/webroot/rsrc/js/phui/behavior-phui-submenu.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/phui/behavior-phui-submenu.js @@ -0,0 +1,44 @@ +/** + * @provides javelin-behavior-phui-submenu + * @requires javelin-behavior + * javelin-stratcom + * javelin-dom + */ + +JX.behavior('phui-submenu', function() { + + JX.Stratcom.listen('click', 'phui-submenu', function(e) { + if (!e.isNormalClick()) { + return; + } + + var node = e.getNode('phui-submenu'); + var data = e.getNodeData('phui-submenu'); + + e.kill(); + + data.open = !data.open; + + for (var ii = 0; ii < data.itemIDs.length; ii++) { + var id = data.itemIDs[ii]; + var item = JX.$(id); + if (data.open) { + JX.DOM.show(item); + } else { + JX.DOM.hide(item); + } + + // Add a class so we can animate zany effects. + JX.DOM.alterClass(item, 'phui-submenu-animate', data.open); + } + + JX.DOM.alterClass(node, 'phui-submenu-open', data.open); + + // Toggle the caret from ">" to "V" when opening the menu, and back again + // when closing it. + var caret = JX.$(data.caretID); + JX.DOM.alterClass(caret, 'caret', data.open); + JX.DOM.alterClass(caret, 'caret-right', !data.open); + }); + +}); diff --git a/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js b/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js --- a/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js +++ b/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js @@ -42,7 +42,7 @@ JX.Stratcom.listen('keydown', null, JX.bind(this, this._onkey)); }, - events: ['open'], + events: ['open', 'close'], properties: { width: null, @@ -83,6 +83,8 @@ this._open = false; this._hide(); + this.invoke('close'); + return this; },