From 7b8a21c1a5b408d20c382870f316c4bc0b910251 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Mon, 8 Dec 2025 15:21:50 +0100 Subject: [PATCH 1/6] refactor(select): migrate popper to floating UI --- CHANGELOG.md | 1 + package-lock.json | 33 ++++++++++++++++++--- package.json | 2 +- src/components/select/select.tsx | 51 ++++++++++++++++++++++---------- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 275729ec..d0b9ab93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed - **storybook**: upgrade to storybook v10 +- **select**: migrate overlay positioning to floating UI ### Deprecated diff --git a/package-lock.json b/package-lock.json index b33621e1..0ad86d57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "7.3.0", "license": "MIT", "dependencies": { - "@popperjs/core": "^2.9.3", + "@floating-ui/dom": "^1.7.4", "chalk": "^4.1.0", "lodash-es": "^4.17.21", "tippy.js": "^6.3.7", @@ -2597,6 +2597,31 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@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/@github/catalyst": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.7.0.tgz", @@ -8059,9 +8084,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index b9548c14..fe37147d 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "typescript": "4.9.5" }, "dependencies": { - "@popperjs/core": "^2.9.3", + "@floating-ui/dom": "^1.7.4", "chalk": "^4.1.0", "lodash-es": "^4.17.21", "tippy.js": "^6.3.7", diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index d3294902..e9f642a4 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -33,11 +33,11 @@ import { inheritAttributes, setOrRemoveAttribute, compareLists } from '../../utils/helpers'; import { SelectChips } from './select-chips'; -import { createPopper, Instance } from '@popperjs/core'; import { isEqual } from 'lodash-es'; import { getActionForKeyboardEvent, KeyboardEventAssociatedAction } from './select-keyboard-event'; import { isFocusable } from '../../utils/accessibility'; import { AriaAttributeName, MutableAriaAttribute } from "../../utils/mutable-aria-attribute"; +import { autoUpdate, computePosition, ComputePositionConfig, offset } from "@floating-ui/dom"; interface SelectStateSchema { states: { @@ -159,6 +159,11 @@ export class Select implements ComponentInterface, MutableAriaAttribute { private labelElement: HTMLWcsLabelElement; private optionsEl!: HTMLDivElement; private optionsId = generateUniqueId("OPTIONS"); + /** + * Callback use to destroy function ref to auto update floating ui position + * @private + */ + private autoUpdateCleanup: () => void = null; private controlEl!: HTMLDivElement; // Only used for multiples. @@ -259,9 +264,7 @@ export class Select implements ComponentInterface, MutableAriaAttribute { /** Function used to compare options, default : deep comparison. */ @Prop() compareWith?: (optionValue: any, selectedValue: any) => boolean = (optionValue, selectedValue) => isEqual(optionValue, selectedValue); - - private popper: Instance; - + /** * Boolean to toggle the text "No result found" (only for autocomplete with filter) * @private @@ -440,7 +443,7 @@ export class Select implements ComponentInterface, MutableAriaAttribute { this.updateSelectedValue(this.value); } - this.popper = this.createPopperInstance(); + this.applyFloatingOptionsPosition(); // if the select is inside a wcs-form-field, we set an id to the wcs-label if present // the wcs-label element reference is kept to compute aria-label value during the rendering @@ -450,17 +453,30 @@ export class Select implements ComponentInterface, MutableAriaAttribute { } } - private createPopperInstance() { - return createPopper(this.controlEl, this.optionsEl, { - placement: "bottom", - modifiers: [ - { - name: 'offset', - options: { - offset: [0, 4] // we want 4px between select control and select options - } - } + private applyFloatingOptionsPosition() { + if(this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + this.autoUpdateCleanup = null; + } + + const positionConfig: Partial = { + placement: 'bottom', + middleware: [ + offset(4), // we want 4px between select control and select options ] + } + + this.autoUpdateCleanup = autoUpdate(this.controlEl, this.optionsEl, () => { + computePosition(this.controlEl, this.optionsEl, positionConfig).then(({x, y}) => { + Object.assign(this.optionsEl.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + }, { + ancestorResize: true, + ancestorScroll: true, + elementResize: true }); } @@ -631,6 +647,9 @@ export class Select implements ComponentInterface, MutableAriaAttribute { } disconnectedCallback() { + if (this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + } this.stateService?.stop(); } @@ -1064,7 +1083,7 @@ export class Select implements ComponentInterface, MutableAriaAttribute { } componentDidRender() { - this.popper?.update(); + this.applyFloatingOptionsPosition(); } private focusedAttributes() { -- GitLab From a6f216d402b05c49b490e4972880a1559afa5505 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Wed, 17 Dec 2025 11:48:07 +0100 Subject: [PATCH 2/6] refactor(dropdown): migrate from popper to floating UI --- CHANGELOG.md | 1 + src/components/dropdown/dropdown.scss | 36 ++----- src/components/dropdown/dropdown.tsx | 131 +++++++++++++++++++++----- 3 files changed, 118 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b9ab93..a984cf78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **storybook**: upgrade to storybook v10 - **select**: migrate overlay positioning to floating UI +- **dropdown**: migrate overlay positioning to floating UI ### Deprecated diff --git a/src/components/dropdown/dropdown.scss b/src/components/dropdown/dropdown.scss index d6fd556c..b86ad1d9 100644 --- a/src/components/dropdown/dropdown.scss +++ b/src/components/dropdown/dropdown.scss @@ -61,6 +61,9 @@ button:focus-visible + wcs-button { } .popover { + position: absolute; + top: 0; + left: 0; display: none; border: var(--wcs-dropdown-overlay-border-width) solid var(--wcs-dropdown-overlay-border-color); border-radius: var(--wcs-dropdown-overlay-border-radius); @@ -109,24 +112,15 @@ button:focus-visible + wcs-button { } // Popover arrow -#arrow, -#arrow::before { +#arrow { position: absolute; + transform: rotate(45deg); width: 8px; height: 8px; background: inherit; border: var(--wcs-dropdown-overlay-border-width) solid var(--wcs-dropdown-overlay-border-color); -} - -#arrow { - visibility: hidden; - z-index: -1; -} - -#arrow::before { - visibility: visible; - content: ''; - transform: rotate(45deg); + z-index: -1; // to position it behind the content + pointer-events: none; } #is-empty { @@ -134,19 +128,3 @@ button:focus-visible + wcs-button { width: 20ch; padding: 0 var(--wcs-dropdown-padding-empty); } - -.popover[data-popper-placement^='top'] > #arrow { - bottom: -5px; -} - -.popover[data-popper-placement^='bottom'] > #arrow { - top: -6px; -} - -.popover[data-popper-placement^='left'] > #arrow { - right: -4px; -} - -.popover[data-popper-placement^='right'] > #arrow { - left: -6px; -} diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index 149e51b6..2eb79690 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -5,7 +5,15 @@ import { WcsButtonShape, WcsButtonSize, } from '../button/button-interface'; -import { createPopper, Instance } from '@popperjs/core'; +import { + arrow, + autoPlacement, + autoUpdate, + computePosition, + ComputePositionConfig, + offset, + Placement +} from '@floating-ui/dom'; import { WcsDropdownPlacement } from './dropdown-interface'; import { clickTargetIsElementOrChildren, @@ -72,6 +80,7 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { * @private */ private popoverDiv!: HTMLDivElement; + private arrowEl!: HTMLElement; /** Hides the arrow in the button */ @Prop({ reflect: true }) noArrow: boolean = false; @@ -93,8 +102,17 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { @State() private expanded: boolean = false; - - private popper: Instance; + /** + * Callback used to destroy function ref to auto update floating ui position + * @private + */ + private autoUpdateCleanup: () => void = null; + + /** + * Store the current placement to pass to the position config + * @private + */ + private currentPlacement: WcsDropdownPlacement; private lastFocusedItemElement: HTMLWcsDropdownItemElement | null; @@ -104,10 +122,8 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { @Watch('placement') protected placementChange() { - this.popper.setOptions({ - ...this.popper.state.options, - placement: this.placement - }).then(_ => this.popper.update()); + this.currentPlacement = this.placement; + this.applyFloatingPopoverPosition(); } @Listen('blur') @@ -117,19 +133,88 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { } componentDidLoad() { - this.popper = createPopper(this.wcsButton, this.popoverDiv, { - placement: this.placement, - modifiers: [ - { - name: 'offset', - options: { - offset: [0, 8] - } - } + this.currentPlacement = this.placement; + this.applyFloatingPopoverPosition(); + + this.fixForFirefoxBelow63(); + } + + private applyFloatingPopoverPosition() { + if (this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + this.autoUpdateCleanup = null; + } + + // see https://gitlab.com/SNCF/wcs/-/merge_requests/636#fl%C3%A8che-dropdown + const floatingOffset = Math.sqrt(2 * this.arrowEl.offsetWidth ** 2) / 2; + + const positionConfig: Partial = { + middleware: [ + offset(floatingOffset), + arrow({element: this.arrowEl}), + ...this.getAutoPlacementMiddleware() ] + }; + + // Only set placement if it's not an 'auto' variant (handled by autoPlacement middleware) + if (!this.currentPlacement.startsWith('auto')) { + positionConfig.placement = this.currentPlacement as Placement; + } + + this.autoUpdateCleanup = autoUpdate(this.wcsButton, this.popoverDiv, () => { + computePosition(this.wcsButton, this.popoverDiv, positionConfig).then((computePositionData) => { + Object.assign(this.popoverDiv.style, { + left: `${computePositionData.x}px`, + top: `${computePositionData.y}px`, + }); + + if (computePositionData.middlewareData.arrow) { + const side = computePositionData.placement.split("-")[0]; + + const staticSide = { + top: "bottom", + right: "left", + bottom: "top", + left: "right" + }[side]; + + const { x, y } = computePositionData.middlewareData.arrow; + + Object.assign(this.arrowEl.style, { + left: x != null ? `${x}px` : '', + top: y != null ? `${y}px` : '', + // Ensure the static side gets unset when + // flipping to other placements' axes. + right: "", + bottom: "", + [staticSide]: `${-this.arrowEl.offsetWidth / 2}px`, + }); + } + }); + }, { + ancestorResize: true, + ancestorScroll: true, + elementResize: true }); + } - this.fixForFirefoxBelow63(); + /** + * Returns autoPlacement middleware if placement is 'auto', 'auto-start' or 'auto-end'. + * In Floating UI, 'auto' placement is handled via the autoPlacement() middleware. + * @private + */ + private getAutoPlacementMiddleware() { + if (this.currentPlacement === 'auto') { + return [autoPlacement()]; + } + if (this.currentPlacement === 'auto-start') { + return [autoPlacement({ alignment: 'start' })]; + } + if (this.currentPlacement === 'auto-end') { + return [autoPlacement({ alignment: 'end' })]; + } + + return []; } private fixForFirefoxBelow63() { @@ -278,9 +363,7 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { } componentDidRender() { - if (this.popper) { - this.popper.update(); - } + this.applyFloatingPopoverPosition(); } componentWillLoad(): Promise | void { @@ -289,6 +372,12 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { ...inheritAttributes(this.el, DROPDOWN_INHERITED_ATTRS), }; } + + disconnectedCallback() { + if (this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + } + } render() { return ( @@ -320,7 +409,7 @@ export class Dropdown implements ComponentInterface, MutableAriaAttribute { aria-labelledby="dropdown-button" tabindex={-1} ref={el => this.popoverDiv = el}> -