While working recently on a project I came across an issue where I had a finite amount of space and a bunch of actions that would be visible or hidden based on its current state. I put some time into thinking how I could fit everything I needed and I thought that +90% of the time the user won't be performing any actions beyond just viewing the data so I started thinking about how to minify the amount of space my buttons took up. I first thought about using icons, but unless they are so universal, it is typically better to just go with text. So I thought about a button that has a popup menu. I'm not sure if flyout is the technical term for it, but that sounded right to me so that is what I went with. One of the benefits of this approach was if I needed to add more buttons down the road I know it won't cause any issues. Likewise I will always know exactly how much real estate it will take up.
I knew that I wanted my angular directive to have a couple of things. First I wanted the flyout to be driven by the developer so it can be reused over and over. This can be easily solved with an ng-transclude directive. Secondly, I wanted to make sure that it was smart enough to figure out where it was positioned in the window and dynamically display based on its position in the window.
The HTML for the flyout should be pretty straight forward. An outer container, .flyoutContainer, for the control, a container for the flyout, .flyout, and a div for the text/button to expand the flyout, .expanderContainer. The .flyoutContainer has a couple events on it, blur and click, to figure out when to display the .flyout, and a ng-class directive to help with any styles you want to apply as well. Here is the HTML result:
Finally we come to the JS code to put it all together. The easy part of the code is to just set the options.isExpanded appropriately based on the current state and event. This will drive the CSS above. The more difficult part of the code is making sure that the positioning of the .flyout is set appropriately based on where it is located in the window. For this we pass in the $window parameter to the directive to get the window height and width. We use the $window param instead of the window global element to make life easier for unit testing. We can then compare those values with the getBoudingClientRect values of the .flyout to determine how much you need to update the top and left properties of it. Here is the resulting JS:
You could have infinite benders take up a finite space! |
I knew that I wanted my angular directive to have a couple of things. First I wanted the flyout to be driven by the developer so it can be reused over and over. This can be easily solved with an ng-transclude directive. Secondly, I wanted to make sure that it was smart enough to figure out where it was positioned in the window and dynamically display based on its position in the window.
The HTML for the flyout should be pretty straight forward. An outer container, .flyoutContainer, for the control, a container for the flyout, .flyout, and a div for the text/button to expand the flyout, .expanderContainer. The .flyoutContainer has a couple events on it, blur and click, to figure out when to display the .flyout, and a ng-class directive to help with any styles you want to apply as well. Here is the HTML result:
<div class='flyoutContainer' tabindex='0' ng-click='click();' ng-blur='lostFocus();' ng-class='{"expanded": options.isExpanded}'> <div class='expanderContainer' > {{options.display || 'Expand'}} </div> <div class='flyout' ng-transclude ></div> </div>Next is the bare bones CSS to handle what we need. We want the .flyout to be absolutely positioned, to start off hidden, and to go on top of other elements. When the .expanded class is applied we will then want to change the visibility so the .flyout and its contents will be displayed. The .flyoutContainer styles are pretty simple. Change the positioning to relative so the absolutely positioned .flyout will be relative to it, and remove any outlining (unless you are into that kind of thing). For the .expanderContainer I just added css to change the pointer to a cursor to make it explicit that it is clickable. Here is the css:
.flyoutContainer{ outline:none; position: relative; } .expanderContainer{ cursor:pointer; } .flyout{ position:absolute; top:auto; left:auto; z-index:1; visibility:hidden; } .expanded .flyout{ visibility:visible; }
Finally we come to the JS code to put it all together. The easy part of the code is to just set the options.isExpanded appropriately based on the current state and event. This will drive the CSS above. The more difficult part of the code is making sure that the positioning of the .flyout is set appropriately based on where it is located in the window. For this we pass in the $window parameter to the directive to get the window height and width. We use the $window param instead of the window global element to make life easier for unit testing. We can then compare those values with the getBoudingClientRect values of the .flyout to determine how much you need to update the top and left properties of it. Here is the resulting JS:
function link(scope, element){ scope.lostFocus = function lostFocus(){ scope.options.isExpanded = false; clearFlyoutPosition(); }; scope.click = function click(){ updateFlyoutPosition(); scope.options.isExpanded = !scope.options.isExpanded; if (!scope.options.isExpanded){ clearFlyoutPosition(); } }; function clearFlyoutPosition(){ var $flyoutElement = angular.element element[0].querySelector('.flyout')); $flyoutElement.css('top', 'auto'); $flyoutElement.css('left', 'auto'); } function updateFlyoutPosition(){ var flyoutElement = element[0].querySelector('.flyout'), $flyoutElement = angular.element(flyoutElement); if ($window.innerHeight < flyoutElement.getBoundingClientRect().bottom){ $flyoutElement.css('top', '-' + (flyoutElement.getBoundingClientRect().height ) + "px"); } else{ // set the top to the flyout container so it starts right at the bottom. $flyoutElement.css('top','auto'); } if ($window.innerWidth < flyoutElement.getBoundingClientRect().right){ $flyoutElement.css('left', "-" + (flyoutElement.getBoundingClientRect().right - $window.innerWidth + 10) + "px"); } else{ $flyoutElement.css('left', 'auto'); } } }Here is the end result with one flyout in the top left and one in the bottom right!