diff --git a/.cursor/skills/chi-tokens/reference.md b/.cursor/skills/chi-tokens/reference.md index 4eedf7d2ea..0f08288f54 100644 --- a/.cursor/skills/chi-tokens/reference.md +++ b/.cursor/skills/chi-tokens/reference.md @@ -49,6 +49,7 @@ | zindex-modal | `50` | | zindex-popover | `60` | | zindex-tooltip | `70` | +| zindex-portal-root | `9999` | ## Token Descriptions (manual) diff --git a/cypress/e2e/chi-js/dropdown.cy.js b/cypress/e2e/chi-js/dropdown.cy.js index cad74b19ed..3c1685ca67 100644 --- a/cypress/e2e/chi-js/dropdown.cy.js +++ b/cypress/e2e/chi-js/dropdown.cy.js @@ -59,7 +59,7 @@ describe('Dropdown', function () { describe('Dropdown Positioning test', () => { it('Dropdown Positioning should work in accordance', () => { - this.chidata.popperPositions.forEach((position) => { + this.chidata.floatingPositions.forEach((position) => { const getValue = `[data-cy="test-more-${position}"]`; cy.get(getValue) diff --git a/cypress/e2e/chi-js/popover.cy.js b/cypress/e2e/chi-js/popover.cy.js index 7c6196cd99..c5f19372a0 100644 --- a/cypress/e2e/chi-js/popover.cy.js +++ b/cypress/e2e/chi-js/popover.cy.js @@ -22,14 +22,21 @@ describe('Popover', function () { describe('Popover Positioning and arrow should be matched ', () => { it('Popover Positioning and arrow should work in accordance', () => { - this.chidata.popperPositions.forEach((position) => { + this.chidata.floatingPositions.forEach((position) => { const getValue = `[data-cy="test-more-${position}"]`; cy.get(getValue) .find('button.chi-button') .should('match', `[data-position="${position}"]`) + .click() .find('+ .chi-popover') - .should('match', `[x-placement="${position}"]`); + .should('be.visible') + .should('have.class', '-active'); + + // Click the button again to dismiss the popover before next iteration + cy.get(getValue) + .find('button.chi-button') + .click(); }); }); }); diff --git a/cypress/fixtures/chidata.json b/cypress/fixtures/chidata.json index cd63e23aa5..853b4c7b02 100644 --- a/cypress/fixtures/chidata.json +++ b/cypress/fixtures/chidata.json @@ -159,7 +159,7 @@ "products-wifi" ], "positions": ["top", "right", "bottom", "left"], - "popperPositions": [ + "floatingPositions": [ "top", "top-start", "top-end", diff --git a/package-lock.json b/package-lock.json index 0fbb4d2b8d..9c22833043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@babel/preset-env": "^7.27.2", + "@floating-ui/dom": "^1.7.5", "@playwright/browser-chromium": "1.51.1", "@playwright/test": "1.51.1", "@types/node": "^24.0.4", @@ -33,7 +34,6 @@ "express-rate-limit": "^7.5.1", "normalize.css": "^8.0.1", "ora": "^8.2.0", - "popper.js": "^1.16.1", "postcss-svg": "^3.0.0", "pug": "^3.0.3", "rollup-plugin-terser": "^7.0.2", @@ -2241,6 +2241,34 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", @@ -16706,18 +16734,6 @@ "node": ">=12.13.0" } }, - "node_modules/popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/portfinder": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", diff --git a/package.json b/package.json index 309d57c072..a1adb4d6f7 100755 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@babel/preset-env": "^7.27.2", + "@floating-ui/dom": "^1.7.5", "@playwright/browser-chromium": "1.51.1", "@playwright/test": "1.51.1", "@types/node": "^24.0.4", @@ -52,7 +53,6 @@ "express-rate-limit": "^7.5.1", "normalize.css": "^8.0.1", "ora": "^8.2.0", - "popper.js": "^1.16.1", "postcss-svg": "^3.0.0", "pug": "^3.0.3", "rollup-plugin-terser": "^7.0.2", diff --git a/src/chi/_global-variables.scss b/src/chi/_global-variables.scss index 9bab05277e..26f48e1ce8 100644 --- a/src/chi/_global-variables.scss +++ b/src/chi/_global-variables.scss @@ -63,3 +63,4 @@ $zindex-fixed-with-backdrop: 40; $zindex-modal: 50; $zindex-popover: 60; $zindex-tooltip: 70; +$zindex-portal-root: 9999; diff --git a/src/chi/components/date-picker/date-picker.scss b/src/chi/components/date-picker/date-picker.scss index 5505aecd83..04cfe609b7 100644 --- a/src/chi/components/date-picker/date-picker.scss +++ b/src/chi/components/date-picker/date-picker.scss @@ -204,22 +204,22 @@ $monday-starting-week: ( &__input { &.-no-arrow { &.chi-popover--top, - &[x-placement^='top'] { + &[class*='chi-popover--top-'] { margin-bottom: 1px; } &.chi-popover--right, - &[x-placement^='right'] { + &[class*='chi-popover--right-'] { margin-left: 1px; } &.chi-popover--bottom, - &[x-placement^='bottom'] { + &[class*='chi-popover--bottom-'] { margin-top: 1px; } &.chi-popover--left, - &[x-placement^='left'] { + &[class*='chi-popover--left-'] { margin-right: 1px; } } diff --git a/src/chi/components/popover/popover.scss b/src/chi/components/popover/popover.scss index 5e9212aabe..290a4fbcf1 100644 --- a/src/chi/components/popover/popover.scss +++ b/src/chi/components/popover/popover.scss @@ -18,10 +18,39 @@ text-align: left; top: 0; white-space: normal; + width: max-content; z-index: $zindex-popover; &.-animated { - transition: opacity 0.2s, transform 0.2s; + &[data-state='open'] { + &.chi-popover--top { animation: chi-popover-slide-top-in 0.2s ease forwards; } + &.chi-popover--top-start { animation: chi-popover-slide-top-in 0.2s ease forwards; } + &.chi-popover--top-end { animation: chi-popover-slide-top-in 0.2s ease forwards; } + &.chi-popover--right { animation: chi-popover-slide-right-in 0.2s ease forwards; } + &.chi-popover--right-start { animation: chi-popover-slide-right-in 0.2s ease forwards; } + &.chi-popover--right-end { animation: chi-popover-slide-right-in 0.2s ease forwards; } + &.chi-popover--bottom { animation: chi-popover-slide-bottom-in 0.2s ease forwards; } + &.chi-popover--bottom-start { animation: chi-popover-slide-bottom-in 0.2s ease forwards; } + &.chi-popover--bottom-end { animation: chi-popover-slide-bottom-in 0.2s ease forwards; } + &.chi-popover--left { animation: chi-popover-slide-left-in 0.2s ease forwards; } + &.chi-popover--left-start { animation: chi-popover-slide-left-in 0.2s ease forwards; } + &.chi-popover--left-end { animation: chi-popover-slide-left-in 0.2s ease forwards; } + } + + &[data-state='closed'] { + &.chi-popover--top { animation: chi-popover-slide-top-out 0.2s ease forwards; } + &.chi-popover--top-start { animation: chi-popover-slide-top-out 0.2s ease forwards; } + &.chi-popover--top-end { animation: chi-popover-slide-top-out 0.2s ease forwards; } + &.chi-popover--right { animation: chi-popover-slide-right-out 0.2s ease forwards; } + &.chi-popover--right-start { animation: chi-popover-slide-right-out 0.2s ease forwards; } + &.chi-popover--right-end { animation: chi-popover-slide-right-out 0.2s ease forwards; } + &.chi-popover--bottom { animation: chi-popover-slide-bottom-out 0.2s ease forwards; } + &.chi-popover--bottom-start { animation: chi-popover-slide-bottom-out 0.2s ease forwards; } + &.chi-popover--bottom-end { animation: chi-popover-slide-bottom-out 0.2s ease forwards; } + &.chi-popover--left { animation: chi-popover-slide-left-out 0.2s ease forwards; } + &.chi-popover--left-start { animation: chi-popover-slide-left-out 0.2s ease forwards; } + &.chi-popover--left-end { animation: chi-popover-slide-left-out 0.2s ease forwards; } + } } &.-active { @@ -123,93 +152,69 @@ display: block; height: $popover-arrow-size; margin: 0; + pointer-events: none; position: absolute; width: $popover-arrow-size; - &::after { - border-color: $color-border-white; - border-style: solid; - border-width: calc(#{$popover-arrow-size} / 2); + &::before { + background: $popover-background-color; + clip-path: var(--chi-arrow-clip, polygon(100% 0, 0 100%, 100% 100%)); content: ''; display: block; + height: 100%; left: 0; position: absolute; top: 0; transform: rotate(45deg); + width: 100%; } } - &:not(.-no-arrow) { - &.chi-popover--top:not([x-placement='bottom']), - &[x-placement^='top'] { - margin-bottom: calc(#{$popover-arrow-size} + 0.25rem); - - .chi-arrow, - .chi-popover__arrow { - bottom: calc((#{$popover-arrow-size} / 2) * -1); - left: calc(50% - (#{$popover-arrow-size} / 2)); - } - - .chi-arrow::after, - .chi-popover__arrow::after { - border-left-color: transparent; - border-top-color: transparent; - box-shadow: 3px 3px 3px 0 rgba(0, 0, 0, 0.12); - } + &.chi-popover--top, + &.chi-popover--top-start, + &.chi-popover--top-end, + &[x-placement^='top'] { + .chi-arrow, + .chi-popover__arrow { + bottom: calc(#{$popover-arrow-size} / -2); + left: calc(50% - #{$popover-arrow-size} / 2); + --chi-arrow-clip: polygon(100% 0, 0 100%, 100% 100%); } + } - &.chi-popover--right:not([x-placement='left']), - &[x-placement^='right'] { - margin-left: calc(#{$popover-arrow-size} + 0.25rem); - - .chi-arrow, - .chi-popover__arrow { - left: calc((#{$popover-arrow-size} / 2) * -1); - top: calc(50% - (#{$popover-arrow-size} / 2)); - } - - .chi-arrow::after, - .chi-popover__arrow::after { - border-right-color: transparent; - border-top-color: transparent; - box-shadow: -3px 3px 7px rgba(0, 0, 0, 0.1); - } + &.chi-popover--bottom, + &.chi-popover--bottom-start, + &.chi-popover--bottom-end, + &[x-placement^='bottom'] { + .chi-arrow, + .chi-popover__arrow { + top: calc(#{$popover-arrow-size} / -2); + left: calc(50% - #{$popover-arrow-size} / 2); + --chi-arrow-clip: polygon(0 0, 100% 0, 0 100%); } + } - &.chi-popover--bottom:not([x-placement='top']), - &[x-placement^='bottom'] { - margin-top: calc(#{$popover-arrow-size} + 0.25rem); - - .chi-arrow, - .chi-popover__arrow { - left: calc(50% - (#{$popover-arrow-size} / 2)); - top: calc((#{$popover-arrow-size} / 2) * -1); - } - - .chi-arrow::after, - .chi-popover__arrow::after { - border-bottom-color: transparent; - border-right-color: transparent; - box-shadow: -3px -3px 7px rgba(0, 0, 0, 0.1); - } + &.chi-popover--right, + &.chi-popover--right-start, + &.chi-popover--right-end, + &[x-placement^='right'] { + .chi-arrow, + .chi-popover__arrow { + left: calc(#{$popover-arrow-size} / -2); + top: calc(50% - #{$popover-arrow-size} / 2); + --chi-arrow-clip: polygon(0 0, 0 100%, 100% 100%); } + } - &.chi-popover--left:not([x-placement='right']), - &[x-placement^='left'] { - margin-right: calc(#{$popover-arrow-size} + 0.25rem); - - .chi-arrow, - .chi-popover__arrow { - right: calc((#{$popover-arrow-size} / 2) * -1); - top: calc(50% - (#{$popover-arrow-size} / 2)); - } - - .chi-arrow::after, - .chi-popover__arrow::after { - border-bottom-color: transparent; - border-left-color: transparent; - box-shadow: 3px -3px 7px rgba(0, 0, 0, 0.1); - } + &.chi-popover--left, + &.chi-popover--left-start, + &.chi-popover--left-end, + &[x-placement^='left'] { + .chi-arrow, + .chi-popover__arrow { + right: calc(#{$popover-arrow-size} / -2); + top: calc(50% - #{$popover-arrow-size} / 2); + --chi-arrow-clip: polygon(0 0, 100% 0, 100% 100%); } } @@ -273,6 +278,10 @@ .chi-icon { color: $popover-modal-close-icon-color; } + + &:hover { + background: none; + } } } @@ -313,6 +322,13 @@ width: 4.375rem; } } + + // Disable open animations when draggable to avoid conflicts with drag movement. + &.-animated { + &[data-state='open'] { + animation: none; + } + } } &.-gradient { @@ -339,75 +355,111 @@ .chi-icon { color: $popover-gradient-close-button-icon-color; } - } - } - // Position offsets for left/right start/end placements only - &[x-placement='right-start'], - &[x-placement='left-start'] { - top: -$popover-gradient-arrow-offset !important; - } - - &[x-placement='right-end'], - &[x-placement='left-end'] { - top: $popover-gradient-arrow-offset !important; + &:hover { + background: none; + } + } } - /* - Arrow styles - */ - - // Right side arrow styling + &.chi-popover--left, + &.chi-popover--right, + &[x-placement='left'], &[x-placement='right'] { - .chi-popover__arrow::after { - border-color: $popover-gradient-right-arrow-color; + .chi-arrow, + .chi-popover__arrow { + margin-top: $popover-gradient-arrow-offset; } } + &.chi-popover--left-start, + &.chi-popover--right-start, + &[x-placement='left-start'], &[x-placement='right-start'] { - .chi-popover__arrow::after { - top: $popover-gradient-arrow-offset; - border-color: $popover-gradient-right-arrow-color; + .chi-arrow, + .chi-popover__arrow { + margin-top: $popover-gradient-arrow-offset; } } + &.chi-popover--left-end, + &.chi-popover--right-end, + &[x-placement='left-end'], &[x-placement='right-end'] { - .chi-popover__arrow::after { - top: auto; - bottom: $popover-gradient-arrow-offset; + .chi-arrow, + .chi-popover__arrow { + margin-top: -$popover-gradient-arrow-offset; } } - // Left side arrow styling - &[x-placement='left'] { - .chi-popover__arrow::after { - border-color: $popover-gradient-left-arrow-color; + &.chi-popover--right-start, + &[x-placement='right-start'] { + .chi-arrow::before, + .chi-popover__arrow::before { + background: $popover-gradient-right-arrow-color; } } + &.chi-popover--left-start, &[x-placement='left-start'] { - .chi-popover__arrow::after { - top: $popover-gradient-arrow-offset; - border-color: $popover-gradient-left-arrow-color; - } - } - - &[x-placement='left-end'] { - .chi-popover__arrow::after { - top: auto; - bottom: $popover-gradient-arrow-offset; + .chi-arrow::before, + .chi-popover__arrow::before { + background: $popover-gradient-left-arrow-color; } } - // Bottom side arrow style with mixed color - &[x-placement^='bottom'] { - .chi-popover__arrow::after { - border-color: $popover-gradient-arrow-mixed-color; + &.chi-popover--bottom-start, + &.chi-popover--bottom, + &[x-placement='bottom-start'], + &[x-placement='bottom'] { + .chi-arrow::before, + .chi-popover__arrow::before { + background: $popover-gradient-arrow-mixed-color; } } } } +@keyframes chi-popover-slide-top-in { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: none; } +} + +@keyframes chi-popover-slide-top-out { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(20px); } +} + +@keyframes chi-popover-slide-bottom-in { + from { opacity: 0; transform: translateY(-20px); } + to { opacity: 1; transform: none; } +} + +@keyframes chi-popover-slide-bottom-out { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(-20px); } +} + +@keyframes chi-popover-slide-right-in { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: none; } +} + +@keyframes chi-popover-slide-right-out { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateX(-20px); } +} + +@keyframes chi-popover-slide-left-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: none; } +} + +@keyframes chi-popover-slide-left-out { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateX(20px); } +} + chi-popover { &:not(.hydrated) { &:not([active]) { diff --git a/src/chi/components/portal/portal.scss b/src/chi/components/portal/portal.scss new file mode 100644 index 0000000000..158f2606c1 --- /dev/null +++ b/src/chi/components/portal/portal.scss @@ -0,0 +1,10 @@ +#chi-portal-root { + position: fixed; + top: 0; + left: 0; + width: 0; + height: 0; + overflow: visible; + pointer-events: none; + z-index: $zindex-portal-root; +} diff --git a/src/chi/javascript/components/auxiliary/overflow-menu.js b/src/chi/javascript/components/auxiliary/overflow-menu.js index f67aa6f1af..bb22134f91 100644 --- a/src/chi/javascript/components/auxiliary/overflow-menu.js +++ b/src/chi/javascript/components/auxiliary/overflow-menu.js @@ -160,7 +160,7 @@ class OverflowMenu { Util.addClass(child, 'chi-dropdown__menu-item'); } if (Util.hasClass(child,'chi-dropdown__trigger')) { - NavigationDropdown.factory(child).disablePopper(); + NavigationDropdown.factory(child).disableFloating(); } newElem.appendChild(child); } @@ -183,7 +183,7 @@ class OverflowMenu { Util.removeClass(child, 'chi-dropdown__menu-item'); } if (Util.hasClass(child,'chi-dropdown__trigger')) { - NavigationDropdown.factory(child).enablePopper(); + NavigationDropdown.factory(child).enableFloating(); if (Util.hasClass(child, CLASS_HAS_ACTIVE)) { Util.addClass(oldTab, CLASS_ACTIVE); } diff --git a/src/chi/javascript/components/date-picker.js b/src/chi/javascript/components/date-picker.js index 073d1c55a3..324e6d9818 100644 --- a/src/chi/javascript/components/date-picker.js +++ b/src/chi/javascript/components/date-picker.js @@ -12,7 +12,8 @@ const DEFAULT_CONFIG = { locale: 'en', min: '01/01/1900', max: '12/31/2099', - format: 'MM/DD/YYYY' + format: 'MM/DD/YYYY', + portal: false }; const WEEK_CLASS_PART = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; @@ -195,6 +196,7 @@ class DatePicker extends Component { arrow: false, classes: ['chi-popover__datepicker'], content: this.elems.container, + portal: this._config.portal, position: 'bottom', preventAutoHide: true, trigger: 'manual' diff --git a/src/chi/javascript/components/dropdown.js b/src/chi/javascript/components/dropdown.js index 662db80b2a..51b25f86e6 100644 --- a/src/chi/javascript/components/dropdown.js +++ b/src/chi/javascript/components/dropdown.js @@ -1,7 +1,8 @@ import {Component} from "../core/component"; import {Util} from "../core/util.js"; -import Popper from 'popper.js'; import {CLASS_HAS_ACTIVE} from "./tab"; +import {createFloating} from '../core/floating.js'; +import {portalElement} from '../core/portal.js'; const CLASS_ACTIVE = "-active"; const CLASS_DROPDOWN = 'chi-dropdown__menu'; @@ -11,7 +12,9 @@ const CLASS_COMPONENT = 'chi-dropdown__trigger'; const COMPONENT_SELECTOR = '.chi-dropdown__trigger'; const COMPONENT_TYPE = "dropdown"; const DEFAULT_CONFIG = { - popper: true, + floating: true, + popper: undefined, + portal: false, dropdownElem: null }; const DEFAULT_POSITION = "bottom-start"; @@ -24,6 +27,23 @@ class Dropdown extends Component { constructor (elem, config) { super(elem, Util.extend(DEFAULT_CONFIG, config)); + this._floating = null; + this._cleanupAutoUpdate = null; + this._portalHandle = null; + Object.defineProperty(this, '_popper', { + configurable: true, + enumerable: false, + get: () => this._floating, + set: (value) => { + this._floating = value; + } + }); + + if (typeof this._config.floating === 'undefined') { + this._config.floating = typeof this._config.popper === 'boolean' ? this._config.popper : true; + } + this._config.popper = this._config.floating; + this._eventCaptured = false; this._shown = Util.hasClass(elem, CLASS_ACTIVE); this._childrenDropdowns = []; @@ -34,8 +54,8 @@ class Dropdown extends Component { this._locateDropdown(); let self = this; - if (this._config.popper) { - this.enablePopper(); + if (this._config.floating) { + this.enableFloating(); } this._triggerClickEventListener = function(e) { @@ -92,6 +112,7 @@ class Dropdown extends Component { function (elem) { const dropdownElem = elem.nextSibling; const config = Util.copyObject(self._config); + config.floating = false; config.popper = false; if (dropdownElem && Util.hasClass(dropdownElem, CLASS_DROPDOWN)) { config.dropdownElem = dropdownElem; @@ -120,43 +141,91 @@ class Dropdown extends Component { return dropdownPosition; } - enablePopper () { - if (this._popper) { + _portalDropdownMenu() { + if (!this._config.portal || !this._dropdownElem) { + return; + } + + const portalId = 'chi-dropdown-portal-' + this.componentCounterNo; + this._elem.setAttribute('data-chi-portal-id', portalId); + this._portalHandle = portalElement(this._dropdownElem, portalId); + + this._syncMenuMinWidth(); + } + + _syncMenuMinWidth() { + if (!this._config.portal || !this._elem || !this._dropdownElem) { + return; + } + const refWidth = this._elem.getBoundingClientRect().width; + if (refWidth > 0) { + this._dropdownElem.style.minWidth = `${refWidth}px`; + } + } + + _restoreDropdownMenu() { + if (!this._portalHandle || !this._dropdownElem) { return; } + this._dropdownElem.style.minWidth = ''; - this._popper = 'loading'; + this._portalHandle.restore(); + this._portalHandle = null; + + if (this._elem) { + this._elem.removeAttribute('data-chi-portal-id'); + } + } + + enableFloating () { + if (this._floating) { + return; + } + + this._floating = 'loading'; const self = this; window.requestAnimationFrame(function() { let dropdownPosition = self._calculateDropdownPosition(); - if (dropdownPosition && typeof Popper !== 'undefined') { - self._popper = new Popper (self._elem, self._dropdownElem, { - modifiers: { - applyStyle: {enabled: true}, - applyChiStyle: { - enabled: true, - fn: self._popperPatchForBottomLeftPropperLocation, - order: 890 - }, - }, - placement: dropdownPosition + if (dropdownPosition) { + self._portalDropdownMenu(); + + // Build floating instance — portal is false here because + // _portalDropdownMenu() already handled portaling with + // ownership tracking + minWidth sync. We pass the strategy + // directly. + self._floating = createFloating(self._elem, self._dropdownElem, { + placement: dropdownPosition, + strategy: self._config.portal ? 'fixed' : 'absolute', + portal: false, + flip: true, + shift: true, + // Skip hideMiddleware when portaled: the menu can be taller + // than the viewport, so scrolling to interact with items moves + // the trigger out of view, causing hide to incorrectly collapse + // the menu mid-interaction. Matches CE behavior. + hideWhenDetached: !self._config.portal, + autoUpdate: false, + initialUpdate: true, }); } }); } - _popperPatchForBottomLeftPropperLocation (data) { - data.styles.left = data.styles.left || 'initial'; - data.styles.right = data.styles.right || 'initial'; - return data; + enablePopper () { + this.enableFloating(); } - disablePopper () { - if (this._popper && typeof this._popper === 'function') { - this._popper.destroy(); + disableFloating () { + if (this._floating && typeof this._floating === 'object' && this._floating.destroy) { + this._floating.destroy(); } - this._popper = null; + this._floating = null; + this._restoreDropdownMenu(); + } + + disablePopper () { + this.disableFloating(); } _clickOnTrigger() { @@ -239,8 +308,15 @@ class Dropdown extends Component { Util.addClass(this._elem, CLASS_ACTIVE); Util.addClass(this._elem, CLASS_HAS_ACTIVE); Util.addClass(this._dropdownElem, CLASS_ACTIVE); - if (this._popper && typeof this._popper.update === "function") { - this._popper.update(); + this._dropdownElem.style.visibility = ''; + this._syncMenuMinWidth(); + if (this._floating && typeof this._floating.update === "function") { + const floatingRef = this._floating; + const self2 = this; + this._floating.update().then(function() { + if (self2._floating !== floatingRef) return; + self2._cleanupAutoUpdate = floatingRef.enableAutoUpdate(); + }); } if (this._parentDropdown) { this._parentDropdown.show(); @@ -260,6 +336,10 @@ class Dropdown extends Component { this._setActiveDescendants(); Util.removeClass(this._elem, CLASS_ACTIVE); Util.removeClass(this._dropdownElem, CLASS_ACTIVE); + if (this._cleanupAutoUpdate) { + this._cleanupAutoUpdate(); + this._cleanupAutoUpdate = null; + } this._shown = false; this._childrenDropdowns.forEach(function(dd) { dd.hide(); @@ -318,12 +398,15 @@ class Dropdown extends Component { this._childrenDropdowns = null; this._parentDropdown = null; this._activedDescendants = null; - this._dropdownElem = null; document.removeEventListener('click', this._documentClickEventListener); this._documentClickEventListener = null; + this.disableFloating(); + this._dropdownElem = null; + if (this._elem) { + this._elem.removeAttribute('data-chi-portal-id'); + } this._elem = null; - this.disablePopper(); } diff --git a/src/chi/javascript/components/popover.js b/src/chi/javascript/components/popover.js index 3792678e1a..a41592ebcb 100644 --- a/src/chi/javascript/components/popover.js +++ b/src/chi/javascript/components/popover.js @@ -1,12 +1,13 @@ import { Util } from '../core/util.js'; import { Component } from '../core/component'; -import Popper from 'popper.js'; import { chi } from '../core/chi'; +import { createFloating } from '../core/floating.js'; const COMPONENT_SELECTOR = '[data-popover-content]'; const COMPONENT_TYPE = 'popover'; const CLASS_POPOVER = 'chi-popover'; -const TRANSITION_DURATION = 200; +const ANIMATION_DURATION = 200; +const ANIMATION_TIMEOUT = ANIMATION_DURATION + 50; const EVENTS = { SHOW_DEPRECATED: 'chi.popover.show', HIDE_DEPRECATED: 'chi.popover.hide', @@ -22,20 +23,36 @@ const DEFAULT_CONFIG = { content: null, delayBetweenInteractions: 50, parent: null, + portal: false, position: 'top', trigger: 'click', preventAutoHide: false }; +const PLACEMENT_CLASSES = [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', +]; + class Popover extends Component { constructor(elem, config) { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._popoverElem = null; - this._popper = null; - this._popperData = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; + this._floating = null; + this._cleanupAutoUpdate = null; + this._animationAbortController = null; + this._animationTimeout = null; this._shown = false; this._config.parent = this._config.parent || this._elem; this._config.position = @@ -94,6 +111,7 @@ class Popover extends Component { } show(force) { + if (!this._popoverElem) return; if (this._shown || (!this.allowConsecutiveActions() && !force)) { return; } @@ -102,38 +120,70 @@ class Popover extends Component { this._elem.dispatchEvent(Util.createEvent(EVENTS.SHOW_DEPRECATED)); // To be removed in Chi 4.0 this._elem.dispatchEvent(Util.createEvent(EVENTS.SHOW)); + if (this._animationAbortController) { + this._animationAbortController.abort(); + this._animationAbortController = null; + } + if (this._animationTimeout) { + clearTimeout(this._animationTimeout); + this._animationTimeout = null; + } + + this._popoverElem.removeAttribute('data-state'); + this._popoverElem.style.left = '0'; + this._popoverElem.style.top = '0'; + this._popoverElem.style.display = 'block'; + this._popoverElem.style.visibility = 'hidden'; + if (!this._config.animate) { Util.addClass(this._popoverElem, chi.classes.ACTIVE); this._popoverElem.setAttribute('aria-hidden', 'false'); + this._popoverElem.style.display = ''; + this._popoverElem.style.visibility = ''; + this._enableAutoUpdate(); return; } const self = this; - const transition = this._popoverElem.style.transition; - self._popoverElem.style.transition = 'none'; - Util.addClass(self._popoverElem, chi.classes.TRANSITIONING); - //Because this popper method is asynchronous, cannot be done in step 1 of - // animation, as it will be executed between step 1 and step 2. - self.resetPosition(); - - Util.threeStepsAnimation( - function() { - self._popoverElem.style.transform = self._preAnimationTransformStyle; - }, - function() { - Util.addClass(self._popoverElem, chi.classes.ACTIVE); - self._popoverElem.style.transition = transition; - self._popoverElem.style.transform = self._postAnimationTransformStyle; - }, - function() { - Util.removeClass(self._popoverElem, chi.classes.TRANSITIONING); - self._popoverElem.setAttribute('aria-hidden', 'false'); - self._popoverElem.dispatchEvent( - Util.createEvent(EVENTS.shown) - ); - }, - TRANSITION_DURATION - ); + + self._updatePosition().then(function() { + if (!self._shown) return; + + self._popoverElem.style.display = ''; + self._popoverElem.style.visibility = ''; + self._popoverElem.setAttribute('data-state', 'open'); + Util.addClass(self._popoverElem, chi.classes.ACTIVE); + + const controller = new AbortController(); + self._animationAbortController = controller; + + self._popoverElem.addEventListener( + 'animationend', + function(e) { + if (e.target !== self._popoverElem) return; + if (!controller.signal.aborted) { + self._popoverElem.setAttribute('aria-hidden', 'false'); + self._enableAutoUpdate(); + self._popoverElem.dispatchEvent( + Util.createEvent(EVENTS.SHOWN) + ); + } + }, + { once: true, signal: controller.signal } + ); + + self._animationTimeout = setTimeout(function() { + self._animationTimeout = null; + if (!controller.signal.aborted) { + controller.abort(); + self._popoverElem.setAttribute('aria-hidden', 'false'); + self._enableAutoUpdate(); + self._popoverElem.dispatchEvent( + Util.createEvent(EVENTS.SHOWN) + ); + } + }, ANIMATION_TIMEOUT); + }); } hide(force) { @@ -142,33 +192,61 @@ class Popover extends Component { } this._shown = false; + this._disableAutoUpdate(); this._elem.dispatchEvent(Util.createEvent(EVENTS.HIDE_DEPRECATED)); // To be removed in Chi 4.0 this._elem.dispatchEvent(Util.createEvent(EVENTS.HIDE)); + if (this._animationAbortController) { + this._animationAbortController.abort(); + this._animationAbortController = null; + } + if (this._animationTimeout) { + clearTimeout(this._animationTimeout); + this._animationTimeout = null; + } + if (!this._config.animate) { Util.removeClass(this._popoverElem, chi.classes.ACTIVE); this._popoverElem.setAttribute('aria-hidden', 'true'); + this._popoverElem.style.display = 'none'; return; } - let self = this; - Util.threeStepsAnimation( - function() { - Util.addClass(self._popoverElem, chi.classes.TRANSITIONING); + const self = this; + + self._popoverElem.setAttribute('data-state', 'closed'); + + const controller = new AbortController(); + self._animationAbortController = controller; + + self._popoverElem.addEventListener( + 'animationend', + function(e) { + if (e.target !== self._popoverElem) return; + if (!controller.signal.aborted) { + Util.removeClass(self._popoverElem, chi.classes.ACTIVE); + self._popoverElem.setAttribute('aria-hidden', 'true'); + self._popoverElem.style.display = 'none'; + self._popoverElem.dispatchEvent( + Util.createEvent(EVENTS.HIDDEN) + ); + } }, - function() { - self._popoverElem.style.transform = self._preAnimationTransformStyle; + { once: true, signal: controller.signal } + ); + + self._animationTimeout = setTimeout(function() { + self._animationTimeout = null; + if (!controller.signal.aborted) { + controller.abort(); Util.removeClass(self._popoverElem, chi.classes.ACTIVE); - }, - function() { - Util.removeClass(self._popoverElem, chi.classes.TRANSITIONING); self._popoverElem.setAttribute('aria-hidden', 'true'); + self._popoverElem.style.display = 'none'; self._popoverElem.dispatchEvent( Util.createEvent(EVENTS.HIDDEN) ); - }, - TRANSITION_DURATION - ); + } + }, ANIMATION_TIMEOUT); } allowConsecutiveActions() { @@ -197,7 +275,7 @@ class Popover extends Component { } resetPosition() { - this._popper.update(); + this._updatePosition(); } _configurePopover() { @@ -205,7 +283,7 @@ class Popover extends Component { this._configurePopoverClasses(); this._configurePopoverContent(); this._configurePopoverIdAria(); - this._configurePopoverPopper(); + this._configurePopoverFloating(); } _configurePopoverElement() { @@ -218,9 +296,18 @@ class Popover extends Component { } else { this._popoverElem = document.querySelector(target); } + this._isPortaled = false; } else { this._popoverElem = document.createElement('section'); - this._config.parent.parentNode.appendChild(this._popoverElem); + + if (this._config.portal) { + // Portal handled by createFloating; element appended during + // _configurePopoverFloating via portalElement. + this._isPortaled = true; + } else { + this._config.parent.parentNode.appendChild(this._popoverElem); + this._isPortaled = false; + } } } @@ -268,53 +355,62 @@ class Popover extends Component { this._popoverElem.setAttribute('aria-modal', 'true'); } - _configurePopoverPopper() { + _configurePopoverFloating() { const self = this; - this._savePopperData = function(data) { - self._popperData = data; - self._preAnimationTransformStyle = null; - self._postAnimationTransformStyle = data.styles.transform; - if (data.placement.indexOf('top') === 0) { - self._preAnimationTransformStyle = `translate3d(${ - data.popper.left - }px, ${data.popper.top + 20}px, 0px)`; - } else if (data.placement.indexOf('right') === 0) { - self._preAnimationTransformStyle = `translate3d(${data.popper.left - - 20}px, ${data.popper.top}px, 0px)`; - } else if (data.placement.indexOf('bottom') === 0) { - self._preAnimationTransformStyle = `translate3d(${ - data.popper.left - }px, ${data.popper.top - 20}px, 0px)`; - } else if (data.placement.indexOf('left') === 0) { - self._preAnimationTransformStyle = `translate3d(${data.popper.left + - 20}px, ${data.popper.top}px, 0px)`; - } else { - self._preAnimationTransformStyle = data.styles.transform; - } - return data; - }; + const arrowEl = this._config.arrow + ? this._popoverElem.querySelector('.chi-popover__arrow') + : null; - this._popper = new Popper(this._config.parent, this._popoverElem, { - modifiers: { - applyStyle: { enabled: true }, - applyChiStyle: { - enabled: true, - fn: this._savePopperData, - // Set order to run after popper applyStyle modifier - // as we need data.styles to be filled. - order: 875 - }, - arrow: { - element: '.chi-popover__arrow', - enabled: this._config.arrow - }, - preventOverflow: { - boundariesElement: "window" - }, + const portalId = this._isPortaled + ? 'chi-popover-portal-' + this.componentCounterNo + : undefined; + + if (portalId) { + this._elem.setAttribute('data-chi-portal-id', portalId); + } + + this._floating = createFloating(this._config.parent, this._popoverElem, { + placement: this._config.position, + portal: this._isPortaled, + ownerId: portalId, + offset: this._config.arrow ? 12 : 0, + flip: true, + shift: true, + arrowElement: arrowEl, + arrowPadding: 0, + autoUpdate: false, + hideWhenDetached: !this._isPortaled, + initialUpdate: false, + onCompute: function (data) { + var placement = data.placement; + var basePlacement = placement.split('-')[0]; + + PLACEMENT_CLASSES.forEach(function (side) { + Util.removeClass(self._popoverElem, 'chi-popover--' + side); + }); + Util.addClass(self._popoverElem, 'chi-popover--' + basePlacement); + Util.addClass(self._popoverElem, 'chi-popover--' + placement); + self._popoverElem.setAttribute('x-placement', placement); }, - removeOnDestroy: true, - placement: this._config.position }); + + this._updatePosition = function () { + return self._floating.update(); + }; + } + + _enableAutoUpdate() { + this._disableAutoUpdate(); + if (this._floating) { + this._cleanupAutoUpdate = this._floating.enableAutoUpdate(); + } + } + + _disableAutoUpdate() { + if (this._cleanupAutoUpdate) { + this._cleanupAutoUpdate(); + this._cleanupAutoUpdate = null; + } } setContent(content) { @@ -327,18 +423,34 @@ class Popover extends Component { } dispose() { + this._disableAutoUpdate(); + if (this._animationAbortController) { + this._animationAbortController.abort(); + this._animationAbortController = null; + } + if (this._animationTimeout) { + clearTimeout(this._animationTimeout); + this._animationTimeout = null; + } this._removeEventHandlers(); + if (this._floating) { + this._floating.destroy(); + this._floating = null; + } else if (this._popoverElem && this._popoverElem.parentNode) { + this._popoverElem.parentNode.removeChild(this._popoverElem); + } this._popoverElem = null; - this._popper.destroy(); + this._cleanupAutoUpdate = null; + this._isPortaled = false; this._config = null; - this._popperData = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; this._mouseClickOnDocument = null; this._mouseClickOnPopover = null; this._mouseClickEventHandler = null; + if (this._elem) { + this._elem.removeAttribute('data-chi-portal-id'); + } this._elem = null; } } diff --git a/src/chi/javascript/components/tooltip.js b/src/chi/javascript/components/tooltip.js index 60064a843d..3267bac257 100644 --- a/src/chi/javascript/components/tooltip.js +++ b/src/chi/javascript/components/tooltip.js @@ -1,16 +1,20 @@ -import Popper from 'popper.js'; import {Component} from "../core/component"; import {Util} from "../core/util.js"; import {KEYS} from '../constants/constants'; +import {createFloating} from '../core/floating.js'; const CLASS_ACTIVE = "-active"; const COMPONENT_SELECTOR = '[data-tooltip]'; const COMPONENT_TYPE = "tooltip"; -const ANIMATION_DELAY = 300; -const DEFAULT_CONFIG = {position: 'top', parent: null}; +const DEFAULT_CONFIG = { + position: 'top', + parent: null, + portal: false, + autoUpdate: true, + hideWhenDetached: true +}; const CLASS_LIGHT = '-light'; const TOOLTIP_COLOR_ATTRIBUTE = 'data-tooltip-color'; -const TOOLTIP_SWITCH_TIMEOUT = 50; const EVENTS = { show: 'chiTooltipShow', hide: 'chiTooltipHide' @@ -22,14 +26,11 @@ class Tooltip extends Component { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._tooltipElem = null; this._tooltipContent = null; - this._popper = null; - this._popperData = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; + this._floating = null; + this._cleanupAutoUpdate = null; this._hovered = false; this._focused = false; this._shown = false; - this._animationTimeout; this._config.parent = this._config.parent || this._elem; this._config.position = config && config.position || @@ -41,41 +42,25 @@ class Tooltip extends Component { this._addEventHandler(this._elem, 'mouseenter', () => { self._hovered = true; - this._animationTimeout = window.setTimeout(() => { - if (!self._shown) { - self.show(); - window.tooltipOpen = true; - clearTimeout(window.tooltipOpenTimeout); - } - }, window.tooltipOpen ? 0 : ANIMATION_DELAY); + if (!self._shown) { + self.show(); + } }); this._addEventHandler(this._elem, 'mouseleave', () => { - window.clearTimeout(this._animationTimeout); - this._animationTimeout = null; self._hovered = false; if (self._shown && !self._focused) { self.hide(); - window.tooltipOpenTimeout = setTimeout(() => { - if (window.tooltipOpen) { - window.tooltipOpen = false; - } - }, TOOLTIP_SWITCH_TIMEOUT); + } + }); + this._addEventHandler(this._elem, 'keyup', function(e) { + if (!self._focused) return; + let code = (e.keyCode ? e.keyCode : e.which); + if (code === KEYS.TAB && !self._shown) { + self.show(); } }); this._addEventHandler(this._elem, 'focus', function() { self._focused = true; - self._addEventHandler(self._elem, 'keyup', function(e) { - let code = (e.keyCode ? e.keyCode : e.which); - - if (code === KEYS.TAB) { - if (!self._shown) { - self.show(); - } - } - }); - if (self._shown) { - self.hide(); - } }); this._addEventHandler(this._elem, 'blur', function() { self._focused = false; @@ -87,18 +72,16 @@ class Tooltip extends Component { show() { this._shown = true; - Util.addClass(this._tooltipElem, CLASS_ACTIVE); - const transition = this._tooltipElem.style.transition; - this._tooltipElem.style.transition = 'none'; - this._tooltipElem.style.transform = this._preAnimationTransformStyle; - this._tooltipElem.style.opacity = '0'; - let self = this; - window.requestAnimationFrame(function(){ - self._tooltipElem.style.transition = transition; - self._tooltipElem.style.transform = self._postAnimationTransformStyle; - self._tooltipElem.style.opacity = '1'; - self._tooltipElem.setAttribute('aria-hidden', 'false'); - self._preventOverflow(); + this._updatePosition().then(() => { + if (!this._shown) return; + + Util.addClass(this._tooltipElem, CLASS_ACTIVE); + this._tooltipElem.setAttribute('aria-hidden', 'false'); + this._elem.setAttribute('aria-describedby', this._tooltipElem.id); + if (this._config.autoUpdate) { + this._enableAutoUpdate(); + } + this._preventOverflow(); }); this._elem.dispatchEvent( Util.createEvent(EVENTS.show) @@ -107,13 +90,10 @@ class Tooltip extends Component { hide() { this._shown = false; + this._disableAutoUpdate(); Util.removeClass(this._tooltipElem, CLASS_ACTIVE); - let self = this; - window.setTimeout(function(){ - self._tooltipElem.style.transform = self._preAnimationTransformStyle; - self._tooltipElem.style.opacity = '0'; - self._tooltipElem.setAttribute('aria-hidden', 'true'); - },0); + this._tooltipElem.setAttribute('aria-hidden', 'true'); + this._elem.removeAttribute('aria-describedby'); this._elem.dispatchEvent( Util.createEvent(EVENTS.hide) ); @@ -122,6 +102,7 @@ class Tooltip extends Component { _createTooltip () { this._tooltipElem = document.createElement('div'); this._tooltipElem.setAttribute('class', 'chi-tooltip'); + this._tooltipElem.setAttribute('role', 'tooltip'); if (this._elem.getAttribute(TOOLTIP_COLOR_ATTRIBUTE) === 'light') { this._tooltipElem.classList.add(CLASS_LIGHT); } @@ -129,43 +110,62 @@ class Tooltip extends Component { this._tooltipContent = document.createElement('span'); this._tooltipContent.innerText = this._elem.dataset.tooltip; this._tooltipElem.appendChild(this._tooltipContent); - document.querySelector('body').appendChild(this._tooltipElem); - this._elem.setAttribute('aria-describedby', this._tooltipElem.id); - this._tooltipElem.setAttribute('aria-hidden', 'true'); let self = this; + const portalId = this._config.portal + ? 'chi-tooltip-portal-' + this.componentCounterNo + : undefined; - this._savePopperData = function (data) { - self._popperData = data; - self._preAnimationTransformStyle = null; - self._postAnimationTransformStyle = data.styles.transform; - if (data.placement.indexOf("top") === 0) { - self._preAnimationTransformStyle = `translate3d(${data.popper.left}px, ${data.popper.top}px, 0px)`; - } else if (data.placement.indexOf("right") === 0) { - self._preAnimationTransformStyle = `translate3d(${data.popper.left}px, ${data.popper.top}px, 0px)`; - } else if (data.placement.indexOf("bottom") === 0) { - self._preAnimationTransformStyle = `translate3d(${data.popper.left}px, ${data.popper.top}px, 0px)`; - } else if (data.placement.indexOf("left") === 0) { - self._preAnimationTransformStyle = `translate3d(${data.popper.left}px, ${data.popper.top}px, 0px)`; - } else { - self._preAnimationTransformStyle = data.styles.transform; - } - return data; - }; + if (portalId) { + this._elem.setAttribute('data-chi-portal-id', portalId); + } - this._popper = new Popper (this._config.parent, this._tooltipElem, { - modifiers: { - applyStyle: {enabled: true}, - offset: {offset: '0px,8px'}, - applyChiStyle: { - enabled: true, - fn: this._savePopperData, - order: 875 // to run after popper applyStyle modifier. We need data.styles to be filled. - }, - }, - removeOnDestroy: true, - placement: this._config.position + // Apply positioning and hide before the element enters the DOM so it is + // never in normal document flow and never flashes at position 0,0 on the + // first show() call. + Object.assign(this._tooltipElem.style, { + position: this._config.portal ? 'fixed' : 'absolute', + left: '0px', + top: '0px', + visibility: 'hidden', }); + + if (!this._config.portal) { + // Non-portal: insert as sibling of trigger so tooltip participates + // in the same stacking context. Matches CE behavior where non-portal + // tooltips can be clipped by overflow containers (use portal to escape). + this._config.parent.parentNode.appendChild(this._tooltipElem); + } + + this._tooltipElem.setAttribute('aria-hidden', 'true'); + + this._floating = createFloating(this._config.parent, this._tooltipElem, { + placement: this._config.position, + offset: 8, + portal: this._config.portal, + ownerId: portalId, + hideWhenDetached: this._config.hideWhenDetached, + autoUpdate: false, + initialUpdate: false, + }); + + this._updatePosition = function () { + return self._floating.update(); + }; + } + + _enableAutoUpdate() { + this._disableAutoUpdate(); + if (this._floating) { + this._cleanupAutoUpdate = this._floating.enableAutoUpdate(); + } + } + + _disableAutoUpdate() { + if (this._cleanupAutoUpdate) { + this._cleanupAutoUpdate(); + this._cleanupAutoUpdate = null; + } } _preventOverflow() { @@ -185,14 +185,21 @@ class Tooltip extends Component { } dispose() { + this._disableAutoUpdate(); + if (this._floating) { + this._floating.destroy(); + this._floating = null; + } else if (this._tooltipElem && this._tooltipElem.parentNode) { + this._tooltipElem.parentNode.removeChild(this._tooltipElem); + } this._tooltipElem = null; this._tooltipContent = null; - this._popper.destroy(); + this._cleanupAutoUpdate = null; this._config = null; - this._popperData = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; this._removeEventHandlers(); + if (this._elem) { + this._elem.removeAttribute('data-chi-portal-id'); + } this._elem = null; } diff --git a/src/chi/javascript/core/floating.js b/src/chi/javascript/core/floating.js new file mode 100644 index 0000000000..4ffc3cfc28 --- /dev/null +++ b/src/chi/javascript/core/floating.js @@ -0,0 +1,308 @@ +/** + * Shared floating positioning utility for vanilla JS components. + * + * Mirrors the CE utility (chi-custom-elements/src/utils/floating.ts): + * - Wraps @floating-ui/dom with Chi-specific defaults + * - Declarative middleware pipeline built from options + * - Integrates with portal.js for DOM portaling + * - Manages autoUpdate lifecycle (enable on show, disable on hide) + * - Handles pointer-events, visibility, hideReady, and FOUC prevention + * + * Each component passes a declarative options object instead of building + * its own inline computePosition + middleware pipeline. + */ + +import { + computePosition, + autoUpdate as floatingAutoUpdate, + flip as flipMiddleware, + shift as shiftMiddleware, + offset as offsetMiddleware, + arrow as arrowMiddleware, + hide as hideMiddleware, +} from '@floating-ui/dom'; +import { portalElement } from './portal.js'; +import { Util } from './util.js'; + +const OPPOSITE_SIDE = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', +}; + +const ARROW_CLIP_PATHS = { + top: 'polygon(100% 0, 0 100%, 100% 100%)', + bottom: 'polygon(0 0, 100% 0, 0 100%)', + left: 'polygon(0 0, 100% 0, 100% 100%)', + right: 'polygon(0 0, 0 100%, 100% 100%)', +}; + +/** + * Applies arrow positioning styles after a computePosition() call. + * + * @param {HTMLElement} arrowEl - The arrow element. + * @param {object} middlewareData - Middleware data from computePosition. + * @param {string} placement - Resolved placement string. + */ +function applyArrowStyles(arrowEl, middlewareData, placement) { + const arrowData = middlewareData.arrow; + if (!arrowData || !arrowEl) return; + + const basePlacement = placement.split('-')[0]; + const staticSide = OPPOSITE_SIDE[basePlacement]; + + const arrowLen = + basePlacement === 'top' || basePlacement === 'bottom' + ? arrowEl.offsetHeight + : arrowEl.offsetWidth; + + Object.assign(arrowEl.style, { + left: arrowData.x != null ? `${arrowData.x}px` : '', + top: arrowData.y != null ? `${arrowData.y}px` : '', + right: '', + bottom: '', + [staticSide]: `${-(arrowLen / 2)}px`, + }); + + arrowEl.style.setProperty( + '--chi-arrow-clip', + ARROW_CLIP_PATHS[basePlacement] || 'none' + ); +} + +/** + * Creates a floating positioning instance wrapping @floating-ui/dom. + * + * @param {Element|null} reference - The reference (trigger) element. + * @param {HTMLElement|null} floating - The floating element to position. + * @param {object} [options] - Configuration options. + * @param {string} [options.placement='bottom'] - Desired placement. + * @param {string} [options.strategy] - 'fixed' or 'absolute'. Auto-derived from portal when omitted. + * @param {number} [options.offset=0] - Offset from reference in px. + * @param {boolean} [options.flip=true] - Enable flip middleware. + * @param {boolean} [options.shift=true] - Enable shift middleware. + * @param {HTMLElement} [options.arrowElement] - Arrow element for arrow middleware. + * @param {number} [options.arrowPadding=0] - Arrow padding. + * @param {boolean} [options.applyStyles=true] - Auto-apply position/left/top styles. + * @param {boolean} [options.hideWhenDetached=false] - Enable hide middleware. + * @param {boolean} [options.autoUpdate=false] - Auto-enable autoUpdate on creation. + * @param {boolean} [options.initialUpdate=true] - Run an initial update on creation. + * @param {boolean} [options.portal=false] - Portal to #chi-portal-root. + * @param {string} [options.ownerId] - Portal owner ID for containsOrPortal checks. + * @param {function} [options.onCompute] - Callback after each position computation. + * Receives { x, y, placement, strategy, middlewareData }. + * @returns {{ update: function, destroy: function, enableAutoUpdate: function }} + */ +export function createFloating(reference, floating, options) { + if (!reference || !floating) { + return { + update: function () { return Promise.resolve(); }, + destroy: function () {}, + enableAutoUpdate: function () { return function () {}; }, + }; + } + + options = options || {}; + + // Resolve options with defaults (mirrors CE's resolvedOptions) + var placement = options.placement || 'bottom'; + var portal = options.portal || false; + var strategy = options.strategy || (portal ? 'fixed' : 'absolute'); + var offsetValue = typeof options.offset === 'number' ? options.offset : 0; + var useFlip = options.flip !== false; + var useShift = options.shift !== false; + var applyStyles = options.applyStyles !== false; + var doAutoUpdate = options.autoUpdate || false; + var initialUpdate = options.initialUpdate !== false; + var hideWhenDetached = options.hideWhenDetached || false; + var arrowElement = options.arrowElement || null; + var arrowPadding = options.arrowPadding || 0; + var ownerId = options.ownerId || undefined; + var onCompute = options.onCompute || null; + + var destroyed = false; + var cleanupAutoUpdate = null; + var hasComputedOnce = false; + var hideReady = false; + var portalHandle = null; + + // Portal the floating element if requested + if (portal) { + portalHandle = portalElement(floating, ownerId); + } + + /** + * Builds the middleware pipeline from resolved options. + */ + function buildMiddleware(overrides) { + var middleware = []; + var effectiveOffset = (overrides && typeof overrides.offset === 'number') + ? overrides.offset + : offsetValue; + + if (effectiveOffset) { + middleware.push(offsetMiddleware(effectiveOffset)); + } + + if (useFlip) { + middleware.push(flipMiddleware()); + } + + if (useShift) { + middleware.push(shiftMiddleware()); + } + + if (arrowElement) { + middleware.push(arrowMiddleware({ element: arrowElement, padding: arrowPadding })); + } + + if (hideWhenDetached) { + middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); + } + + return middleware; + } + + /** + * Core update: runs computePosition and applies styles. + */ + function doUpdate(overrides) { + if (destroyed || !reference || !floating) return Promise.resolve(); + + var effectivePlacement = (overrides && overrides.placement) || placement; + + return computePosition(reference, floating, { + placement: effectivePlacement, + strategy: strategy, + middleware: buildMiddleware(overrides), + }).then(function (result) { + if (destroyed) return; + + var x = result.x; + var y = result.y; + var resultPlacement = result.placement; + var resultStrategy = result.strategy; + var middlewareData = result.middlewareData; + + if (applyStyles) { + Object.assign(floating.style, { + position: resultStrategy, + left: Util.roundByDPR(x) + 'px', + top: Util.roundByDPR(y) + 'px', + }); + + if (hideWhenDetached && middlewareData.hide && hideReady) { + floating.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; + } + } + + if (arrowElement) { + applyArrowStyles(arrowElement, middlewareData, resultPlacement); + } + + if (!hasComputedOnce) { + hasComputedOnce = true; + floating.style.visibility = ''; + + if (portal) { + floating.style.pointerEvents = 'auto'; + } + requestAnimationFrame(function () { + hideReady = true; + }); + } + + if (onCompute) { + onCompute({ + x: x, + y: y, + placement: resultPlacement, + strategy: resultStrategy, + middlewareData: middlewareData, + }); + } + }); + } + + // Set initial position strategy before first update + if (applyStyles && floating) { + floating.style.position = strategy; + } + + if (initialUpdate) { + doUpdate(); + } + + if (doAutoUpdate) { + cleanupAutoUpdate = floatingAutoUpdate(reference, floating, function () { + doUpdate(); + }); + } + + return { + /** + * Recalculates position. Optionally override placement or offset. + * + * @param {object} [overrides] - { placement, offset } + * @returns {Promise} + */ + update: function (overrides) { + if (destroyed) return Promise.resolve(); + if (overrides && overrides.placement) placement = overrides.placement; + if (overrides && typeof overrides.offset === 'number') offsetValue = overrides.offset; + return doUpdate(overrides); + }, + + /** + * Destroys the floating instance. Cleans up autoUpdate, inline styles, + * and restores portal. + */ + destroy: function () { + if (destroyed) return; + destroyed = true; + + if (cleanupAutoUpdate) { + cleanupAutoUpdate(); + cleanupAutoUpdate = null; + } + + if (applyStyles && floating) { + floating.style.position = ''; + floating.style.left = ''; + floating.style.top = ''; + floating.style.visibility = ''; + } + + if (portalHandle) { + portalHandle.restore(); + portalHandle = null; + } + }, + + /** + * Enables autoUpdate (scroll/resize/mutation tracking). Returns a + * cleanup function that disables it. + * + * @returns {function} Cleanup function. + */ + enableAutoUpdate: function () { + if (destroyed || !reference || !floating) return function () {}; + + if (cleanupAutoUpdate) { + cleanupAutoUpdate(); + } + + cleanupAutoUpdate = floatingAutoUpdate(reference, floating, function () { + doUpdate(); + }); + + return function () { + if (cleanupAutoUpdate) { + cleanupAutoUpdate(); + cleanupAutoUpdate = null; + } + }; + }, + }; +} diff --git a/src/chi/javascript/core/portal.js b/src/chi/javascript/core/portal.js new file mode 100644 index 0000000000..5058260a0e --- /dev/null +++ b/src/chi/javascript/core/portal.js @@ -0,0 +1,103 @@ +/** + * Shared portal utility for vanilla JS floating components. + * + * Mirrors the CE portal system (chi-custom-elements/src/utils/portal.ts): + * - Portaled elements are appended to a shared #chi-portal-root container + * (styled via portal.scss with z-index: $zindex-portal-root / 9999) + * - PortalHandle captures the element's original DOM position and provides + * a restore() method for clean teardown + * + * This ensures portaled floating elements render above modals, backdrops, + * and other overlay stacking contexts — consistent with the CE layer. + */ + +const PORTAL_ROOT_ID = 'chi-portal-root'; + +let portalRoot = null; + +/** + * Lazily creates or finds the shared #chi-portal-root container. + * The root element is styled by portal.scss (position: fixed, z-index: 9999, + * pointer-events: none, overflow: visible). + * + * @returns {HTMLElement} The portal root element. + */ +export function getPortalRoot() { + if (!portalRoot || !portalRoot.isConnected) { + portalRoot = document.getElementById(PORTAL_ROOT_ID); + if (!portalRoot) { + portalRoot = document.createElement('div'); + portalRoot.id = PORTAL_ROOT_ID; + document.body.appendChild(portalRoot); + } + } + return portalRoot; +} + +/** + * Moves an element into the shared #chi-portal-root container and returns + * a handle for restoring it to its original DOM position. + * + * @param {HTMLElement} el - The element to portal. + * @param {string} [ownerId] - Optional owner ID for portal-aware containment checks. + * Sets data-chi-portal-owner on the element. + * @returns {{ originalParent: HTMLElement|null, originalNextSibling: Node|null, restore: function }} + */ +export function portalElement(el, ownerId) { + const originalParent = el.parentElement; + const originalNextSibling = el.nextSibling; + + if (ownerId) { + el.setAttribute('data-chi-portal-owner', ownerId); + } + + getPortalRoot().appendChild(el); + + // Portal root has pointer-events: none (so it doesn't block clicks to + // content behind it). Portaled children inherit this, so we must + // explicitly re-enable pointer-events on each portaled element. + // This mirrors the CE's floating.ts behavior. + el.style.pointerEvents = 'auto'; + + return { + originalParent, + originalNextSibling, + restore() { + el.removeAttribute('data-chi-portal-owner'); + el.style.pointerEvents = ''; + if (originalParent && originalParent.isConnected) { + originalParent.insertBefore(el, originalNextSibling); + } else if (el.parentElement) { + el.parentElement.removeChild(el); + } + }, + }; +} + +/** + * Portal-aware DOM containment check. Returns true if target is a DOM + * descendant of host, OR if target is inside a portaled element that + * belongs to host (via data-chi-portal-id / data-chi-portal-owner attributes). + * + * @param {HTMLElement} host - The host element (should have data-chi-portal-id). + * @param {HTMLElement} target - The target element to check. + * @returns {boolean} + */ +export function containsOrPortal(host, target) { + if (host.contains(target)) return true; + + const portalIdEl = host.querySelector('[data-chi-portal-id]'); + const hostId = + host.getAttribute('data-chi-portal-id') || + (portalIdEl && portalIdEl.getAttribute('data-chi-portal-id')); + + if (!hostId) return false; + + let node = target; + while (node && node !== document.documentElement) { + if (node.getAttribute('data-chi-portal-owner') === hostId) return true; + node = node.parentElement; + } + + return false; +} diff --git a/src/chi/javascript/core/util.js b/src/chi/javascript/core/util.js index 0aaa0c109e..38f348f972 100644 --- a/src/chi/javascript/core/util.js +++ b/src/chi/javascript/core/util.js @@ -550,4 +550,14 @@ export class Util { static noOp () {} + /** + * Rounds a pixel value to the nearest device pixel to prevent sub-pixel + * blurriness on HiDPI / Retina displays. Matches chi-custom-elements' + * roundByDPR() in floating.ts. + */ + static roundByDPR (value) { + const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1; + return Math.round(value * dpr) / dpr; + } + } diff --git a/src/chi/themes/brightspeed/css-variables.scss b/src/chi/themes/brightspeed/css-variables.scss index 0982f70b0b..58fcba66cf 100644 --- a/src/chi/themes/brightspeed/css-variables.scss +++ b/src/chi/themes/brightspeed/css-variables.scss @@ -46,6 +46,7 @@ --chi-zindex-modal: #{$zindex-modal}; --chi-zindex-popover: #{$zindex-popover}; --chi-zindex-tooltip: #{$zindex-tooltip}; + --chi-zindex-portal-root: #{$zindex-portal-root}; // ── Typography - Font Family ────────────────────────────────────── --chi-font-family-base: #{$font-family-base}; diff --git a/src/chi/themes/brightspeed/index.scss b/src/chi/themes/brightspeed/index.scss index 197921c0f9..9fa9b3db7f 100644 --- a/src/chi/themes/brightspeed/index.scss +++ b/src/chi/themes/brightspeed/index.scss @@ -62,6 +62,7 @@ $theme: 'brightspeed'; @import '../../components/picker/picker-groups'; @import '../../components/picker/picker'; @import '../../components/popover/popover'; + @import '../../components/portal/portal'; @import '../../components/price/price'; @import '../../components/progress/progress'; @import '../../components/radio/radio'; diff --git a/src/chi/themes/centurylink/css-variables.scss b/src/chi/themes/centurylink/css-variables.scss index 211c67247e..3d72b3ac88 100644 --- a/src/chi/themes/centurylink/css-variables.scss +++ b/src/chi/themes/centurylink/css-variables.scss @@ -44,6 +44,7 @@ --chi-zindex-modal: #{$zindex-modal}; --chi-zindex-popover: #{$zindex-popover}; --chi-zindex-tooltip: #{$zindex-tooltip}; + --chi-zindex-portal-root: #{$zindex-portal-root}; // ── Typography - Font Family ────────────────────────────────────── --chi-font-family-base: #{$font-family-base}; diff --git a/src/chi/themes/centurylink/index.scss b/src/chi/themes/centurylink/index.scss index b0f5424744..34a9132c18 100644 --- a/src/chi/themes/centurylink/index.scss +++ b/src/chi/themes/centurylink/index.scss @@ -62,6 +62,7 @@ $theme: 'centurylink'; @import '../../components/picker/picker-groups'; @import '../../components/picker/picker'; @import '../../components/popover/popover'; + @import '../../components/portal/portal'; @import '../../components/price/price'; @import '../../components/progress/progress'; @import '../../components/radio/radio'; diff --git a/src/chi/themes/colt/css-variables.scss b/src/chi/themes/colt/css-variables.scss index 86de7d70bb..cb77afbc8e 100644 --- a/src/chi/themes/colt/css-variables.scss +++ b/src/chi/themes/colt/css-variables.scss @@ -46,6 +46,7 @@ --chi-zindex-modal: #{$zindex-modal}; --chi-zindex-popover: #{$zindex-popover}; --chi-zindex-tooltip: #{$zindex-tooltip}; + --chi-zindex-portal-root: #{$zindex-portal-root}; // ── Typography - Font Family ────────────────────────────────────── --chi-font-family-base: #{$font-family-base}; diff --git a/src/chi/themes/colt/index.scss b/src/chi/themes/colt/index.scss index 90cecb8649..ee64c2809a 100644 --- a/src/chi/themes/colt/index.scss +++ b/src/chi/themes/colt/index.scss @@ -62,6 +62,7 @@ $theme: 'colt'; @import '../../components/picker/picker-groups'; @import '../../components/picker/picker'; @import '../../components/popover/popover'; + @import '../../components/portal/portal'; @import '../../components/price/price'; @import '../../components/progress/progress'; @import '../../components/radio/radio'; diff --git a/src/chi/themes/connect/_variables.scss b/src/chi/themes/connect/_variables.scss index f2c8f511b6..5371a22528 100644 --- a/src/chi/themes/connect/_variables.scss +++ b/src/chi/themes/connect/_variables.scss @@ -2051,7 +2051,7 @@ $popover-modal-background-color: $popover-background-color; $popover-modal-header-background-color: $color-teal-70; $popover-modal-header-background-image: $modal-header-background-image; $popover-modal-header-text-color: $color-text-white; -$popover-modal-close-icon-color: $modal-close-icon-color; +$popover-modal-close-icon-color: $color-text-white; $popover-modal-header-text-color: $color-white; $popover-drag-icon-color: $color-icon-white; $popover-gradient-header-background: linear-gradient(90deg, $color-red-50 0%, $color-orange-50 100%); diff --git a/src/chi/themes/connect/css-variables.scss b/src/chi/themes/connect/css-variables.scss index f6887854e0..c86fbd34bf 100644 --- a/src/chi/themes/connect/css-variables.scss +++ b/src/chi/themes/connect/css-variables.scss @@ -44,6 +44,7 @@ --chi-zindex-modal: #{$zindex-modal}; --chi-zindex-popover: #{$zindex-popover}; --chi-zindex-tooltip: #{$zindex-tooltip}; + --chi-zindex-portal-root: #{$zindex-portal-root}; // ── Typography - Font Family ────────────────────────────────────── --chi-font-family-base: #{$font-family-base}; diff --git a/src/chi/themes/connect/index.scss b/src/chi/themes/connect/index.scss index 8305fc2436..68e5285c01 100644 --- a/src/chi/themes/connect/index.scss +++ b/src/chi/themes/connect/index.scss @@ -62,6 +62,7 @@ $theme: 'connect'; @import '../../components/picker/picker-groups'; @import '../../components/picker/picker'; @import '../../components/popover/popover'; + @import '../../components/portal/portal'; @import '../../components/price/price'; @import '../../components/progress/progress'; @import '../../components/radio/radio'; diff --git a/src/chi/themes/lumen/css-variables.scss b/src/chi/themes/lumen/css-variables.scss index dc891d658f..39a1ef0604 100644 --- a/src/chi/themes/lumen/css-variables.scss +++ b/src/chi/themes/lumen/css-variables.scss @@ -44,6 +44,7 @@ --chi-zindex-modal: #{$zindex-modal}; --chi-zindex-popover: #{$zindex-popover}; --chi-zindex-tooltip: #{$zindex-tooltip}; + --chi-zindex-portal-root: #{$zindex-portal-root}; // ── Typography - Font Family ────────────────────────────────────── --chi-font-family-base: #{$font-family-base}; diff --git a/src/chi/themes/lumen/index.scss b/src/chi/themes/lumen/index.scss index d824428182..9d99de1c5f 100644 --- a/src/chi/themes/lumen/index.scss +++ b/src/chi/themes/lumen/index.scss @@ -62,6 +62,7 @@ $theme: 'lumen'; @import '../../components/picker/picker-groups'; @import '../../components/picker/picker'; @import '../../components/popover/popover'; + @import '../../components/portal/portal'; @import '../../components/price/price'; @import '../../components/progress/progress'; @import '../../components/radio/radio'; diff --git a/src/chi/themes/portal/css-variables.scss b/src/chi/themes/portal/css-variables.scss index 532cb0f8cc..5ad9b99283 100644 --- a/src/chi/themes/portal/css-variables.scss +++ b/src/chi/themes/portal/css-variables.scss @@ -46,6 +46,7 @@ --chi-zindex-modal: #{$zindex-modal}; --chi-zindex-popover: #{$zindex-popover}; --chi-zindex-tooltip: #{$zindex-tooltip}; + --chi-zindex-portal-root: #{$zindex-portal-root}; // ── Typography - Font Family ────────────────────────────────────── --chi-font-family-base: #{$font-family-base}; diff --git a/src/chi/themes/portal/index.scss b/src/chi/themes/portal/index.scss index 858de42906..9311dcdc29 100644 --- a/src/chi/themes/portal/index.scss +++ b/src/chi/themes/portal/index.scss @@ -62,6 +62,7 @@ $theme: 'portal'; @import '../../components/picker/picker-groups'; @import '../../components/picker/picker'; @import '../../components/popover/popover'; + @import '../../components/portal/portal'; @import '../../components/price/price'; @import '../../components/progress/progress'; @import '../../components/radio/radio'; diff --git a/tests/components/popover.pug b/tests/components/popover.pug index b76fa8485e..00d5cc1a96 100644 --- a/tests/components/popover.pug +++ b/tests/components/popover.pug @@ -31,7 +31,7 @@ block content each side in moreSides div(class=`test-more-${side}`) - span.-text--h2=`Popper side ${side}` + span.-text--h2=`Floating side ${side}` .-d--flex.-justify-content--center.-align-items--center(style="margin: 120px 220px;") button(data-position=side, data-popover-content='

