@hashicorp/design-system-components
Version:
Helios Design System Components
190 lines (172 loc) • 8.64 kB
JavaScript
import { modifier } from 'ember-modifier';
import { assert } from '@ember/debug';
import { autoUpdate, limitShift, offset, flip, shift, autoPlacement, arrow, size, computePosition } from '@floating-ui/dom';
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
let HdsEnableCollisionDetectionOptions = /*#__PURE__*/function (HdsEnableCollisionDetectionOptions) {
HdsEnableCollisionDetectionOptions["Shift"] = "shift";
HdsEnableCollisionDetectionOptions["Flip"] = "flip";
HdsEnableCollisionDetectionOptions["Auto"] = "auto";
return HdsEnableCollisionDetectionOptions;
}({});
const DEFAULT_PLACEMENT = 'bottom';
const PLACEMENTS = ['top', 'top-start', 'top-end', 'right', 'right-start', 'right-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'left-start', 'left-end'];
const ENABLE_COLLISION_DETECTION_OPTIONS = Object.values(HdsEnableCollisionDetectionOptions);
// share the same default value of "padding" for `flip/shift/autoPlacement` options
// this refers to the minimum distance from the boundaries' edges (the viewport)
// before the floating element changes its position (flips, shifts, or autoplace itself)
const DEFAULT_EDGE_DISTANCE = 8;
// we use this function to process all the options provided to the modifier in a single place,
// in relation to the Floating UI APIs, and keep the modifier code more clean/simple
const getFloatingUIOptions = options => {
const {
placement = DEFAULT_PLACEMENT,
strategy = 'absolute',
// we don't need to use `fixed` if we use the Popover API for the "floating" element (it puts the element in the `top-layer`)
offsetOptions,
flipOptions = {
padding: DEFAULT_EDGE_DISTANCE
},
shiftOptions = {
padding: DEFAULT_EDGE_DISTANCE,
limiter: limitShift()
},
autoPlacementOptions = {
padding: DEFAULT_EDGE_DISTANCE
},
middlewareExtra = [],
enableCollisionDetection,
arrowElement,
arrowPadding,
matchToggleWidth
} = options;
// we build dynamically the list of middleware functions to invoke, depending on the options provided
const middleware = [];
// https://floating-ui.com/docs/offset
middleware.push(offset(offsetOptions));
// https://floating-ui.com/docs/flip
// https://floating-ui.com/docs/shift
// https://floating-ui.com/docs/autoPlacement
if (enableCollisionDetection === true || enableCollisionDetection === 'flip') {
middleware.push(flip(flipOptions));
}
if (enableCollisionDetection === true || enableCollisionDetection === 'shift') {
middleware.push(shift(shiftOptions));
}
if (enableCollisionDetection === 'auto') {
middleware.push(autoPlacement(autoPlacementOptions));
}
// https://floating-ui.com/docs/arrow
if (arrowElement) {
middleware.push(arrow({
element: arrowElement,
padding: arrowPadding ?? 0
}));
}
// https://floating-ui.com/docs/size#match-reference-width
if (matchToggleWidth) {
middleware.push(size({
apply({
rects,
elements
}) {
// wrap Object.assign inside a requestAnimationFrame to avoid ResizeObserver loop limit exceeded error
// https://github.com/floating-ui/floating-ui/issues/1740
requestAnimationFrame(() => {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`
});
});
}
}));
}
middleware.push(...middlewareExtra);
return {
placement,
strategy,
middleware
};
};
// Notice: we use a function-based modifier here instead of a class-based one
// because it's quite simple in its logic, and doesn't require injecting services
// see: https://github.com/ember-modifier/ember-modifier#function-based-modifiers
var anchoredPositionModifier = modifier((element, positional, named = {}) => {
// the element that "floats" next to the "anchor" (whose position is calculated in relation to the anchor)
// notice: this is the element the Ember modifier is attached to
const _floatingElement = element;
// the element that acts as an "anchor" for the "floating" element
// it can be a DOM (string) selector or a DOM element
// notice: it's expressed as "positional" argument (array of arguments) for the modifier
const _anchorTarget = positional[0];
const _anchorElement = typeof _anchorTarget === 'string' ? document.querySelector(_anchorTarget) : _anchorTarget;
assert('`hds-anchored-position` modifier - the provided "anchoring" element is not defined correctly', _anchorElement instanceof HTMLElement || _anchorElement instanceof SVGElement);
// the "arrow" element (optional) associated with the "floating" element
// it can be a DOM selector (string) or a DOM element
// notice: it's declared inside the "named" argument (object) for the modifier
// but we need to extract it also here so it can be used to assign inline styles to it
let arrowElement;
if (named.arrowElement) {
assert('`hds-anchored-position` modifier - the `element` provided for the "arrow" element is not a valid DOM node', named.arrowElement instanceof HTMLElement || named.arrowElement instanceof SVGElement);
arrowElement = named.arrowElement;
} else if (named.arrowSelector) {
assert('`hds-anchored-position` modifier - the `selector` provided for the "arrow" element must be a string', typeof named.arrowSelector === 'string');
const selectedArrowElement = document.querySelector(named.arrowSelector);
if (selectedArrowElement instanceof HTMLElement) {
arrowElement = selectedArrowElement;
} else {
assert('`hds-anchored-position` modifier - the `selector` provided for the "arrow" element is not a valid DOM selector');
}
}
// the Floating UI "options" to apply to the "floating" element
// notice: we spread the `named` argument and override its `arrowElement` value instead of setting it directly because Ember complains that modifier's arguments must be immutable
const floatingOptions = getFloatingUIOptions({
...named,
arrowElement
});
const computeFloatingPosition = async () => {
// important to know: `computePosition()` is not stateful, it only positions the "floating" element once
// see: https://floating-ui.com/docs/computePosition
const state = await computePosition(_anchorElement, _floatingElement, floatingOptions);
const {
x,
y,
placement,
strategy,
middlewareData
} = state;
Object.assign(_floatingElement.style, {
position: strategy,
top: `${y}px`,
left: `${x}px`
// TODO? commenting this for now, will need to make this conditional to some argument (and understand how this relates to the `@height` argument)
// maxHeight: `${middlewareData.size.availableHeight - 10}px`,
});
if (arrowElement && middlewareData.arrow) {
// we assign a "data" attribute to the "arrow" element so we can use CSS (in the consuming components) to position/rotate it accordingly and we avoid calculating at runtime values that technically we already know
// (similar to what Tippy.js does: https://github.com/atomiks/tippyjs/blob/master/src/scss/svg-arrow.scss)
// IMPORTANT: floating-ui assumes the "arrow" container is square!
arrowElement.setAttribute('data-hds-anchored-arrow-placement', placement);
// we set `x` or `y` value (depends on the position of the arrow in relation to the "floating" element placement)
// see: https://floating-ui.com/docs/arrow#usage
Object.assign(arrowElement.style, {
left: middlewareData.arrow.x != null ? `${middlewareData.arrow.x}px` : '',
top: middlewareData.arrow.y != null ? `${middlewareData.arrow.y}px` : ''
});
}
};
// the `autoUpdate` function automatically updates the position of the floating element when necessary.
// it should only be called when the floating element is mounted on the DOM or visible on the screen.
// it returns a "cleanup" function that should be invoked when the floating element is removed from the DOM or hidden from the screen.
// see: https://floating-ui.com/docs/autoUpdate
const cleanupFloatingUI = autoUpdate(_anchorElement, _floatingElement,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
computeFloatingPosition);
// this (teardown) function is run when the element is removed from the DOM
return () => {
cleanupFloatingUI();
};
});
export { DEFAULT_PLACEMENT, ENABLE_COLLISION_DETECTION_OPTIONS, HdsEnableCollisionDetectionOptions, PLACEMENTS, anchoredPositionModifier as default, getFloatingUIOptions };
//# sourceMappingURL=hds-anchored-position.js.map