From 903eb649dbecfafb6ca1c5c6af303bd8448a9194 Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Sun, 1 Mar 2026 23:39:13 -0800 Subject: [PATCH 1/7] Replace deprecated Popper.js with Floating UI --- cypress/e2e/chi-js/dropdown.cy.js | 2 +- cypress/e2e/chi-js/popover.cy.js | 4 +- cypress/fixtures/chidata.json | 2 +- package-lock.json | 45 ++- package.json | 2 +- .../components/date-picker/date-picker.scss | 8 +- src/chi/components/popover/popover.scss | 291 +++++++++++------- src/chi/javascript/components/dropdown.js | 50 +-- src/chi/javascript/components/popover.js | 192 +++++++----- src/chi/javascript/components/tooltip.js | 75 ++--- src/chi/themes/connect/_variables.scss | 2 +- tests/components/popover.pug | 2 +- tests/custom-elements/popover.pug | 2 +- tests/js/dropdown.pug | 2 +- 14 files changed, 408 insertions(+), 271 deletions(-) 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..38c04311db 100644 --- a/cypress/e2e/chi-js/popover.cy.js +++ b/cypress/e2e/chi-js/popover.cy.js @@ -22,14 +22,14 @@ 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}"]`) .find('+ .chi-popover') - .should('match', `[x-placement="${position}"]`); + .should('be.visible'); }); }); }); diff --git a/cypress/fixtures/chidata.json b/cypress/fixtures/chidata.json index 33438f95af..289cfad7be 100644 --- a/cypress/fixtures/chidata.json +++ b/cypress/fixtures/chidata.json @@ -454,7 +454,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 cfa5e9b9a0..9e40740c70 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", @@ -1603,9 +1603,9 @@ } }, "node_modules/@centurylink/chi-custom-elements": { - "version": "1.56.0", - "resolved": "https://npm.pkg.github.com/download/@centurylink/chi-custom-elements/1.56.0/ef76df8c241dd5e926ec5624817473e11f606795", - "integrity": "sha512-Ko15gIUn5kPuO+DEGkbDgCd3iAP3u3sEvOEPmXyIuv+Wx8Wo4JgwWRngtjy7n0g84eo9tc2Ztxl+K+4jalpbog==", + "version": "1.55.0", + "resolved": "https://npm.pkg.github.com/download/@centurylink/chi-custom-elements/1.55.0/140e3d08c7a4d12e82eac14368a4004ccde45452", + "integrity": "sha512-oO2P4ctmA08RxZgmLDxB0ZLbq9zqfHQSpXc2ZDhgbD0MN3EcjUQWLuzWJ/jVhBhmB4ZqUoQPwoeW3STDeZijtA==", "license": "MIT", "peerDependencies": { "@stencil/core": "^4.35.1" @@ -2256,6 +2256,31 @@ "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==", + "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==", + "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==", + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", @@ -16675,18 +16700,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 ac59e26058..e63bd7b56e 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/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..dd95b30e75 100644 --- a/src/chi/components/popover/popover.scss +++ b/src/chi/components/popover/popover.scss @@ -21,7 +21,38 @@ z-index: $zindex-popover; &.-animated { - transition: opacity 0.2s, transform 0.2s; + // CSS @keyframes handle show/hide animations via data-state attribute. + // No CSS transition needed — animations are driven by @keyframes. + + &[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 { @@ -118,98 +149,81 @@ } } + // Arrow — positioned by Floating UI's arrow() middleware via inline styles. + // The arrow div acts as a filter container; its ::before pseudo-element is + // the visible triangle (a rotated square clipped by clip-path). + // + // Clipping strategy: + // 1. ::before is a square rotated 45° → visual diamond shape + // 2. clip-path (set via --chi-arrow-clip CSS variable from floating.ts) + // removes the inner half that overlaps the popover body + & .chi-arrow, & .chi-popover__arrow { 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); - } + // CSS-only fallback arrow positioning for static / documentation usage. + // When Floating UI runs, its inline styles (higher specificity) override these. + &.chi-popover--top, + &.chi-popover--top-start, + &.chi-popover--top-end { + .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 { + .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 { + .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 { + .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 +287,10 @@ .chi-icon { color: $popover-modal-close-icon-color; } + + &:hover { + background: none; + } } } @@ -339,73 +357,124 @@ .chi-icon { color: $popover-gradient-close-button-icon-color; } + + &:hover { + background: none; + } } } - // Position offsets for left/right start/end placements only - &[x-placement='right-start'], - &[x-placement='left-start'] { - top: -$popover-gradient-arrow-offset !important; + // Position offsets for left/right start/end placements only. + // Uses margin-top (not top) to avoid overriding Floating UI's + // computed inline top value. + &.chi-popover--right-start, + &.chi-popover--left-start { + margin-top: -$popover-gradient-arrow-offset; } - &[x-placement='right-end'], - &[x-placement='left-end'] { - top: $popover-gradient-arrow-offset !important; + &.chi-popover--right-end, + &.chi-popover--left-end { + margin-top: $popover-gradient-arrow-offset; } /* - Arrow styles + Arrow gradient styles — override the arrow background color + to match the gradient header/edges per placement direction. + Floating UI handles all positioning; CSS only sets the color. */ - // Right side arrow styling - &[x-placement='right'] { - .chi-popover__arrow::after { - border-color: $popover-gradient-right-arrow-color; + // Right side: arrow matches right edge of gradient header + &.chi-popover--right, + &.chi-popover--right-start, + &.chi-popover--right-end { + .chi-arrow::before, + .chi-popover__arrow::before { + background: $popover-gradient-right-arrow-color; } } - &[x-placement='right-start'] { - .chi-popover__arrow::after { - top: $popover-gradient-arrow-offset; - border-color: $popover-gradient-right-arrow-color; + // Left side: arrow matches left edge of gradient header + &.chi-popover--left, + &.chi-popover--left-start, + &.chi-popover--left-end { + .chi-arrow::before, + .chi-popover__arrow::before { + background: $popover-gradient-left-arrow-color; } } - &[x-placement='right-end'] { - .chi-popover__arrow::after { - top: auto; - bottom: $popover-gradient-arrow-offset; + // Bottom side: arrow uses mixed color (blend of left + right) + &.chi-popover--bottom, + &.chi-popover--bottom-start, + &.chi-popover--bottom-end { + .chi-arrow::before, + .chi-popover__arrow::before { + background: $popover-gradient-arrow-mixed-color; } } - // Left side arrow styling - &[x-placement='left'] { - .chi-popover__arrow::after { - border-color: $popover-gradient-left-arrow-color; + // Align arrow to gradient for gradient use case + &.chi-popover--left, + &.chi-popover--right { + .chi-arrow, + .chi-popover__arrow { + margin-top: -1.75rem; } } - &[x-placement='left-start'] { - .chi-popover__arrow::after { - top: $popover-gradient-arrow-offset; - border-color: $popover-gradient-left-arrow-color; + &.chi-popover--left-start, + &.chi-popover--right-start { + .chi-arrow, + .chi-popover__arrow { + margin-top: 0.75rem; } } + } +} - &[x-placement='left-end'] { - .chi-popover__arrow::after { - top: auto; - bottom: $popover-gradient-arrow-offset; - } - } +// @keyframes for popover show/hide animations. +// Uses transform: none in the final keyframe (not translateY(0)) +// to avoid creating a CSS containing block that would trap nested +// position: fixed elements. - // Bottom side arrow style with mixed color - &[x-placement^='bottom'] { - .chi-popover__arrow::after { - border-color: $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 { diff --git a/src/chi/javascript/components/dropdown.js b/src/chi/javascript/components/dropdown.js index 662db80b2a..5ced5c4e6e 100644 --- a/src/chi/javascript/components/dropdown.js +++ b/src/chi/javascript/components/dropdown.js @@ -1,6 +1,6 @@ import {Component} from "../core/component"; import {Util} from "../core/util.js"; -import Popper from 'popper.js'; +import {computePosition, flip, shift} from '@floating-ui/dom'; import {CLASS_HAS_ACTIVE} from "./tab"; const CLASS_ACTIVE = "-active"; @@ -130,30 +130,42 @@ class Dropdown extends Component { 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 - }, + if (dropdownPosition) { + self._popper = { + _placement: dropdownPosition, + _reference: self._elem, + _floating: self._dropdownElem, + update() { + return computePosition(this._reference, this._floating, { + placement: this._placement, + middleware: [flip(), shift()], + }).then(({x, y}) => { + Object.assign(this._floating.style, { + position: 'absolute', + left: `${x}px`, + top: `${y}px`, + transform: 'none', + willChange: '', + right: '', + }); + }); }, - placement: dropdownPosition - }); + destroy() { + // Clean up inline styles + this._floating.style.position = ''; + this._floating.style.left = ''; + this._floating.style.top = ''; + this._floating.style.transform = ''; + this._floating.style.willChange = ''; + } + }; + self._popper.update(); } }); } - _popperPatchForBottomLeftPropperLocation (data) { - data.styles.left = data.styles.left || 'initial'; - data.styles.right = data.styles.right || 'initial'; - return data; - } - disablePopper () { - if (this._popper && typeof this._popper === 'function') { + if (this._popper && typeof this._popper === 'object' && this._popper.destroy) { this._popper.destroy(); } this._popper = null; diff --git a/src/chi/javascript/components/popover.js b/src/chi/javascript/components/popover.js index 3792678e1a..c06941bff6 100644 --- a/src/chi/javascript/components/popover.js +++ b/src/chi/javascript/components/popover.js @@ -1,6 +1,6 @@ import { Util } from '../core/util.js'; import { Component } from '../core/component'; -import Popper from 'popper.js'; +import { computePosition, flip, shift, offset, arrow as arrowMiddleware } from '@floating-ui/dom'; import { chi } from '../core/chi'; const COMPONENT_SELECTOR = '[data-popover-content]'; @@ -32,8 +32,7 @@ class Popover extends Component { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._popoverElem = null; - this._popper = null; - this._popperData = null; + this._floatingCleanup = null; this._preAnimationTransformStyle = null; this._postAnimationTransformStyle = null; this._shown = false; @@ -112,28 +111,28 @@ class Popover extends Component { 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 - ); + // computePosition is async — wait for position to be computed before animating + self._updatePosition().then(function() { + 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 + ); + }); } hide(force) { @@ -197,7 +196,7 @@ class Popover extends Component { } resetPosition() { - this._popper.update(); + this._updatePosition(); } _configurePopover() { @@ -205,7 +204,7 @@ class Popover extends Component { this._configurePopoverClasses(); this._configurePopoverContent(); this._configurePopoverIdAria(); - this._configurePopoverPopper(); + this._configurePopoverFloating(); } _configurePopoverElement() { @@ -268,53 +267,102 @@ 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; + const arrowEl = this._config.arrow + ? this._popoverElem.querySelector('.chi-popover__arrow') + : null; + + const OPPOSITE_SIDE = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }; + + // Clip-path polygons for each arrow direction. + // Applied to ::before via --chi-arrow-clip CSS custom property. + 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%)', + }; + + this._updatePosition = function() { + const middleware = [ + offset(self._config.arrow ? 12 : 0), + flip(), + shift(), + ]; + + if (arrowEl) { + middleware.push(arrowMiddleware({ element: arrowEl })); } - return data; + + return computePosition(self._config.parent, self._popoverElem, { + placement: self._config.position, + middleware: middleware, + }).then(({x, y, placement, middlewareData}) => { + Object.assign(self._popoverElem.style, { + position: 'absolute', + left: `${x}px`, + top: `${y}px`, + }); + + const basePlacement = placement.split('-')[0]; + + // Update placement class on popover for CSS arrow styling + ['top', 'bottom', 'left', 'right'].forEach(function(side) { + Util.removeClass(self._popoverElem, 'chi-popover--' + side); + }); + Util.addClass(self._popoverElem, 'chi-popover--' + basePlacement); + + // Apply arrow positioning + if (arrowEl && middlewareData.arrow) { + const {x: arrowX, y: arrowY} = middlewareData.arrow; + const staticSide = OPPOSITE_SIDE[basePlacement]; + + // Measure arrow size for static-side offset + const arrowLen = (basePlacement === 'top' || basePlacement === 'bottom') + ? arrowEl.offsetHeight + : arrowEl.offsetWidth; + + Object.assign(arrowEl.style, { + left: arrowX != null ? `${arrowX}px` : '', + top: arrowY != null ? `${arrowY}px` : '', + right: '', + bottom: '', + [staticSide]: `${-(arrowLen / 2)}px`, + }); + + // Set clip-path direction on arrow ::before via CSS custom property + arrowEl.style.setProperty( + '--chi-arrow-clip', + ARROW_CLIP_PATHS[basePlacement] || 'none' + ); + } + + // Animation transforms are RELATIVE offsets — left/top handles absolute positioning. + // Post = final position (no additional transform needed). + // Pre = 20px offset in the incoming direction for slide-in animation. + self._postAnimationTransformStyle = 'none'; + if (basePlacement === 'top') { + self._preAnimationTransformStyle = 'translate3d(0, 20px, 0)'; + } else if (basePlacement === 'right') { + self._preAnimationTransformStyle = 'translate3d(-20px, 0, 0)'; + } else if (basePlacement === 'bottom') { + self._preAnimationTransformStyle = 'translate3d(0, -20px, 0)'; + } else if (basePlacement === 'left') { + self._preAnimationTransformStyle = 'translate3d(20px, 0, 0)'; + } else { + self._preAnimationTransformStyle = 'none'; + } + }); }; - 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" - }, - }, - removeOnDestroy: true, - placement: this._config.position - }); + // Initial position computation + this._updatePosition(); } setContent(content) { @@ -328,10 +376,12 @@ class Popover extends Component { dispose() { this._removeEventHandlers(); + if (this._popoverElem && this._popoverElem.parentNode) { + this._popoverElem.parentNode.removeChild(this._popoverElem); + } this._popoverElem = null; - this._popper.destroy(); + this._floatingCleanup = null; this._config = null; - this._popperData = null; this._preAnimationTransformStyle = null; this._postAnimationTransformStyle = null; diff --git a/src/chi/javascript/components/tooltip.js b/src/chi/javascript/components/tooltip.js index 60064a843d..50836d39b0 100644 --- a/src/chi/javascript/components/tooltip.js +++ b/src/chi/javascript/components/tooltip.js @@ -1,4 +1,4 @@ -import Popper from 'popper.js'; +import {computePosition, flip, shift, offset} from '@floating-ui/dom'; import {Component} from "../core/component"; import {Util} from "../core/util.js"; import {KEYS} from '../constants/constants'; @@ -22,8 +22,7 @@ class Tooltip extends Component { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._tooltipElem = null; this._tooltipContent = null; - this._popper = null; - this._popperData = null; + this._floatingCleanup = null; this._preAnimationTransformStyle = null; this._postAnimationTransformStyle = null; this._hovered = false; @@ -90,15 +89,19 @@ class Tooltip extends Component { 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(); + + // Re-compute position before showing, then animate + this._updatePosition().then(function() { + self._tooltipElem.style.transform = self._preAnimationTransformStyle; + 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._elem.dispatchEvent( Util.createEvent(EVENTS.show) @@ -135,37 +138,25 @@ class Tooltip extends Component { let 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}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; + this._updatePosition = function () { + return computePosition(self._config.parent, self._tooltipElem, { + placement: self._config.position, + middleware: [offset(8), flip(), shift()], + }).then(({x, y}) => { + Object.assign(self._tooltipElem.style, { + position: 'absolute', + left: `${x}px`, + top: `${y}px`, + }); + + // Transforms are relative offsets — left/top handles absolute positioning. + // Tooltip uses opacity-only animation, so no directional offset needed. + self._postAnimationTransformStyle = 'none'; + self._preAnimationTransformStyle = 'none'; + }); }; - 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 - }); + this._updatePosition(); } _preventOverflow() { @@ -185,11 +176,13 @@ class Tooltip extends Component { } dispose() { + if (this._tooltipElem && this._tooltipElem.parentNode) { + this._tooltipElem.parentNode.removeChild(this._tooltipElem); + } this._tooltipElem = null; this._tooltipContent = null; - this._popper.destroy(); + this._floatingCleanup = null; this._config = null; - this._popperData = null; this._preAnimationTransformStyle = null; this._postAnimationTransformStyle = null; this._removeEventHandlers(); diff --git a/src/chi/themes/connect/_variables.scss b/src/chi/themes/connect/_variables.scss index 3acb9cc4b2..5d2da10a47 100644 --- a/src/chi/themes/connect/_variables.scss +++ b/src/chi/themes/connect/_variables.scss @@ -2020,7 +2020,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/tests/components/popover.pug b/tests/components/popover.pug index 8d4200bb01..85aafa4827 100644 --- a/tests/components/popover.pug +++ b/tests/components/popover.pug @@ -32,7 +32,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 07cbcdeb9e..a03bc805c4 100644 --- a/tests/js/dropdown.pug +++ b/tests/js/dropdown.pug @@ -42,7 +42,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 From 1dcfcff9aeb21183ad09052ad2c244a85013022d Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Mon, 2 Mar 2026 16:32:22 -0800 Subject: [PATCH 2/7] popover e2e test fix --- cypress/e2e/chi-js/popover.cy.js | 9 ++++++++- package-lock.json | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/chi-js/popover.cy.js b/cypress/e2e/chi-js/popover.cy.js index 38c04311db..c5f19372a0 100644 --- a/cypress/e2e/chi-js/popover.cy.js +++ b/cypress/e2e/chi-js/popover.cy.js @@ -28,8 +28,15 @@ describe('Popover', function () { cy.get(getValue) .find('button.chi-button') .should('match', `[data-position="${position}"]`) + .click() .find('+ .chi-popover') - .should('be.visible'); + .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/package-lock.json b/package-lock.json index 9e40740c70..f41d6d826e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2260,6 +2260,7 @@ "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" @@ -2269,6 +2270,7 @@ "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", @@ -2279,6 +2281,7 @@ "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": { From 067a2e39fae4c8159c424e52f892d8937b6d0e58 Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Mon, 2 Mar 2026 20:34:04 -0800 Subject: [PATCH 3/7] Hide floating elements when reference scrolls out of view --- src/chi/javascript/components/dropdown.js | 33 ++++++++++++++--- src/chi/javascript/components/popover.js | 44 +++++++++++++++++++++-- src/chi/javascript/components/tooltip.js | 2 +- sri.json | 4 +-- 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/chi/javascript/components/dropdown.js b/src/chi/javascript/components/dropdown.js index 5ced5c4e6e..5a21b5e565 100644 --- a/src/chi/javascript/components/dropdown.js +++ b/src/chi/javascript/components/dropdown.js @@ -1,6 +1,6 @@ import {Component} from "../core/component"; import {Util} from "../core/util.js"; -import {computePosition, flip, shift} from '@floating-ui/dom'; +import {computePosition, autoUpdate as floatingAutoUpdate, flip, shift, hide as hideMiddleware} from '@floating-ui/dom'; import {CLASS_HAS_ACTIVE} from "./tab"; const CLASS_ACTIVE = "-active"; @@ -135,22 +135,43 @@ class Dropdown extends Component { _placement: dropdownPosition, _reference: self._elem, _floating: self._dropdownElem, + _autoUpdateCleanup: null, update() { return computePosition(this._reference, this._floating, { placement: this._placement, - middleware: [flip(), shift()], - }).then(({x, y}) => { + strategy: 'fixed', + middleware: [flip(), shift(), hideMiddleware({ strategy: 'referenceHidden' })], + }).then(({x, y, middlewareData}) => { Object.assign(this._floating.style, { - position: 'absolute', + position: 'fixed', left: `${x}px`, top: `${y}px`, transform: 'none', willChange: '', right: '', }); + if (middlewareData.hide) { + this._floating.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; + } }); }, + enableAutoUpdate() { + this.disableAutoUpdate(); + const ref = this._reference; + const floating = this._floating; + const popper = this; + this._autoUpdateCleanup = floatingAutoUpdate( + ref, floating, function() { popper.update(); } + ); + }, + disableAutoUpdate() { + if (this._autoUpdateCleanup) { + this._autoUpdateCleanup(); + this._autoUpdateCleanup = null; + } + }, destroy() { + this.disableAutoUpdate(); // Clean up inline styles this._floating.style.position = ''; this._floating.style.left = ''; @@ -253,6 +274,7 @@ class Dropdown extends Component { Util.addClass(this._dropdownElem, CLASS_ACTIVE); if (this._popper && typeof this._popper.update === "function") { this._popper.update(); + this._popper.enableAutoUpdate(); } if (this._parentDropdown) { this._parentDropdown.show(); @@ -272,6 +294,9 @@ class Dropdown extends Component { this._setActiveDescendants(); Util.removeClass(this._elem, CLASS_ACTIVE); Util.removeClass(this._dropdownElem, CLASS_ACTIVE); + if (this._popper && typeof this._popper.disableAutoUpdate === "function") { + this._popper.disableAutoUpdate(); + } this._shown = false; this._childrenDropdowns.forEach(function(dd) { dd.hide(); diff --git a/src/chi/javascript/components/popover.js b/src/chi/javascript/components/popover.js index c06941bff6..2eced06b9a 100644 --- a/src/chi/javascript/components/popover.js +++ b/src/chi/javascript/components/popover.js @@ -1,6 +1,6 @@ import { Util } from '../core/util.js'; import { Component } from '../core/component'; -import { computePosition, flip, shift, offset, arrow as arrowMiddleware } from '@floating-ui/dom'; +import { computePosition, autoUpdate as floatingAutoUpdate, flip, shift, offset, arrow as arrowMiddleware, hide as hideMiddleware } from '@floating-ui/dom'; import { chi } from '../core/chi'; const COMPONENT_SELECTOR = '[data-popover-content]'; @@ -33,6 +33,7 @@ class Popover extends Component { this._popoverElem = null; this._floatingCleanup = null; + this._autoUpdateCleanup = null; this._preAnimationTransformStyle = null; this._postAnimationTransformStyle = null; this._shown = false; @@ -104,6 +105,7 @@ class Popover extends Component { if (!this._config.animate) { Util.addClass(this._popoverElem, chi.classes.ACTIVE); this._popoverElem.setAttribute('aria-hidden', 'false'); + this._enableAutoUpdate(); return; } @@ -129,6 +131,7 @@ class Popover extends Component { self._popoverElem.dispatchEvent( Util.createEvent(EVENTS.shown) ); + self._enableAutoUpdate(); }, TRANSITION_DURATION ); @@ -141,6 +144,7 @@ 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)); @@ -219,7 +223,10 @@ class Popover extends Component { } } else { this._popoverElem = document.createElement('section'); - this._config.parent.parentNode.appendChild(this._popoverElem); + // Portal to document.body so position:fixed is always viewport-relative. + // Matches CE behavior — avoids ancestors with transforms/filters/overflow + // creating an unintended containing block. + document.body.appendChild(this._popoverElem); } } @@ -300,16 +307,28 @@ class Popover extends Component { middleware.push(arrowMiddleware({ element: arrowEl })); } + // Hide the popover when the reference scrolls out of the viewport. + // Without this, shift() clamps the popover at the viewport edge, + // making it appear stuck. With hide(), the popover cleanly disappears. + middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); + return computePosition(self._config.parent, self._popoverElem, { placement: self._config.position, + strategy: 'fixed', middleware: middleware, }).then(({x, y, placement, middlewareData}) => { Object.assign(self._popoverElem.style, { - position: 'absolute', + position: 'fixed', left: `${x}px`, top: `${y}px`, }); + // Hide popover when reference is scrolled out of view + if (middlewareData.hide) { + self._popoverElem.style.visibility = + middlewareData.hide.referenceHidden ? 'hidden' : ''; + } + const basePlacement = placement.split('-')[0]; // Update placement class on popover for CSS arrow styling @@ -365,6 +384,23 @@ class Popover extends Component { this._updatePosition(); } + _enableAutoUpdate() { + this._disableAutoUpdate(); + const self = this; + this._autoUpdateCleanup = floatingAutoUpdate( + self._config.parent, + self._popoverElem, + function() { self._updatePosition(); } + ); + } + + _disableAutoUpdate() { + if (this._autoUpdateCleanup) { + this._autoUpdateCleanup(); + this._autoUpdateCleanup = null; + } + } + setContent(content) { Util.empty(this._popoverElem); if (content instanceof Element) { @@ -375,12 +411,14 @@ class Popover extends Component { } dispose() { + this._disableAutoUpdate(); this._removeEventHandlers(); if (this._popoverElem && this._popoverElem.parentNode) { this._popoverElem.parentNode.removeChild(this._popoverElem); } this._popoverElem = null; this._floatingCleanup = null; + this._autoUpdateCleanup = null; this._config = null; this._preAnimationTransformStyle = null; this._postAnimationTransformStyle = null; diff --git a/src/chi/javascript/components/tooltip.js b/src/chi/javascript/components/tooltip.js index 50836d39b0..a45c6f12fe 100644 --- a/src/chi/javascript/components/tooltip.js +++ b/src/chi/javascript/components/tooltip.js @@ -144,7 +144,7 @@ class Tooltip extends Component { middleware: [offset(8), flip(), shift()], }).then(({x, y}) => { Object.assign(self._tooltipElem.style, { - position: 'absolute', + position: 'fixed', left: `${x}px`, top: `${y}px`, }); diff --git a/sri.json b/sri.json index 235fe1daf8..82eec5698c 100644 --- a/sri.json +++ b/sri.json @@ -21,6 +21,6 @@ "dist/assets/themes/colt/images/favicon.ico": "sha256-01eRZwbyuQHUlu+olKBDR6JW2BpEIQgJvyvtgnJ8aoc=", "dist/assets/themes/colt/images/background-hero.png": "sha256-z3ObQ7Ovb1KKHLyl1nO5adiyxC++90EZQ6QYVmGs6FA=", "dist/assets/themes/colt/images/background-login.png": "sha256-V60LOksMkHO8xJNTRQUXUk+Vn/xJ0Vxa9+BQVN5Wakg=", - "dist/js/ce/ux-chi-ce/ux-chi-ce.esm.js": "sha256-DSEX8q8ySCVHSY6E9CG/IsoUwENg21lRRM6/S9VHhsk=", - "dist/js/ce/ux-chi-ce/ux-chi-ce.js": "sha256-uwblV0VFkSI96uIvK5TSQH2bAFWQjo5Z9iEsZa9Uctk=" + "dist/js/ce/ux-chi-ce/ux-chi-ce.esm.js": "sha256-C5vbETPXx80HS43JCedLVe07BodkvSGtKk3yGqUxgQc=", + "dist/js/ce/ux-chi-ce/ux-chi-ce.js": "sha256-Wu/qz5OnOdBWbSurIRpdLJT34HCCRPhtvoP3rsoB7Vs=" } \ No newline at end of file From 05a7dcf2443ce41adce32284899684b63055330b Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Tue, 3 Mar 2026 21:30:50 -0800 Subject: [PATCH 4/7] Improved popover arrow placement for gradient variant --- src/chi/components/popover/popover.scss | 64 ++++++++---------- .../components/auxiliary/overflow-menu.js | 4 +- src/chi/javascript/components/dropdown.js | 65 +++++++++++++------ src/chi/javascript/components/popover.js | 22 ++++++- src/chi/javascript/components/tooltip.js | 44 +++++++++++-- 5 files changed, 135 insertions(+), 64 deletions(-) diff --git a/src/chi/components/popover/popover.scss b/src/chi/components/popover/popover.scss index dd95b30e75..5909f08dd8 100644 --- a/src/chi/components/popover/popover.scss +++ b/src/chi/components/popover/popover.scss @@ -367,26 +367,38 @@ // Position offsets for left/right start/end placements only. // Uses margin-top (not top) to avoid overriding Floating UI's // computed inline top value. - &.chi-popover--right-start, - &.chi-popover--left-start { - margin-top: -$popover-gradient-arrow-offset; + + &.chi-popover--left, + &.chi-popover--right { + .chi-arrow, + .chi-popover__arrow { + margin-top: $popover-gradient-arrow-offset; + } } - &.chi-popover--right-end, - &.chi-popover--left-end { - margin-top: $popover-gradient-arrow-offset; + &.chi-popover--left-start, + &.chi-popover--right-start { + .chi-arrow, + .chi-popover__arrow { + margin-top: $popover-gradient-arrow-offset; + } } - /* - Arrow gradient styles — override the arrow background color - to match the gradient header/edges per placement direction. - Floating UI handles all positioning; CSS only sets the color. - */ + &.chi-popover--left-end, + &.chi-popover--right-end { + .chi-arrow, + .chi-popover__arrow { + margin-top: -$popover-gradient-arrow-offset; + } + } + + + // Arrow gradient styles — override the arrow background color + // to match the gradient header/edges per placement direction. + // Floating UI handles all positioning; CSS only sets the color. // Right side: arrow matches right edge of gradient header - &.chi-popover--right, - &.chi-popover--right-start, - &.chi-popover--right-end { + &.chi-popover--right-start { .chi-arrow::before, .chi-popover__arrow::before { background: $popover-gradient-right-arrow-color; @@ -394,9 +406,7 @@ } // Left side: arrow matches left edge of gradient header - &.chi-popover--left, - &.chi-popover--left-start, - &.chi-popover--left-end { + &.chi-popover--left-start { .chi-arrow::before, .chi-popover__arrow::before { background: $popover-gradient-left-arrow-color; @@ -404,31 +414,13 @@ } // Bottom side: arrow uses mixed color (blend of left + right) - &.chi-popover--bottom, &.chi-popover--bottom-start, - &.chi-popover--bottom-end { + &.chi-popover--bottom { .chi-arrow::before, .chi-popover__arrow::before { background: $popover-gradient-arrow-mixed-color; } } - - // Align arrow to gradient for gradient use case - &.chi-popover--left, - &.chi-popover--right { - .chi-arrow, - .chi-popover__arrow { - margin-top: -1.75rem; - } - } - - &.chi-popover--left-start, - &.chi-popover--right-start { - .chi-arrow, - .chi-popover__arrow { - margin-top: 0.75rem; - } - } } } 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/dropdown.js b/src/chi/javascript/components/dropdown.js index 5a21b5e565..3e94aecd2f 100644 --- a/src/chi/javascript/components/dropdown.js +++ b/src/chi/javascript/components/dropdown.js @@ -11,7 +11,8 @@ 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, dropdownElem: null }; const DEFAULT_POSITION = "bottom-start"; @@ -24,6 +25,21 @@ class Dropdown extends Component { constructor (elem, config) { super(elem, Util.extend(DEFAULT_CONFIG, config)); + this._floating = 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 +50,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 +108,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,18 +137,18 @@ class Dropdown extends Component { return dropdownPosition; } - enablePopper () { - if (this._popper) { + enableFloating () { + if (this._floating) { return; } - this._popper = 'loading'; + this._floating = 'loading'; const self = this; window.requestAnimationFrame(function() { let dropdownPosition = self._calculateDropdownPosition(); if (dropdownPosition) { - self._popper = { + self._floating = { _placement: dropdownPosition, _reference: self._elem, _floating: self._dropdownElem, @@ -159,9 +176,9 @@ class Dropdown extends Component { this.disableAutoUpdate(); const ref = this._reference; const floating = this._floating; - const popper = this; + const floatingInstance = this; this._autoUpdateCleanup = floatingAutoUpdate( - ref, floating, function() { popper.update(); } + ref, floating, function() { floatingInstance.update(); } ); }, disableAutoUpdate() { @@ -180,16 +197,24 @@ class Dropdown extends Component { this._floating.style.willChange = ''; } }; - self._popper.update(); + self._floating.update(); } }); } - disablePopper () { - if (this._popper && typeof this._popper === 'object' && this._popper.destroy) { - this._popper.destroy(); + enablePopper () { + this.enableFloating(); + } + + disableFloating () { + if (this._floating && typeof this._floating === 'object' && this._floating.destroy) { + this._floating.destroy(); } - this._popper = null; + this._floating = null; + } + + disablePopper () { + this.disableFloating(); } _clickOnTrigger() { @@ -272,9 +297,9 @@ 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._popper.enableAutoUpdate(); + if (this._floating && typeof this._floating.update === "function") { + this._floating.update(); + this._floating.enableAutoUpdate(); } if (this._parentDropdown) { this._parentDropdown.show(); @@ -294,8 +319,8 @@ class Dropdown extends Component { this._setActiveDescendants(); Util.removeClass(this._elem, CLASS_ACTIVE); Util.removeClass(this._dropdownElem, CLASS_ACTIVE); - if (this._popper && typeof this._popper.disableAutoUpdate === "function") { - this._popper.disableAutoUpdate(); + if (this._floating && typeof this._floating.disableAutoUpdate === "function") { + this._floating.disableAutoUpdate(); } this._shown = false; this._childrenDropdowns.forEach(function(dd) { @@ -360,7 +385,7 @@ class Dropdown extends Component { document.removeEventListener('click', this._documentClickEventListener); this._documentClickEventListener = null; this._elem = null; - this.disablePopper(); + this.disableFloating(); } diff --git a/src/chi/javascript/components/popover.js b/src/chi/javascript/components/popover.js index 2eced06b9a..2daa5dc624 100644 --- a/src/chi/javascript/components/popover.js +++ b/src/chi/javascript/components/popover.js @@ -27,6 +27,21 @@ const DEFAULT_CONFIG = { 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)); @@ -331,11 +346,14 @@ class Popover extends Component { const basePlacement = placement.split('-')[0]; - // Update placement class on popover for CSS arrow styling - ['top', 'bottom', 'left', 'right'].forEach(function(side) { + // Update placement class on popover for CSS styling. + // Keep base side class for existing rules and add full placement + // class so consumers can target start/end alignments. + 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); // Apply arrow positioning if (arrowEl && middlewareData.arrow) { diff --git a/src/chi/javascript/components/tooltip.js b/src/chi/javascript/components/tooltip.js index a45c6f12fe..28a8f9f901 100644 --- a/src/chi/javascript/components/tooltip.js +++ b/src/chi/javascript/components/tooltip.js @@ -1,4 +1,4 @@ -import {computePosition, flip, shift, offset} from '@floating-ui/dom'; +import {computePosition, autoUpdate as floatingAutoUpdate, flip, shift, offset, hide as hideMiddleware} from '@floating-ui/dom'; import {Component} from "../core/component"; import {Util} from "../core/util.js"; import {KEYS} from '../constants/constants'; @@ -7,7 +7,12 @@ 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, + autoUpdate: true, + hideWhenDetached: true +}; const CLASS_LIGHT = '-light'; const TOOLTIP_COLOR_ATTRIBUTE = 'data-tooltip-color'; const TOOLTIP_SWITCH_TIMEOUT = 50; @@ -100,6 +105,9 @@ class Tooltip extends Component { self._tooltipElem.style.transform = self._postAnimationTransformStyle; self._tooltipElem.style.opacity = '1'; self._tooltipElem.setAttribute('aria-hidden', 'false'); + if (self._config.autoUpdate) { + self._enableAutoUpdate(); + } self._preventOverflow(); }); }); @@ -110,6 +118,7 @@ class Tooltip extends Component { hide() { this._shown = false; + this._disableAutoUpdate(); Util.removeClass(this._tooltipElem, CLASS_ACTIVE); let self = this; window.setTimeout(function(){ @@ -139,16 +148,26 @@ class Tooltip extends Component { let self = this; this._updatePosition = function () { + const middleware = [offset(8), flip(), shift()]; + if (self._config.hideWhenDetached) { + middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); + } + return computePosition(self._config.parent, self._tooltipElem, { placement: self._config.position, - middleware: [offset(8), flip(), shift()], - }).then(({x, y}) => { + strategy: 'fixed', + middleware, + }).then(({x, y, middlewareData}) => { Object.assign(self._tooltipElem.style, { position: 'fixed', left: `${x}px`, top: `${y}px`, }); + if (self._config.hideWhenDetached && middlewareData.hide) { + self._tooltipElem.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; + } + // Transforms are relative offsets — left/top handles absolute positioning. // Tooltip uses opacity-only animation, so no directional offset needed. self._postAnimationTransformStyle = 'none'; @@ -159,6 +178,22 @@ class Tooltip extends Component { this._updatePosition(); } + _enableAutoUpdate() { + this._disableAutoUpdate(); + this._floatingCleanup = floatingAutoUpdate( + this._config.parent, + this._tooltipElem, + () => this._updatePosition() + ); + } + + _disableAutoUpdate() { + if (this._floatingCleanup) { + this._floatingCleanup(); + this._floatingCleanup = null; + } + } + _preventOverflow() { if (Util.checkOverflow(this._tooltipElem)) { const text = this._elem.dataset.tooltip; @@ -176,6 +211,7 @@ class Tooltip extends Component { } dispose() { + this._disableAutoUpdate(); if (this._tooltipElem && this._tooltipElem.parentNode) { this._tooltipElem.parentNode.removeChild(this._tooltipElem); } From 8a1142df55c2f24cfc528c868b8f21d3ea72dc80 Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Wed, 4 Mar 2026 18:31:17 -0800 Subject: [PATCH 5/7] Draggable popover animation change --- src/chi/components/popover/popover.scss | 7 +++++++ sri.json | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/chi/components/popover/popover.scss b/src/chi/components/popover/popover.scss index 5909f08dd8..70c5fde47c 100644 --- a/src/chi/components/popover/popover.scss +++ b/src/chi/components/popover/popover.scss @@ -331,6 +331,13 @@ width: 4.375rem; } } + + // Disable open animations when draggable to avoid conflicts with drag movement. + &.-animated { + &[data-state='open'] { + animation: none; + } + } } &.-gradient { diff --git a/sri.json b/sri.json index 82eec5698c..4a672c9c8e 100644 --- a/sri.json +++ b/sri.json @@ -1,12 +1,12 @@ { - "dist/chi.css": "sha256-VmDVUHSXTBBaNCH6NTsj9VmoyQvBu0eSK7Kwc+NJ1/8=", - "dist/chi-centurylink.css": "sha256-Lmjx/IxI3hvV7R6loI7c5AuLtDEDokYEMwo6ooL+ivg=", - "dist/chi-portal.css": "sha256-VNM30ZbklI2GY5lOPiY+mYNdMKyLbAgIRSLyTmp/B+4=", - "dist/chi-connect.css": "sha256-p5IDVv/tdrSJz/Q17FDGZ7i85IGvyqrtsBtKCCyQlGU=", - "dist/chi-brightspeed.css": "sha256-8jgNLo2sOgw7SByP69ryjvbLpR/pfRpUoxhNmjaY+ro=", - "dist/chi-colt.css": "sha256-ObYaBefqZ2K78Wc5FEviYoi8TR3EngdDFNtUoPBp3Lo=", - "dist/chi-test.css": "sha256-XG5gDQgCVG3wu2Po1ZHjz8+CDlFBT49qSEawHivGxqc=", - "dist/js/chi.js": "sha256-TWL+xA4fFh/UsDOhGnyz2gET/P0XSfqK6ZWFTsPyPu8=", + "dist/chi.css": "sha256-PQkgemICfhCHW6pjsnYxh4Ew1IVBSHwcZNs7z7B+MvQ=", + "dist/chi-centurylink.css": "sha256-r2lTsnbXTuQf7XJbHeQNHLjVinXd3kdBtWxhBw5XRIc=", + "dist/chi-portal.css": "sha256-lqKwny7Zf7bmH8PRw+MsJTcWPt3/31vJQ4Ymhr2waGU=", + "dist/chi-connect.css": "sha256-rTKCvfyjSV74LYcPrQIvb4W2BijoLmbB7eBmXNwVKoQ=", + "dist/chi-brightspeed.css": "sha256-Gcfsr1DWyWBsXOqA5j45D2F0+CxcoKNWIONIl3tplts=", + "dist/chi-colt.css": "sha256-V23Ki4hrNI+aB72hzYqMzLp2GRgIhwVSaTNZ7L7flbI=", + "dist/chi-test.css": "sha256-dhwPQw4E2r2l9/wOSVkyTyNCiwUgGH1vxHrZ3Pyxrp0=", + "dist/js/chi.js": "sha256-gcXCCJFj82cL7HPCeFzFGmbkyQJYiM2vrOVGu8irXUA=", "dist/assets/themes/lumen/images/favicon.svg": "sha256-+0ITKaXKx702ZWOzublRl83MJVOIbUMTQG8JvN+76B0=", "dist/assets/themes/lumen/images/favicon.ico": "sha256-EkKmbH+i/VIQAtUl7NF4bPVaaJZCeBc5xWx8LTcMJp0=", "dist/assets/themes/portal/images/favicon.svg": "sha256-+0ITKaXKx702ZWOzublRl83MJVOIbUMTQG8JvN+76B0=", @@ -21,6 +21,6 @@ "dist/assets/themes/colt/images/favicon.ico": "sha256-01eRZwbyuQHUlu+olKBDR6JW2BpEIQgJvyvtgnJ8aoc=", "dist/assets/themes/colt/images/background-hero.png": "sha256-z3ObQ7Ovb1KKHLyl1nO5adiyxC++90EZQ6QYVmGs6FA=", "dist/assets/themes/colt/images/background-login.png": "sha256-V60LOksMkHO8xJNTRQUXUk+Vn/xJ0Vxa9+BQVN5Wakg=", - "dist/js/ce/ux-chi-ce/ux-chi-ce.esm.js": "sha256-C5vbETPXx80HS43JCedLVe07BodkvSGtKk3yGqUxgQc=", - "dist/js/ce/ux-chi-ce/ux-chi-ce.js": "sha256-Wu/qz5OnOdBWbSurIRpdLJT34HCCRPhtvoP3rsoB7Vs=" + "dist/js/ce/ux-chi-ce/ux-chi-ce.esm.js": "sha256-DSEX8q8ySCVHSY6E9CG/IsoUwENg21lRRM6/S9VHhsk=", + "dist/js/ce/ux-chi-ce/ux-chi-ce.js": "sha256-uwblV0VFkSI96uIvK5TSQH2bAFWQjo5Z9iEsZa9Uctk=" } \ No newline at end of file From 8c09039a4467bd81f0d3962e1a8ca60e89264ee3 Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Wed, 18 Mar 2026 23:15:42 -0700 Subject: [PATCH 6/7] Updated floating element components --- package-lock.json | 6 +- src/chi/components/popover/popover.scss | 32 +-- src/chi/javascript/components/date-picker.js | 4 +- src/chi/javascript/components/dropdown.js | 72 ++++++- src/chi/javascript/components/popover.js | 197 ++++++++++++------- src/chi/javascript/components/tooltip.js | 96 +++------ src/chi/javascript/core/util.js | 10 + sri.json | 16 +- 8 files changed, 239 insertions(+), 194 deletions(-) diff --git a/package-lock.json b/package-lock.json index cfe3280320..d948fc8979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1603,9 +1603,9 @@ } }, "node_modules/@centurylink/chi-custom-elements": { - "version": "1.55.0", - "resolved": "https://npm.pkg.github.com/download/@centurylink/chi-custom-elements/1.55.0/140e3d08c7a4d12e82eac14368a4004ccde45452", - "integrity": "sha512-oO2P4ctmA08RxZgmLDxB0ZLbq9zqfHQSpXc2ZDhgbD0MN3EcjUQWLuzWJ/jVhBhmB4ZqUoQPwoeW3STDeZijtA==", + "version": "1.56.0", + "resolved": "https://npm.pkg.github.com/download/@centurylink/chi-custom-elements/1.56.0/ef76df8c241dd5e926ec5624817473e11f606795", + "integrity": "sha512-Ko15gIUn5kPuO+DEGkbDgCd3iAP3u3sEvOEPmXyIuv+Wx8Wo4JgwWRngtjy7n0g84eo9tc2Ztxl+K+4jalpbog==", "license": "MIT", "peerDependencies": { "@stencil/core": "^4.35.1" diff --git a/src/chi/components/popover/popover.scss b/src/chi/components/popover/popover.scss index 70c5fde47c..36e1974c4d 100644 --- a/src/chi/components/popover/popover.scss +++ b/src/chi/components/popover/popover.scss @@ -18,12 +18,10 @@ text-align: left; top: 0; white-space: normal; + width: max-content; z-index: $zindex-popover; &.-animated { - // CSS @keyframes handle show/hide animations via data-state attribute. - // No CSS transition needed — animations are driven by @keyframes. - &[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; } @@ -149,15 +147,6 @@ } } - // Arrow — positioned by Floating UI's arrow() middleware via inline styles. - // The arrow div acts as a filter container; its ::before pseudo-element is - // the visible triangle (a rotated square clipped by clip-path). - // - // Clipping strategy: - // 1. ::before is a square rotated 45° → visual diamond shape - // 2. clip-path (set via --chi-arrow-clip CSS variable from floating.ts) - // removes the inner half that overlaps the popover body - & .chi-arrow, & .chi-popover__arrow { display: block; @@ -181,8 +170,6 @@ } } - // CSS-only fallback arrow positioning for static / documentation usage. - // When Floating UI runs, its inline styles (higher specificity) override these. &.chi-popover--top, &.chi-popover--top-start, &.chi-popover--top-end { @@ -371,10 +358,6 @@ } } - // Position offsets for left/right start/end placements only. - // Uses margin-top (not top) to avoid overriding Floating UI's - // computed inline top value. - &.chi-popover--left, &.chi-popover--right { .chi-arrow, @@ -399,12 +382,6 @@ } } - - // Arrow gradient styles — override the arrow background color - // to match the gradient header/edges per placement direction. - // Floating UI handles all positioning; CSS only sets the color. - - // Right side: arrow matches right edge of gradient header &.chi-popover--right-start { .chi-arrow::before, .chi-popover__arrow::before { @@ -412,7 +389,6 @@ } } - // Left side: arrow matches left edge of gradient header &.chi-popover--left-start { .chi-arrow::before, .chi-popover__arrow::before { @@ -420,7 +396,6 @@ } } - // Bottom side: arrow uses mixed color (blend of left + right) &.chi-popover--bottom-start, &.chi-popover--bottom { .chi-arrow::before, @@ -431,11 +406,6 @@ } } -// @keyframes for popover show/hide animations. -// Uses transform: none in the final keyframe (not translateY(0)) -// to avoid creating a CSS containing block that would trap nested -// position: fixed elements. - @keyframes chi-popover-slide-top-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } 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 3e94aecd2f..0ae1d497c2 100644 --- a/src/chi/javascript/components/dropdown.js +++ b/src/chi/javascript/components/dropdown.js @@ -13,6 +13,7 @@ const COMPONENT_TYPE = "dropdown"; const DEFAULT_CONFIG = { floating: true, popper: undefined, + portal: false, dropdownElem: null }; const DEFAULT_POSITION = "bottom-start"; @@ -26,6 +27,8 @@ class Dropdown extends Component { constructor (elem, config) { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._floating = null; + this._portalOriginalParent = null; + this._portalOriginalNextSibling = null; Object.defineProperty(this, '_popper', { configurable: true, enumerable: false, @@ -137,6 +140,47 @@ class Dropdown extends Component { return dropdownPosition; } + _portalDropdownMenu() { + if (!this._config.portal || !this._dropdownElem) { + return; + } + this._portalOriginalParent = this._dropdownElem.parentElement; + this._portalOriginalNextSibling = this._dropdownElem.nextSibling; + + this._dropdownElem.style.zIndex = '10'; + + document.body.appendChild(this._dropdownElem); + + 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._portalOriginalParent || !this._dropdownElem) { + return; + } + this._dropdownElem.style.zIndex = ''; + this._dropdownElem.style.minWidth = ''; + + if (this._portalOriginalParent.isConnected) { + this._portalOriginalParent.insertBefore( + this._dropdownElem, + this._portalOriginalNextSibling + ); + } + this._portalOriginalParent = null; + this._portalOriginalNextSibling = null; + } + enableFloating () { if (this._floating) { return; @@ -148,6 +192,9 @@ class Dropdown extends Component { let dropdownPosition = self._calculateDropdownPosition(); if (dropdownPosition) { + self._portalDropdownMenu(); + + const strategy = self._config.portal ? 'fixed' : 'absolute'; self._floating = { _placement: dropdownPosition, _reference: self._elem, @@ -156,13 +203,13 @@ class Dropdown extends Component { update() { return computePosition(this._reference, this._floating, { placement: this._placement, - strategy: 'fixed', + strategy: strategy, middleware: [flip(), shift(), hideMiddleware({ strategy: 'referenceHidden' })], }).then(({x, y, middlewareData}) => { Object.assign(this._floating.style, { - position: 'fixed', - left: `${x}px`, - top: `${y}px`, + position: strategy, + left: `${Util.roundByDPR(x)}px`, + top: `${Util.roundByDPR(y)}px`, transform: 'none', willChange: '', right: '', @@ -189,12 +236,12 @@ class Dropdown extends Component { }, destroy() { this.disableAutoUpdate(); - // Clean up inline styles this._floating.style.position = ''; this._floating.style.left = ''; this._floating.style.top = ''; this._floating.style.transform = ''; this._floating.style.willChange = ''; + this._floating.style.visibility = ''; } }; self._floating.update(); @@ -211,6 +258,7 @@ class Dropdown extends Component { this._floating.destroy(); } this._floating = null; + this._restoreDropdownMenu(); } disablePopper () { @@ -297,9 +345,15 @@ class Dropdown extends Component { Util.addClass(this._elem, CLASS_ACTIVE); Util.addClass(this._elem, CLASS_HAS_ACTIVE); Util.addClass(this._dropdownElem, CLASS_ACTIVE); + this._dropdownElem.style.visibility = ''; + this._syncMenuMinWidth(); if (this._floating && typeof this._floating.update === "function") { - this._floating.update(); - this._floating.enableAutoUpdate(); + const floatingRef = this._floating; + const self2 = this; + this._floating.update().then(function() { + if (self2._floating !== floatingRef) return; + floatingRef.enableAutoUpdate(); + }); } if (this._parentDropdown) { this._parentDropdown.show(); @@ -380,12 +434,12 @@ 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._elem = null; this.disableFloating(); + this._dropdownElem = null; + this._elem = null; } diff --git a/src/chi/javascript/components/popover.js b/src/chi/javascript/components/popover.js index 2daa5dc624..b9ca7f024e 100644 --- a/src/chi/javascript/components/popover.js +++ b/src/chi/javascript/components/popover.js @@ -6,7 +6,8 @@ import { chi } from '../core/chi'; 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,6 +23,7 @@ const DEFAULT_CONFIG = { content: null, delayBetweenInteractions: 50, parent: null, + portal: false, position: 'top', trigger: 'click', preventAutoHide: false @@ -49,8 +51,8 @@ class Popover extends Component { this._popoverElem = null; this._floatingCleanup = null; this._autoUpdateCleanup = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; + this._animationAbortController = null; + this._animationTimeout = null; this._shown = false; this._config.parent = this._config.parent || this._elem; this._config.position = @@ -109,6 +111,7 @@ class Popover extends Component { } show(force) { + if (!this._popoverElem) return; if (this._shown || (!this.allowConsecutiveActions() && !force)) { return; } @@ -117,39 +120,69 @@ 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); - // computePosition is async — wait for position to be computed before animating self._updatePosition().then(function() { - 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; + 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) + ); + } }, - function() { - Util.removeClass(self._popoverElem, chi.classes.TRANSITIONING); + { 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) + Util.createEvent(EVENTS.SHOWN) ); - self._enableAutoUpdate(); - }, - TRANSITION_DURATION - ); + } + }, ANIMATION_TIMEOUT); }); } @@ -163,30 +196,57 @@ class Popover extends Component { 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() { @@ -236,12 +296,17 @@ class Popover extends Component { } else { this._popoverElem = document.querySelector(target); } + this._isPortaled = false; } else { this._popoverElem = document.createElement('section'); - // Portal to document.body so position:fixed is always viewport-relative. - // Matches CE behavior — avoids ancestors with transforms/filters/overflow - // creating an unintended containing block. - document.body.appendChild(this._popoverElem); + + if (this._config.portal) { + document.body.appendChild(this._popoverElem); + this._isPortaled = true; + } else { + this._config.parent.parentNode.appendChild(this._popoverElem); + this._isPortaled = false; + } } } @@ -302,8 +367,6 @@ class Popover extends Component { left: 'right', }; - // Clip-path polygons for each arrow direction. - // Applied to ::before via --chi-arrow-clip CSS custom property. const ARROW_CLIP_PATHS = { top: 'polygon(100% 0, 0 100%, 100% 100%)', bottom: 'polygon(0 0, 100% 0, 0 100%)', @@ -322,23 +385,23 @@ class Popover extends Component { middleware.push(arrowMiddleware({ element: arrowEl })); } - // Hide the popover when the reference scrolls out of the viewport. - // Without this, shift() clamps the popover at the viewport edge, - // making it appear stuck. With hide(), the popover cleanly disappears. - middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); + if (!self._isPortaled) { + middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); + } + + const strategy = self._isPortaled ? 'fixed' : 'absolute'; return computePosition(self._config.parent, self._popoverElem, { placement: self._config.position, - strategy: 'fixed', + strategy: strategy, middleware: middleware, }).then(({x, y, placement, middlewareData}) => { Object.assign(self._popoverElem.style, { - position: 'fixed', - left: `${x}px`, - top: `${y}px`, + position: strategy, + left: `${Util.roundByDPR(x)}px`, + top: `${Util.roundByDPR(y)}px`, }); - // Hide popover when reference is scrolled out of view if (middlewareData.hide) { self._popoverElem.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; @@ -346,21 +409,16 @@ class Popover extends Component { const basePlacement = placement.split('-')[0]; - // Update placement class on popover for CSS styling. - // Keep base side class for existing rules and add full placement - // class so consumers can target start/end alignments. 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); - // Apply arrow positioning if (arrowEl && middlewareData.arrow) { const {x: arrowX, y: arrowY} = middlewareData.arrow; const staticSide = OPPOSITE_SIDE[basePlacement]; - // Measure arrow size for static-side offset const arrowLen = (basePlacement === 'top' || basePlacement === 'bottom') ? arrowEl.offsetHeight : arrowEl.offsetWidth; @@ -373,33 +431,13 @@ class Popover extends Component { [staticSide]: `${-(arrowLen / 2)}px`, }); - // Set clip-path direction on arrow ::before via CSS custom property arrowEl.style.setProperty( '--chi-arrow-clip', ARROW_CLIP_PATHS[basePlacement] || 'none' ); } - - // Animation transforms are RELATIVE offsets — left/top handles absolute positioning. - // Post = final position (no additional transform needed). - // Pre = 20px offset in the incoming direction for slide-in animation. - self._postAnimationTransformStyle = 'none'; - if (basePlacement === 'top') { - self._preAnimationTransformStyle = 'translate3d(0, 20px, 0)'; - } else if (basePlacement === 'right') { - self._preAnimationTransformStyle = 'translate3d(-20px, 0, 0)'; - } else if (basePlacement === 'bottom') { - self._preAnimationTransformStyle = 'translate3d(0, -20px, 0)'; - } else if (basePlacement === 'left') { - self._preAnimationTransformStyle = 'translate3d(20px, 0, 0)'; - } else { - self._preAnimationTransformStyle = 'none'; - } }); }; - - // Initial position computation - this._updatePosition(); } _enableAutoUpdate() { @@ -430,6 +468,14 @@ 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._popoverElem && this._popoverElem.parentNode) { this._popoverElem.parentNode.removeChild(this._popoverElem); @@ -437,9 +483,8 @@ class Popover extends Component { this._popoverElem = null; this._floatingCleanup = null; this._autoUpdateCleanup = null; + this._isPortaled = false; this._config = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; this._mouseClickOnDocument = null; this._mouseClickOnPopover = null; diff --git a/src/chi/javascript/components/tooltip.js b/src/chi/javascript/components/tooltip.js index 28a8f9f901..679d9bb076 100644 --- a/src/chi/javascript/components/tooltip.js +++ b/src/chi/javascript/components/tooltip.js @@ -6,16 +6,15 @@ import {KEYS} from '../constants/constants'; const CLASS_ACTIVE = "-active"; const COMPONENT_SELECTOR = '[data-tooltip]'; const COMPONENT_TYPE = "tooltip"; -const ANIMATION_DELAY = 300; 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' @@ -28,12 +27,9 @@ class Tooltip extends Component { this._tooltipElem = null; this._tooltipContent = null; this._floatingCleanup = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = 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 || @@ -45,38 +41,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(); } @@ -91,25 +74,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.opacity = '0'; - let self = this; - - // Re-compute position before showing, then animate - this._updatePosition().then(function() { - self._tooltipElem.style.transform = self._preAnimationTransformStyle; - 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'); - if (self._config.autoUpdate) { - self._enableAutoUpdate(); - } - self._preventOverflow(); - }); + this._tooltipElem.style.visibility = ''; + this._updatePosition().then(() => { + if (!this._shown) return; + + Util.addClass(this._tooltipElem, CLASS_ACTIVE); + this._tooltipElem.setAttribute('aria-hidden', 'false'); + if (this._config.autoUpdate) { + this._enableAutoUpdate(); + } + this._preventOverflow(); }); this._elem.dispatchEvent( Util.createEvent(EVENTS.show) @@ -120,12 +94,7 @@ class Tooltip extends Component { 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.dispatchEvent( Util.createEvent(EVENTS.hide) ); @@ -141,11 +110,15 @@ class Tooltip extends Component { this._tooltipContent = document.createElement('span'); this._tooltipContent.innerText = this._elem.dataset.tooltip; this._tooltipElem.appendChild(this._tooltipContent); + // Original Popper.js always appended tooltip to body. + // Default strategy is 'absolute' (matching original + CE default). + // When portal: true, use 'fixed' (matching CE portal behavior). document.querySelector('body').appendChild(this._tooltipElem); this._elem.setAttribute('aria-describedby', this._tooltipElem.id); this._tooltipElem.setAttribute('aria-hidden', 'true'); let self = this; + const strategy = self._config.portal ? 'fixed' : 'absolute'; this._updatePosition = function () { const middleware = [offset(8), flip(), shift()]; @@ -155,27 +128,20 @@ class Tooltip extends Component { return computePosition(self._config.parent, self._tooltipElem, { placement: self._config.position, - strategy: 'fixed', + strategy: strategy, middleware, }).then(({x, y, middlewareData}) => { Object.assign(self._tooltipElem.style, { - position: 'fixed', - left: `${x}px`, - top: `${y}px`, + position: strategy, + left: `${Util.roundByDPR(x)}px`, + top: `${Util.roundByDPR(y)}px`, }); if (self._config.hideWhenDetached && middlewareData.hide) { self._tooltipElem.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; } - - // Transforms are relative offsets — left/top handles absolute positioning. - // Tooltip uses opacity-only animation, so no directional offset needed. - self._postAnimationTransformStyle = 'none'; - self._preAnimationTransformStyle = 'none'; }); }; - - this._updatePosition(); } _enableAutoUpdate() { @@ -219,8 +185,6 @@ class Tooltip extends Component { this._tooltipContent = null; this._floatingCleanup = null; this._config = null; - this._preAnimationTransformStyle = null; - this._postAnimationTransformStyle = null; this._removeEventHandlers(); this._elem = null; } 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/sri.json b/sri.json index 4a672c9c8e..793ee8fcd0 100644 --- a/sri.json +++ b/sri.json @@ -1,12 +1,12 @@ { - "dist/chi.css": "sha256-PQkgemICfhCHW6pjsnYxh4Ew1IVBSHwcZNs7z7B+MvQ=", - "dist/chi-centurylink.css": "sha256-r2lTsnbXTuQf7XJbHeQNHLjVinXd3kdBtWxhBw5XRIc=", - "dist/chi-portal.css": "sha256-lqKwny7Zf7bmH8PRw+MsJTcWPt3/31vJQ4Ymhr2waGU=", - "dist/chi-connect.css": "sha256-rTKCvfyjSV74LYcPrQIvb4W2BijoLmbB7eBmXNwVKoQ=", - "dist/chi-brightspeed.css": "sha256-Gcfsr1DWyWBsXOqA5j45D2F0+CxcoKNWIONIl3tplts=", - "dist/chi-colt.css": "sha256-V23Ki4hrNI+aB72hzYqMzLp2GRgIhwVSaTNZ7L7flbI=", - "dist/chi-test.css": "sha256-dhwPQw4E2r2l9/wOSVkyTyNCiwUgGH1vxHrZ3Pyxrp0=", - "dist/js/chi.js": "sha256-gcXCCJFj82cL7HPCeFzFGmbkyQJYiM2vrOVGu8irXUA=", + "dist/chi.css": "sha256-XEEvw+7g4yyubS2+PZDtEEVUK2Be+vN1v6jnnqFMB28=", + "dist/chi-centurylink.css": "sha256-zkKGUXlrhKK9MXi0A1PQRpG8npmm3th86m5/euejEXQ=", + "dist/chi-portal.css": "sha256-3lKjL0d2q31gchsQcqmfERPayzPbe7Y5BuwvuIEexyU=", + "dist/chi-connect.css": "sha256-dsXn4j3lYUDR5OB+rz9suITTtXlvpGYX/zQrNVQ/LqA=", + "dist/chi-brightspeed.css": "sha256-90MLg5FGCam4t6Tejy0pjSlhBYvI6BTBcDZtvnAQq/U=", + "dist/chi-colt.css": "sha256-FGON54ZWIeHTg6iwambq3uUE6HC1Ayfc8IKYmKZ+Bt4=", + "dist/chi-test.css": "sha256-MHn9YBbb4hccKtGnPOdZBUyiIutcsGdbOy+jAe9A2Js=", + "dist/js/chi.js": "sha256-LwzhmcHdihCTpPgToZ561tziC+0tDkycsWiWJN1Zgl4=", "dist/assets/themes/lumen/images/favicon.svg": "sha256-+0ITKaXKx702ZWOzublRl83MJVOIbUMTQG8JvN+76B0=", "dist/assets/themes/lumen/images/favicon.ico": "sha256-EkKmbH+i/VIQAtUl7NF4bPVaaJZCeBc5xWx8LTcMJp0=", "dist/assets/themes/portal/images/favicon.svg": "sha256-+0ITKaXKx702ZWOzublRl83MJVOIbUMTQG8JvN+76B0=", From 8f9fc9c5e205a8a0d0d9baf91820e9802b480b7a Mon Sep 17 00:00:00 2001 From: Matt Nickles Date: Thu, 19 Mar 2026 19:35:27 -0700 Subject: [PATCH 7/7] Improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/chi/javascript/core/floating.js — shared createFloating() factory that wraps Floating UI's computePosition / autoUpdate with flip, shift, and optional hide-when-detached middleware - Add src/chi/javascript/core/portal.js — portalElement() helper that moves an element to a #chi-portal-root container and returns a handle to restore it, replacing manual parent/sibling tracking - Add src/chi/components/portal/portal.scss and $zindex-portal-root: 9999 variable; expose as --chi-zindex-portal-root CSS custom property across all six themes - Refactor dropdown.js to use createFloating + portalElement, removing the inline computePosition object and eliminating _portalOriginalParent/_portalOriginalNextSibling state in favor of _portalHandle; skip hide-when-detached middleware when portaled to prevent mid-scroll collapse - Refactor popover.js and tooltip.js similarly to delegate to createFloating - Extend popover.scss arrow-direction rules to also match Popper [x-placement^='…'] attribute selectors to eliminate a low risk breaking change --- .cursor/skills/chi-tokens/reference.md | 1 + src/chi/_global-variables.scss | 1 + src/chi/components/popover/popover.scss | 34 +- src/chi/components/portal/portal.scss | 10 + src/chi/javascript/components/dropdown.js | 109 +++---- src/chi/javascript/components/popover.js | 133 +++----- src/chi/javascript/components/tooltip.js | 100 +++--- src/chi/javascript/core/floating.js | 308 ++++++++++++++++++ src/chi/javascript/core/portal.js | 103 ++++++ src/chi/themes/brightspeed/css-variables.scss | 1 + src/chi/themes/brightspeed/index.scss | 1 + src/chi/themes/centurylink/css-variables.scss | 1 + src/chi/themes/centurylink/index.scss | 1 + src/chi/themes/colt/css-variables.scss | 1 + src/chi/themes/colt/index.scss | 1 + src/chi/themes/connect/css-variables.scss | 1 + src/chi/themes/connect/index.scss | 1 + src/chi/themes/lumen/css-variables.scss | 1 + src/chi/themes/lumen/index.scss | 1 + src/chi/themes/portal/css-variables.scss | 1 + src/chi/themes/portal/index.scss | 1 + sri.json | 16 +- 22 files changed, 609 insertions(+), 218 deletions(-) create mode 100644 src/chi/components/portal/portal.scss create mode 100644 src/chi/javascript/core/floating.js create mode 100644 src/chi/javascript/core/portal.js diff --git a/.cursor/skills/chi-tokens/reference.md b/.cursor/skills/chi-tokens/reference.md index 1888db7011..e7e86e99dc 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/src/chi/_global-variables.scss b/src/chi/_global-variables.scss index 2472624d2b..22b4662ff3 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/popover/popover.scss b/src/chi/components/popover/popover.scss index 36e1974c4d..290a4fbcf1 100644 --- a/src/chi/components/popover/popover.scss +++ b/src/chi/components/popover/popover.scss @@ -172,7 +172,8 @@ &.chi-popover--top, &.chi-popover--top-start, - &.chi-popover--top-end { + &.chi-popover--top-end, + &[x-placement^='top'] { .chi-arrow, .chi-popover__arrow { bottom: calc(#{$popover-arrow-size} / -2); @@ -183,7 +184,8 @@ &.chi-popover--bottom, &.chi-popover--bottom-start, - &.chi-popover--bottom-end { + &.chi-popover--bottom-end, + &[x-placement^='bottom'] { .chi-arrow, .chi-popover__arrow { top: calc(#{$popover-arrow-size} / -2); @@ -194,7 +196,8 @@ &.chi-popover--right, &.chi-popover--right-start, - &.chi-popover--right-end { + &.chi-popover--right-end, + &[x-placement^='right'] { .chi-arrow, .chi-popover__arrow { left: calc(#{$popover-arrow-size} / -2); @@ -205,7 +208,8 @@ &.chi-popover--left, &.chi-popover--left-start, - &.chi-popover--left-end { + &.chi-popover--left-end, + &[x-placement^='left'] { .chi-arrow, .chi-popover__arrow { right: calc(#{$popover-arrow-size} / -2); @@ -359,7 +363,9 @@ } &.chi-popover--left, - &.chi-popover--right { + &.chi-popover--right, + &[x-placement='left'], + &[x-placement='right'] { .chi-arrow, .chi-popover__arrow { margin-top: $popover-gradient-arrow-offset; @@ -367,7 +373,9 @@ } &.chi-popover--left-start, - &.chi-popover--right-start { + &.chi-popover--right-start, + &[x-placement='left-start'], + &[x-placement='right-start'] { .chi-arrow, .chi-popover__arrow { margin-top: $popover-gradient-arrow-offset; @@ -375,21 +383,25 @@ } &.chi-popover--left-end, - &.chi-popover--right-end { + &.chi-popover--right-end, + &[x-placement='left-end'], + &[x-placement='right-end'] { .chi-arrow, .chi-popover__arrow { margin-top: -$popover-gradient-arrow-offset; } } - &.chi-popover--right-start { + &.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 { + &.chi-popover--left-start, + &[x-placement='left-start'] { .chi-arrow::before, .chi-popover__arrow::before { background: $popover-gradient-left-arrow-color; @@ -397,7 +409,9 @@ } &.chi-popover--bottom-start, - &.chi-popover--bottom { + &.chi-popover--bottom, + &[x-placement='bottom-start'], + &[x-placement='bottom'] { .chi-arrow::before, .chi-popover__arrow::before { background: $popover-gradient-arrow-mixed-color; 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/dropdown.js b/src/chi/javascript/components/dropdown.js index 0ae1d497c2..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 {computePosition, autoUpdate as floatingAutoUpdate, flip, shift, hide as hideMiddleware} from '@floating-ui/dom'; 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'; @@ -27,8 +28,8 @@ class Dropdown extends Component { constructor (elem, config) { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._floating = null; - this._portalOriginalParent = null; - this._portalOriginalNextSibling = null; + this._cleanupAutoUpdate = null; + this._portalHandle = null; Object.defineProperty(this, '_popper', { configurable: true, enumerable: false, @@ -144,12 +145,10 @@ class Dropdown extends Component { if (!this._config.portal || !this._dropdownElem) { return; } - this._portalOriginalParent = this._dropdownElem.parentElement; - this._portalOriginalNextSibling = this._dropdownElem.nextSibling; - this._dropdownElem.style.zIndex = '10'; - - document.body.appendChild(this._dropdownElem); + const portalId = 'chi-dropdown-portal-' + this.componentCounterNo; + this._elem.setAttribute('data-chi-portal-id', portalId); + this._portalHandle = portalElement(this._dropdownElem, portalId); this._syncMenuMinWidth(); } @@ -165,20 +164,17 @@ class Dropdown extends Component { } _restoreDropdownMenu() { - if (!this._portalOriginalParent || !this._dropdownElem) { + if (!this._portalHandle || !this._dropdownElem) { return; } - this._dropdownElem.style.zIndex = ''; this._dropdownElem.style.minWidth = ''; - if (this._portalOriginalParent.isConnected) { - this._portalOriginalParent.insertBefore( - this._dropdownElem, - this._portalOriginalNextSibling - ); + this._portalHandle.restore(); + this._portalHandle = null; + + if (this._elem) { + this._elem.removeAttribute('data-chi-portal-id'); } - this._portalOriginalParent = null; - this._portalOriginalNextSibling = null; } enableFloating () { @@ -194,57 +190,24 @@ class Dropdown extends Component { if (dropdownPosition) { self._portalDropdownMenu(); - const strategy = self._config.portal ? 'fixed' : 'absolute'; - self._floating = { - _placement: dropdownPosition, - _reference: self._elem, - _floating: self._dropdownElem, - _autoUpdateCleanup: null, - update() { - return computePosition(this._reference, this._floating, { - placement: this._placement, - strategy: strategy, - middleware: [flip(), shift(), hideMiddleware({ strategy: 'referenceHidden' })], - }).then(({x, y, middlewareData}) => { - Object.assign(this._floating.style, { - position: strategy, - left: `${Util.roundByDPR(x)}px`, - top: `${Util.roundByDPR(y)}px`, - transform: 'none', - willChange: '', - right: '', - }); - if (middlewareData.hide) { - this._floating.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; - } - }); - }, - enableAutoUpdate() { - this.disableAutoUpdate(); - const ref = this._reference; - const floating = this._floating; - const floatingInstance = this; - this._autoUpdateCleanup = floatingAutoUpdate( - ref, floating, function() { floatingInstance.update(); } - ); - }, - disableAutoUpdate() { - if (this._autoUpdateCleanup) { - this._autoUpdateCleanup(); - this._autoUpdateCleanup = null; - } - }, - destroy() { - this.disableAutoUpdate(); - this._floating.style.position = ''; - this._floating.style.left = ''; - this._floating.style.top = ''; - this._floating.style.transform = ''; - this._floating.style.willChange = ''; - this._floating.style.visibility = ''; - } - }; - self._floating.update(); + // 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, + }); } }); } @@ -352,7 +315,7 @@ class Dropdown extends Component { const self2 = this; this._floating.update().then(function() { if (self2._floating !== floatingRef) return; - floatingRef.enableAutoUpdate(); + self2._cleanupAutoUpdate = floatingRef.enableAutoUpdate(); }); } if (this._parentDropdown) { @@ -373,8 +336,9 @@ class Dropdown extends Component { this._setActiveDescendants(); Util.removeClass(this._elem, CLASS_ACTIVE); Util.removeClass(this._dropdownElem, CLASS_ACTIVE); - if (this._floating && typeof this._floating.disableAutoUpdate === "function") { - this._floating.disableAutoUpdate(); + if (this._cleanupAutoUpdate) { + this._cleanupAutoUpdate(); + this._cleanupAutoUpdate = null; } this._shown = false; this._childrenDropdowns.forEach(function(dd) { @@ -439,6 +403,9 @@ class Dropdown extends Component { this._documentClickEventListener = null; this.disableFloating(); this._dropdownElem = null; + if (this._elem) { + this._elem.removeAttribute('data-chi-portal-id'); + } this._elem = null; } diff --git a/src/chi/javascript/components/popover.js b/src/chi/javascript/components/popover.js index b9ca7f024e..a41592ebcb 100644 --- a/src/chi/javascript/components/popover.js +++ b/src/chi/javascript/components/popover.js @@ -1,7 +1,7 @@ import { Util } from '../core/util.js'; import { Component } from '../core/component'; -import { computePosition, autoUpdate as floatingAutoUpdate, flip, shift, offset, arrow as arrowMiddleware, hide as hideMiddleware } from '@floating-ui/dom'; import { chi } from '../core/chi'; +import { createFloating } from '../core/floating.js'; const COMPONENT_SELECTOR = '[data-popover-content]'; const COMPONENT_TYPE = 'popover'; @@ -49,8 +49,8 @@ class Popover extends Component { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._popoverElem = null; - this._floatingCleanup = null; - this._autoUpdateCleanup = null; + this._floating = null; + this._cleanupAutoUpdate = null; this._animationAbortController = null; this._animationTimeout = null; this._shown = false; @@ -301,7 +301,8 @@ class Popover extends Component { this._popoverElem = document.createElement('section'); if (this._config.portal) { - document.body.appendChild(this._popoverElem); + // Portal handled by createFloating; element appended during + // _configurePopoverFloating via portalElement. this._isPortaled = true; } else { this._config.parent.parentNode.appendChild(this._popoverElem); @@ -360,100 +361,55 @@ class Popover extends Component { ? this._popoverElem.querySelector('.chi-popover__arrow') : null; - 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%)', - }; - - this._updatePosition = function() { - const middleware = [ - offset(self._config.arrow ? 12 : 0), - flip(), - shift(), - ]; - - if (arrowEl) { - middleware.push(arrowMiddleware({ element: arrowEl })); - } + const portalId = this._isPortaled + ? 'chi-popover-portal-' + this.componentCounterNo + : undefined; - if (!self._isPortaled) { - middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); - } - - const strategy = self._isPortaled ? 'fixed' : 'absolute'; - - return computePosition(self._config.parent, self._popoverElem, { - placement: self._config.position, - strategy: strategy, - middleware: middleware, - }).then(({x, y, placement, middlewareData}) => { - Object.assign(self._popoverElem.style, { - position: strategy, - left: `${Util.roundByDPR(x)}px`, - top: `${Util.roundByDPR(y)}px`, - }); - - if (middlewareData.hide) { - self._popoverElem.style.visibility = - middlewareData.hide.referenceHidden ? 'hidden' : ''; - } - - const basePlacement = placement.split('-')[0]; + if (portalId) { + this._elem.setAttribute('data-chi-portal-id', portalId); + } - PLACEMENT_CLASSES.forEach(function(side) { + 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); + }, + }); - if (arrowEl && middlewareData.arrow) { - const {x: arrowX, y: arrowY} = middlewareData.arrow; - const staticSide = OPPOSITE_SIDE[basePlacement]; - - const arrowLen = (basePlacement === 'top' || basePlacement === 'bottom') - ? arrowEl.offsetHeight - : arrowEl.offsetWidth; - - Object.assign(arrowEl.style, { - left: arrowX != null ? `${arrowX}px` : '', - top: arrowY != null ? `${arrowY}px` : '', - right: '', - bottom: '', - [staticSide]: `${-(arrowLen / 2)}px`, - }); - - arrowEl.style.setProperty( - '--chi-arrow-clip', - ARROW_CLIP_PATHS[basePlacement] || 'none' - ); - } - }); + this._updatePosition = function () { + return self._floating.update(); }; } _enableAutoUpdate() { this._disableAutoUpdate(); - const self = this; - this._autoUpdateCleanup = floatingAutoUpdate( - self._config.parent, - self._popoverElem, - function() { self._updatePosition(); } - ); + if (this._floating) { + this._cleanupAutoUpdate = this._floating.enableAutoUpdate(); + } } _disableAutoUpdate() { - if (this._autoUpdateCleanup) { - this._autoUpdateCleanup(); - this._autoUpdateCleanup = null; + if (this._cleanupAutoUpdate) { + this._cleanupAutoUpdate(); + this._cleanupAutoUpdate = null; } } @@ -477,12 +433,14 @@ class Popover extends Component { this._animationTimeout = null; } this._removeEventHandlers(); - if (this._popoverElem && this._popoverElem.parentNode) { + 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._floatingCleanup = null; - this._autoUpdateCleanup = null; + this._cleanupAutoUpdate = null; this._isPortaled = false; this._config = null; @@ -490,6 +448,9 @@ class Popover extends Component { 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 679d9bb076..3267bac257 100644 --- a/src/chi/javascript/components/tooltip.js +++ b/src/chi/javascript/components/tooltip.js @@ -1,7 +1,7 @@ -import {computePosition, autoUpdate as floatingAutoUpdate, flip, shift, offset, hide as hideMiddleware} from '@floating-ui/dom'; 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]'; @@ -26,7 +26,8 @@ class Tooltip extends Component { super(elem, Util.extend(DEFAULT_CONFIG, config)); this._tooltipElem = null; this._tooltipContent = null; - this._floatingCleanup = null; + this._floating = null; + this._cleanupAutoUpdate = null; this._hovered = false; this._focused = false; this._shown = false; @@ -60,9 +61,6 @@ class Tooltip extends Component { }); this._addEventHandler(this._elem, 'focus', function() { self._focused = true; - if (self._shown) { - self.hide(); - } }); this._addEventHandler(this._elem, 'blur', function() { self._focused = false; @@ -74,12 +72,12 @@ class Tooltip extends Component { show() { this._shown = true; - this._tooltipElem.style.visibility = ''; 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(); } @@ -95,6 +93,7 @@ class Tooltip extends Component { this._disableAutoUpdate(); Util.removeClass(this._tooltipElem, CLASS_ACTIVE); this._tooltipElem.setAttribute('aria-hidden', 'true'); + this._elem.removeAttribute('aria-describedby'); this._elem.dispatchEvent( Util.createEvent(EVENTS.hide) ); @@ -103,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); } @@ -110,53 +110,61 @@ class Tooltip extends Component { this._tooltipContent = document.createElement('span'); this._tooltipContent.innerText = this._elem.dataset.tooltip; this._tooltipElem.appendChild(this._tooltipContent); - // Original Popper.js always appended tooltip to body. - // Default strategy is 'absolute' (matching original + CE default). - // When portal: true, use 'fixed' (matching CE portal behavior). - document.querySelector('body').appendChild(this._tooltipElem); - this._elem.setAttribute('aria-describedby', this._tooltipElem.id); - this._tooltipElem.setAttribute('aria-hidden', 'true'); let self = this; - const strategy = self._config.portal ? 'fixed' : 'absolute'; + const portalId = this._config.portal + ? 'chi-tooltip-portal-' + this.componentCounterNo + : undefined; - this._updatePosition = function () { - const middleware = [offset(8), flip(), shift()]; - if (self._config.hideWhenDetached) { - middleware.push(hideMiddleware({ strategy: 'referenceHidden' })); - } + if (portalId) { + this._elem.setAttribute('data-chi-portal-id', portalId); + } + + // 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, + }); - return computePosition(self._config.parent, self._tooltipElem, { - placement: self._config.position, - strategy: strategy, - middleware, - }).then(({x, y, middlewareData}) => { - Object.assign(self._tooltipElem.style, { - position: strategy, - left: `${Util.roundByDPR(x)}px`, - top: `${Util.roundByDPR(y)}px`, - }); - - if (self._config.hideWhenDetached && middlewareData.hide) { - self._tooltipElem.style.visibility = middlewareData.hide.referenceHidden ? 'hidden' : ''; - } - }); + this._updatePosition = function () { + return self._floating.update(); }; } _enableAutoUpdate() { this._disableAutoUpdate(); - this._floatingCleanup = floatingAutoUpdate( - this._config.parent, - this._tooltipElem, - () => this._updatePosition() - ); + if (this._floating) { + this._cleanupAutoUpdate = this._floating.enableAutoUpdate(); + } } _disableAutoUpdate() { - if (this._floatingCleanup) { - this._floatingCleanup(); - this._floatingCleanup = null; + if (this._cleanupAutoUpdate) { + this._cleanupAutoUpdate(); + this._cleanupAutoUpdate = null; } } @@ -178,14 +186,20 @@ class Tooltip extends Component { dispose() { this._disableAutoUpdate(); - if (this._tooltipElem && this._tooltipElem.parentNode) { + 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._floatingCleanup = null; + this._cleanupAutoUpdate = null; this._config = 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/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 a98f541f49..7af6c64832 100644 --- a/src/chi/themes/brightspeed/index.scss +++ b/src/chi/themes/brightspeed/index.scss @@ -63,6 +63,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 c0490ff83d..bfbcebc4cd 100644 --- a/src/chi/themes/centurylink/index.scss +++ b/src/chi/themes/centurylink/index.scss @@ -63,6 +63,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 e9f84b6b98..421b85522a 100644 --- a/src/chi/themes/colt/index.scss +++ b/src/chi/themes/colt/index.scss @@ -63,6 +63,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/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 9badefbf75..0e4f50befb 100644 --- a/src/chi/themes/connect/index.scss +++ b/src/chi/themes/connect/index.scss @@ -63,6 +63,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 82c02772ad..7911fefda1 100644 --- a/src/chi/themes/lumen/index.scss +++ b/src/chi/themes/lumen/index.scss @@ -63,6 +63,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 93599fe0ee..1428765aac 100644 --- a/src/chi/themes/portal/index.scss +++ b/src/chi/themes/portal/index.scss @@ -63,6 +63,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/sri.json b/sri.json index 793ee8fcd0..216b17cf79 100644 --- a/sri.json +++ b/sri.json @@ -1,12 +1,12 @@ { - "dist/chi.css": "sha256-XEEvw+7g4yyubS2+PZDtEEVUK2Be+vN1v6jnnqFMB28=", - "dist/chi-centurylink.css": "sha256-zkKGUXlrhKK9MXi0A1PQRpG8npmm3th86m5/euejEXQ=", - "dist/chi-portal.css": "sha256-3lKjL0d2q31gchsQcqmfERPayzPbe7Y5BuwvuIEexyU=", - "dist/chi-connect.css": "sha256-dsXn4j3lYUDR5OB+rz9suITTtXlvpGYX/zQrNVQ/LqA=", - "dist/chi-brightspeed.css": "sha256-90MLg5FGCam4t6Tejy0pjSlhBYvI6BTBcDZtvnAQq/U=", - "dist/chi-colt.css": "sha256-FGON54ZWIeHTg6iwambq3uUE6HC1Ayfc8IKYmKZ+Bt4=", - "dist/chi-test.css": "sha256-MHn9YBbb4hccKtGnPOdZBUyiIutcsGdbOy+jAe9A2Js=", - "dist/js/chi.js": "sha256-LwzhmcHdihCTpPgToZ561tziC+0tDkycsWiWJN1Zgl4=", + "dist/chi.css": "sha256-DZB6TxN7pvETNFhQQkC/+IQBi0POrv4r9CULN422LbM=", + "dist/chi-centurylink.css": "sha256-aEDFqcBGFi3Hz7PORuZ+wjchTLNvikh1pVVun9hEY34=", + "dist/chi-portal.css": "sha256-NVUhoBfxyQ+SY1NkXZtOEdiWGhfRhD1u03Wc5rIWXO0=", + "dist/chi-connect.css": "sha256-2lTOt3Z5tg+Ih9KDH86tyD83YWgBl1NoL7Nhc5Q6acg=", + "dist/chi-brightspeed.css": "sha256-g4HsLusehMlqn4KBlyCR9YKa2TE/8PITc9N2tPaFeP0=", + "dist/chi-colt.css": "sha256-er6zHrsr2UNQgSn5OY3S4oE2zRlUuED8Omokbkw3IFU=", + "dist/chi-test.css": "sha256-AtitT2Qk6oXPYp15xdx/h2jh+9rm0AN47a9qB2YUCUo=", + "dist/js/chi.js": "sha256-F4J5bMl7iJbdxDroC+CFkAcvj1TAfM7kYaXFqjScnyU=", "dist/assets/themes/lumen/images/favicon.svg": "sha256-+0ITKaXKx702ZWOzublRl83MJVOIbUMTQG8JvN+76B0=", "dist/assets/themes/lumen/images/favicon.ico": "sha256-EkKmbH+i/VIQAtUl7NF4bPVaaJZCeBc5xWx8LTcMJp0=", "dist/assets/themes/portal/images/favicon.svg": "sha256-+0ITKaXKx702ZWOzublRl83MJVOIbUMTQG8JvN+76B0=",