Popover Title

Popover content.

').chi-button.autostart Popover diff --git a/tests/custom-elements/popover.pug b/tests/custom-elements/popover.pug index ff1f09ed71..5be24bb39c 100644 --- a/tests/custom-elements/popover.pug +++ b/tests/custom-elements/popover.pug @@ -25,7 +25,7 @@ block content each side in moreSides div(class=`test-more-${side} -position--relative`, data-cy=`test-more-${side}`) - span.-text--h2=`Popper side ${side}` + span.-text--h2=`Floating side ${side}` .-d--flex.-justify-content--center.-align-items--center(style="margin: 120px 220px;") button(id=side).chi-button Popover chi-popover(position=side, title="Popover Title", variant="text", arrow, reference=`#${side}`, active) diff --git a/tests/js/dropdown.pug b/tests/js/dropdown.pug index 068dd71387..2ae2980332 100644 --- a/tests/js/dropdown.pug +++ b/tests/js/dropdown.pug @@ -41,7 +41,7 @@ block content each side in moreSides .chi-dropdown button.chi-button.chi-dropdown__trigger.-has-active(data-position=`${side}`,data-cy=`test-more-${side}`)=`${side} position` - .chi-dropdown__menu(style='width: 15rem; position: absolute; will-change: transform; right: initial; top: 0px; left: initial; transform: translate3d(0px, -112px, 0px);', x-placement=`${side}`) + .chi-dropdown__menu(style='width: 15rem;') a.chi-dropdown__menu-item(href='#') Item 1 a.chi-dropdown__menu-item(href='#') Item 2 a.chi-dropdown__menu-item(href='#') Item 3