diff --git a/addon/private/modifiers/floating-ui.js b/addon/private/modifiers/floating-ui.js new file mode 100644 index 000000000..11966811f --- /dev/null +++ b/addon/private/modifiers/floating-ui.js @@ -0,0 +1,156 @@ +import { modifier } from 'ember-modifier'; +import { assert } from '@ember/debug'; + +import { + autoUpdate, + computePosition, + flip, + hide, + offset, + arrow, +} from '@floating-ui/dom'; + +import { merge } from 'merge-anything'; + +export default modifier( + ( + floatingElement, + [_referenceElement, _arrowElement], + { defaultPlacement = 'bottom-start', options = {} } + ) => { + const referenceElement = + typeof _referenceElement === 'string' + ? document.querySelector(_referenceElement) + : _referenceElement; + + const arrowElement = + typeof _arrowElement === 'string' + ? document.querySelector(_arrowElement) + : _arrowElement; + + const defaultOptions = { + floater: { + offset: 6, + }, + arrow: { + offset: 4, + padding: 3, + position: 'min(15%, 12px)', + }, + }; + options = merge(defaultOptions, options); + + assert( + `FloatingUI (modifier): No reference element was defined.`, + referenceElement instanceof HTMLElement + ); + + assert( + `FloatingUI (modifier): The reference and floating elements cannot be the same element.`, + floatingElement !== referenceElement + ); + + assert( + `FloatingUI (modifier): @placement must start with either 'bottom' or 'top'.`, + defaultPlacement.startsWith('bottom') || + defaultPlacement.startsWith('top') + ); + + Object.assign(floatingElement.style, { + position: 'fixed', + top: '0', + left: '0', + }); + + let middleware = [ + offset(options.floater.offset), + flip(), + hide({ strategy: 'referenceHidden' }), + hide({ strategy: 'escaped' }), + ]; + + if (arrowElement) { + middleware.push( + arrow({ + element: arrowElement, + padding: options.arrow.padding, + }) + ); + } + + let update = async () => { + let { x, y, placement, middlewareData } = await computePosition( + referenceElement, + floatingElement, + { + middleware, + placement: defaultPlacement, + } + ); + + Object.assign(floatingElement.style, { + transform: `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`, + visibility: middlewareData.hide?.referenceHidden ? 'hidden' : 'visible', + }); + + if (middlewareData.arrow) { + const { x } = middlewareData.arrow; + const [side, alignment] = placement.split('-'); + const isAligned = alignment != null; + + const unsetSides = { + top: '', + bottom: '', + left: '', + right: '', + }; + + const mainSide = { + top: 'bottom', + bottom: 'top', + }[side]; + + const rotation = { + top: '180deg', + bottom: '0deg', + }[side]; + + const crossSide = { + 'top-start': 'left', + 'top-end': 'right', + 'bottom-start': 'left', + 'bottom-end': 'right', + }[placement]; + + Object.assign(arrowElement.style, { + ...unsetSides, + transform: `rotate(${rotation})`, + }); + + if (isAligned) { + Object.assign(arrowElement.style, { + [crossSide]: + typeof options.arrow.position === 'string' + ? options.arrow.position + : `${options.arrow.position}px`, + }); + } else { + Object.assign(arrowElement.style, { + left: x != null ? `${x}px` : '', + }); + } + + Object.assign(arrowElement.style, { + [mainSide]: `${-options.arrow.offset}px`, + }); + } + }; + + let cleanup = autoUpdate(referenceElement, floatingElement, update); + + return () => { + cleanup(); + }; + }, + { eager: false } +); diff --git a/package-lock.json b/package-lock.json index 81eacd178..fd7d9cc55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1888,6 +1888,19 @@ } } }, + "@floating-ui/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.1.tgz", + "integrity": "sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==" + }, + "@floating-ui/dom": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.4.tgz", + "integrity": "sha512-maYJRv+sAXTy4K9mzdv0JPyNW5YPVHrqtY90tEdI6XNpuLOP26Ci2pfwPsKBA/Wh4Z3FX5sUrtUFTdMYj9v+ug==", + "requires": { + "@floating-ui/core": "^1.0.1" + } + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -21796,6 +21809,11 @@ "get-intrinsic": "^1.1.1" } }, + "is-what": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz", + "integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ==" + }, "is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -23423,6 +23441,14 @@ } } }, + "merge-anything": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.3.tgz", + "integrity": "sha512-pMb85+QShjqye+99Dkrg9m6EbTjDXwZFQbPysx/lNkuwjT+UJZlQvpnOy0P8kgGXzUx8iWSoNQel5QJjoyWHmQ==", + "requires": { + "is-what": "^4.1.7" + } + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", diff --git a/package.json b/package.json index 3b5a3c3ee..9151e565c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "dependencies": { "@duetds/date-picker": "^1.4.0", "@embroider/macros": "^1.9.0", + "@floating-ui/dom": "^1.0.4", "@glimmer/component": "^1.1.2", "@glimmer/tracking": "^1.1.2", "@zestia/ember-auto-focus": "^4.2.0", @@ -65,6 +66,7 @@ "ember-modifier": "^3.2.7", "ember-named-blocks-polyfill": "^0.2.5", "ember-test-selectors": "^6.0.0", + "merge-anything": "^5.1.3", "tracked-toolbox": "^1.2.3" }, "devDependencies": { diff --git a/tests/dummy/app/styles/app.scss b/tests/dummy/app/styles/app.scss index 85727b29e..fdd904cc3 100644 --- a/tests/dummy/app/styles/app.scss +++ b/tests/dummy/app/styles/app.scss @@ -2,10 +2,10 @@ @import 'app/styles/ember-appuniversum.scss'; // DUMMY STYLES -@import "d-component"; -@import "d-swatch"; -@import "d-editor-chrome"; -@import "d-editor-mockup"; +@import 'd-component'; +@import 'd-swatch'; +@import 'd-editor-chrome'; +@import 'd-editor-mockup'; // QUICK FIXES AND HACKS -@import "shame"; +@import 'shame'; diff --git a/tests/integration/private/modifiers/floating-ui-test.js b/tests/integration/private/modifiers/floating-ui-test.js new file mode 100644 index 000000000..44e0b3ca9 --- /dev/null +++ b/tests/integration/private/modifiers/floating-ui-test.js @@ -0,0 +1,25 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +import FloatingUiModifier from '@appuniversum/ember-appuniversum/private/modifiers/floating-ui'; + +module('Integration | Private Modifier | floating-ui', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + this.set('floatingUi', FloatingUiModifier); + + await render(hbs` + +