VSlideGroup.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import { createVNode as _createVNode } from "vue";
  2. // Styles
  3. import "./VSlideGroup.css";
  4. // Components
  5. import { VFadeTransition } from "../transitions/index.mjs";
  6. import { VIcon } from "../VIcon/index.mjs"; // Composables
  7. import { makeComponentProps } from "../../composables/component.mjs";
  8. import { makeDisplayProps, useDisplay } from "../../composables/display.mjs";
  9. import { useGoTo } from "../../composables/goto.mjs";
  10. import { makeGroupProps, useGroup } from "../../composables/group.mjs";
  11. import { IconValue } from "../../composables/icons.mjs";
  12. import { useRtl } from "../../composables/locale.mjs";
  13. import { useResizeObserver } from "../../composables/resizeObserver.mjs";
  14. import { makeTagProps } from "../../composables/tag.mjs"; // Utilities
  15. import { computed, shallowRef, watch } from 'vue';
  16. import { calculateCenteredTarget, calculateUpdatedTarget, getClientSize, getOffsetSize, getScrollPosition, getScrollSize } from "./helpers.mjs";
  17. import { focusableChildren, genericComponent, IN_BROWSER, propsFactory, useRender } from "../../util/index.mjs"; // Types
  18. export const VSlideGroupSymbol = Symbol.for('vuetify:v-slide-group');
  19. export const makeVSlideGroupProps = propsFactory({
  20. centerActive: Boolean,
  21. direction: {
  22. type: String,
  23. default: 'horizontal'
  24. },
  25. symbol: {
  26. type: null,
  27. default: VSlideGroupSymbol
  28. },
  29. nextIcon: {
  30. type: IconValue,
  31. default: '$next'
  32. },
  33. prevIcon: {
  34. type: IconValue,
  35. default: '$prev'
  36. },
  37. showArrows: {
  38. type: [Boolean, String],
  39. validator: v => typeof v === 'boolean' || ['always', 'desktop', 'mobile'].includes(v)
  40. },
  41. ...makeComponentProps(),
  42. ...makeDisplayProps({
  43. mobile: null
  44. }),
  45. ...makeTagProps(),
  46. ...makeGroupProps({
  47. selectedClass: 'v-slide-group-item--active'
  48. })
  49. }, 'VSlideGroup');
  50. export const VSlideGroup = genericComponent()({
  51. name: 'VSlideGroup',
  52. props: makeVSlideGroupProps(),
  53. emits: {
  54. 'update:modelValue': value => true
  55. },
  56. setup(props, _ref) {
  57. let {
  58. slots
  59. } = _ref;
  60. const {
  61. isRtl
  62. } = useRtl();
  63. const {
  64. displayClasses,
  65. mobile
  66. } = useDisplay(props);
  67. const group = useGroup(props, props.symbol);
  68. const isOverflowing = shallowRef(false);
  69. const scrollOffset = shallowRef(0);
  70. const containerSize = shallowRef(0);
  71. const contentSize = shallowRef(0);
  72. const isHorizontal = computed(() => props.direction === 'horizontal');
  73. const {
  74. resizeRef: containerRef,
  75. contentRect: containerRect
  76. } = useResizeObserver();
  77. const {
  78. resizeRef: contentRef,
  79. contentRect
  80. } = useResizeObserver();
  81. const goTo = useGoTo();
  82. const goToOptions = computed(() => {
  83. return {
  84. container: containerRef.el,
  85. duration: 200,
  86. easing: 'easeOutQuart'
  87. };
  88. });
  89. const firstSelectedIndex = computed(() => {
  90. if (!group.selected.value.length) return -1;
  91. return group.items.value.findIndex(item => item.id === group.selected.value[0]);
  92. });
  93. const lastSelectedIndex = computed(() => {
  94. if (!group.selected.value.length) return -1;
  95. return group.items.value.findIndex(item => item.id === group.selected.value[group.selected.value.length - 1]);
  96. });
  97. if (IN_BROWSER) {
  98. let frame = -1;
  99. watch(() => [group.selected.value, containerRect.value, contentRect.value, isHorizontal.value], () => {
  100. cancelAnimationFrame(frame);
  101. frame = requestAnimationFrame(() => {
  102. if (containerRect.value && contentRect.value) {
  103. const sizeProperty = isHorizontal.value ? 'width' : 'height';
  104. containerSize.value = containerRect.value[sizeProperty];
  105. contentSize.value = contentRect.value[sizeProperty];
  106. isOverflowing.value = containerSize.value + 1 < contentSize.value;
  107. }
  108. if (firstSelectedIndex.value >= 0 && contentRef.el) {
  109. // TODO: Is this too naive? Should we store element references in group composable?
  110. const selectedElement = contentRef.el.children[lastSelectedIndex.value];
  111. scrollToChildren(selectedElement, props.centerActive);
  112. }
  113. });
  114. });
  115. }
  116. const isFocused = shallowRef(false);
  117. function scrollToChildren(children, center) {
  118. let target = 0;
  119. if (center) {
  120. target = calculateCenteredTarget({
  121. containerElement: containerRef.el,
  122. isHorizontal: isHorizontal.value,
  123. selectedElement: children
  124. });
  125. } else {
  126. target = calculateUpdatedTarget({
  127. containerElement: containerRef.el,
  128. isHorizontal: isHorizontal.value,
  129. isRtl: isRtl.value,
  130. selectedElement: children
  131. });
  132. }
  133. scrollToPosition(target);
  134. }
  135. function scrollToPosition(newPosition) {
  136. if (!IN_BROWSER || !containerRef.el) return;
  137. const offsetSize = getOffsetSize(isHorizontal.value, containerRef.el);
  138. const scrollPosition = getScrollPosition(isHorizontal.value, isRtl.value, containerRef.el);
  139. const scrollSize = getScrollSize(isHorizontal.value, containerRef.el);
  140. if (scrollSize <= offsetSize ||
  141. // Prevent scrolling by only a couple of pixels, which doesn't look smooth
  142. Math.abs(newPosition - scrollPosition) < 16) return;
  143. if (isHorizontal.value && isRtl.value && containerRef.el) {
  144. const {
  145. scrollWidth,
  146. offsetWidth: containerWidth
  147. } = containerRef.el;
  148. newPosition = scrollWidth - containerWidth - newPosition;
  149. }
  150. if (isHorizontal.value) {
  151. goTo.horizontal(newPosition, goToOptions.value);
  152. } else {
  153. goTo(newPosition, goToOptions.value);
  154. }
  155. }
  156. function onScroll(e) {
  157. const {
  158. scrollTop,
  159. scrollLeft
  160. } = e.target;
  161. scrollOffset.value = isHorizontal.value ? scrollLeft : scrollTop;
  162. }
  163. function onFocusin(e) {
  164. isFocused.value = true;
  165. if (!isOverflowing.value || !contentRef.el) return;
  166. // Focused element is likely to be the root of an item, so a
  167. // breadth-first search will probably find it in the first iteration
  168. for (const el of e.composedPath()) {
  169. for (const item of contentRef.el.children) {
  170. if (item === el) {
  171. scrollToChildren(item);
  172. return;
  173. }
  174. }
  175. }
  176. }
  177. function onFocusout(e) {
  178. isFocused.value = false;
  179. }
  180. // Affix clicks produce onFocus that we have to ignore to avoid extra scrollToChildren
  181. let ignoreFocusEvent = false;
  182. function onFocus(e) {
  183. if (!ignoreFocusEvent && !isFocused.value && !(e.relatedTarget && contentRef.el?.contains(e.relatedTarget))) focus();
  184. ignoreFocusEvent = false;
  185. }
  186. function onFocusAffixes() {
  187. ignoreFocusEvent = true;
  188. }
  189. function onKeydown(e) {
  190. if (!contentRef.el) return;
  191. function toFocus(location) {
  192. e.preventDefault();
  193. focus(location);
  194. }
  195. if (isHorizontal.value) {
  196. if (e.key === 'ArrowRight') {
  197. toFocus(isRtl.value ? 'prev' : 'next');
  198. } else if (e.key === 'ArrowLeft') {
  199. toFocus(isRtl.value ? 'next' : 'prev');
  200. }
  201. } else {
  202. if (e.key === 'ArrowDown') {
  203. toFocus('next');
  204. } else if (e.key === 'ArrowUp') {
  205. toFocus('prev');
  206. }
  207. }
  208. if (e.key === 'Home') {
  209. toFocus('first');
  210. } else if (e.key === 'End') {
  211. toFocus('last');
  212. }
  213. }
  214. function focus(location) {
  215. if (!contentRef.el) return;
  216. let el;
  217. if (!location) {
  218. const focusable = focusableChildren(contentRef.el);
  219. el = focusable[0];
  220. } else if (location === 'next') {
  221. el = contentRef.el.querySelector(':focus')?.nextElementSibling;
  222. if (!el) return focus('first');
  223. } else if (location === 'prev') {
  224. el = contentRef.el.querySelector(':focus')?.previousElementSibling;
  225. if (!el) return focus('last');
  226. } else if (location === 'first') {
  227. el = contentRef.el.firstElementChild;
  228. } else if (location === 'last') {
  229. el = contentRef.el.lastElementChild;
  230. }
  231. if (el) {
  232. el.focus({
  233. preventScroll: true
  234. });
  235. }
  236. }
  237. function scrollTo(location) {
  238. const direction = isHorizontal.value && isRtl.value ? -1 : 1;
  239. const offsetStep = (location === 'prev' ? -direction : direction) * containerSize.value;
  240. let newPosition = scrollOffset.value + offsetStep;
  241. // TODO: improve it
  242. if (isHorizontal.value && isRtl.value && containerRef.el) {
  243. const {
  244. scrollWidth,
  245. offsetWidth: containerWidth
  246. } = containerRef.el;
  247. newPosition += scrollWidth - containerWidth;
  248. }
  249. scrollToPosition(newPosition);
  250. }
  251. const slotProps = computed(() => ({
  252. next: group.next,
  253. prev: group.prev,
  254. select: group.select,
  255. isSelected: group.isSelected
  256. }));
  257. const hasAffixes = computed(() => {
  258. switch (props.showArrows) {
  259. // Always show arrows on desktop & mobile
  260. case 'always':
  261. return true;
  262. // Always show arrows on desktop
  263. case 'desktop':
  264. return !mobile.value;
  265. // Show arrows on mobile when overflowing.
  266. // This matches the default 2.2 behavior
  267. case true:
  268. return isOverflowing.value || Math.abs(scrollOffset.value) > 0;
  269. // Always show on mobile
  270. case 'mobile':
  271. return mobile.value || isOverflowing.value || Math.abs(scrollOffset.value) > 0;
  272. // https://material.io/components/tabs#scrollable-tabs
  273. // Always show arrows when
  274. // overflowed on desktop
  275. default:
  276. return !mobile.value && (isOverflowing.value || Math.abs(scrollOffset.value) > 0);
  277. }
  278. });
  279. const hasPrev = computed(() => {
  280. // 1 pixel in reserve, may be lost after rounding
  281. return Math.abs(scrollOffset.value) > 1;
  282. });
  283. const hasNext = computed(() => {
  284. if (!containerRef.value) return false;
  285. const scrollSize = getScrollSize(isHorizontal.value, containerRef.el);
  286. const clientSize = getClientSize(isHorizontal.value, containerRef.el);
  287. const scrollSizeMax = scrollSize - clientSize;
  288. // 1 pixel in reserve, may be lost after rounding
  289. return scrollSizeMax - Math.abs(scrollOffset.value) > 1;
  290. });
  291. useRender(() => _createVNode(props.tag, {
  292. "class": ['v-slide-group', {
  293. 'v-slide-group--vertical': !isHorizontal.value,
  294. 'v-slide-group--has-affixes': hasAffixes.value,
  295. 'v-slide-group--is-overflowing': isOverflowing.value
  296. }, displayClasses.value, props.class],
  297. "style": props.style,
  298. "tabindex": isFocused.value || group.selected.value.length ? -1 : 0,
  299. "onFocus": onFocus
  300. }, {
  301. default: () => [hasAffixes.value && _createVNode("div", {
  302. "key": "prev",
  303. "class": ['v-slide-group__prev', {
  304. 'v-slide-group__prev--disabled': !hasPrev.value
  305. }],
  306. "onMousedown": onFocusAffixes,
  307. "onClick": () => hasPrev.value && scrollTo('prev')
  308. }, [slots.prev?.(slotProps.value) ?? _createVNode(VFadeTransition, null, {
  309. default: () => [_createVNode(VIcon, {
  310. "icon": isRtl.value ? props.nextIcon : props.prevIcon
  311. }, null)]
  312. })]), _createVNode("div", {
  313. "key": "container",
  314. "ref": containerRef,
  315. "class": "v-slide-group__container",
  316. "onScroll": onScroll
  317. }, [_createVNode("div", {
  318. "ref": contentRef,
  319. "class": "v-slide-group__content",
  320. "onFocusin": onFocusin,
  321. "onFocusout": onFocusout,
  322. "onKeydown": onKeydown
  323. }, [slots.default?.(slotProps.value)])]), hasAffixes.value && _createVNode("div", {
  324. "key": "next",
  325. "class": ['v-slide-group__next', {
  326. 'v-slide-group__next--disabled': !hasNext.value
  327. }],
  328. "onMousedown": onFocusAffixes,
  329. "onClick": () => hasNext.value && scrollTo('next')
  330. }, [slots.next?.(slotProps.value) ?? _createVNode(VFadeTransition, null, {
  331. default: () => [_createVNode(VIcon, {
  332. "icon": isRtl.value ? props.prevIcon : props.nextIcon
  333. }, null)]
  334. })])]
  335. }));
  336. return {
  337. selected: group.selected,
  338. scrollTo,
  339. scrollOffset,
  340. focus,
  341. hasPrev,
  342. hasNext
  343. };
  344. }
  345. });
  346. //# sourceMappingURL=VSlideGroup.mjs.map