Here is a fiddle of my solution that:
- Is accessible (you can use tab keys to activate/deactivate).
- Allows a user to hover the popover and for the popover to remain open.
- Allows multiple popovers on the page, but only a single popover to be activated at any given time.
- Doesn't rely on any third party, though the bootstrap popover styles have been borrowed.
The way this works is that we instantiate however many popovers we will have on the page in a popover
array (see the TODO in the comments on how to wire this up).
Then anytime a user tabs into or hovers into an element that should trigger a popover we activate that specific popover in the popover
array. When the user is no longer hovering the element we set a timeout for that specific popover
in the array. If that timeout has elapsed it does a quick check to see if the user has re-hovered or re-focused (via tabbing) the element. If so then we keep the popover alive. If not we hide the popover.
For the CSS I did not want to rely on using bootstrap so I borrowed the styles directly from bootstrap. If you try to use bootstrap's popover styles you may run into some weird behavior where bootstrap is running it's own scripts on our custom popover which we do not want.
HTML:
<section>
<a href="#"
ng-mouseover="showPopover(i)"
ng-mouseleave="hidePopover(i)"
ng-focus="showPopover(i)"
ng-blur="hidePopover(i)">
I trigger a popover - {{i}}
</a>
<popover popover-show="popover[i].popoverTracker">
<div class="arrow"></div>
<div class="custom-popover-content"
ng-mouseover="showPopover(i)"
ng-mouseleave="hidePopover(i)"
ng-focus="showPopover(i)"
ng-blur="hidePopover(i)">
<a href="#"
ng-focus="showPopover(i)"
ng-blur="hidePopover(i)">You can tab into me, I'm accessible!</a>
<br/>
<a href="#"
ng-focus="showPopover(i)"
ng-blur="hidePopover(i)">You can tab into me, I'm accessible!</a>
</div>
</popover>
</section>
Angular Controller and Directive:
angular.module('controllers', []);
angular.module('directives', []);
angular.module('myApp', ['ngAnimate', 'controllers', 'directives']);
angular.module('controllers')
.controller('MainCtrl', function ($scope, $timeout) {
$scope.popover = [];
(function init() {
// TODO: Make this dynamic so that we can pass it a value and it will generate the right amount
// Initializing the array of popovers on startup
createPopoverTrackers(20);
})();
// Creating an array of popovers equal to the number of popovers on the page
function createPopoverTrackers(count) {
for(var i = 0; i < count; i++) {
$scope.popover.push({
popoverTracker: false,
popoverKeepAlive: false,
timer: null
})
}
}
// A user has focused on an element that has an associated popover
$scope.queueOpenPopover = function(index) {
// Show our specified tracker
$scope.popover[index].popoverTracker = true;
// Hide the rest
Object.keys($scope.popover)
.filter(function(trackerIndex) {
return trackerIndex != index
})
.forEach(function(trackerIndex) {
$scope.popover[trackerIndex].popoverTracker = false;
$scope.popover[trackerIndex].popoverKeepAlive = false;
const timer = $scope.popover[trackerIndex].timer;
if(timer) {
$timeout.cancel(timer);
$scope.popover[trackerIndex].timer = null;
}
})
};
// Queuing up the demise of the popover
$scope.queueKillPopover = function(index) {
$scope.popover[index].timer = $timeout(function() {
if (!$scope.popover[index].popoverKeepAlive) {
// Popover or the popover trigger were not hovered within the time limit, kill it!
$scope.popover[index].popoverTracker = false;
}
}, 700);
};
// When a user focuses into the actual popover itself or it's trigger, we need to keep it alive
$scope.showPopover = function(index) {
$scope.popover[index].popoverKeepAlive = true;
$scope.queueOpenPopover(index);
};
// The user has removed focus from the popover or it's trigger, set this to false so the timer knows to kill it
$scope.hidePopover = function(index) {
$scope.popover[index].popoverKeepAlive = false;
$scope.queueKillPopover(index);
};
});
angular.module('directives')
.directive('popover', function () {
return {
restrict: 'E',
replace: true,
transclude: true,
scope: {
'popoverShow': '='
},
template: '<div class="custom-popover bottom" ng-show="popoverShow" ng-transclude></div>'
};
});
CSS borrowed from bootstrap:
.custom-popover {
position: absolute;
z-index: 1010;
max-width: 276px;
padding: 1px;
text-align: left;
white-space: normal;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 6px;
box-shadow: 0 5px 10px rgba(0,0,0,0.2);
background-clip: padding-box;
}
.custom-popover .arrow,
.custom-popover .arrow:after {
position: absolute;
display: block;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.custom-popover .arrow {
border-width: 11px;
}
.custom-popover .arrow:after {
border-width: 10px;
content: "";
}
.custom-popover.bottom {
margin-top: 10px;
}
.custom-popover.bottom .arrow {
top: -11px;
left: 50%;
margin-left: -11px;
border-bottom-color: rgba(0, 0, 0, 0.25);
border-top-width: 0;
}
.custom-popover.bottom .arrow:after {
top: 1px;
margin-left: -10px;
border-bottom-color: #ffffff;
border-top-width: 0;
content: " ";
}
.custom-popover-content {
padding: 9px 14px;
}