386 lines
13 KiB
JavaScript
386 lines
13 KiB
JavaScript
// Composables
|
|
import { useToggleScope } from "../../composables/toggleScope.mjs"; // Utilities
|
|
import { computed, nextTick, onScopeDispose, ref, watch } from 'vue';
|
|
import { anchorToPoint, getOffset } from "./util/point.mjs";
|
|
import { clamp, consoleError, convertToUnit, destructComputed, flipAlign, flipCorner, flipSide, getAxis, getScrollParents, IN_BROWSER, isFixedPosition, nullifyTransforms, parseAnchor, propsFactory } from "../../util/index.mjs";
|
|
import { Box, getOverflow, getTargetBox } from "../../util/box.mjs"; // Types
|
|
const locationStrategies = {
|
|
static: staticLocationStrategy,
|
|
// specific viewport position, usually centered
|
|
connected: connectedLocationStrategy // connected to a certain element
|
|
};
|
|
export const makeLocationStrategyProps = propsFactory({
|
|
locationStrategy: {
|
|
type: [String, Function],
|
|
default: 'static',
|
|
validator: val => typeof val === 'function' || val in locationStrategies
|
|
},
|
|
location: {
|
|
type: String,
|
|
default: 'bottom'
|
|
},
|
|
origin: {
|
|
type: String,
|
|
default: 'auto'
|
|
},
|
|
offset: [Number, String, Array]
|
|
}, 'VOverlay-location-strategies');
|
|
export function useLocationStrategies(props, data) {
|
|
const contentStyles = ref({});
|
|
const updateLocation = ref();
|
|
if (IN_BROWSER) {
|
|
useToggleScope(() => !!(data.isActive.value && props.locationStrategy), reset => {
|
|
watch(() => props.locationStrategy, reset);
|
|
onScopeDispose(() => {
|
|
window.removeEventListener('resize', onResize);
|
|
updateLocation.value = undefined;
|
|
});
|
|
window.addEventListener('resize', onResize, {
|
|
passive: true
|
|
});
|
|
if (typeof props.locationStrategy === 'function') {
|
|
updateLocation.value = props.locationStrategy(data, props, contentStyles)?.updateLocation;
|
|
} else {
|
|
updateLocation.value = locationStrategies[props.locationStrategy](data, props, contentStyles)?.updateLocation;
|
|
}
|
|
});
|
|
}
|
|
function onResize(e) {
|
|
updateLocation.value?.(e);
|
|
}
|
|
return {
|
|
contentStyles,
|
|
updateLocation
|
|
};
|
|
}
|
|
function staticLocationStrategy() {
|
|
// TODO
|
|
}
|
|
|
|
/** Get size of element ignoring max-width/max-height */
|
|
function getIntrinsicSize(el, isRtl) {
|
|
// const scrollables = new Map<Element, [number, number]>()
|
|
// el.querySelectorAll('*').forEach(el => {
|
|
// const x = el.scrollLeft
|
|
// const y = el.scrollTop
|
|
// if (x || y) {
|
|
// scrollables.set(el, [x, y])
|
|
// }
|
|
// })
|
|
|
|
// const initialMaxWidth = el.style.maxWidth
|
|
// const initialMaxHeight = el.style.maxHeight
|
|
// el.style.removeProperty('max-width')
|
|
// el.style.removeProperty('max-height')
|
|
|
|
if (isRtl) {
|
|
el.style.removeProperty('left');
|
|
} else {
|
|
el.style.removeProperty('right');
|
|
}
|
|
|
|
/* eslint-disable-next-line sonarjs/prefer-immediate-return */
|
|
const contentBox = nullifyTransforms(el);
|
|
if (isRtl) {
|
|
contentBox.x += parseFloat(el.style.right || 0);
|
|
} else {
|
|
contentBox.x -= parseFloat(el.style.left || 0);
|
|
}
|
|
contentBox.y -= parseFloat(el.style.top || 0);
|
|
|
|
// el.style.maxWidth = initialMaxWidth
|
|
// el.style.maxHeight = initialMaxHeight
|
|
// scrollables.forEach((position, el) => {
|
|
// el.scrollTo(...position)
|
|
// })
|
|
|
|
return contentBox;
|
|
}
|
|
function connectedLocationStrategy(data, props, contentStyles) {
|
|
const activatorFixed = Array.isArray(data.target.value) || isFixedPosition(data.target.value);
|
|
if (activatorFixed) {
|
|
Object.assign(contentStyles.value, {
|
|
position: 'fixed',
|
|
top: 0,
|
|
[data.isRtl.value ? 'right' : 'left']: 0
|
|
});
|
|
}
|
|
const {
|
|
preferredAnchor,
|
|
preferredOrigin
|
|
} = destructComputed(() => {
|
|
const parsedAnchor = parseAnchor(props.location, data.isRtl.value);
|
|
const parsedOrigin = props.origin === 'overlap' ? parsedAnchor : props.origin === 'auto' ? flipSide(parsedAnchor) : parseAnchor(props.origin, data.isRtl.value);
|
|
|
|
// Some combinations of props may produce an invalid origin
|
|
if (parsedAnchor.side === parsedOrigin.side && parsedAnchor.align === flipAlign(parsedOrigin).align) {
|
|
return {
|
|
preferredAnchor: flipCorner(parsedAnchor),
|
|
preferredOrigin: flipCorner(parsedOrigin)
|
|
};
|
|
} else {
|
|
return {
|
|
preferredAnchor: parsedAnchor,
|
|
preferredOrigin: parsedOrigin
|
|
};
|
|
}
|
|
});
|
|
const [minWidth, minHeight, maxWidth, maxHeight] = ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'].map(key => {
|
|
return computed(() => {
|
|
const val = parseFloat(props[key]);
|
|
return isNaN(val) ? Infinity : val;
|
|
});
|
|
});
|
|
const offset = computed(() => {
|
|
if (Array.isArray(props.offset)) {
|
|
return props.offset;
|
|
}
|
|
if (typeof props.offset === 'string') {
|
|
const offset = props.offset.split(' ').map(parseFloat);
|
|
if (offset.length < 2) offset.push(0);
|
|
return offset;
|
|
}
|
|
return typeof props.offset === 'number' ? [props.offset, 0] : [0, 0];
|
|
});
|
|
let observe = false;
|
|
const observer = new ResizeObserver(() => {
|
|
if (observe) updateLocation();
|
|
});
|
|
watch([data.target, data.contentEl], (_ref, _ref2) => {
|
|
let [newTarget, newContentEl] = _ref;
|
|
let [oldTarget, oldContentEl] = _ref2;
|
|
if (oldTarget && !Array.isArray(oldTarget)) observer.unobserve(oldTarget);
|
|
if (newTarget && !Array.isArray(newTarget)) observer.observe(newTarget);
|
|
if (oldContentEl) observer.unobserve(oldContentEl);
|
|
if (newContentEl) observer.observe(newContentEl);
|
|
}, {
|
|
immediate: true
|
|
});
|
|
onScopeDispose(() => {
|
|
observer.disconnect();
|
|
});
|
|
|
|
// eslint-disable-next-line max-statements
|
|
function updateLocation() {
|
|
observe = false;
|
|
requestAnimationFrame(() => observe = true);
|
|
if (!data.target.value || !data.contentEl.value) return;
|
|
const targetBox = getTargetBox(data.target.value);
|
|
const contentBox = getIntrinsicSize(data.contentEl.value, data.isRtl.value);
|
|
const scrollParents = getScrollParents(data.contentEl.value);
|
|
const viewportMargin = 12;
|
|
if (!scrollParents.length) {
|
|
scrollParents.push(document.documentElement);
|
|
if (!(data.contentEl.value.style.top && data.contentEl.value.style.left)) {
|
|
contentBox.x -= parseFloat(document.documentElement.style.getPropertyValue('--v-body-scroll-x') || 0);
|
|
contentBox.y -= parseFloat(document.documentElement.style.getPropertyValue('--v-body-scroll-y') || 0);
|
|
}
|
|
}
|
|
const viewport = scrollParents.reduce((box, el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
const scrollBox = new Box({
|
|
x: el === document.documentElement ? 0 : rect.x,
|
|
y: el === document.documentElement ? 0 : rect.y,
|
|
width: el.clientWidth,
|
|
height: el.clientHeight
|
|
});
|
|
if (box) {
|
|
return new Box({
|
|
x: Math.max(box.left, scrollBox.left),
|
|
y: Math.max(box.top, scrollBox.top),
|
|
width: Math.min(box.right, scrollBox.right) - Math.max(box.left, scrollBox.left),
|
|
height: Math.min(box.bottom, scrollBox.bottom) - Math.max(box.top, scrollBox.top)
|
|
});
|
|
}
|
|
return scrollBox;
|
|
}, undefined);
|
|
viewport.x += viewportMargin;
|
|
viewport.y += viewportMargin;
|
|
viewport.width -= viewportMargin * 2;
|
|
viewport.height -= viewportMargin * 2;
|
|
let placement = {
|
|
anchor: preferredAnchor.value,
|
|
origin: preferredOrigin.value
|
|
};
|
|
function checkOverflow(_placement) {
|
|
const box = new Box(contentBox);
|
|
const targetPoint = anchorToPoint(_placement.anchor, targetBox);
|
|
const contentPoint = anchorToPoint(_placement.origin, box);
|
|
let {
|
|
x,
|
|
y
|
|
} = getOffset(targetPoint, contentPoint);
|
|
switch (_placement.anchor.side) {
|
|
case 'top':
|
|
y -= offset.value[0];
|
|
break;
|
|
case 'bottom':
|
|
y += offset.value[0];
|
|
break;
|
|
case 'left':
|
|
x -= offset.value[0];
|
|
break;
|
|
case 'right':
|
|
x += offset.value[0];
|
|
break;
|
|
}
|
|
switch (_placement.anchor.align) {
|
|
case 'top':
|
|
y -= offset.value[1];
|
|
break;
|
|
case 'bottom':
|
|
y += offset.value[1];
|
|
break;
|
|
case 'left':
|
|
x -= offset.value[1];
|
|
break;
|
|
case 'right':
|
|
x += offset.value[1];
|
|
break;
|
|
}
|
|
box.x += x;
|
|
box.y += y;
|
|
box.width = Math.min(box.width, maxWidth.value);
|
|
box.height = Math.min(box.height, maxHeight.value);
|
|
const overflows = getOverflow(box, viewport);
|
|
return {
|
|
overflows,
|
|
x,
|
|
y
|
|
};
|
|
}
|
|
let x = 0;
|
|
let y = 0;
|
|
const available = {
|
|
x: 0,
|
|
y: 0
|
|
};
|
|
const flipped = {
|
|
x: false,
|
|
y: false
|
|
};
|
|
let resets = -1;
|
|
while (true) {
|
|
if (resets++ > 10) {
|
|
consoleError('Infinite loop detected in connectedLocationStrategy');
|
|
break;
|
|
}
|
|
const {
|
|
x: _x,
|
|
y: _y,
|
|
overflows
|
|
} = checkOverflow(placement);
|
|
x += _x;
|
|
y += _y;
|
|
contentBox.x += _x;
|
|
contentBox.y += _y;
|
|
|
|
// flip
|
|
{
|
|
const axis = getAxis(placement.anchor);
|
|
const hasOverflowX = overflows.x.before || overflows.x.after;
|
|
const hasOverflowY = overflows.y.before || overflows.y.after;
|
|
let reset = false;
|
|
['x', 'y'].forEach(key => {
|
|
if (key === 'x' && hasOverflowX && !flipped.x || key === 'y' && hasOverflowY && !flipped.y) {
|
|
const newPlacement = {
|
|
anchor: {
|
|
...placement.anchor
|
|
},
|
|
origin: {
|
|
...placement.origin
|
|
}
|
|
};
|
|
const flip = key === 'x' ? axis === 'y' ? flipAlign : flipSide : axis === 'y' ? flipSide : flipAlign;
|
|
newPlacement.anchor = flip(newPlacement.anchor);
|
|
newPlacement.origin = flip(newPlacement.origin);
|
|
const {
|
|
overflows: newOverflows
|
|
} = checkOverflow(newPlacement);
|
|
if (newOverflows[key].before <= overflows[key].before && newOverflows[key].after <= overflows[key].after || newOverflows[key].before + newOverflows[key].after < (overflows[key].before + overflows[key].after) / 2) {
|
|
placement = newPlacement;
|
|
reset = flipped[key] = true;
|
|
}
|
|
}
|
|
});
|
|
if (reset) continue;
|
|
}
|
|
|
|
// shift
|
|
if (overflows.x.before) {
|
|
x += overflows.x.before;
|
|
contentBox.x += overflows.x.before;
|
|
}
|
|
if (overflows.x.after) {
|
|
x -= overflows.x.after;
|
|
contentBox.x -= overflows.x.after;
|
|
}
|
|
if (overflows.y.before) {
|
|
y += overflows.y.before;
|
|
contentBox.y += overflows.y.before;
|
|
}
|
|
if (overflows.y.after) {
|
|
y -= overflows.y.after;
|
|
contentBox.y -= overflows.y.after;
|
|
}
|
|
|
|
// size
|
|
{
|
|
const overflows = getOverflow(contentBox, viewport);
|
|
available.x = viewport.width - overflows.x.before - overflows.x.after;
|
|
available.y = viewport.height - overflows.y.before - overflows.y.after;
|
|
x += overflows.x.before;
|
|
contentBox.x += overflows.x.before;
|
|
y += overflows.y.before;
|
|
contentBox.y += overflows.y.before;
|
|
}
|
|
break;
|
|
}
|
|
const axis = getAxis(placement.anchor);
|
|
Object.assign(contentStyles.value, {
|
|
'--v-overlay-anchor-origin': `${placement.anchor.side} ${placement.anchor.align}`,
|
|
transformOrigin: `${placement.origin.side} ${placement.origin.align}`,
|
|
// transform: `translate(${pixelRound(x)}px, ${pixelRound(y)}px)`,
|
|
top: convertToUnit(pixelRound(y)),
|
|
left: data.isRtl.value ? undefined : convertToUnit(pixelRound(x)),
|
|
right: data.isRtl.value ? convertToUnit(pixelRound(-x)) : undefined,
|
|
minWidth: convertToUnit(axis === 'y' ? Math.min(minWidth.value, targetBox.width) : minWidth.value),
|
|
maxWidth: convertToUnit(pixelCeil(clamp(available.x, minWidth.value === Infinity ? 0 : minWidth.value, maxWidth.value))),
|
|
maxHeight: convertToUnit(pixelCeil(clamp(available.y, minHeight.value === Infinity ? 0 : minHeight.value, maxHeight.value)))
|
|
});
|
|
return {
|
|
available,
|
|
contentBox
|
|
};
|
|
}
|
|
watch(() => [preferredAnchor.value, preferredOrigin.value, props.offset, props.minWidth, props.minHeight, props.maxWidth, props.maxHeight], () => updateLocation());
|
|
nextTick(() => {
|
|
const result = updateLocation();
|
|
|
|
// TODO: overflowing content should only require a single updateLocation call
|
|
// Icky hack to make sure the content is positioned consistently
|
|
if (!result) return;
|
|
const {
|
|
available,
|
|
contentBox
|
|
} = result;
|
|
if (contentBox.height > available.y) {
|
|
requestAnimationFrame(() => {
|
|
updateLocation();
|
|
requestAnimationFrame(() => {
|
|
updateLocation();
|
|
});
|
|
});
|
|
}
|
|
});
|
|
return {
|
|
updateLocation
|
|
};
|
|
}
|
|
function pixelRound(val) {
|
|
return Math.round(val * devicePixelRatio) / devicePixelRatio;
|
|
}
|
|
function pixelCeil(val) {
|
|
return Math.ceil(val * devicePixelRatio) / devicePixelRatio;
|
|
}
|
|
//# sourceMappingURL=locationStrategies.mjs.map
|