diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass index bf9e11828cf..40cf64e5c21 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass @@ -1,23 +1,14 @@ -// Imports -@import './_variables.scss' +@forward './variables' +@use 'sass:math' +@use 'sass:map' +@use '../../styles/settings' +@use '../../styles/tools' +@use './variables' as * // Block .v-slide-group display: flex - &:not(.v-slide-group--has-affixes) - > .v-slide-group__prev, - > .v-slide-group__next - display: none - - // Needed increased specificity - // to overwrite v-tabs pointer - // replacement - &.v-item-group - > .v-slide-group__next, - > .v-slide-group__prev - cursor: pointer - .v-slide-item display: inline-flex flex: 0 1 auto @@ -30,22 +21,31 @@ flex: 0 1 $slide-group-prev-basis justify-content: center min-width: $slide-group-prev-basis + cursor: pointer + + &--disabled + pointer-events: none + opacity: var(--v-disabled-opacity) .v-slide-group__content display: flex flex: 1 0 auto position: relative - transition: $primary-transition + transition: 0.2s all settings.$standard-easing white-space: nowrap -.v-slide-group__wrapper + > * + white-space: initial + +.v-slide-group__container contain: content display: flex flex: 1 1 auto overflow: hidden // Modifiers -.v-slide-group__next, -.v-slide-group__prev - &--disabled - pointer-events: none +.v-slide-group--vertical + &, + .v-slide-group__container, + .v-slide-group__content + flex-direction: column diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.ts b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.ts deleted file mode 100644 index 01af07e698d..00000000000 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.ts +++ /dev/null @@ -1,517 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Styles -import './VSlideGroup.sass' - -// Components -import VIcon from '../VIcon' -import { VFadeTransition } from '../transitions' - -// Extensions -import { BaseItemGroup } from '../VItemGroup/VItemGroup' - -// Mixins -import Mobile from '../../mixins/mobile' - -// Directives -import Resize from '../../directives/resize' -import Touch from '../../directives/touch' - -// Utilities -import mixins, { ExtractVue } from '../../util/mixins' - -// Types -import Vue, { VNode } from 'vue' -import { composedPath } from '../../util/helpers' - -interface TouchEvent { - touchstartX: number - touchstartY: number - touchmoveX: number - touchmoveY: number - stopPropagation: Function -} - -interface Widths { - content: number - wrapper: number -} - -interface options extends Vue { - $refs: { - content: HTMLElement - wrapper: HTMLElement - } -} - -function bias (val: number) { - const c = 0.501 - const x = Math.abs(val) - return Math.sign(val) * (x / ((1 / c - 2) * (1 - x) + 1)) -} - -export function calculateUpdatedOffset ( - selectedElement: HTMLElement, - widths: Widths, - rtl: boolean, - currentScrollOffset: number -): number { - const clientWidth = selectedElement.clientWidth - const offsetLeft = rtl - ? (widths.content - selectedElement.offsetLeft - clientWidth) - : selectedElement.offsetLeft - - if (rtl) { - currentScrollOffset = -currentScrollOffset - } - - const totalWidth = widths.wrapper + currentScrollOffset - const itemOffset = clientWidth + offsetLeft - const additionalOffset = clientWidth * 0.4 - - if (offsetLeft <= currentScrollOffset) { - currentScrollOffset = Math.max(offsetLeft - additionalOffset, 0) - } else if (totalWidth <= itemOffset) { - currentScrollOffset = Math.min(currentScrollOffset - (totalWidth - itemOffset - additionalOffset), widths.content - widths.wrapper) - } - - return rtl ? -currentScrollOffset : currentScrollOffset -} - -export function calculateCenteredOffset ( - selectedElement: HTMLElement, - widths: Widths, - rtl: boolean -): number { - const { offsetLeft, clientWidth } = selectedElement - - if (rtl) { - const offsetCentered = widths.content - offsetLeft - clientWidth / 2 - widths.wrapper / 2 - return -Math.min(widths.content - widths.wrapper, Math.max(0, offsetCentered)) - } else { - const offsetCentered = offsetLeft + clientWidth / 2 - widths.wrapper / 2 - return Math.min(widths.content - widths.wrapper, Math.max(0, offsetCentered)) - } -} - -export const BaseSlideGroup = mixins -/* eslint-enable indent */ ->( - BaseItemGroup, - Mobile, - /* @vue/component */ -).extend({ - name: 'base-slide-group', - - directives: { - Resize, - Touch, - }, - - props: { - activeClass: { - type: String, - default: 'v-slide-item--active', - }, - centerActive: Boolean, - nextIcon: { - type: String, - default: '$next', - }, - prevIcon: { - type: String, - default: '$prev', - }, - showArrows: { - type: [Boolean, String], - validator: v => ( - typeof v === 'boolean' || [ - 'always', - 'desktop', - 'mobile', - ].includes(v) - ), - }, - }, - - data: () => ({ - internalItemsLength: 0, - isOverflowing: false, - resizeTimeout: 0, - startX: 0, - isSwipingHorizontal: false, - isSwiping: false, - scrollOffset: 0, - widths: { - content: 0, - wrapper: 0, - }, - }), - - computed: { - canTouch (): boolean { - return typeof window !== 'undefined' - }, - __cachedNext (): VNode { - return this.genTransition('next') - }, - __cachedPrev (): VNode { - return this.genTransition('prev') - }, - classes (): object { - return { - ...BaseItemGroup.options.computed.classes.call(this), - 'v-slide-group': true, - 'v-slide-group--has-affixes': this.hasAffixes, - 'v-slide-group--is-overflowing': this.isOverflowing, - } - }, - hasAffixes (): Boolean { - switch (this.showArrows) { - // Always show arrows on desktop & mobile - case 'always': return true - - // Always show arrows on desktop - case 'desktop': return !this.isMobile - - // Show arrows on mobile when overflowing. - // This matches the default 2.2 behavior - case true: return this.isOverflowing || Math.abs(this.scrollOffset) > 0 - - // Always show on mobile - case 'mobile': return ( - this.isMobile || - (this.isOverflowing || Math.abs(this.scrollOffset) > 0) - ) - - // https://material.io/components/tabs#scrollable-tabs - // Always show arrows when - // overflowed on desktop - default: return ( - !this.isMobile && - (this.isOverflowing || Math.abs(this.scrollOffset) > 0) - ) - } - }, - hasNext (): boolean { - if (!this.hasAffixes) return false - - const { content, wrapper } = this.widths - - // Check one scroll ahead to know the width of right-most item - return content > Math.abs(this.scrollOffset) + wrapper - }, - hasPrev (): boolean { - return this.hasAffixes && this.scrollOffset !== 0 - }, - }, - - watch: { - internalValue: 'setWidths', - // When overflow changes, the arrows alter - // the widths of the content and wrapper - // and need to be recalculated - isOverflowing: 'setWidths', - scrollOffset (val) { - const scroll = - val <= 0 - ? bias(-val) - : val > this.widths.content - this.widths.wrapper - ? -(this.widths.content - this.widths.wrapper) + bias(this.widths.content - this.widths.wrapper - val) - : -val - - this.$refs.content.style.transform = `translateX(${scroll}px)` - }, - }, - - beforeUpdate () { - this.internalItemsLength = (this.$children || []).length - }, - - updated () { - if (this.internalItemsLength === (this.$children || []).length) return - this.setWidths() - }, - - methods: { - onScroll () { - this.$refs.wrapper.scrollLeft = 0 - }, - onFocusin (e: FocusEvent) { - if (!this.isOverflowing) return - - // Focused element is likely to be the root of an item, so a - // breadth-first search will probably find it in the first iteration - for (const el of composedPath(e)) { - for (const vm of this.items) { - if (vm.$el === el) { - this.scrollOffset = calculateUpdatedOffset( - vm.$el as HTMLElement, - this.widths, - this.$vuetify.rtl, - this.scrollOffset - ) - return - } - } - } - }, - // Always generate next for scrollable hint - genNext (): VNode | null { - const slot = this.$scopedSlots.next - ? this.$scopedSlots.next({}) - : this.$slots.next || this.__cachedNext - - return this.$createElement('div', { - staticClass: 'v-slide-group__next', - class: { - 'v-slide-group__next--disabled': !this.hasNext, - }, - on: { - click: () => this.onAffixClick('next'), - }, - key: 'next', - }, [slot]) - }, - genContent (): VNode { - return this.$createElement('div', { - staticClass: 'v-slide-group__content', - ref: 'content', - on: { - focusin: this.onFocusin, - }, - }, this.$slots.default) - }, - genData (): object { - return { - class: this.classes, - directives: [{ - name: 'resize', - value: this.onResize, - }], - } - }, - genIcon (location: 'prev' | 'next'): VNode | null { - let icon = location - - if (this.$vuetify.rtl && location === 'prev') { - icon = 'next' - } else if (this.$vuetify.rtl && location === 'next') { - icon = 'prev' - } - - const upperLocation = `${location[0].toUpperCase()}${location.slice(1)}` - const hasAffix = (this as any)[`has${upperLocation}`] - - if ( - !this.showArrows && - !hasAffix - ) return null - - return this.$createElement(VIcon, { - props: { - disabled: !hasAffix, - }, - }, (this as any)[`${icon}Icon`]) - }, - // Always generate prev for scrollable hint - genPrev (): VNode | null { - const slot = this.$scopedSlots.prev - ? this.$scopedSlots.prev({}) - : this.$slots.prev || this.__cachedPrev - - return this.$createElement('div', { - staticClass: 'v-slide-group__prev', - class: { - 'v-slide-group__prev--disabled': !this.hasPrev, - }, - on: { - click: () => this.onAffixClick('prev'), - }, - key: 'prev', - }, [slot]) - }, - genTransition (location: 'prev' | 'next') { - return this.$createElement(VFadeTransition, [this.genIcon(location)]) - }, - genWrapper (): VNode { - return this.$createElement('div', { - staticClass: 'v-slide-group__wrapper', - directives: [{ - name: 'touch', - value: { - start: (e: TouchEvent) => this.overflowCheck(e, this.onTouchStart), - move: (e: TouchEvent) => this.overflowCheck(e, this.onTouchMove), - end: (e: TouchEvent) => this.overflowCheck(e, this.onTouchEnd), - }, - }], - ref: 'wrapper', - on: { - scroll: this.onScroll, - }, - }, [this.genContent()]) - }, - calculateNewOffset (direction: 'prev' | 'next', widths: Widths, rtl: boolean, currentScrollOffset: number) { - const sign = rtl ? -1 : 1 - const newAbosluteOffset = sign * currentScrollOffset + - (direction === 'prev' ? -1 : 1) * widths.wrapper - - return sign * Math.max(Math.min(newAbosluteOffset, widths.content - widths.wrapper), 0) - }, - onAffixClick (location: 'prev' | 'next') { - this.$emit(`click:${location}`) - this.scrollTo(location) - }, - onResize () { - /* istanbul ignore next */ - if (this._isDestroyed) return - - this.setWidths() - }, - onTouchStart (e: TouchEvent) { - const { content } = this.$refs - - this.startX = this.scrollOffset + e.touchstartX as number - - content.style.setProperty('transition', 'none') - content.style.setProperty('willChange', 'transform') - }, - onTouchMove (e: TouchEvent) { - if (!this.canTouch) return - - if (!this.isSwiping) { - // only calculate disableSwipeHorizontal during the first onTouchMove invoke - // in order to ensure disableSwipeHorizontal value is consistent between onTouchStart and onTouchEnd - const diffX = e.touchmoveX - e.touchstartX - const diffY = e.touchmoveY - e.touchstartY - this.isSwipingHorizontal = Math.abs(diffX) > Math.abs(diffY) - this.isSwiping = true - } - - if (this.isSwipingHorizontal) { - // sliding horizontally - this.scrollOffset = this.startX - e.touchmoveX - // temporarily disable window vertical scrolling - document.documentElement.style.overflowY = 'hidden' - } - }, - onTouchEnd () { - if (!this.canTouch) return - - const { content, wrapper } = this.$refs - const maxScrollOffset = content.clientWidth - wrapper.clientWidth - - content.style.setProperty('transition', null) - content.style.setProperty('willChange', null) - - if (this.$vuetify.rtl) { - /* istanbul ignore else */ - if (this.scrollOffset > 0 || !this.isOverflowing) { - this.scrollOffset = 0 - } else if (this.scrollOffset <= -maxScrollOffset) { - this.scrollOffset = -maxScrollOffset - } - } else { - /* istanbul ignore else */ - if (this.scrollOffset < 0 || !this.isOverflowing) { - this.scrollOffset = 0 - } else if (this.scrollOffset >= maxScrollOffset) { - this.scrollOffset = maxScrollOffset - } - } - - this.isSwiping = false - // rollback whole page scrolling to default - document.documentElement.style.removeProperty('overflow-y') - }, - overflowCheck (e: TouchEvent, fn: (e: TouchEvent) => void) { - e.stopPropagation() - this.isOverflowing && fn(e) - }, - scrollIntoView /* istanbul ignore next */ () { - if (!this.selectedItem && this.items.length) { - const lastItemPosition = this.items[this.items.length - 1].$el.getBoundingClientRect() - const wrapperPosition = this.$refs.wrapper.getBoundingClientRect() - - if ( - (this.$vuetify.rtl && wrapperPosition.right < lastItemPosition.right) || - (!this.$vuetify.rtl && wrapperPosition.left > lastItemPosition.left) - ) { - this.scrollTo('prev') - } - } - - if (!this.selectedItem) { - return - } - - if ( - this.selectedIndex === 0 || - (!this.centerActive && !this.isOverflowing) - ) { - this.scrollOffset = 0 - } else if (this.centerActive) { - this.scrollOffset = calculateCenteredOffset( - this.selectedItem.$el as HTMLElement, - this.widths, - this.$vuetify.rtl - ) - } else if (this.isOverflowing) { - this.scrollOffset = calculateUpdatedOffset( - this.selectedItem.$el as HTMLElement, - this.widths, - this.$vuetify.rtl, - this.scrollOffset - ) - } - }, - scrollTo /* istanbul ignore next */ (location: 'prev' | 'next') { - this.scrollOffset = this.calculateNewOffset(location, { - // Force reflow - content: this.$refs.content ? this.$refs.content.clientWidth : 0, - wrapper: this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0, - }, this.$vuetify.rtl, this.scrollOffset) - }, - setWidths /* istanbul ignore next */ () { - window.requestAnimationFrame(() => { - const { content, wrapper } = this.$refs - - this.widths = { - content: content ? content.clientWidth : 0, - wrapper: wrapper ? wrapper.clientWidth : 0, - } - - // https://github.com/vuetifyjs/vuetify/issues/13212 - // We add +1 to the wrappers width to prevent an issue where the `clientWidth` - // gets calculated wrongly by the browser if using a different zoom-level. - this.isOverflowing = this.widths.wrapper + 1 < this.widths.content - - this.scrollIntoView() - }) - }, - }, - - render (h): VNode { - return h('div', this.genData(), [ - this.genPrev(), - this.genWrapper(), - this.genNext(), - ]) - }, -}) - -export default BaseSlideGroup.extend({ - name: 'v-slide-group', - - provide (): object { - return { - slideGroup: this, - } - }, -}) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx new file mode 100644 index 00000000000..ccb4999b94e --- /dev/null +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -0,0 +1,384 @@ +// Styles +import './VSlideGroup.sass' + +// Components +import { VFadeTransition } from '@/components/transitions' +import { VIcon } from '@/components/VIcon' + +// Composables +import { makeGroupProps, useGroup } from '@/composables/group' +import { makeTagProps } from '@/composables/tag' +import { useDisplay } from '@/composables' +import { useResizeObserver } from '@/composables/resizeObserver' +import { useRtl } from '@/composables/rtl' + +// Utilities +import { bias, calculateCenteredOffset, calculateUpdatedOffset } from './helpers' +import { clamp, defineComponent, useRender } from '@/util' +import { computed, ref, watch, watchEffect } from 'vue' + +// Types +import type { GroupProvide } from '@/composables/group' +import type { InjectionKey } from 'vue' + +export const VSlideGroupSymbol: InjectionKey = Symbol.for('vuetify:v-slide-group') + +export const VSlideGroup = defineComponent({ + name: 'VSlideGroup', + + props: { + activeClass: { + type: String, + default: 'v-slide-item--active', + }, + centerActive: Boolean, + direction: { + type: String, + default: 'horizontal', + }, + symbol: { + type: null, + default: VSlideGroupSymbol, + }, + nextIcon: { + type: String, + default: '$next', + }, + prevIcon: { + type: String, + default: '$prev', + }, + showArrows: { + type: [Boolean, String], + validator: (v: any) => ( + typeof v === 'boolean' || [ + 'always', + 'desktop', + 'mobile', + ].includes(v) + ), + }, + ...makeTagProps(), + ...makeGroupProps(), + }, + + emits: { + 'update:modelValue': (value: any) => true, + }, + + setup (props, { slots }) { + const { isRtl } = useRtl() + const { mobile } = useDisplay() + const group = useGroup(props, props.symbol) + const isOverflowing = ref(false) + const scrollOffset = ref(0) + const containerSize = ref(0) + const contentSize = ref(0) + const isHorizontal = computed(() => props.direction === 'horizontal') + + const { resizeRef: containerRef, contentRect: containerRect } = useResizeObserver() + const { resizeRef: contentRef, contentRect } = useResizeObserver() + + watchEffect(() => { + if (!containerRect.value || !contentRect.value) return + + const sizeProperty = isHorizontal.value ? 'width' : 'height' + + containerSize.value = containerRect.value[sizeProperty] + contentSize.value = contentRect.value[sizeProperty] + + isOverflowing.value = containerSize.value + 1 < contentSize.value + }) + + watch(group.selected, selected => { + if (!selected.length || !contentRef.value) return + + const index = group.items.value.findIndex(item => item.id === selected[selected.length - 1]) + + // TODO: Is this too naive? Should we store element references in group composable? + const selectedElement = contentRef.value.children[index] as HTMLElement + + if (index === 0 || !isOverflowing.value) { + scrollOffset.value = 0 + } else if (props.centerActive) { + scrollOffset.value = calculateCenteredOffset({ + selectedElement, + containerSize: containerSize.value, + contentSize: contentSize.value, + isRtl: isRtl.value, + isHorizontal: isHorizontal.value, + }) + } else if (isOverflowing.value) { + scrollOffset.value = calculateUpdatedOffset({ + selectedElement, + containerSize: containerSize.value, + contentSize: contentSize.value, + isRtl: isRtl.value, + currentScrollOffset: scrollOffset.value, + isHorizontal: isHorizontal.value, + }) + } + }) + + const disableTransition = ref(false) + + let startTouch = 0 + let startOffset = 0 + + function onTouchstart (e: TouchEvent) { + const sizeProperty = isHorizontal.value ? 'clientX' : 'clientY' + startOffset = scrollOffset.value + startTouch = e.touches[0][sizeProperty] + disableTransition.value = true + } + + function onTouchmove (e: TouchEvent) { + if (!isOverflowing.value) return + + const sizeProperty = isHorizontal.value ? 'clientX' : 'clientY' + scrollOffset.value = startOffset + startTouch - e.touches[0][sizeProperty] + } + + function onTouchend (e: TouchEvent) { + const maxScrollOffset = contentSize.value - containerSize.value + + if (isRtl.value) { + if (scrollOffset.value > 0 || !isOverflowing.value) { + scrollOffset.value = 0 + } else if (scrollOffset.value <= -maxScrollOffset) { + scrollOffset.value = -maxScrollOffset + } + } else { + if (scrollOffset.value < 0 || !isOverflowing.value) { + scrollOffset.value = 0 + } else if (scrollOffset.value >= maxScrollOffset) { + scrollOffset.value = maxScrollOffset + } + } + + disableTransition.value = false + } + + function onScroll () { + containerRef.value && (containerRef.value.scrollLeft = 0) + } + + const isFocused = ref(false) + function onFocusin (e: FocusEvent) { + isFocused.value = true + + if (!isOverflowing.value || !contentRef.value) return + + // Focused element is likely to be the root of an item, so a + // breadth-first search will probably find it in the first iteration + for (const el of e.composedPath()) { + for (const item of contentRef.value.children) { + if (item === el) { + scrollOffset.value = calculateUpdatedOffset({ + selectedElement: item as HTMLElement, + containerSize: containerSize.value, + contentSize: contentSize.value, + isRtl: isRtl.value, + currentScrollOffset: scrollOffset.value, + isHorizontal: isHorizontal.value, + }) + return + } + } + } + } + + function onFocusout (e: FocusEvent) { + isFocused.value = false + } + + function onFocus (e: FocusEvent) { + if ( + !isFocused.value && + !(e.relatedTarget && contentRef.value?.contains(e.relatedTarget as Node)) + ) focus() + } + + function onKeydown (e: KeyboardEvent) { + if (!contentRef.value) return + + if (e.key === (isHorizontal.value ? 'ArrowRight' : 'ArrowDown')) { + focus('next') + } else if (e.key === (isHorizontal.value ? 'ArrowLeft' : 'ArrowUp')) { + focus('prev') + } else if (e.key === 'Home') { + focus('first') + } else if (e.key === 'End') { + focus('last') + } + } + + function focus (location?: 'next' | 'prev' | 'first' | 'last') { + if (!contentRef.value) return + + if (!location) { + contentRef.value.querySelector('[tabindex]') + const focusable = [...contentRef.value.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + )].filter(el => !el.hasAttribute('disabled')) as HTMLElement[] + focusable[0]?.focus() + } else if (location === 'next') { + const el = contentRef.value.querySelector(':focus')?.nextElementSibling as HTMLElement | undefined + if (el) el.focus() + else focus('first') + } else if (location === 'prev') { + const el = contentRef.value.querySelector(':focus')?.previousElementSibling as HTMLElement | undefined + if (el) el.focus() + else focus('last') + } else if (location === 'first') { + (contentRef.value.firstElementChild as HTMLElement)?.focus() + } else if (location === 'last') { + (contentRef.value.lastElementChild as HTMLElement)?.focus() + } + } + + function scrollTo (location: 'prev' | 'next') { + const sign = isRtl.value ? -1 : 1 + const newAbosluteOffset = sign * scrollOffset.value + + (location === 'prev' ? -1 : 1) * containerSize.value + + scrollOffset.value = sign * clamp(newAbosluteOffset, 0, contentSize.value - containerSize.value) + } + + const contentStyles = computed(() => { + const scrollAmount = scrollOffset.value <= 0 + ? bias(-scrollOffset.value) + : scrollOffset.value > contentSize.value - containerSize.value + ? -(contentSize.value - containerSize.value) + bias(contentSize.value - containerSize.value - scrollOffset.value) + : -scrollOffset.value + + return { + transform: `translate${isHorizontal.value ? 'X' : 'Y'}(${scrollAmount}px)`, + transition: disableTransition.value ? 'none' : '', + willChange: disableTransition.value ? 'transform' : '', + } + }) + + const slotProps = computed(() => ({ + next: group.next, + prev: group.prev, + select: group.select, + isSelected: group.isSelected, + })) + + const hasAffixes = computed(() => { + switch (props.showArrows) { + // Always show arrows on desktop & mobile + case 'always': return true + + // Always show arrows on desktop + case 'desktop': return mobile.value + + // Show arrows on mobile when overflowing. + // This matches the default 2.2 behavior + case true: return isOverflowing.value || Math.abs(scrollOffset.value) > 0 + + // Always show on mobile + case 'mobile': return ( + mobile.value || + (isOverflowing.value || Math.abs(scrollOffset.value) > 0) + ) + + // https://material.io/components/tabs#scrollable-tabs + // Always show arrows when + // overflowed on desktop + default: return ( + !mobile.value && + (isOverflowing.value || Math.abs(scrollOffset.value) > 0) + ) + } + }) + + const hasPrev = computed(() => { + return hasAffixes.value && scrollOffset.value > 0 + }) + + const hasNext = computed(() => { + if (!hasAffixes.value) return false + + // Check one scroll ahead to know the width of right-most item + return contentSize.value > Math.abs(scrollOffset.value) + containerSize.value + }) + + useRender(() => ( + + { hasAffixes.value && ( +
scrollTo('prev') } + > + { slots.prev?.(slotProps.value) ?? ( + + + + ) } +
+ ) } + +
+
+ { slots.default?.(slotProps.value) } +
+
+ + { hasAffixes.value && ( +
scrollTo('next') } + > + { slots.next?.(slotProps.value) ?? ( + + + + ) } +
+ ) } +
+ )) + + return { + selected: group.selected, + scrollTo, + scrollOffset, + focus, + } + }, +}) + +export type VSlideGroup = InstanceType diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx new file mode 100644 index 00000000000..8278ad69215 --- /dev/null +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx @@ -0,0 +1,23 @@ +// Composables +import { makeGroupItemProps, useGroupItem } from '@/composables/group' +import { VSlideGroupSymbol } from './VSlideGroup' + +// Utilities +import { defineComponent } from '@/util' + +export const VSlideGroupItem = defineComponent({ + name: 'VSlideGroupItem', + + props: { + ...makeGroupItemProps(), + }, + + setup (props, { slots }) { + const slideGroupItem = useGroupItem(props, VSlideGroupSymbol) + + return () => slots.default?.({ + isSelected: slideGroupItem.isSelected.value, + select: slideGroupItem.select, + }) + }, +}) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideItem.ts b/packages/vuetify/src/components/VSlideGroup/VSlideItem.ts deleted file mode 100644 index da19f3786d1..00000000000 --- a/packages/vuetify/src/components/VSlideGroup/VSlideItem.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Extensions -import { BaseItem } from '../VItemGroup/VItem' - -// Mixins -import { factory as GroupableFactory } from '../../mixins/groupable' -import mixins from '../../util/mixins' - -export default mixins( - BaseItem, - GroupableFactory('slideGroup') - /* @vue/component */ -).extend({ - name: 'v-slide-item', -}) diff --git a/packages/vuetify/src/components/VSlideGroup/_variables.scss b/packages/vuetify/src/components/VSlideGroup/_variables.scss index dce2fa9cfa1..b6107d36879 100644 --- a/packages/vuetify/src/components/VSlideGroup/_variables.scss +++ b/packages/vuetify/src/components/VSlideGroup/_variables.scss @@ -1,3 +1,6 @@ -@import '../../styles/styles.sass'; +@use 'sass:math'; +@use 'sass:map'; +@use '../../styles/settings'; +@use '../../styles/tools'; $slide-group-prev-basis: 52px !default; diff --git a/packages/vuetify/src/components/VSlideGroup/helpers.ts b/packages/vuetify/src/components/VSlideGroup/helpers.ts new file mode 100644 index 00000000000..06363f5da7c --- /dev/null +++ b/packages/vuetify/src/components/VSlideGroup/helpers.ts @@ -0,0 +1,66 @@ +export function bias (val: number) { + const c = 0.501 + const x = Math.abs(val) + return Math.sign(val) * (x / ((1 / c - 2) * (1 - x) + 1)) +} + +export function calculateUpdatedOffset ({ + selectedElement, + containerSize, + contentSize, + isRtl, + currentScrollOffset, + isHorizontal, +}: { + selectedElement: HTMLElement + containerSize: number + contentSize: number + isRtl: boolean + currentScrollOffset: number + isHorizontal: boolean +}): number { + const clientSize = isHorizontal ? selectedElement.clientWidth : selectedElement.clientHeight + const offsetStart = isHorizontal ? selectedElement.offsetLeft : selectedElement.offsetTop + const adjustedOffsetStart = isRtl ? (contentSize - offsetStart - clientSize) : offsetStart + + if (isRtl) { + currentScrollOffset = -currentScrollOffset + } + + const totalSize = containerSize + currentScrollOffset + const itemOffset = clientSize + adjustedOffsetStart + const additionalOffset = clientSize * 0.4 + + if (adjustedOffsetStart <= currentScrollOffset) { + currentScrollOffset = Math.max(adjustedOffsetStart - additionalOffset, 0) + } else if (totalSize <= itemOffset) { + currentScrollOffset = Math.min(currentScrollOffset - (totalSize - itemOffset - additionalOffset), contentSize - containerSize) + } + + return isRtl ? -currentScrollOffset : currentScrollOffset +} + +export function calculateCenteredOffset ({ + selectedElement, + containerSize, + contentSize, + isRtl, + isHorizontal, +}: { + selectedElement: HTMLElement + containerSize: number + contentSize: number + isRtl: boolean + isHorizontal: boolean +}): number { + const clientSize = isHorizontal ? selectedElement.clientWidth : selectedElement.clientHeight + const offsetStart = isHorizontal ? selectedElement.offsetLeft : selectedElement.offsetTop + + if (isRtl) { + const offsetCentered = contentSize - offsetStart - clientSize / 2 - containerSize / 2 + return -Math.min(contentSize - containerSize, Math.max(0, offsetCentered)) + } else { + const offsetCentered = offsetStart + clientSize / 2 - containerSize / 2 + return Math.min(contentSize - containerSize, Math.max(0, offsetCentered)) + } +} diff --git a/packages/vuetify/src/components/VSlideGroup/index.ts b/packages/vuetify/src/components/VSlideGroup/index.ts index ca6cb1ce6cf..c4e9fc8b40e 100644 --- a/packages/vuetify/src/components/VSlideGroup/index.ts +++ b/packages/vuetify/src/components/VSlideGroup/index.ts @@ -1,14 +1,2 @@ -import VSlideGroup from './VSlideGroup' -import VSlideItem from './VSlideItem' - -export { - VSlideGroup, - VSlideItem, -} - -export default { - $_vuetify_subcomponents: { - VSlideGroup, - VSlideItem, - }, -} +export * from './VSlideGroup' +export * from './VSlideGroupItem' diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass new file mode 100644 index 00000000000..689dd17d271 --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -0,0 +1,30 @@ +@forward './variables' +@use './variables' as * + +.v-tab + --v-btn-height: 100% + display: inline-flex + position: relative + max-width: $tab-max-width + min-width: $tab-min-width + + .v-slide-group--vertical & + justify-content: start + +.v-tab__slider + position: absolute + bottom: 0 + left: 0 + height: $tab-slider-size + width: 100% + background: currentColor + pointer-events: none + opacity: 0 + + .v-tab--selected & + opacity: 1 + + .v-slide-group--vertical & + top: 0 + height: 100% + width: $tab-slider-size diff --git a/packages/vuetify/src/components/VTabs/VTab.ts b/packages/vuetify/src/components/VTabs/VTab.ts deleted file mode 100644 index 695f27d9262..00000000000 --- a/packages/vuetify/src/components/VTabs/VTab.ts +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Mixins -import { factory as GroupableFactory } from '../../mixins/groupable' -import Routable from '../../mixins/routable' -import Themeable from '../../mixins/themeable' - -// Utilities -import { keyCodes } from './../../util/helpers' -import mixins from '../../util/mixins' -import { ExtractVue } from './../../util/mixins' - -// Types -import { VNode } from 'vue/types' - -const baseMixins = mixins( - Routable, - // Must be after routable - // to overwrite activeClass - GroupableFactory('tabsBar'), - Themeable -) - -interface options extends ExtractVue { - $el: HTMLElement -} - -export default baseMixins.extend().extend( - /* @vue/component */ -).extend({ - name: 'v-tab', - - props: { - ripple: { - type: [Boolean, Object], - default: true, - }, - }, - - data: () => ({ - proxyClass: 'v-tab--active', - }), - - computed: { - classes (): object { - return { - 'v-tab': true, - ...Routable.options.computed.classes.call(this), - 'v-tab--disabled': this.disabled, - ...this.groupClasses, - } - }, - value (): any { - let to = this.to || this.href || '' - - if (this.$router && - this.to === Object(this.to) - ) { - const resolve = this.$router.resolve( - this.to, - this.$route, - this.append - ) - - to = resolve.href - } - - return to.replace('#', '') - }, - }, - - methods: { - click (e: KeyboardEvent | MouseEvent): void { - // Prevent keyboard actions - // from children elements - // within disabled tabs - if (this.disabled) { - e.preventDefault() - return - } - - // If user provides an - // actual link, do not - // prevent default - if (this.href && - this.href.indexOf('#') > -1 - ) e.preventDefault() - - if (e.detail) this.$el.blur() - - this.$emit('click', e) - - this.to || this.toggle() - }, - toggle () { - // VItemGroup treats a change event as a click - if (!this.isActive) { - this.$emit('change') - } - }, - }, - - render (h): VNode { - const { tag, data } = this.generateRouteLink() - - data.attrs = { - ...data.attrs, - 'aria-selected': String(this.isActive), - role: 'tab', - tabindex: 0, - } - data.on = { - ...data.on, - keydown: (e: KeyboardEvent) => { - if (e.keyCode === keyCodes.enter) this.click(e) - - this.$emit('keydown', e) - }, - } - - return h(tag, data, this.$slots.default) - }, -}) diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx new file mode 100644 index 00000000000..0d46fdd2e02 --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -0,0 +1,158 @@ +// Styles +import './VTab.sass' + +// Components +import { VBtn } from '@/components/VBtn' +import { VTabsSymbol } from './VTabs' + +// Composables +import { makeGroupItemProps, useGroupItem } from '@/composables/group' +import { makeRouterProps } from '@/composables/router' +import { makeTagProps } from '@/composables/tag' +import { makeThemeProps } from '@/composables/theme' +import { provideDefaults } from '@/composables/defaults' +import { useTextColor } from '@/composables/color' + +// Utilities +import { computed, ref, toRef, watch } from 'vue' +import { defineComponent, pick, standardEasing, useRender } from '@/util' + +// Types +import type { PropType } from 'vue' + +export const VTab = defineComponent({ + name: 'VTab', + + props: { + fixed: Boolean, + icon: [Boolean, String], + prependIcon: String, + appendIcon: String, + + stacked: Boolean, + title: String, + + ripple: { + type: Boolean, + default: true, + }, + color: String, + sliderColor: String, + hideSlider: Boolean, + + direction: { + type: String as PropType<'horizontal' | 'vertical'>, + default: 'horizontal', + }, + + ...makeTagProps(), + ...makeRouterProps(), + ...makeGroupItemProps({ + selectedClass: 'v-tab--selected', + }), + ...makeThemeProps(), + }, + + setup (props, { slots, attrs }) { + const { isSelected, select, selectedClass } = useGroupItem(props, VTabsSymbol) + const { textColorClasses: sliderColorClasses, textColorStyles: sliderColorStyles } = useTextColor(props, 'sliderColor') + const isHorizontal = computed(() => props.direction === 'horizontal') + + provideDefaults({ + VBtn: { + block: toRef(props, 'fixed'), + color: computed(() => isSelected.value ? props.color : undefined), + variant: 'text', + }, + }, { + scoped: true, + }) + + const rootEl = ref() + const sliderEl = ref() + watch(isSelected, isSelected => { + if (isSelected) { + const prevEl: HTMLElement | undefined = rootEl.value?.$el.parentElement?.querySelector('.v-tab--selected .v-tab__slider') + const nextEl = sliderEl.value + + if (!prevEl || !nextEl) return + + const color = getComputedStyle(prevEl).color + + const prevBox = prevEl.getBoundingClientRect() + const nextBox = nextEl.getBoundingClientRect() + + const xy = isHorizontal.value ? 'x' : 'y' + const XY = isHorizontal.value ? 'X' : 'Y' + const widthHeight = isHorizontal.value ? 'width' : 'height' + + const delta = prevBox[xy] - nextBox[xy] + const origin = + Math.sign(delta) > 0 ? (isHorizontal.value ? 'right' : 'bottom') + : Math.sign(delta) < 0 ? (isHorizontal.value ? 'left' : 'top') + : 'center' + const size = Math.abs(delta) + (Math.sign(delta) < 0 ? prevBox[widthHeight] : nextBox[widthHeight]) + const scale = size / nextBox[widthHeight] + + const sigma = 1.5 + nextEl.animate({ + backgroundColor: [color, ''], + transform: [`translate${XY}(${delta}px)`, `translate${XY}(${delta / sigma}px) scale${XY}(${(scale - 1) / sigma + 1})`, ''], + transformOrigin: Array(3).fill(origin), + }, { + duration: 225, + easing: standardEasing, + }) + } + }) + + useRender(() => { + const [btnProps] = pick(props, [ + 'href', + 'to', + 'replace', + 'icon', + 'stacked', + 'prependIcon', + 'appendIcon', + 'ripple', + 'theme', + 'disabled', + ]) + + return ( + !props.disabled && select(!isSelected.value) } + { ...btnProps } + { ...attrs } + > + { slots.default ? slots.default() : props.title } + { !props.hideSlider && ( +
+ ) } + + ) + }) + + return { + isSelected, + } + }, +}) + +export type VTab = InstanceType diff --git a/packages/vuetify/src/components/VTabs/VTabItem.ts b/packages/vuetify/src/components/VTabs/VTabItem.ts deleted file mode 100644 index 089f6d46f79..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabItem.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Extensions -import VWindowItem from '../VWindow/VWindowItem' - -/* @vue/component */ -export default VWindowItem.extend({ - name: 'v-tab-item', - - props: { - id: String, - }, - - methods: { - genWindowItem () { - const item = VWindowItem.options.methods.genWindowItem.call(this) - - item.data!.domProps = item.data!.domProps || {} - item.data!.domProps.id = this.id || this.value - - return item - }, - }, -}) diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index 572fcbd0bbe..a96aaef8c61 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -1,228 +1,52 @@ -// Imports -@import './_variables.scss' +@use 'sass:math' +@forward './variables' +@use 'sass:map' +@use '../../styles/settings' +@use '../../styles/tools' +@use './variables' as * -+theme(v-tabs) using ($material) - > .v-tabs-bar - background-color: map-get($material, $tabs-bar-background-color) - - .v-tab:not(.v-tab--active), - .v-tab:not(.v-tab--active) > .v-icon, - .v-tab:not(.v-tab--active) > .v-btn, - .v-tab--disabled - color: map-get($material, 'tabs') - - .v-tab - +states($material) - -+theme(v-tabs-items) using ($material) - background-color: map-get($material, 'cards') - -.v-tabs-bar - &.primary, - &.secondary, - &.accent, - &.success, - &.error, - &.warning, - &.info - .v-tab, - .v-tabs-slider - color: map-deep-get($material-dark, 'text', 'primary') - -// Block .v-tabs - flex: 1 1 auto - width: 100% - - .v-menu__activator - height: 100% - - &.v.tabs--vertical.v-tabs--right - flex-direction: row-reverse - - &:not(.v-tabs--vertical) - .v-tab - white-space: normal - - // https://github.com/vuetifyjs/vuetify/issues/8294 - &.v-tabs--right - > .v-slide-group--is-overflowing.v-tabs-bar--is-mobile:not(.v-slide-group--has-affixes) - .v-slide-group__next - display: initial - visibility: hidden - - // https://github.com/vuetifyjs/vuetify/issues/6932 - &:not(.v-tabs--right) - > .v-slide-group--is-overflowing.v-tabs-bar--is-mobile:not(.v-slide-group--has-affixes) - .v-slide-group__prev - display: initial - visibility: hidden - -// Element -.v-tabs-bar - border-radius: inherit - height: $tabs-bar-height - - &.v-item-group > * - cursor: initial - -.v-tab - align-items: center - cursor: pointer display: flex - flex: 0 1 auto - font-size: $tab-font-size - font-weight: $tab-font-weight - justify-content: center - letter-spacing: $tabs-item-letter-spacing - line-height: $tab-line-height - min-width: $tabs-item-min-width - max-width: $tabs-item-max-width - outline: none - padding: $tabs-item-padding - position: relative - text-align: center - text-decoration: none - text-transform: uppercase - transition: none - user-select: none - - // Needs increased specificity - &.v-tab - color: inherit - - &:before - background-color: currentColor - bottom: 0 - content: '' - left: 0 - opacity: 0 - pointer-events: none - position: absolute - right: 0 - top: 0 - transition: $primary-transition - - -.v-tabs-slider - background-color: currentColor - height: 100% - width: 100% - - &-wrapper - bottom: 0 - margin: 0 !important - position: absolute - transition: $primary-transition - z-index: 1 - -// Modifier -.v-tabs--align-with-title > .v-tabs-bar:not(.v-tabs-bar--show-arrows):not(.v-slide-group--is-overflowing) > .v-slide-group__wrapper > .v-tabs-bar__content - & > .v-tab:first-child, - & > .v-tabs-slider-wrapper + .v-tab - +ltr() - margin-left: $tabs-item-align-with-title-margin - - +rtl() - margin-right: $tabs-item-align-with-title-margin -.v-tabs--fixed-tabs > .v-tabs-bar, -.v-tabs--centered > .v-tabs-bar - .v-tabs-bar__content > *:last-child - +ltr() - margin-right: auto + @at-root + @include tools.density('v-tabs', $tabs-density) using ($modifier) + height: $tabs-height + $modifier - +rtl() - margin-left: auto + &.v-tabs--stacked + height: $tabs-stacked-height + $modifier - .v-tabs-bar__content > *:first-child:not(.v-tabs-slider-wrapper), - .v-tabs-slider-wrapper + * - +ltr() - margin-left: auto - - +rtl() - margin-right: auto - -.v-tabs--fixed-tabs > .v-tabs-bar - .v-tab - flex: 1 1 auto - width: 100% - -.v-tabs--grow > .v-tabs-bar - .v-tab - flex: 1 0 auto - max-width: none - -.v-tabs--icons-and-text > .v-tabs-bar - height: $tabs-icons-and-text-bar-height - - .v-tab - flex-direction: column-reverse - - > *:first-child - margin-bottom: $tabs-icons-and-text-first-tab-margin-bottom - -.v-tabs--overflow > .v-tabs-bar - .v-tab - flex: 1 0 auto - -.v-tabs--right > .v-tabs-bar - .v-tab:first-child, - .v-tabs-slider-wrapper + .v-tab - +ltr() - margin-left: auto - - +rtl() - margin-right: auto - - .v-tab:last-child - +ltr() - margin-right: 0 - - +rtl() - margin-left: 0 - -.v-tabs--vertical - display: flex - - & > .v-tabs-bar - flex: 1 0 auto + &.v-slide-group--vertical height: auto - .v-slide-group__next, - .v-slide-group__prev - display: none - - .v-tabs-bar__content - flex-direction: column - .v-tab - height: $tabs-item-vertical-height - - .v-tabs-slider - height: 100% + --v-btn-height: #{$tabs-height} - & > .v-window - flex: 0 1 100% +.v-tabs--align-with-title:not(.v-slide-group--has-affixes) + .v-tab:first-child + margin-inline-start: $tab-align-with-title-margin - &.v-tabs--icons-and-text - & > .v-tabs-bar - .v-tab - height: $tabs-item-vertical-icons-and-text-height +.v-tabs--fixed-tabs, +.v-tabs--centered + .v-slide-group__content > .v-tab:last-child + margin-inline-end: auto -.v-tab--active - color: inherit + .v-slide-group__content > .v-tab:first-child + margin-inline-start: auto - &.v-tab:not(:focus)::before - opacity: 0 +.v-tabs--grow .v-tab + flex: 1 0 auto + max-width: none - .v-icon, - .v-btn.v-btn--flat - color: inherit +.v-tabs--right + .v-tab:first-child + margin-inline-start: auto -.v-tab--disabled - opacity: $tab-disabled-opacity - - &, - & * - pointer-events: none + .v-tab:last-child + margin-inline-end: 0 + +@media #{map-get(settings.$display-breakpoints, 'md-and-down')} + .v-tabs.v-slide-group--is-overflowing:not(.v-slide-group--has-affixes) + .v-tab:first-child + margin-inline-start: 52px + .v-tab:last-child + margin-inline-end: 52px diff --git a/packages/vuetify/src/components/VTabs/VTabs.ts b/packages/vuetify/src/components/VTabs/VTabs.ts deleted file mode 100644 index eef2fd0c2f1..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabs.ts +++ /dev/null @@ -1,312 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Styles -import './VTabs.sass' - -// Components -import VTabsBar from './VTabsBar' -import VTabsItems from './VTabsItems' -import VTabsSlider from './VTabsSlider' - -// Mixins -import Colorable from '../../mixins/colorable' -import Proxyable from '../../mixins/proxyable' -import Themeable from '../../mixins/themeable' - -// Directives -import Resize from '../../directives/resize' - -// Utilities -import { convertToUnit } from '../../util/helpers' -import { ExtractVue } from './../../util/mixins' -import mixins from '../../util/mixins' - -// Types -import { VNode } from 'vue/types' - -const baseMixins = mixins( - Colorable, - Proxyable, - Themeable -) - -interface options extends ExtractVue { - $refs: { - items: InstanceType - } -} - -export default baseMixins.extend().extend({ - name: 'v-tabs', - - directives: { - Resize, - }, - - props: { - activeClass: { - type: String, - default: '', - }, - alignWithTitle: Boolean, - backgroundColor: String, - centerActive: Boolean, - centered: Boolean, - fixedTabs: Boolean, - grow: Boolean, - height: { - type: [Number, String], - default: undefined, - }, - hideSlider: Boolean, - iconsAndText: Boolean, - mobileBreakpoint: [String, Number], - nextIcon: { - type: String, - default: '$next', - }, - optional: Boolean, - prevIcon: { - type: String, - default: '$prev', - }, - right: Boolean, - showArrows: [Boolean, String], - sliderColor: String, - sliderSize: { - type: [Number, String], - default: 2, - }, - vertical: Boolean, - }, - - data () { - return { - resizeTimeout: 0, - slider: { - height: null as null | number, - left: null as null | number, - right: null as null | number, - top: null as null | number, - width: null as null | number, - }, - transitionTime: 300, - } - }, - - computed: { - classes (): object { - return { - 'v-tabs--align-with-title': this.alignWithTitle, - 'v-tabs--centered': this.centered, - 'v-tabs--fixed-tabs': this.fixedTabs, - 'v-tabs--grow': this.grow, - 'v-tabs--icons-and-text': this.iconsAndText, - 'v-tabs--right': this.right, - 'v-tabs--vertical': this.vertical, - ...this.themeClasses, - } - }, - isReversed (): boolean { - return this.$vuetify.rtl && this.vertical - }, - sliderStyles (): object { - return { - height: convertToUnit(this.slider.height), - left: this.isReversed ? undefined : convertToUnit(this.slider.left), - right: this.isReversed ? convertToUnit(this.slider.right) : undefined, - top: this.vertical ? convertToUnit(this.slider.top) : undefined, - transition: this.slider.left != null ? null : 'none', - width: convertToUnit(this.slider.width), - } - }, - computedColor (): string { - if (this.color) return this.color - else if (this.isDark && !this.appIsDark) return 'white' - else return 'primary' - }, - }, - - watch: { - alignWithTitle: 'callSlider', - centered: 'callSlider', - centerActive: 'callSlider', - fixedTabs: 'callSlider', - grow: 'callSlider', - iconsAndText: 'callSlider', - right: 'callSlider', - showArrows: 'callSlider', - vertical: 'callSlider', - '$vuetify.application.left': 'onResize', - '$vuetify.application.right': 'onResize', - '$vuetify.rtl': 'onResize', - }, - - mounted () { - this.$nextTick(() => { - window.setTimeout(this.callSlider, 30) - }) - }, - - methods: { - callSlider () { - if ( - this.hideSlider || - !this.$refs.items || - !this.$refs.items.selectedItems.length - ) { - this.slider.width = 0 - return false - } - - this.$nextTick(() => { - // Give screen time to paint - const activeTab = this.$refs.items.selectedItems[0] - /* istanbul ignore if */ - if (!activeTab || !activeTab.$el) { - this.slider.width = 0 - this.slider.left = 0 - return - } - const el = activeTab.$el as HTMLElement - - this.slider = { - height: !this.vertical ? Number(this.sliderSize) : el.scrollHeight, - left: this.vertical ? 0 : el.offsetLeft, - right: this.vertical ? 0 : el.offsetLeft + el.offsetWidth, - top: el.offsetTop, - width: this.vertical ? Number(this.sliderSize) : el.scrollWidth, - } - }) - - return true - }, - genBar (items: VNode[], slider: VNode | null) { - const data = { - style: { - height: convertToUnit(this.height), - }, - props: { - activeClass: this.activeClass, - centerActive: this.centerActive, - dark: this.dark, - light: this.light, - mandatory: !this.optional, - mobileBreakpoint: this.mobileBreakpoint, - nextIcon: this.nextIcon, - prevIcon: this.prevIcon, - showArrows: this.showArrows, - value: this.internalValue, - }, - on: { - 'call:slider': this.callSlider, - change: (val: any) => { - this.internalValue = val - }, - }, - ref: 'items', - } - - this.setTextColor(this.computedColor, data) - this.setBackgroundColor(this.backgroundColor, data) - - return this.$createElement(VTabsBar, data, [ - this.genSlider(slider), - items, - ]) - }, - genItems (items: VNode | null, item: VNode[]) { - // If user provides items - // opt to use theirs - if (items) return items - - // If no tabs are provided - // render nothing - if (!item.length) return null - - return this.$createElement(VTabsItems, { - props: { - value: this.internalValue, - }, - on: { - change: (val: any) => { - this.internalValue = val - }, - }, - }, item) - }, - genSlider (slider: VNode | null) { - if (this.hideSlider) return null - - if (!slider) { - slider = this.$createElement(VTabsSlider, { - props: { color: this.sliderColor }, - }) - } - - return this.$createElement('div', { - staticClass: 'v-tabs-slider-wrapper', - style: this.sliderStyles, - }, [slider]) - }, - onResize () { - if (this._isDestroyed) return - - clearTimeout(this.resizeTimeout) - this.resizeTimeout = window.setTimeout(this.callSlider, 0) - }, - parseNodes () { - let items = null - let slider = null - const item = [] - const tab = [] - const slot = this.$slots.default || [] - const length = slot.length - - for (let i = 0; i < length; i++) { - const vnode = slot[i] - - if (vnode.componentOptions) { - switch (vnode.componentOptions.Ctor.options.name) { - case 'v-tabs-slider': slider = vnode - break - case 'v-tabs-items': items = vnode - break - case 'v-tab-item': item.push(vnode) - break - // case 'v-tab' - intentionally omitted - default: tab.push(vnode) - } - } else { - tab.push(vnode) - } - } - - /** - * tab: array of `v-tab` - * slider: single `v-tabs-slider` - * items: single `v-tabs-items` - * item: array of `v-tab-item` - */ - return { tab, slider, items, item } - }, - }, - - render (h): VNode { - const { tab, slider, items, item } = this.parseNodes() - - return h('div', { - staticClass: 'v-tabs', - class: this.classes, - directives: [{ - name: 'resize', - modifiers: { quiet: true }, - value: this.onResize, - }], - }, [ - this.genBar(tab, slider), - this.genItems(items, item), - ]) - }, -}) diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx new file mode 100644 index 00000000000..cfb5c40a98f --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -0,0 +1,111 @@ +// Styles +import './VTabs.sass' + +// Components +import { VSlideGroup } from '@/components/VSlideGroup' +import { VTab } from './VTab' + +// Composables +import { makeDensityProps, useDensity } from '@/composables/density' +import { makeTagProps } from '@/composables/tag' +import { provideDefaults } from '@/composables/defaults' + +// Utilities +import { computed, toRef } from 'vue' +import { defineComponent } from '@/util' + +// Types +import type { GroupProvide } from '@/composables/group' +import type { InjectionKey, PropType } from 'vue' + +export type TabItem = string | Record + +function parseItems (items: TabItem[] | undefined) { + if (!items) return [] + + return items.map(item => { + if (typeof item === 'string') return { title: item, value: item } + + return item + }) +} + +export const VTabsSymbol: InjectionKey = Symbol.for('vuetify:v-tabs') + +export const VTabs = defineComponent({ + name: 'VTabs', + + props: { + alignWithTitle: Boolean, + color: String, + direction: { + type: String as PropType<'horizontal' | 'vertical'>, + default: 'horizontal', + }, + fixedTabs: Boolean, + items: { + type: Array as PropType, + default: () => ([]), + }, + stacked: Boolean, + backgroundColor: String, + centered: Boolean, + grow: Boolean, + height: { + type: [Number, String], + default: undefined, + }, + hideSlider: Boolean, + optional: Boolean, + right: Boolean, + sliderColor: String, + + ...makeDensityProps(), + ...makeTagProps(), + }, + + setup (props, { slots, attrs }) { + const parsedItems = computed(() => parseItems(props.items)) + const { densityClasses } = useDensity(props) + + provideDefaults({ + VTab: { + color: toRef(props, 'color'), + direction: toRef(props, 'direction'), + stacked: toRef(props, 'stacked'), + fixed: toRef(props, 'fixedTabs'), + sliderColor: toRef(props, 'sliderColor'), + hideSlider: toRef(props, 'hideSlider'), + }, + }) + + return () => ( + + { slots.default ? slots.default() : parsedItems.value.map(item => ( + + )) } + + ) + }, +}) + +export type VTabs = InstanceType diff --git a/packages/vuetify/src/components/VTabs/VTabsBar.ts b/packages/vuetify/src/components/VTabs/VTabsBar.ts deleted file mode 100644 index feb3d009223..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabsBar.ts +++ /dev/null @@ -1,104 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Extensions -import { BaseSlideGroup } from '../VSlideGroup/VSlideGroup' - -// Components -import VTab from './VTab' - -// Mixins -import Themeable from '../../mixins/themeable' -import SSRBootable from '../../mixins/ssr-bootable' - -// Utilities -import mixins from '../../util/mixins' - -// Types -// import { Route } from 'vue-router' -import { VNode } from 'vue' - -type VTabInstance = InstanceType - -export default mixins( - BaseSlideGroup, - SSRBootable, - Themeable - /* @vue/component */ -).extend({ - name: 'v-tabs-bar', - - provide () { - return { - tabsBar: this, - } - }, - - computed: { - classes () { - return { - ...BaseSlideGroup.options.computed.classes.call(this), - 'v-tabs-bar': true, - 'v-tabs-bar--is-mobile': this.isMobile, - // TODO: Remove this and move to v-slide-group - 'v-tabs-bar--show-arrows': this.showArrows, - ...this.themeClasses, - } - }, - }, - - watch: { - items: 'callSlider', - internalValue: 'callSlider', - $route: 'onRouteChange', - }, - - methods: { - callSlider () { - if (!this.isBooted) return - - this.$emit('call:slider') - }, - genContent () { - const render = BaseSlideGroup.options.methods.genContent.call(this) - - render.data = render.data || {} - render.data.staticClass += ' v-tabs-bar__content' - - return render - }, - onRouteChange (val: Route, oldVal: Route) { - /* istanbul ignore next */ - if (this.mandatory) return - - const items = this.items as unknown as VTabInstance[] - const newPath = val.path - const oldPath = oldVal.path - - let hasNew = false - let hasOld = false - - for (const item of items) { - if (item.to === oldPath) hasOld = true - else if (item.to === newPath) hasNew = true - - if (hasNew && hasOld) break - } - - // If we have an old item and not a new one - // it's assumed that the user navigated to - // a path that is not present in the items - if (!hasNew && hasOld) this.internalValue = undefined - }, - }, - - render (h): VNode { - const render = BaseSlideGroup.options.render.call(this, h) - - render.data!.attrs = { - role: 'tablist', - } - - return render - }, -}) diff --git a/packages/vuetify/src/components/VTabs/VTabsItems.ts b/packages/vuetify/src/components/VTabs/VTabsItems.ts deleted file mode 100644 index ca930454377..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabsItems.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Extensions -import VWindow from '../VWindow/VWindow' - -// Types & Components -import { BaseItemGroup, GroupableInstance } from './../VItemGroup/VItemGroup' - -/* @vue/component */ -export default VWindow.extend({ - name: 'v-tabs-items', - - props: { - mandatory: { - type: Boolean, - default: false, - }, - }, - - computed: { - classes (): object { - return { - ...VWindow.options.computed.classes.call(this), - 'v-tabs-items': true, - } - }, - isDark (): boolean { - return this.rootIsDark - }, - }, - - methods: { - getValue (item: GroupableInstance, i: number) { - return item.id || BaseItemGroup.options.methods.getValue.call(this, item, i) - }, - }, -}) diff --git a/packages/vuetify/src/components/VTabs/VTabsSlider.ts b/packages/vuetify/src/components/VTabs/VTabsSlider.ts deleted file mode 100644 index b0800efb435..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabsSlider.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Mixins -import Colorable from '../../mixins/colorable' - -// Utilities -import mixins from '../../util/mixins' - -// Types -import { VNode } from 'vue/types' - -/* @vue/component */ -export default mixins(Colorable).extend({ - name: 'v-tabs-slider', - - render (h): VNode { - return h('div', this.setBackgroundColor(this.color, { - staticClass: 'v-tabs-slider', - })) - }, -}) diff --git a/packages/vuetify/src/components/VTabs/__tests__/VTabsSlider.spec.ts b/packages/vuetify/src/components/VTabs/__tests__/VTabsSlider.spec.ts deleted file mode 100644 index 97e6b59da2f..00000000000 --- a/packages/vuetify/src/components/VTabs/__tests__/VTabsSlider.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-nocheck -/* eslint-disable */ - -// Components -// import VTabsSlider from '../VTabsSlider' - -// Utilities -import { mount, Wrapper } from '@vue/test-utils' - -// Types -// import Vue from 'vue' - -describe.skip('VTabsSlider.ts', () => { - let mountFunction: (options?: object) => Wrapper - - beforeEach(() => { - mountFunction = (options = {}) => { - return mount(VTabsSlider, { - ...options, - }) - } - }) - - it('should render a tabs slider', () => { - const wrapper = mountFunction({ - propsData: { - color: 'blue lighten-1', - }, - }) - - expect(wrapper.element.classList).toContain('blue') - expect(wrapper.element.classList).toContain('lighten-1') - }) -}) diff --git a/packages/vuetify/src/components/VTabs/_variables.scss b/packages/vuetify/src/components/VTabs/_variables.scss index 0450679d807..71cd9e41f81 100644 --- a/packages/vuetify/src/components/VTabs/_variables.scss +++ b/packages/vuetify/src/components/VTabs/_variables.scss @@ -1,17 +1,11 @@ -@import '../../styles/styles.sass'; +@use 'sass:math'; +@use 'sass:map'; -$tab-disabled-opacity: .5 !default; -$tab-font-size: map-deep-get($typography, 'subtitle-2', 'size') !default; -$tab-font-weight: map-deep-get($typography, 'subtitle-2', 'weight') !default; -$tab-line-height: normal !default; -$tabs-bar-background-color: 'cards' !default; -$tabs-bar-height: 48px !default; -$tabs-icons-and-text-bar-height: 72px !default; -$tabs-icons-and-text-first-tab-margin-bottom: 6px !default; -$tabs-item-align-with-title-margin: 42px !default; -$tabs-item-letter-spacing: .0892857143em !default; -$tabs-item-max-width: 360px !default; -$tabs-item-min-width: 90px !default; -$tabs-item-padding: 0 16px !default; -$tabs-item-vertical-height: $tabs-bar-height !default; -$tabs-item-vertical-icons-and-text-height: $tabs-icons-and-text-bar-height !default; +$tabs-density: ( 'default': 0, 'comfortable' : -1, 'compact': -3) !default; +$tabs-height: 48px !default; +$tabs-stacked-height: 72px !default; + +$tab-align-with-title-margin: 42px !default; +$tab-max-width: 360px !default; +$tab-min-width: 90px !default; +$tab-slider-size: 2px !default; diff --git a/packages/vuetify/src/components/VTabs/index.ts b/packages/vuetify/src/components/VTabs/index.ts index 62ea0d14c93..e3a24f56450 100644 --- a/packages/vuetify/src/components/VTabs/index.ts +++ b/packages/vuetify/src/components/VTabs/index.ts @@ -1,17 +1,2 @@ -import VTabs from './VTabs' -import VTab from './VTab' -import VTabsItems from './VTabsItems' -import VTabItem from './VTabItem' -import VTabsSlider from './VTabsSlider' - -export { VTabs, VTab, VTabItem, VTabsItems, VTabsSlider } - -export default { - $_vuetify_subcomponents: { - VTabs, - VTab, - VTabsItems, - VTabItem, - VTabsSlider, - }, -} +export { VTabs } from './VTabs' +export { VTab } from './VTab' diff --git a/packages/vuetify/src/components/VWindow/VWindow.tsx b/packages/vuetify/src/components/VWindow/VWindow.tsx index 02f5db7605e..5b00cd80220 100644 --- a/packages/vuetify/src/components/VWindow/VWindow.tsx +++ b/packages/vuetify/src/components/VWindow/VWindow.tsx @@ -17,6 +17,7 @@ import { Touch } from '@/directives/touch' // Utilities import { computed, defineComponent, provide, ref, watch } from 'vue' +import { useRender } from '@/util' // Types import type { ComputedRef, InjectionKey, PropType, Ref } from 'vue' @@ -206,7 +207,7 @@ export const VWindow = defineComponent({ } }) - return () => ( + useRender(() => ( - ) + )) + + return { + group, + } }, }) diff --git a/packages/vuetify/src/components/index.ts b/packages/vuetify/src/components/index.ts index 7e2fe9ab51b..609ddac84d1 100644 --- a/packages/vuetify/src/components/index.ts +++ b/packages/vuetify/src/components/index.ts @@ -69,7 +69,7 @@ export * from './VSelectionControl' export * from './VSelectionControlGroup' export * from './VSheet' // export * from './VSkeletonLoader' -// export * from './VSlideGroup' +export * from './VSlideGroup' export * from './VSlider' // export * from './VSnackbar' // export * from './VSparkline' @@ -77,7 +77,7 @@ export * from './VSlider' // export * from './VStepper' export * from './VSwitch' export * from './VSystemBar' -// export * from './VTabs' +export * from './VTabs' export * from './VTable' export * from './VTextarea' export * from './VTextField' diff --git a/packages/vuetify/src/composables/group.ts b/packages/vuetify/src/composables/group.ts index a034bfd1333..e9617999a5b 100644 --- a/packages/vuetify/src/composables/group.ts +++ b/packages/vuetify/src/composables/group.ts @@ -8,13 +8,13 @@ import { consoleWarn, deepEqual, findChildren, getCurrentInstance, getUid, props // Types import type { ComponentInternalInstance, ComputedRef, ExtractPropTypes, InjectionKey, PropType, Ref, UnwrapRef } from 'vue' -interface GroupItem { +export interface GroupItem { id: number value: Ref disabled: Ref } -interface GroupProps { +export interface GroupProps { disabled: boolean modelValue: unknown multiple?: boolean @@ -70,7 +70,7 @@ export const makeGroupItemProps = propsFactory({ selectedClass: String, }, 'group-item') -type GroupItemProps = ExtractPropTypes> +export type GroupItemProps = ExtractPropTypes> // Composables export function useGroupItem ( @@ -170,8 +170,11 @@ export function useGroup ( .filter(cmp => !!cmp.provides[injectKey as any]) // TODO: Fix in TS 4.4 const index = instances.indexOf(vm) - if (index > -1) items.splice(index, 0, unwrapped) - else items.push(unwrapped) + if (index > -1) { + items.splice(index, 0, unwrapped) + } else { + items.push(unwrapped) + } } function unregister (id: number) { @@ -203,39 +206,42 @@ export function useGroup ( isUnmounted = true }) - function select (id: number, isSelected: boolean) { + function select (id: number, value?: boolean) { const item = items.find(item => item.id === id) - if (isSelected && item?.disabled) return + if (value && item?.disabled) return if (props.multiple) { const internalValue = selected.value.slice() const index = internalValue.findIndex(v => v === id) + const isSelected = ~index + value = value ?? !isSelected // We can't remove value if group is // mandatory, value already exists, // and it is the only value if ( + isSelected && props.mandatory && - index > -1 && internalValue.length <= 1 ) return // We can't add value if it would // cause max limit to be exceeded if ( + !isSelected && props.max != null && - index < 0 && internalValue.length + 1 > props.max ) return - if (index < 0 && isSelected) internalValue.push(id) - else if (index >= 0 && !isSelected) internalValue.splice(index, 1) + if (index < 0 && value) internalValue.push(id) + else if (index >= 0 && !value) internalValue.splice(index, 1) selected.value = internalValue } else { - if (props.mandatory && selected.value.includes(id)) return + const isSelected = selected.value.includes(id) + if (props.mandatory && isSelected) return - selected.value = isSelected ? [id] : [] + selected.value = (value ?? !isSelected) ? [id] : [] } }