diff --git a/docs/product/components/popovers.html b/docs/product/components/popovers.html
index 9f68cdd469..71d088b315 100644
--- a/docs/product/components/popovers.html
+++ b/docs/product/components/popovers.html
@@ -3,20 +3,6 @@
title: Popovers
description: Popovers are small content containers that provide a contextual overlay. They can be used as in-context feature explanations, dropdowns, or tooltips.
---
-
-
{% icon Wave %}
-
-
- Help wanted! Each of our components will eventually ship with default JavaScript interactivity. We could use your help writing that JavaScript. To get started, check out the spec and a work in progress pull request.
-
- It can be tempting to hack together a quick fix in jQuery for displaying a Popover for your feature, but it may be just as much work as doing it properly as a Stimulus component—and this way every developer at Stack Overflow now has Popovers at their disposal.
-
-
-
{% header h2 | Classes %}
@@ -40,23 +26,118 @@
+
+ {% header h2 | JavaScript Attributes %}
+
+
+
+
+
Attribute
+
Applied to
+
Description
+
+
+
+
+
id="{POPOVER_ID}"
+
.s-popover
+
A unique id that the popover’s toggling element can target. Matches the value of [aria-controls] on the toggling element.
+
+
+
data-controller="s-popover"
+
Toggling element
+
Wires up the button to the popover controller.
+
+
+
data-action="s-popover#toggle"
+
Toggling element
+
Wires up the button to toggle the visibility of a generic popover.
+
+
+
aria-controls="{POPOVER_ID}"
+
Toggling element
+
Associates the button to the desired popover element.
+
+
+
data-s-popover-toggle-class="[class list]"
+
Toggling element
+
Adds an optional space-delineated list of classes to be toggled on the originating element when the popover is shown or hidden.
Dictates where to place the popover in relation to the toggle reference element. By default, the placement value is bottom. Accepted placements are auto, top, right, bottom, left. Each placement can take an additional -start and -end variation.
+
+
+
+
+
{% header h2 | Examples %}
-
-
-
-
-
.s-popover--arrow__bl
-
Popover arrow appears bottom left
+
+
+
+
+
+ Saved filters
+
+
+ Save custom sorting & filtering for easy access.
+
+
+
+
+
+
+
+
There’s no data associated with your account yet. Please check back tomorrow.
+
-
-
Popovers are provided without any positioning applied to them. It’s up to the implementer to choose how they’ll be positioned. This will most likely be achieved by adding something like ps-absolute t8 l8 to .s-popover. Positioning for arrows is provided, however. Arrow direction (top, right, bottom, left) will appear first, followed by the secondary (center, top, right, bottom, left). For example, a popover with an arrow child of .s-popover--arrow_tc will appear on the top of the .s-popover and centered horizontally. Though there are sensible defaults applied to the width of popovers, you may need to adjust manually.
+
+ {% header h3 | Automatic %}
+
If you’ve added the proper Stimulus controllers to your popover, positioning and arrow direction will be managed for you by Popper.js, a powerful popover positioning library we’ve added as a dependency.
+
To promote being able to tab to an open popover, it’s best to place the popover immediately after the toggling button in the markup as siblings.
+
+
+{% highlight html linenos %}
+
+
+
+ …
+
+{% endhighlight %}
+
+
+ {% header h3 | Manual %}
+
Popovers can also be positioning manually. This will most likely be achieved by adding something like ps-absolute t8 l8 to .s-popover. Manually positioning for arrows is also provided. Arrow direction (top, right, bottom, left) will appear first, followed by the secondary (center, top, right, bottom, left). For example, a popover with an arrow child of .s-popover--arrow_tc will appear on the top of the .s-popover and centered horizontally. Though there are sensible defaults applied to the width of popovers, you may need to adjust manually.
By default, popovers are hidden. Adding the class .is-visible will show the popover.
- {% header h3 | Standard %}
{% highlight html linenos %}
@@ -153,28 +234,4 @@
-
- {% header h3 | Dismissible %}
-
In the case of new feature callouts, it may be appropriate to include an explicit dismiss button. You can add one using the styling provided by .s-popover--close.
-
-{% highlight html linenos %}
-
-
-
- …
-
-{% endhighlight %}
-
-
-
-
-
.s-popover--close
-
Popover presented with a close button
-
-
-
-
-
diff --git a/lib/css/atomic/_stacks-misc.less b/lib/css/atomic/_stacks-misc.less
index 005aca5d45..e7c99b56b6 100644
--- a/lib/css/atomic/_stacks-misc.less
+++ b/lib/css/atomic/_stacks-misc.less
@@ -213,12 +213,12 @@
.z-base { z-index: @zi-base !important; }
.z-active { z-index: @zi-active !important; }
.z-selected { z-index: @zi-selected !important; }
-.z-nav { z-index: @zi-navigation !important; }
-.z-nav-fixed { z-index: @zi-navigation-fixed !important; }
.z-dropdown { z-index: @zi-dropdown !important; }
.z-popover { z-index: @zi-popovers !important; }
.z-tooltip { z-index: @zi-tooltips !important; }
.z-banner { z-index: @zi-banners !important; }
+.z-nav { z-index: @zi-navigation !important; }
+.z-nav-fixed { z-index: @zi-navigation-fixed !important; }
.z-modal { z-index: @zi-modals !important; }
.z-modal-bg { z-index: @zi-modals-background !important; }
diff --git a/lib/css/components/_stacks-popovers.less b/lib/css/components/_stacks-popovers.less
index b061f278f3..de4ab4d738 100644
--- a/lib/css/components/_stacks-popovers.less
+++ b/lib/css/components/_stacks-popovers.less
@@ -40,6 +40,24 @@
}
}
+// Auto adjust margins for auto-placed popovers
+
+.s-popover[x-placement^="top"] {
+ margin-bottom: @su8 + 2px;
+}
+
+.s-popover[x-placement^="right"] {
+ margin-left: @su8 + 2px;
+}
+
+.s-popover[x-placement^="bottom"] {
+ margin-top: @su8 + 2px;
+}
+
+.s-popover[x-placement^="left"] {
+ margin-right: @su8 + 2px;
+}
+
// ============================================================================
// $ CLOSE BUTTON
// ----------------------------------------------------------------------------
@@ -51,7 +69,6 @@
padding: @su8 !important;
}
-
// ============================================================================
// $ ARROWS
// ----------------------------------------------------------------------------
@@ -70,6 +87,7 @@
// $ BASE STYLE
// Sets the base arrow style for tooltips, popovers, and dropdowns.
// ----------------------------------------------------------------------------
+.s-popover--arrow,
.s-popover--arrow__tl,
.s-popover--arrow__tc,
.s-popover--arrow__tr,
@@ -97,6 +115,7 @@
// ============================================================================
// $$ TOP
// ----------------------------------------------------------------------------
+.s-popover[x-placement^="bottom"] .s-popover--arrow,
.s-popover--arrow__tl,
.s-popover--arrow__tc,
.s-popover--arrow__tr {
@@ -116,6 +135,7 @@
// $$ BOTTOM
// ----------------------------------------------------------------------------
+.s-popover[x-placement^="top"] .s-popover--arrow,
.s-popover--arrow__bl,
.s-popover--arrow__bc,
.s-popover--arrow__br {
@@ -135,6 +155,8 @@
// $$ TOP & BOTTOM LEFT
// ----------------------------------------------------------------------------
+.s-popover[x-placement="top-start"] .s-popover--arrow,
+.s-popover[x-placement="bottom-start"] .s-popover--arrow,
.s-popover--arrow__tl,
.s-popover--arrow__bl {
&:before,
@@ -145,6 +167,8 @@
// $$ TOP & BOTTOM CENTER
// ----------------------------------------------------------------------------
+.s-popover[x-placement="top"] .s-popover--arrow,
+.s-popover[x-placement="bottom"] .s-popover--arrow,
.s-popover--arrow__tc,
.s-popover--arrow__bc {
&:before,
@@ -156,6 +180,8 @@
// $$ TOP & BOTTOM RIGHT
// ----------------------------------------------------------------------------
+.s-popover[x-placement="top-end"] .s-popover--arrow,
+.s-popover[x-placement="bottom-end"] .s-popover--arrow,
.s-popover--arrow__tr,
.s-popover--arrow__br {
&:before,
@@ -166,6 +192,7 @@
// $$ LEFT
// ----------------------------------------------------------------------------
+.s-popover[x-placement^="right"] .s-popover--arrow,
.s-popover--arrow__lt,
.s-popover--arrow__lc,
.s-popover--arrow__lb {
@@ -185,6 +212,7 @@
// $$ RIGHT
// ----------------------------------------------------------------------------
+.s-popover[x-placement^="left"] .s-popover--arrow,
.s-popover--arrow__rt,
.s-popover--arrow__rc,
.s-popover--arrow__rb {
@@ -204,6 +232,8 @@
// $$ LEFT & RIGHT TOP
// ----------------------------------------------------------------------------
+.s-popover[x-placement="left-start"] .s-popover--arrow,
+.s-popover[x-placement="right-start"] .s-popover--arrow,
.s-popover--arrow__lt,
.s-popover--arrow__rt {
&:before,
@@ -214,6 +244,8 @@
// $$ LEFT & RIGHT CENTER
// ----------------------------------------------------------------------------
+.s-popover[x-placement="left"] .s-popover--arrow,
+.s-popover[x-placement="right"] .s-popover--arrow,
.s-popover--arrow__lc,
.s-popover--arrow__rc {
&:before,
@@ -225,6 +257,8 @@
// $$ LEFT & RIGHT BOTTOM
// ----------------------------------------------------------------------------
+.s-popover[x-placement="left-end"] .s-popover--arrow,
+.s-popover[x-placement="right-end"] .s-popover--arrow,
.s-popover--arrow__lb,
.s-popover--arrow__rb {
&:before,
diff --git a/lib/css/exports/_stacks-constants-helpers.less b/lib/css/exports/_stacks-constants-helpers.less
index 417baee60e..92fa8a13f8 100644
--- a/lib/css/exports/_stacks-constants-helpers.less
+++ b/lib/css/exports/_stacks-constants-helpers.less
@@ -25,12 +25,12 @@
@zi-base: 0; // Reset to 0
@zi-selected: 25; // Pop above siblings
@zi-active: 30; // Pop above selected siblings
-@zi-navigation: 1000; // Navigation Bars
-@zi-navigation-fixed: 1050; // Fixed navigation bars
-@zi-dropdown: 2000; // Dropdowns
-@zi-popovers: 3000; // Popovers, Popups, Autocompletes
-@zi-tooltips: 4000; // Tooltips
-@zi-banners: 5000; // Banners
+@zi-dropdown: 1000; // Dropdowns
+@zi-popovers: 2000; // Popovers, Popups, Autocompletes
+@zi-tooltips: 3000; // Tooltips
+@zi-banners: 4000; // Banners
+@zi-navigation: 5000; // Navigation Bars
+@zi-navigation-fixed: 5050; // Fixed navigation bars
@zi-modals: 9000; // Modals
@zi-modals-background: @zi-modals - 50;
diff --git a/lib/js/controllers/s-popover.js b/lib/js/controllers/s-popover.js
new file mode 100644
index 0000000000..d6d29e0bbf
--- /dev/null
+++ b/lib/js/controllers/s-popover.js
@@ -0,0 +1,134 @@
+(function () {
+ "use strict";
+ Stacks.addController("s-popover", {
+ targets: [],
+
+ /**
+ * Initializes popper.js and document events on controller connect
+ */
+ connect: function() {
+ var popoverId = this.element.getAttribute("aria-controls");
+
+ if (!popoverId) {
+ throw "[aria-controls=\"{POPOVER_ID}\"] required";
+ }
+
+ this.popoverElement = document.getElementById(popoverId);
+
+ this.popper = new Popper(this.element, this.popoverElement, {
+ placement: this.data.get("placement") || "bottom",
+ });
+ },
+
+ /**
+ * Cleans up popper.js elements and disconnects all added event listeners on controller disconnect
+ */
+ disconnect: function() {
+ this.popper.destroy();
+ this._unbindDocumentEvents();
+ },
+
+ /**
+ * Toggles the visibility of the popover when called as a Stimulus action
+ * @param {Event} event - The event object from the Stimulus action call
+ */
+ toggle: function(event) {
+ this._toggle();
+ },
+
+ /**
+ * Shows the popover
+ */
+ show: function() {
+ this._toggle(true);
+ },
+
+ /**
+ * Hides the popover
+ */
+ hide: function() {
+ this._toggle(false);
+ },
+
+ /**
+ * Toggles the visibility of the popover element
+ * @param {boolean=} show - Optional parameter that force shows/hides the element or toggles it if left undefined
+ */
+ _toggle: function(show) {
+ this.popoverElement.classList.toggle("is-visible", show);
+ this._toggleOptionalClasses(show);
+
+ if (this.popoverElement.classList.contains("is-visible")) {
+ this._bindDocumentEvents();
+ }
+ else {
+ this._unbindDocumentEvents();
+ }
+ },
+
+ /**
+ * Binds global events to the document for hiding popovers on user interaction
+ */
+ _bindDocumentEvents: function() {
+ // in order for removeEventListener to remove the right event, this bound function needs a constant reference
+ this._boundClickFn = this._boundClickFn || this._hideOnOutsideClick.bind(this);
+ this._boundKeypressFn = this._boundKeypressFn || this._hideOnEscapePress.bind(this);
+
+ document.addEventListener("click", this._boundClickFn);
+ document.addEventListener("keyup", this._boundKeypressFn);
+ },
+
+ /**
+ * Unbinds global events to the document for hiding popovers on user interaction
+ */
+ _unbindDocumentEvents: function() {
+ document.removeEventListener("click", this._boundClickFn);
+ document.removeEventListener("keyup", this._boundKeypressFn);
+ },
+
+ /**
+ * Forces the popover to hide if a user clicks outside of it or its reference element
+ * @param {Event} e - The document click event
+ */
+ _hideOnOutsideClick: function(e) {
+ // check if the document was clicked inside either the reference element or the popover itself
+ // note: .contains also returns true if the node itself matches the target element
+ if (!this.element.contains(e.target) && !this.popoverElement.contains(e.target)) {
+ this.hide();
+ }
+ },
+
+ /**
+ * Forces the popover to hide if the user presses escape while it, one of its childen, or the reference element are focused
+ * @param {Event} e - The document keyup event
+ */
+ _hideOnEscapePress: function(e) {
+ // if the ESC key (27) wasn't pressed or if no popovers are showing, return
+ if (e.which !== 27 || !this.popoverElement.classList.contains("is-visible")) {
+ return;
+ }
+
+ // check if the target was inside the popover element and refocus the triggering element
+ // note: .contains also returns true if the node itself matches the target element
+ if (this.popoverElement.contains(e.target)) {
+ this.element.focus();
+ }
+
+ this.hide();
+ },
+
+ /**
+ * Toggles all classes on the originating element based on the `class-toggle` data
+ * @param {boolean=} show - Optional parameter that force shows/hides the classes or toggles them if left undefined
+ */
+ _toggleOptionalClasses: function(show) {
+ if (!this.data.has("toggle-class")) {
+ return;
+ }
+ var cl = this.element.classList;
+ this.data.get("toggle-class").split(/\s+/).forEach(function (cls) {
+ cl.toggle(cls, show);
+ });
+ }
+ });
+})();
diff --git a/lib/js/stacks.polyfills.js b/lib/js/stacks.polyfills.js
index 0504571968..4d4088cb76 100644
--- a/lib/js/stacks.polyfills.js
+++ b/lib/js/stacks.polyfills.js
@@ -25,7 +25,7 @@ import 'mdn-polyfills/NodeList.prototype.forEach';
var oldToggle = DOMTokenList.prototype.toggle;
DOMTokenList.prototype.toggle = function (cls, force) {
- if (arguments.length === 1) {
+ if (arguments.length === 1 || typeof arguments[1] === "undefined") {
return oldToggle.apply(this, arguments);
}
if (force) {
diff --git a/package-lock.json b/package-lock.json
index 4b14df19b7..e3e878aaea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4092,6 +4092,11 @@
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.3.3.tgz",
"integrity": "sha512-1n3Z4p3IOxArEs1VRXnZ/RXdfEniAUS9jb68g58FIXMNkPJeZd+Qh4Uq7/e0LVxAQGos1eIUrqrt4FpjdnEd+Q=="
},
+ "popper.js": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.15.0.tgz",
+ "integrity": "sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA=="
+ },
"portfinder": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz",
diff --git a/package.json b/package.json
index b9530371f7..413f740b6b 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"dependencies": {
"@stackoverflow/stacks-icons": "^1.19.0",
"backstopjs": "^3.9.2",
+ "popper.js": "^1.15.0",
"stimulus": "^1.1.1"
}
}