locationStrategies.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // Composables
  2. import { useToggleScope } from "../../composables/toggleScope.mjs"; // Utilities
  3. import { computed, nextTick, onScopeDispose, ref, watch } from 'vue';
  4. import { anchorToPoint, getOffset } from "./util/point.mjs";
  5. import { clamp, consoleError, convertToUnit, destructComputed, flipAlign, flipCorner, flipSide, getAxis, getScrollParents, IN_BROWSER, isFixedPosition, nullifyTransforms, parseAnchor, propsFactory } from "../../util/index.mjs";
  6. import { Box, getOverflow, getTargetBox } from "../../util/box.mjs"; // Types
  7. const locationStrategies = {
  8. static: staticLocationStrategy,
  9. // specific viewport position, usually centered
  10. connected: connectedLocationStrategy // connected to a certain element
  11. };
  12. export const makeLocationStrategyProps = propsFactory({
  13. locationStrategy: {
  14. type: [String, Function],
  15. default: 'static',
  16. validator: val => typeof val === 'function' || val in locationStrategies
  17. },
  18. location: {
  19. type: String,
  20. default: 'bottom'
  21. },
  22. origin: {
  23. type: String,
  24. default: 'auto'
  25. },
  26. offset: [Number, String, Array]
  27. }, 'VOverlay-location-strategies');
  28. export function useLocationStrategies(props, data) {
  29. const contentStyles = ref({});
  30. const updateLocation = ref();
  31. if (IN_BROWSER) {
  32. useToggleScope(() => !!(data.isActive.value && props.locationStrategy), reset => {
  33. watch(() => props.locationStrategy, reset);
  34. onScopeDispose(() => {
  35. window.removeEventListener('resize', onResize);
  36. updateLocation.value = undefined;
  37. });
  38. window.addEventListener('resize', onResize, {
  39. passive: true
  40. });
  41. if (typeof props.locationStrategy === 'function') {
  42. updateLocation.value = props.locationStrategy(data, props, contentStyles)?.updateLocation;
  43. } else {
  44. updateLocation.value = locationStrategies[props.locationStrategy](data, props, contentStyles)?.updateLocation;
  45. }
  46. });
  47. }
  48. function onResize(e) {
  49. updateLocation.value?.(e);
  50. }
  51. return {
  52. contentStyles,
  53. updateLocation
  54. };
  55. }
  56. function staticLocationStrategy() {
  57. // TODO
  58. }
  59. /** Get size of element ignoring max-width/max-height */
  60. function getIntrinsicSize(el, isRtl) {
  61. // const scrollables = new Map<Element, [number, number]>()
  62. // el.querySelectorAll('*').forEach(el => {
  63. // const x = el.scrollLeft
  64. // const y = el.scrollTop
  65. // if (x || y) {
  66. // scrollables.set(el, [x, y])
  67. // }
  68. // })
  69. // const initialMaxWidth = el.style.maxWidth
  70. // const initialMaxHeight = el.style.maxHeight
  71. // el.style.removeProperty('max-width')
  72. // el.style.removeProperty('max-height')
  73. /* eslint-disable-next-line sonarjs/prefer-immediate-return */
  74. const contentBox = nullifyTransforms(el);
  75. if (isRtl) {
  76. contentBox.x += parseFloat(el.style.right || 0);
  77. } else {
  78. contentBox.x -= parseFloat(el.style.left || 0);
  79. }
  80. contentBox.y -= parseFloat(el.style.top || 0);
  81. // el.style.maxWidth = initialMaxWidth
  82. // el.style.maxHeight = initialMaxHeight
  83. // scrollables.forEach((position, el) => {
  84. // el.scrollTo(...position)
  85. // })
  86. return contentBox;
  87. }
  88. function connectedLocationStrategy(data, props, contentStyles) {
  89. const activatorFixed = Array.isArray(data.target.value) || isFixedPosition(data.target.value);
  90. if (activatorFixed) {
  91. Object.assign(contentStyles.value, {
  92. position: 'fixed',
  93. top: 0,
  94. [data.isRtl.value ? 'right' : 'left']: 0
  95. });
  96. }
  97. const {
  98. preferredAnchor,
  99. preferredOrigin
  100. } = destructComputed(() => {
  101. const parsedAnchor = parseAnchor(props.location, data.isRtl.value);
  102. const parsedOrigin = props.origin === 'overlap' ? parsedAnchor : props.origin === 'auto' ? flipSide(parsedAnchor) : parseAnchor(props.origin, data.isRtl.value);
  103. // Some combinations of props may produce an invalid origin
  104. if (parsedAnchor.side === parsedOrigin.side && parsedAnchor.align === flipAlign(parsedOrigin).align) {
  105. return {
  106. preferredAnchor: flipCorner(parsedAnchor),
  107. preferredOrigin: flipCorner(parsedOrigin)
  108. };
  109. } else {
  110. return {
  111. preferredAnchor: parsedAnchor,
  112. preferredOrigin: parsedOrigin
  113. };
  114. }
  115. });
  116. const [minWidth, minHeight, maxWidth, maxHeight] = ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'].map(key => {
  117. return computed(() => {
  118. const val = parseFloat(props[key]);
  119. return isNaN(val) ? Infinity : val;
  120. });
  121. });
  122. const offset = computed(() => {
  123. if (Array.isArray(props.offset)) {
  124. return props.offset;
  125. }
  126. if (typeof props.offset === 'string') {
  127. const offset = props.offset.split(' ').map(parseFloat);
  128. if (offset.length < 2) offset.push(0);
  129. return offset;
  130. }
  131. return typeof props.offset === 'number' ? [props.offset, 0] : [0, 0];
  132. });
  133. let observe = false;
  134. const observer = new ResizeObserver(() => {
  135. if (observe) updateLocation();
  136. });
  137. watch([data.target, data.contentEl], (_ref, _ref2) => {
  138. let [newTarget, newContentEl] = _ref;
  139. let [oldTarget, oldContentEl] = _ref2;
  140. if (oldTarget && !Array.isArray(oldTarget)) observer.unobserve(oldTarget);
  141. if (newTarget && !Array.isArray(newTarget)) observer.observe(newTarget);
  142. if (oldContentEl) observer.unobserve(oldContentEl);
  143. if (newContentEl) observer.observe(newContentEl);
  144. }, {
  145. immediate: true
  146. });
  147. onScopeDispose(() => {
  148. observer.disconnect();
  149. });
  150. // eslint-disable-next-line max-statements
  151. function updateLocation() {
  152. observe = false;
  153. requestAnimationFrame(() => observe = true);
  154. if (!data.target.value || !data.contentEl.value) return;
  155. const targetBox = getTargetBox(data.target.value);
  156. const contentBox = getIntrinsicSize(data.contentEl.value, data.isRtl.value);
  157. const scrollParents = getScrollParents(data.contentEl.value);
  158. const viewportMargin = 12;
  159. if (!scrollParents.length) {
  160. scrollParents.push(document.documentElement);
  161. if (!(data.contentEl.value.style.top && data.contentEl.value.style.left)) {
  162. contentBox.x -= parseFloat(document.documentElement.style.getPropertyValue('--v-body-scroll-x') || 0);
  163. contentBox.y -= parseFloat(document.documentElement.style.getPropertyValue('--v-body-scroll-y') || 0);
  164. }
  165. }
  166. const viewport = scrollParents.reduce((box, el) => {
  167. const rect = el.getBoundingClientRect();
  168. const scrollBox = new Box({
  169. x: el === document.documentElement ? 0 : rect.x,
  170. y: el === document.documentElement ? 0 : rect.y,
  171. width: el.clientWidth,
  172. height: el.clientHeight
  173. });
  174. if (box) {
  175. return new Box({
  176. x: Math.max(box.left, scrollBox.left),
  177. y: Math.max(box.top, scrollBox.top),
  178. width: Math.min(box.right, scrollBox.right) - Math.max(box.left, scrollBox.left),
  179. height: Math.min(box.bottom, scrollBox.bottom) - Math.max(box.top, scrollBox.top)
  180. });
  181. }
  182. return scrollBox;
  183. }, undefined);
  184. viewport.x += viewportMargin;
  185. viewport.y += viewportMargin;
  186. viewport.width -= viewportMargin * 2;
  187. viewport.height -= viewportMargin * 2;
  188. let placement = {
  189. anchor: preferredAnchor.value,
  190. origin: preferredOrigin.value
  191. };
  192. function checkOverflow(_placement) {
  193. const box = new Box(contentBox);
  194. const targetPoint = anchorToPoint(_placement.anchor, targetBox);
  195. const contentPoint = anchorToPoint(_placement.origin, box);
  196. let {
  197. x,
  198. y
  199. } = getOffset(targetPoint, contentPoint);
  200. switch (_placement.anchor.side) {
  201. case 'top':
  202. y -= offset.value[0];
  203. break;
  204. case 'bottom':
  205. y += offset.value[0];
  206. break;
  207. case 'left':
  208. x -= offset.value[0];
  209. break;
  210. case 'right':
  211. x += offset.value[0];
  212. break;
  213. }
  214. switch (_placement.anchor.align) {
  215. case 'top':
  216. y -= offset.value[1];
  217. break;
  218. case 'bottom':
  219. y += offset.value[1];
  220. break;
  221. case 'left':
  222. x -= offset.value[1];
  223. break;
  224. case 'right':
  225. x += offset.value[1];
  226. break;
  227. }
  228. box.x += x;
  229. box.y += y;
  230. box.width = Math.min(box.width, maxWidth.value);
  231. box.height = Math.min(box.height, maxHeight.value);
  232. const overflows = getOverflow(box, viewport);
  233. return {
  234. overflows,
  235. x,
  236. y
  237. };
  238. }
  239. let x = 0;
  240. let y = 0;
  241. const available = {
  242. x: 0,
  243. y: 0
  244. };
  245. const flipped = {
  246. x: false,
  247. y: false
  248. };
  249. let resets = -1;
  250. while (true) {
  251. if (resets++ > 10) {
  252. consoleError('Infinite loop detected in connectedLocationStrategy');
  253. break;
  254. }
  255. const {
  256. x: _x,
  257. y: _y,
  258. overflows
  259. } = checkOverflow(placement);
  260. x += _x;
  261. y += _y;
  262. contentBox.x += _x;
  263. contentBox.y += _y;
  264. // flip
  265. {
  266. const axis = getAxis(placement.anchor);
  267. const hasOverflowX = overflows.x.before || overflows.x.after;
  268. const hasOverflowY = overflows.y.before || overflows.y.after;
  269. let reset = false;
  270. ['x', 'y'].forEach(key => {
  271. if (key === 'x' && hasOverflowX && !flipped.x || key === 'y' && hasOverflowY && !flipped.y) {
  272. const newPlacement = {
  273. anchor: {
  274. ...placement.anchor
  275. },
  276. origin: {
  277. ...placement.origin
  278. }
  279. };
  280. const flip = key === 'x' ? axis === 'y' ? flipAlign : flipSide : axis === 'y' ? flipSide : flipAlign;
  281. newPlacement.anchor = flip(newPlacement.anchor);
  282. newPlacement.origin = flip(newPlacement.origin);
  283. const {
  284. overflows: newOverflows
  285. } = checkOverflow(newPlacement);
  286. 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) {
  287. placement = newPlacement;
  288. reset = flipped[key] = true;
  289. }
  290. }
  291. });
  292. if (reset) continue;
  293. }
  294. // shift
  295. if (overflows.x.before) {
  296. x += overflows.x.before;
  297. contentBox.x += overflows.x.before;
  298. }
  299. if (overflows.x.after) {
  300. x -= overflows.x.after;
  301. contentBox.x -= overflows.x.after;
  302. }
  303. if (overflows.y.before) {
  304. y += overflows.y.before;
  305. contentBox.y += overflows.y.before;
  306. }
  307. if (overflows.y.after) {
  308. y -= overflows.y.after;
  309. contentBox.y -= overflows.y.after;
  310. }
  311. // size
  312. {
  313. const overflows = getOverflow(contentBox, viewport);
  314. available.x = viewport.width - overflows.x.before - overflows.x.after;
  315. available.y = viewport.height - overflows.y.before - overflows.y.after;
  316. x += overflows.x.before;
  317. contentBox.x += overflows.x.before;
  318. y += overflows.y.before;
  319. contentBox.y += overflows.y.before;
  320. }
  321. break;
  322. }
  323. const axis = getAxis(placement.anchor);
  324. Object.assign(contentStyles.value, {
  325. '--v-overlay-anchor-origin': `${placement.anchor.side} ${placement.anchor.align}`,
  326. transformOrigin: `${placement.origin.side} ${placement.origin.align}`,
  327. // transform: `translate(${pixelRound(x)}px, ${pixelRound(y)}px)`,
  328. top: convertToUnit(pixelRound(y)),
  329. left: data.isRtl.value ? undefined : convertToUnit(pixelRound(x)),
  330. right: data.isRtl.value ? convertToUnit(pixelRound(-x)) : undefined,
  331. minWidth: convertToUnit(axis === 'y' ? Math.min(minWidth.value, targetBox.width) : minWidth.value),
  332. maxWidth: convertToUnit(pixelCeil(clamp(available.x, minWidth.value === Infinity ? 0 : minWidth.value, maxWidth.value))),
  333. maxHeight: convertToUnit(pixelCeil(clamp(available.y, minHeight.value === Infinity ? 0 : minHeight.value, maxHeight.value)))
  334. });
  335. return {
  336. available,
  337. contentBox
  338. };
  339. }
  340. watch(() => [preferredAnchor.value, preferredOrigin.value, props.offset, props.minWidth, props.minHeight, props.maxWidth, props.maxHeight], () => updateLocation());
  341. nextTick(() => {
  342. const result = updateLocation();
  343. // TODO: overflowing content should only require a single updateLocation call
  344. // Icky hack to make sure the content is positioned consistently
  345. if (!result) return;
  346. const {
  347. available,
  348. contentBox
  349. } = result;
  350. if (contentBox.height > available.y) {
  351. requestAnimationFrame(() => {
  352. updateLocation();
  353. requestAnimationFrame(() => {
  354. updateLocation();
  355. });
  356. });
  357. }
  358. });
  359. return {
  360. updateLocation
  361. };
  362. }
  363. function pixelRound(val) {
  364. return Math.round(val * devicePixelRatio) / devicePixelRatio;
  365. }
  366. function pixelCeil(val) {
  367. return Math.ceil(val * devicePixelRatio) / devicePixelRatio;
  368. }
  369. //# sourceMappingURL=locationStrategies.mjs.map