From 2771d92b7591b7aef338b0b89ecae18b977e11d2 Mon Sep 17 00:00:00 2001 From: nekosaur Date: Thu, 17 Feb 2022 00:38:20 +0100 Subject: [PATCH 01/20] feat(VTabs): update to v3 --- .../vuetify/src/components/VTabs/VTab.sass | 2 + packages/vuetify/src/components/VTabs/VTab.ts | 124 ------- .../vuetify/src/components/VTabs/VTab.tsx | 87 +++++ .../vuetify/src/components/VTabs/VTabs.sass | 214 ++++++------ .../vuetify/src/components/VTabs/VTabs.ts | 312 ------------------ .../vuetify/src/components/VTabs/VTabs.tsx | 144 ++++++++ .../src/components/VTabs/VTabsSlider.sass | 16 + .../src/components/VTabs/VTabsSlider.ts | 22 -- .../src/components/VTabs/VTabsSlider.tsx | 27 ++ .../src/components/VTabs/_variables.scss | 12 +- .../vuetify/src/components/VTabs/index.ts | 19 +- packages/vuetify/src/components/index.ts | 2 +- packages/vuetify/src/composables/group.ts | 10 +- 13 files changed, 408 insertions(+), 583 deletions(-) create mode 100644 packages/vuetify/src/components/VTabs/VTab.sass delete mode 100644 packages/vuetify/src/components/VTabs/VTab.ts create mode 100644 packages/vuetify/src/components/VTabs/VTab.tsx delete mode 100644 packages/vuetify/src/components/VTabs/VTabs.ts create mode 100644 packages/vuetify/src/components/VTabs/VTabs.tsx create mode 100644 packages/vuetify/src/components/VTabs/VTabsSlider.sass delete mode 100644 packages/vuetify/src/components/VTabs/VTabsSlider.ts create mode 100644 packages/vuetify/src/components/VTabs/VTabsSlider.tsx diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass new file mode 100644 index 00000000000..b597dcd5326 --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -0,0 +1,2 @@ +.v-tab + display: flex 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..1e1caee0c5e --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -0,0 +1,87 @@ +import './VTab.sass' + +// Mixins + +// Utilities +import { makeRouterProps, useLink } from '@/composables/router' +import { makeTagProps } from '@/composables/tag' +import { defineComponent, pick } from '@/util' +import { VBtn } from '..' +import { provideDefaults } from '@/composables/defaults' +import { makeGroupItemProps, useGroupItem } from '@/composables/group' +import { VTabsSymbol } from '.' +import { computed, toRef } from 'vue' +import { makeThemeProps } from '@/composables/theme' + +// Types + +export const VTab = defineComponent({ + name: 'VTab', + + props: { + icon: [Boolean, String], + prependIcon: String, + appendIcon: String, + + stacked: Boolean, + title: String, + + ripple: { + type: Boolean, + default: true, + }, + color: String, + ...makeTagProps(), + ...makeRouterProps(), + ...makeGroupItemProps({ + selectedClass: 'v-tab--selected', + }), + ...makeThemeProps(), + }, + + setup (props, { slots, attrs }) { + const { isSelected, select, selectedClass } = useGroupItem(props, VTabsSymbol) + + provideDefaults({ + VBtn: { + variant: 'text', + rounded: 0, + minWidth: 90, + maxWidth: 360, + color: computed(() => isSelected.value ? props.color : undefined), + }, + }, { + scoped: true, + }) + + return () => { + const [btnProps] = pick(props, [ + 'href', + 'to', + 'replace', + 'icon', + 'stacked', + 'prependIcon', + 'appendIcon', + 'ripple', + 'theme', + 'disabled', + ]) + + return ( +
+ !props.disabled && select(!isSelected.value) } + { ...btnProps } + > + { slots.default ? slots.default() : props.title } + +
+ ) + } + }, +}) diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index 572fcbd0bbe..69a54c0ec06 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -1,39 +1,51 @@ -// Imports -@import './_variables.scss' - -+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') +@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 + position: relative + display: flex flex: 1 1 auto width: 100% + &--vertical + flex-direction: column + + &--horizontal + flex-direction: row + .v-menu__activator height: 100% @@ -66,81 +78,81 @@ &.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 +// .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: settings.$standard-easing + + +// .v-tabs-slider +// background-color: currentColor +// height: 100% +// width: 100% + +// &-wrapper +// bottom: 0 +// margin: 0 !important +// position: absolute +// transition: settings.$standard-easing +// 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() + +tools.ltr() margin-left: $tabs-item-align-with-title-margin - +rtl() + +tools.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() + +tools.ltr() margin-right: auto - +rtl() + +tools.rtl() margin-left: auto .v-tabs-bar__content > *:first-child:not(.v-tabs-slider-wrapper), .v-tabs-slider-wrapper + * - +ltr() + +tools.ltr() margin-left: auto - +rtl() + +tools.rtl() margin-right: auto .v-tabs--fixed-tabs > .v-tabs-bar @@ -169,17 +181,17 @@ .v-tabs--right > .v-tabs-bar .v-tab:first-child, .v-tabs-slider-wrapper + .v-tab - +ltr() + +tools.ltr() margin-left: auto - +rtl() + +tools.rtl() margin-right: auto .v-tab:last-child - +ltr() + +tools.ltr() margin-right: 0 - +rtl() + +tools.rtl() margin-left: 0 .v-tabs--vertical @@ -210,19 +222,19 @@ .v-tab height: $tabs-item-vertical-icons-and-text-height -.v-tab--active - color: inherit +// .v-tab--active +// color: inherit - &.v-tab:not(:focus)::before - opacity: 0 +// &.v-tab:not(:focus)::before +// opacity: 0 - .v-icon, - .v-btn.v-btn--flat - color: inherit +// .v-icon, +// .v-btn.v-btn--flat +// color: inherit -.v-tab--disabled - opacity: $tab-disabled-opacity +// .v-tab--disabled +// opacity: $tab-disabled-opacity - &, - & * - pointer-events: none +// &, +// & * +// pointer-events: none 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..ae68aaba572 --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -0,0 +1,144 @@ +// Styles +import './VTabs.sass' + +// Components +import { VTab } from './VTab' +import { VTabsSlider } from './VTabsSlider' + +// Directives +import Resize from '../../directives/resize' + +// Utilities +import { convertToUnit } from '../../util/helpers' +import { defineComponent } from '@/util' +import { makeTagProps } from '@/composables/tag' +import { computed, ref, toRef, watchEffect } from 'vue' + +// Types +import type { InjectionKey, PropType } from 'vue' +import { provideDefaults } from '@/composables/defaults' +import type { GroupProvide } from '@/composables/group' +import { makeGroupProps, useGroup } from '@/composables/group' + +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: { + items: { + type: Array as PropType, + default: () => ([]), + }, + stacked: Boolean, + color: String, + direction: { + type: String, + default: 'horizontal', + }, + ...makeTagProps(), + ...makeGroupProps({ + mandatory: 'force' as const, + }), + // 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, + // }, + }, + + emits: { + 'update:modelValue': (value: any) => true, + }, + + setup (props, { slots }) { + const rootRef = ref() + const items = computed(() => parseItems(props.items)) + const group = useGroup(props, VTabsSymbol) + + provideDefaults({ + VTab: { + stacked: toRef(props, 'stacked'), + color: toRef(props, 'color'), + }, + }) + + const sliderStyles = ref({}) + + watchEffect(() => { + const index = group.items.value.findIndex(item => item.id === group.selected.value[0]) + + if (index < 0 || !rootRef.value) return + + const el = rootRef.value.querySelectorAll('.v-tab')[index] as HTMLElement + + if (props.direction === 'horizontal') { + sliderStyles.value = { + left: convertToUnit(el.offsetLeft), + width: convertToUnit(el.offsetWidth), + } + } else { + sliderStyles.value = { + top: convertToUnit(el.offsetTop), + height: convertToUnit(el.offsetHeight), + } + } + }, { + flush: 'post', + }) + + return () => ( + + { slots.default ? slots.default() : items.value.map(item => ( + + )) } + + + ) + }, +}) diff --git a/packages/vuetify/src/components/VTabs/VTabsSlider.sass b/packages/vuetify/src/components/VTabs/VTabsSlider.sass new file mode 100644 index 00000000000..40f6f102b81 --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTabsSlider.sass @@ -0,0 +1,16 @@ +@forward './variables' +@use './variables' as * +@use '../../styles/settings' + +.v-tabs-slider + position: absolute + transition: .15s settings.$standard-easing + background: rgb(var(--v-theme-on-surface)) + + .v-tabs--horizontal & + height: $tabs-slider-size + bottom: 0 + + .v-tabs--vertical & + width: $tabs-slider-size + left: 0 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/VTabsSlider.tsx b/packages/vuetify/src/components/VTabs/VTabsSlider.tsx new file mode 100644 index 00000000000..57601eaf231 --- /dev/null +++ b/packages/vuetify/src/components/VTabs/VTabsSlider.tsx @@ -0,0 +1,27 @@ +import './VTabsSlider.sass' + +import { defineComponent } from '@/util' +import { useBackgroundColor } from '@/composables/color' +import { toRef } from 'vue' + +export const VTabsSlider = defineComponent({ + name: 'VTabsSlider', + + props: { + color: String, + }, + + setup (props, { slots }) { + const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(toRef(props, 'color')) + + return () => ( +
+ ) + }, +}) diff --git a/packages/vuetify/src/components/VTabs/_variables.scss b/packages/vuetify/src/components/VTabs/_variables.scss index 0450679d807..04bd5b2c0f4 100644 --- a/packages/vuetify/src/components/VTabs/_variables.scss +++ b/packages/vuetify/src/components/VTabs/_variables.scss @@ -1,8 +1,13 @@ -@import '../../styles/styles.sass'; +@use 'sass:math'; +@use 'sass:map'; +@use '../../styles/settings'; +@use '../../styles/tools'; $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-font-size: map-deep-get($typography, 'subtitle-2', 'size') !default; +// $tab-font-weight: map-deep-get($typography, 'subtitle-2', 'weight') !default; +$tab-font-size: '14px'; +$tab-font-weight: 'normal'; $tab-line-height: normal !default; $tabs-bar-background-color: 'cards' !default; $tabs-bar-height: 48px !default; @@ -15,3 +20,4 @@ $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-slider-size: 2px !default; diff --git a/packages/vuetify/src/components/VTabs/index.ts b/packages/vuetify/src/components/VTabs/index.ts index 62ea0d14c93..544bb20d99d 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 * from './VTabs' +export * from './VTab' diff --git a/packages/vuetify/src/components/index.ts b/packages/vuetify/src/components/index.ts index f0d23fa19cc..9235d5c7323 100644 --- a/packages/vuetify/src/components/index.ts +++ b/packages/vuetify/src/components/index.ts @@ -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 f070c7c8e5e..09e62800122 100644 --- a/packages/vuetify/src/composables/group.ts +++ b/packages/vuetify/src/composables/group.ts @@ -2,7 +2,7 @@ import { useProxiedModel } from './proxiedModel' // Utilities -import { computed, inject, onBeforeUnmount, onMounted, provide, reactive, toRef } from 'vue' +import { computed, inject, onBeforeUnmount, onMounted, provide, reactive, shallowReactive, toRef } from 'vue' import { consoleWarn, deepEqual, findChildren, getCurrentInstance, getUid, propsFactory, wrapInArray } from '@/util' // Types @@ -142,6 +142,7 @@ export function useGroup ( ) { let isUnmounted = false const items = reactive([]) + // const refs = shallowReactive([]) const selected = useProxiedModel( props, 'modelValue', @@ -170,8 +171,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) { From 4999862384ce771b35d95cf6843e91cffc71707f Mon Sep 17 00:00:00 2001 From: nekosaur Date: Sat, 19 Feb 2022 17:30:02 +0100 Subject: [PATCH 02/20] wip: added overflow and touch features --- .../components/VSlideGroup/VSlideGroup.sass | 12 +- .../src/components/VSlideGroup/VSlideGroup.ts | 517 ------------------ .../components/VSlideGroup/VSlideGroup.tsx | 497 +++++++++++++++++ .../VSlideGroup/VSlideGroupItem.tsx | 20 + .../src/components/VSlideGroup/VSlideItem.ts | 17 - .../components/VSlideGroup/_variables.scss | 5 +- .../src/components/VSlideGroup/index.ts | 16 +- .../vuetify/src/components/VTabs/VTab.sass | 2 +- .../vuetify/src/components/VTabs/VTab.tsx | 24 +- .../vuetify/src/components/VTabs/VTabItem.ts | 25 - .../vuetify/src/components/VTabs/VTabs.sass | 108 ++-- .../vuetify/src/components/VTabs/VTabs.tsx | 80 ++- .../vuetify/src/components/VTabs/VTabsBar.ts | 104 ---- .../src/components/VTabs/VTabsItems.ts | 38 -- packages/vuetify/src/components/index.ts | 2 +- packages/vuetify/src/composables/group.ts | 6 +- .../vuetify/src/composables/slideGroup.ts | 201 +++++++ 17 files changed, 861 insertions(+), 813 deletions(-) delete mode 100644 packages/vuetify/src/components/VSlideGroup/VSlideGroup.ts create mode 100644 packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx create mode 100644 packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx delete mode 100644 packages/vuetify/src/components/VSlideGroup/VSlideItem.ts delete mode 100644 packages/vuetify/src/components/VTabs/VTabItem.ts delete mode 100644 packages/vuetify/src/components/VTabs/VTabsBar.ts delete mode 100644 packages/vuetify/src/components/VTabs/VTabsItems.ts create mode 100644 packages/vuetify/src/composables/slideGroup.ts diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass index bf9e11828cf..6ef18660f76 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass @@ -1,5 +1,9 @@ -// Imports -@import './_variables.scss' +@use 'sass:math' +@forward './variables' +@use 'sass:map' +@use '../../styles/settings' +@use '../../styles/tools' +@use './variables' as * // Block .v-slide-group @@ -35,10 +39,10 @@ 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 +.v-slide-group__container contain: content display: flex flex: 1 1 auto 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..9ce1dd3dc4d --- /dev/null +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -0,0 +1,497 @@ +// Styles +import './VSlideGroup.sass' + +// Composables +import { makeGroupProps } from '@/composables/group' +import { useSlideGroup } from '@/composables/slideGroup' +import { makeTagProps } from '@/composables/tag' + +// Utilities +import { defineComponent } from '@/util' +import { computed } from 'vue' + +export const VSlideGroup = defineComponent({ + name: 'VSlideGroup', + + props: { + ...makeTagProps(), + ...makeGroupProps({ + mandatory: true, + }), + }, + + emits: { + 'update:modelValue': (value: any) => true, + }, + + setup (props, { slots }) { + const { + containerRef, + contentRef, + contentStyles, + next, + prev, + select, + isSelected, + } = useSlideGroup(props) + + const slotProps = computed(() => ({ + next, + prev, + select, + isSelected, + })) + + return () => ( + + { slots.prepend && ( +
+ { slots.prepend(slotProps.value) } +
+ ) } +
+
+ { slots.default?.(slotProps.value) } +
+
+ { slots.append && ( +
+ { slots.append(slotProps.value) } +
+ ) } +
+ ) + }, +}) + +// 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/VSlideGroupItem.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx new file mode 100644 index 00000000000..40bd802255a --- /dev/null +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx @@ -0,0 +1,20 @@ +import { makeGroupItemProps } from '@/composables/group' +import { useSlideGroupItem } from '@/composables/slideGroup' +import { defineComponent } from '@/util' + +export const VSlideGroupItem = defineComponent({ + name: 'VSlideGroupItem', + + props: { + ...makeGroupItemProps(), + }, + + setup (props, { slots }) { + const slideGroupItem = useSlideGroupItem(props) + + 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/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 index b597dcd5326..954c2df705f 100644 --- a/packages/vuetify/src/components/VTabs/VTab.sass +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -1,2 +1,2 @@ .v-tab - display: flex + display: inline-flex diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index 1e1caee0c5e..70a4bb99c4b 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -19,6 +19,7 @@ export const VTab = defineComponent({ name: 'VTab', props: { + fixed: Boolean, icon: [Boolean, String], prependIcon: String, appendIcon: String, @@ -48,6 +49,7 @@ export const VTab = defineComponent({ rounded: 0, minWidth: 90, maxWidth: 360, + block: toRef(props, 'fixed'), color: computed(() => isSelected.value ? props.color : undefined), }, }, { @@ -69,18 +71,16 @@ export const VTab = defineComponent({ ]) return ( -
- !props.disabled && select(!isSelected.value) } - { ...btnProps } - > - { slots.default ? slots.default() : props.title } - -
+ !props.disabled && select(!isSelected.value) } + { ...btnProps } + > + { slots.default ? slots.default() : props.title } + ) } }, 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 69a54c0ec06..c0b48387485 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -35,48 +35,52 @@ // Block .v-tabs - position: relative display: flex - flex: 1 1 auto - width: 100% - &--vertical - flex-direction: column +.v-tabs__container + contain: content + display: flex + flex: 1 1 auto + overflow: hidden - &--horizontal - flex-direction: row +.v-tabs__content + display: flex + flex: 1 0 auto + position: relative + transition: 0.2s all settings.$standard-easing + white-space: nowrap - .v-menu__activator - height: 100% +.v-tabs--vertical + .v-tabs__content, .v-tabs__container + flex-direction: column - &.v.tabs--vertical.v-tabs--right - flex-direction: row-reverse +.v-tabs--horizontal + .v-tabs__content, .v-tabs__container + flex-direction: row - &:not(.v-tabs--vertical) - .v-tab - white-space: normal + // .v-menu__activator + // height: 100% - // 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 + // &.v.tabs--vertical.v-tabs--right + // flex-direction: row-reverse - // 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 + // &:not(.v-tabs--vertical) + // .v-tab + // white-space: normal -// Element -.v-tabs-bar - border-radius: inherit - height: $tabs-bar-height + // // 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 - &.v-item-group > * - cursor: initial + // // 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 // .v-tab // align-items: center @@ -194,33 +198,33 @@ +tools.rtl() margin-left: 0 -.v-tabs--vertical - display: flex +// .v-tabs--vertical +// display: flex - & > .v-tabs-bar - flex: 1 0 auto - height: auto +// & > .v-tabs-bar +// flex: 1 0 auto +// height: auto - .v-slide-group__next, - .v-slide-group__prev - display: none +// .v-slide-group__next, +// .v-slide-group__prev +// display: none - .v-tabs-bar__content - flex-direction: column +// .v-tabs-bar__content +// flex-direction: column - .v-tab - height: $tabs-item-vertical-height +// .v-tab +// height: $tabs-item-vertical-height - .v-tabs-slider - height: 100% +// .v-tabs-slider +// height: 100% - & > .v-window - flex: 0 1 100% +// & > .v-window +// flex: 0 1 100% - &.v-tabs--icons-and-text - & > .v-tabs-bar - .v-tab - height: $tabs-item-vertical-icons-and-text-height +// &.v-tabs--icons-and-text +// & > .v-tabs-bar +// .v-tab +// height: $tabs-item-vertical-icons-and-text-height // .v-tab--active // color: inherit diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index ae68aaba572..e640155b33f 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -12,13 +12,15 @@ import Resize from '../../directives/resize' import { convertToUnit } from '../../util/helpers' import { defineComponent } from '@/util' import { makeTagProps } from '@/composables/tag' -import { computed, ref, toRef, watchEffect } from 'vue' +import { computed, ref, toRef, watch } from 'vue' // Types import type { InjectionKey, PropType } from 'vue' import { provideDefaults } from '@/composables/defaults' import type { GroupProvide } from '@/composables/group' import { makeGroupProps, useGroup } from '@/composables/group' +import { useResizeObserver } from '@/composables/resizeObserver' +import { useSlideGroup } from '@/composables/slideGroup' export type TabItem = string | Record @@ -45,7 +47,7 @@ export const VTabs = defineComponent({ stacked: Boolean, color: String, direction: { - type: String, + type: String as PropType<'horizontal' | 'vertical'>, default: 'horizontal', }, ...makeTagProps(), @@ -54,9 +56,9 @@ export const VTabs = defineComponent({ }), // alignWithTitle: Boolean, // backgroundColor: String, - // centerActive: Boolean, + centerActive: Boolean, // centered: Boolean, - // fixedTabs: Boolean, + fixedTabs: Boolean, // grow: Boolean, // height: { // type: [Number, String], @@ -88,56 +90,86 @@ export const VTabs = defineComponent({ }, setup (props, { slots }) { - const rootRef = ref() - const items = computed(() => parseItems(props.items)) - const group = useGroup(props, VTabsSymbol) + const parsedItems = computed(() => parseItems(props.items)) + const { + containerRef, + containerListeners, + contentRef, + contentStyles, + items, + selected, + } = useSlideGroup(props, VTabsSymbol) provideDefaults({ VTab: { stacked: toRef(props, 'stacked'), color: toRef(props, 'color'), + fixed: toRef(props, 'fixedTabs'), }, }) const sliderStyles = ref({}) - watchEffect(() => { - const index = group.items.value.findIndex(item => item.id === group.selected.value[0]) + function updateSliderStyles (el: HTMLElement) { + if (!selected.value.length) return - if (index < 0 || !rootRef.value) return + const index = items.value.findIndex(item => item.id === selected.value[0]) - const el = rootRef.value.querySelectorAll('.v-tab')[index] as HTMLElement + if (index < 0 || !el) return + + const selectedElement = el.querySelectorAll('.v-tab')[index] as HTMLElement if (props.direction === 'horizontal') { sliderStyles.value = { - left: convertToUnit(el.offsetLeft), - width: convertToUnit(el.offsetWidth), + left: convertToUnit(selectedElement.offsetLeft), + width: convertToUnit(selectedElement.offsetWidth), } } else { sliderStyles.value = { - top: convertToUnit(el.offsetTop), - height: convertToUnit(el.offsetHeight), + top: convertToUnit(selectedElement.offsetTop), + height: convertToUnit(selectedElement.offsetHeight), } } - }, { + } + + const { resizeRef } = useResizeObserver(entries => { + if (!entries.length) return + + updateSliderStyles(entries[0].target as HTMLElement) + }) + + watch(selected, () => resizeRef.value && updateSliderStyles(resizeRef.value as HTMLElement), { flush: 'post', }) return () => ( - { slots.default ? slots.default() : items.value.map(item => ( - - )) } - +
+
+ { slots.default ? slots.default() : parsedItems.value.map(item => ( + + )) } + +
+
) }, 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/index.ts b/packages/vuetify/src/components/index.ts index 9235d5c7323..2399916ab16 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' diff --git a/packages/vuetify/src/composables/group.ts b/packages/vuetify/src/composables/group.ts index 09e62800122..a3022747a30 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 ( diff --git a/packages/vuetify/src/composables/slideGroup.ts b/packages/vuetify/src/composables/slideGroup.ts new file mode 100644 index 00000000000..ef8c2986fb2 --- /dev/null +++ b/packages/vuetify/src/composables/slideGroup.ts @@ -0,0 +1,201 @@ +// Utilities +import { computed, ref, watch } from 'vue' +import { useGroup, useGroupItem } from './group' +import { useResizeObserver } from './resizeObserver' + +// Types +import type { InjectionKey } from 'vue' +import type { GroupItemProps, GroupProps, GroupProvide } from './group' +import { useRtl } from '.' + +export const VSlideGroupSymbol: InjectionKey = Symbol.for('vuetify:v-slide-group') + +function bias (val: number) { + const c = 0.501 + const x = Math.abs(val) + return Math.sign(val) * (x / ((1 / c - 2) * (1 - x) + 1)) +} + +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)) + } +} + +export function useSlideGroup ( + props: GroupProps & { + direction: 'vertical' | 'horizontal' + centerActive: boolean | undefined + }, + injectKey: InjectionKey = VSlideGroupSymbol +) { + const { isRtl } = useRtl() + const group = useGroup(props, injectKey) + const isOverflowing = ref(false) + const scrollOffset = ref(0) + const containerSize = ref(0) + const contentSize = ref(0) + const contentRef = ref() + const isHorizontal = computed(() => props.direction === 'horizontal') + + const { resizeRef: containerRef } = useResizeObserver(() => { + const sizeProperty = isHorizontal.value ? 'clientWidth' : 'clientHeight' + + containerSize.value = containerRef.value?.[sizeProperty] ?? 0 + contentSize.value = contentRef.value?.[sizeProperty] ?? 0 + + 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[0]) + + // 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 + let firstMove = true + + 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 + firstMove = true + } + + const containerListeners = { + onTouchend, + onTouchmove, + onTouchstart, + } + + 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' : '', + } + }) + + return { ...group, containerRef, contentRef, contentStyles, containerListeners } +} + +export function useSlideGroupItem (props: GroupItemProps, injectKey: InjectionKey = VSlideGroupSymbol) { + return useGroupItem(props, injectKey) +} From 5d2a95b31c7f984fdc2ad5c905092a5f216aeefb Mon Sep 17 00:00:00 2001 From: John Leider Date: Mon, 21 Feb 2022 17:25:54 -0600 Subject: [PATCH 03/20] chore(VSlideGroup): template code clean-up and improve types --- packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx | 2 ++ packages/vuetify/src/composables/slideGroup.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index 9ce1dd3dc4d..00a432e8de0 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -53,6 +53,7 @@ export const VSlideGroup = defineComponent({ { slots.prepend(slotProps.value) }
) } +
+ { slots.append && (
{ slots.append(slotProps.value) } diff --git a/packages/vuetify/src/composables/slideGroup.ts b/packages/vuetify/src/composables/slideGroup.ts index ef8c2986fb2..713339e2d4c 100644 --- a/packages/vuetify/src/composables/slideGroup.ts +++ b/packages/vuetify/src/composables/slideGroup.ts @@ -79,8 +79,8 @@ export function calculateCenteredOffset ({ export function useSlideGroup ( props: GroupProps & { - direction: 'vertical' | 'horizontal' - centerActive: boolean | undefined + direction?: 'vertical' | 'horizontal' + centerActive?: boolean | undefined }, injectKey: InjectionKey = VSlideGroupSymbol ) { From 400e33bcaea9bc33991d1947852763b603089fa0 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 22 Feb 2022 10:49:02 -0600 Subject: [PATCH 04/20] refactor(VTabs): minor code clean-up --- .../vuetify/src/components/VTabs/VTab.tsx | 11 ++++---- .../vuetify/src/components/VTabs/VTabs.tsx | 25 +++++++++++-------- .../src/components/VTabs/VTabsSlider.sass | 2 +- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index 70a4bb99c4b..f3fad55f39c 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -9,7 +9,7 @@ import { defineComponent, pick } from '@/util' import { VBtn } from '..' import { provideDefaults } from '@/composables/defaults' import { makeGroupItemProps, useGroupItem } from '@/composables/group' -import { VTabsSymbol } from '.' +import { VTabsSymbol } from './VTabs' import { computed, toRef } from 'vue' import { makeThemeProps } from '@/composables/theme' @@ -32,6 +32,7 @@ export const VTab = defineComponent({ default: true, }, color: String, + ...makeTagProps(), ...makeRouterProps(), ...makeGroupItemProps({ @@ -45,12 +46,12 @@ export const VTab = defineComponent({ provideDefaults({ VBtn: { - variant: 'text', - rounded: 0, - minWidth: 90, - maxWidth: 360, block: toRef(props, 'fixed'), color: computed(() => isSelected.value ? props.color : undefined), + height: 'auto', + maxWidth: 360, + minWidth: 90, + variant: 'text', }, }, { scoped: true, diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index e640155b33f..908a8441370 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -21,6 +21,7 @@ import type { GroupProvide } from '@/composables/group' import { makeGroupProps, useGroup } from '@/composables/group' import { useResizeObserver } from '@/composables/resizeObserver' import { useSlideGroup } from '@/composables/slideGroup' +import { makeDensityProps, useDensity } from '@/composables/density' export type TabItem = string | Record @@ -40,25 +41,25 @@ export const VTabs = defineComponent({ name: 'VTabs', props: { - items: { - type: Array as PropType, - default: () => ([]), - }, - stacked: Boolean, + // alignWithTitle: Boolean, + centerActive: Boolean, color: String, direction: { type: String as PropType<'horizontal' | 'vertical'>, default: 'horizontal', }, + fixedTabs: Boolean, + items: { + type: Array as PropType, + default: () => ([]), + }, + stacked: Boolean, + + ...makeDensityProps(), + ...makeGroupProps({ mandatory: 'force' as const }), ...makeTagProps(), - ...makeGroupProps({ - mandatory: 'force' as const, - }), - // alignWithTitle: Boolean, // backgroundColor: String, - centerActive: Boolean, // centered: Boolean, - fixedTabs: Boolean, // grow: Boolean, // height: { // type: [Number, String], @@ -91,6 +92,7 @@ export const VTabs = defineComponent({ setup (props, { slots }) { const parsedItems = computed(() => parseItems(props.items)) + const { densityClasses } = useDensity(props) const { containerRef, containerListeners, @@ -148,6 +150,7 @@ export const VTabs = defineComponent({ class={[ 'v-tabs', `v-tabs--${props.direction}`, + densityClasses.value, ]} role="tablist" > diff --git a/packages/vuetify/src/components/VTabs/VTabsSlider.sass b/packages/vuetify/src/components/VTabs/VTabsSlider.sass index 40f6f102b81..6a74f67116d 100644 --- a/packages/vuetify/src/components/VTabs/VTabsSlider.sass +++ b/packages/vuetify/src/components/VTabs/VTabsSlider.sass @@ -5,7 +5,7 @@ .v-tabs-slider position: absolute transition: .15s settings.$standard-easing - background: rgb(var(--v-theme-on-surface)) + background: currentColor .v-tabs--horizontal & height: $tabs-slider-size From e61f5836d57ef94dd1d895d3526a5e8c1ae151e1 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 22 Feb 2022 11:11:20 -0600 Subject: [PATCH 05/20] feat(VTabs): add density and align-with-title support --- .../vuetify/src/components/VTabs/VTabs.sass | 36 +++++-------------- .../vuetify/src/components/VTabs/VTabs.tsx | 5 ++- .../src/components/VTabs/_variables.scss | 5 +-- 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index c0b48387485..443c73c10a5 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -5,38 +5,14 @@ @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 display: flex + @at-root + @include tools.density('v-tabs', $tabs-density) using ($modifier) + height: $tabs-height + $modifier + .v-tabs__container contain: content display: flex @@ -58,6 +34,10 @@ .v-tabs__content, .v-tabs__container flex-direction: row +.v-tabs--align-with-title + .v-tab:first-child + margin-inline-start: 40px + // .v-menu__activator // height: 100% diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index 908a8441370..d37f7e9a1ae 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -41,7 +41,7 @@ export const VTabs = defineComponent({ name: 'VTabs', props: { - // alignWithTitle: Boolean, + alignWithTitle: Boolean, centerActive: Boolean, color: String, direction: { @@ -150,6 +150,9 @@ export const VTabs = defineComponent({ class={[ 'v-tabs', `v-tabs--${props.direction}`, + { + 'v-tabs--align-with-title': props.alignWithTitle, + }, densityClasses.value, ]} role="tablist" diff --git a/packages/vuetify/src/components/VTabs/_variables.scss b/packages/vuetify/src/components/VTabs/_variables.scss index 04bd5b2c0f4..86a956cda90 100644 --- a/packages/vuetify/src/components/VTabs/_variables.scss +++ b/packages/vuetify/src/components/VTabs/_variables.scss @@ -10,7 +10,8 @@ $tab-font-size: '14px'; $tab-font-weight: 'normal'; $tab-line-height: normal !default; $tabs-bar-background-color: 'cards' !default; -$tabs-bar-height: 48px !default; +$tabs-density: ( 'default': 0, 'comfortable' : -1, 'compact': -3) !default; +$tabs-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; @@ -18,6 +19,6 @@ $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-height: $tabs-height !default; $tabs-item-vertical-icons-and-text-height: $tabs-icons-and-text-bar-height !default; $tabs-slider-size: 2px !default; From ca6fc0f5f10a94a805ebd8297805be6b7dc0646c Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 22 Feb 2022 11:39:39 -0600 Subject: [PATCH 06/20] chore(VTabs): change export code --- packages/vuetify/src/components/VTabs/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/index.ts b/packages/vuetify/src/components/VTabs/index.ts index 544bb20d99d..e3a24f56450 100644 --- a/packages/vuetify/src/components/VTabs/index.ts +++ b/packages/vuetify/src/components/VTabs/index.ts @@ -1,2 +1,2 @@ -export * from './VTabs' -export * from './VTab' +export { VTabs } from './VTabs' +export { VTab } from './VTab' From a8ac49b540c57ad6cad26e7856a3143044abce71 Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 24 Feb 2022 16:06:31 +1100 Subject: [PATCH 07/20] feat: replace slider with a static element --- .../vuetify/src/components/VTabs/VTab.sass | 17 +++++++ .../vuetify/src/components/VTabs/VTab.tsx | 39 +++++++++++++-- .../vuetify/src/components/VTabs/VTabs.tsx | 49 +------------------ .../src/components/VTabs/VTabsSlider.sass | 16 ------ .../src/components/VTabs/VTabsSlider.tsx | 27 ---------- .../VTabs/__tests__/VTabsSlider.spec.ts | 34 ------------- .../src/components/VTabs/_variables.scss | 2 +- 7 files changed, 56 insertions(+), 128 deletions(-) delete mode 100644 packages/vuetify/src/components/VTabs/VTabsSlider.sass delete mode 100644 packages/vuetify/src/components/VTabs/VTabsSlider.tsx delete mode 100644 packages/vuetify/src/components/VTabs/__tests__/VTabsSlider.spec.ts diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass index 954c2df705f..8c071bd6917 100644 --- a/packages/vuetify/src/components/VTabs/VTab.sass +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -1,2 +1,19 @@ +@forward './variables' +@use './variables' as * + .v-tab display: inline-flex + position: relative + +.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 diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index f3fad55f39c..5474392be69 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -3,14 +3,14 @@ import './VTab.sass' // Mixins // Utilities -import { makeRouterProps, useLink } from '@/composables/router' +import { makeRouterProps } from '@/composables/router' import { makeTagProps } from '@/composables/tag' -import { defineComponent, pick } from '@/util' +import { defineComponent, pick, standardEasing } from '@/util' import { VBtn } from '..' import { provideDefaults } from '@/composables/defaults' import { makeGroupItemProps, useGroupItem } from '@/composables/group' import { VTabsSymbol } from './VTabs' -import { computed, toRef } from 'vue' +import { computed, ref, toRef, watch } from 'vue' import { makeThemeProps } from '@/composables/theme' // Types @@ -57,6 +57,37 @@ export const VTab = defineComponent({ 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 prevBox = prevEl.getBoundingClientRect() + const nextBox = nextEl.getBoundingClientRect() + + const delta = prevBox.x - nextBox.x + const origin = + Math.sign(delta) > 0 ? 'right' + : Math.sign(delta) < 0 ? 'left' + : 'center' + const width = Math.abs(delta) + (origin === 'left' ? prevBox.width : nextBox.width) + const scale = width / nextBox.width + + const sigma = 1.5 + nextEl.animate({ + transform: [`translateX(${delta}px)`, `translateX(${delta / sigma}px) scaleX(${(scale - 1) / sigma + 1})`, ''], + transformOrigin: Array(3).fill(origin), + }, { + duration: 225, + easing: standardEasing, + }) + } + }) + return () => { const [btnProps] = pick(props, [ 'href', @@ -73,6 +104,7 @@ export const VTab = defineComponent({ return ( { slots.default ? slots.default() : props.title } +
) } diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index d37f7e9a1ae..500a2ca9e16 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -3,23 +3,19 @@ import './VTabs.sass' // Components import { VTab } from './VTab' -import { VTabsSlider } from './VTabsSlider' // Directives -import Resize from '../../directives/resize' // Utilities -import { convertToUnit } from '../../util/helpers' import { defineComponent } from '@/util' import { makeTagProps } from '@/composables/tag' -import { computed, ref, toRef, watch } from 'vue' +import { computed, toRef } from 'vue' // Types import type { InjectionKey, PropType } from 'vue' import { provideDefaults } from '@/composables/defaults' import type { GroupProvide } from '@/composables/group' -import { makeGroupProps, useGroup } from '@/composables/group' -import { useResizeObserver } from '@/composables/resizeObserver' +import { makeGroupProps } from '@/composables/group' import { useSlideGroup } from '@/composables/slideGroup' import { makeDensityProps, useDensity } from '@/composables/density' @@ -98,8 +94,6 @@ export const VTabs = defineComponent({ containerListeners, contentRef, contentStyles, - items, - selected, } = useSlideGroup(props, VTabsSymbol) provideDefaults({ @@ -110,43 +104,8 @@ export const VTabs = defineComponent({ }, }) - const sliderStyles = ref({}) - - function updateSliderStyles (el: HTMLElement) { - if (!selected.value.length) return - - const index = items.value.findIndex(item => item.id === selected.value[0]) - - if (index < 0 || !el) return - - const selectedElement = el.querySelectorAll('.v-tab')[index] as HTMLElement - - if (props.direction === 'horizontal') { - sliderStyles.value = { - left: convertToUnit(selectedElement.offsetLeft), - width: convertToUnit(selectedElement.offsetWidth), - } - } else { - sliderStyles.value = { - top: convertToUnit(selectedElement.offsetTop), - height: convertToUnit(selectedElement.offsetHeight), - } - } - } - - const { resizeRef } = useResizeObserver(entries => { - if (!entries.length) return - - updateSliderStyles(entries[0].target as HTMLElement) - }) - - watch(selected, () => resizeRef.value && updateSliderStyles(resizeRef.value as HTMLElement), { - flush: 'post', - }) - return () => ( ( )) } -
diff --git a/packages/vuetify/src/components/VTabs/VTabsSlider.sass b/packages/vuetify/src/components/VTabs/VTabsSlider.sass deleted file mode 100644 index 6a74f67116d..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabsSlider.sass +++ /dev/null @@ -1,16 +0,0 @@ -@forward './variables' -@use './variables' as * -@use '../../styles/settings' - -.v-tabs-slider - position: absolute - transition: .15s settings.$standard-easing - background: currentColor - - .v-tabs--horizontal & - height: $tabs-slider-size - bottom: 0 - - .v-tabs--vertical & - width: $tabs-slider-size - left: 0 diff --git a/packages/vuetify/src/components/VTabs/VTabsSlider.tsx b/packages/vuetify/src/components/VTabs/VTabsSlider.tsx deleted file mode 100644 index 57601eaf231..00000000000 --- a/packages/vuetify/src/components/VTabs/VTabsSlider.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import './VTabsSlider.sass' - -import { defineComponent } from '@/util' -import { useBackgroundColor } from '@/composables/color' -import { toRef } from 'vue' - -export const VTabsSlider = defineComponent({ - name: 'VTabsSlider', - - props: { - color: String, - }, - - setup (props, { slots }) { - const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(toRef(props, 'color')) - - return () => ( -
- ) - }, -}) 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 86a956cda90..71cb5c50057 100644 --- a/packages/vuetify/src/components/VTabs/_variables.scss +++ b/packages/vuetify/src/components/VTabs/_variables.scss @@ -9,6 +9,7 @@ $tab-disabled-opacity: .5 !default; $tab-font-size: '14px'; $tab-font-weight: 'normal'; $tab-line-height: normal !default; +$tab-slider-size: 2px !default; $tabs-bar-background-color: 'cards' !default; $tabs-density: ( 'default': 0, 'comfortable' : -1, 'compact': -3) !default; $tabs-height: 48px !default; @@ -21,4 +22,3 @@ $tabs-item-min-width: 90px !default; $tabs-item-padding: 0 16px !default; $tabs-item-vertical-height: $tabs-height !default; $tabs-item-vertical-icons-and-text-height: $tabs-icons-and-text-bar-height !default; -$tabs-slider-size: 2px !default; From 80034ed8e3749c3e3c80527968df2b8edf2939d3 Mon Sep 17 00:00:00 2001 From: Kael Date: Mon, 28 Feb 2022 17:19:30 +1100 Subject: [PATCH 08/20] chore: remove commented code --- .../components/VSlideGroup/VSlideGroup.tsx | 421 +----------------- .../vuetify/src/components/VTabs/VTab.tsx | 2 + .../vuetify/src/components/VTabs/VTabs.sass | 119 ----- .../vuetify/src/components/VTabs/VTabs.tsx | 2 + 4 files changed, 5 insertions(+), 539 deletions(-) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index 00a432e8de0..8250645b41d 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -77,423 +77,4 @@ export const VSlideGroup = defineComponent({ }, }) -// 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, -// } -// }, -// }) +export type VSlideGroup = InstanceType diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index 5474392be69..107b9ebf8f0 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -119,3 +119,5 @@ export const VTab = defineComponent({ } }, }) + +export type VTab = InstanceType diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index 443c73c10a5..6c0295fe2f6 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -38,80 +38,6 @@ .v-tab:first-child margin-inline-start: 40px - // .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 - -// .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: settings.$standard-easing - - -// .v-tabs-slider -// background-color: currentColor -// height: 100% -// width: 100% - -// &-wrapper -// bottom: 0 -// margin: 0 !important -// position: absolute -// transition: settings.$standard-easing -// 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, @@ -177,48 +103,3 @@ +tools.rtl() margin-left: 0 - -// .v-tabs--vertical -// display: flex - -// & > .v-tabs-bar -// flex: 1 0 auto -// 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-window -// flex: 0 1 100% - -// &.v-tabs--icons-and-text -// & > .v-tabs-bar -// .v-tab -// height: $tabs-item-vertical-icons-and-text-height - -// .v-tab--active -// color: inherit - -// &.v-tab:not(:focus)::before -// opacity: 0 - -// .v-icon, -// .v-btn.v-btn--flat -// color: inherit - -// .v-tab--disabled -// opacity: $tab-disabled-opacity - -// &, -// & * -// pointer-events: none diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index 500a2ca9e16..df20521572d 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -135,3 +135,5 @@ export const VTabs = defineComponent({ ) }, }) + +export type VTabs = InstanceType From d2888917579443d7560fa284ff8b6ce144ab6fa9 Mon Sep 17 00:00:00 2001 From: Kael Date: Mon, 28 Feb 2022 18:29:22 +1100 Subject: [PATCH 09/20] refactor: use VSlideGroup directly --- .../components/VSlideGroup/VSlideGroup.tsx | 18 ++++--- .../vuetify/src/components/VTabs/VTab.tsx | 40 ++++++++++---- .../vuetify/src/components/VTabs/VTabs.tsx | 52 ++++++------------- 3 files changed, 57 insertions(+), 53 deletions(-) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index 8250645b41d..7f8dd15e7b2 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -7,13 +7,14 @@ import { useSlideGroup } from '@/composables/slideGroup' import { makeTagProps } from '@/composables/tag' // Utilities -import { defineComponent } from '@/util' +import { defineComponent, useRender } from '@/util' import { computed } from 'vue' export const VSlideGroup = defineComponent({ name: 'VSlideGroup', props: { + symbol: null, ...makeTagProps(), ...makeGroupProps({ mandatory: true, @@ -32,8 +33,9 @@ export const VSlideGroup = defineComponent({ next, prev, select, + selected, isSelected, - } = useSlideGroup(props) + } = useSlideGroup(props, props.symbol) const slotProps = computed(() => ({ next, @@ -42,7 +44,7 @@ export const VSlideGroup = defineComponent({ isSelected, })) - return () => ( + useRender(() => ( { slots.prepend(slotProps.value) }
- ) } + )}
{ slots.append(slotProps.value) }
- ) } + )} - ) + )) + + return { + selected, + } }, }) diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index 107b9ebf8f0..d987aa13713 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -1,19 +1,20 @@ import './VTab.sass' -// Mixins +// Components +import { VBtn } from '@/components/VBtn' +import { VTabsSymbol } from './VTabs' -// Utilities -import { makeRouterProps } from '@/composables/router' -import { makeTagProps } from '@/composables/tag' -import { defineComponent, pick, standardEasing } from '@/util' -import { VBtn } from '..' +// Composables +import { useTextColor } from '@/composables/color' import { provideDefaults } from '@/composables/defaults' import { makeGroupItemProps, useGroupItem } from '@/composables/group' -import { VTabsSymbol } from './VTabs' -import { computed, ref, toRef, watch } from 'vue' +import { makeRouterProps } from '@/composables/router' +import { makeTagProps } from '@/composables/tag' import { makeThemeProps } from '@/composables/theme' -// Types +// Utilities +import { computed, ref, toRef, watch } from 'vue' +import { defineComponent, pick, standardEasing, useRender } from '@/util' export const VTab = defineComponent({ name: 'VTab', @@ -32,6 +33,7 @@ export const VTab = defineComponent({ default: true, }, color: String, + sliderColor: String, ...makeTagProps(), ...makeRouterProps(), @@ -43,6 +45,7 @@ export const VTab = defineComponent({ setup (props, { slots, attrs }) { const { isSelected, select, selectedClass } = useGroupItem(props, VTabsSymbol) + const { textColorClasses: sliderColorClasses, textColorStyles: sliderColorStyles } = useTextColor(props, 'sliderColor') provideDefaults({ VBtn: { @@ -66,6 +69,8 @@ export const VTab = defineComponent({ if (!prevEl || !nextEl) return + const color = getComputedStyle(prevEl).color + const prevBox = prevEl.getBoundingClientRect() const nextBox = nextEl.getBoundingClientRect() @@ -79,6 +84,7 @@ export const VTab = defineComponent({ const sigma = 1.5 nextEl.animate({ + backgroundColor: [color, ''], transform: [`translateX(${delta}px)`, `translateX(${delta / sigma}px) scaleX(${(scale - 1) / sigma + 1})`, ''], transformOrigin: Array(3).fill(origin), }, { @@ -88,7 +94,7 @@ export const VTab = defineComponent({ } }) - return () => { + useRender(() => { const [btnProps] = pick(props, [ 'href', 'to', @@ -111,11 +117,23 @@ export const VTab = defineComponent({ ]} onClick={ () => !props.disabled && select(!isSelected.value) } { ...btnProps } + { ...attrs } > { slots.default ? slots.default() : props.title } -
+
) + }) + + return { + isSelected, } }, }) diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index df20521572d..f01cdfd29b9 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -3,21 +3,21 @@ import './VTabs.sass' // Components import { VTab } from './VTab' +import { VSlideGroup } from '@/components/VSlideGroup' -// Directives +// Composables +import { provideDefaults } from '@/composables/defaults' +import { makeDensityProps, useDensity } from '@/composables/density' +import { makeGroupProps } from '@/composables/group' +import { makeTagProps } from '@/composables/tag' // Utilities -import { defineComponent } from '@/util' -import { makeTagProps } from '@/composables/tag' import { computed, toRef } from 'vue' +import { defineComponent } from '@/util' // Types import type { InjectionKey, PropType } from 'vue' -import { provideDefaults } from '@/composables/defaults' import type { GroupProvide } from '@/composables/group' -import { makeGroupProps } from '@/composables/group' -import { useSlideGroup } from '@/composables/slideGroup' -import { makeDensityProps, useDensity } from '@/composables/density' export type TabItem = string | Record @@ -52,7 +52,6 @@ export const VTabs = defineComponent({ stacked: Boolean, ...makeDensityProps(), - ...makeGroupProps({ mandatory: 'force' as const }), ...makeTagProps(), // backgroundColor: String, // centered: Boolean, @@ -82,19 +81,9 @@ export const VTabs = defineComponent({ // }, }, - emits: { - 'update:modelValue': (value: any) => true, - }, - - setup (props, { slots }) { + setup (props, { slots, attrs }) { const parsedItems = computed(() => parseItems(props.items)) const { densityClasses } = useDensity(props) - const { - containerRef, - containerListeners, - contentRef, - contentStyles, - } = useSlideGroup(props, VTabsSymbol) provideDefaults({ VTab: { @@ -105,7 +94,7 @@ export const VTabs = defineComponent({ }) return () => ( - -
-
- { slots.default ? slots.default() : parsedItems.value.map(item => ( - - )) } -
-
-
+ { slots.default ? slots.default() : parsedItems.value.map(item => ( + + )) } + ) }, }) From 15976d74d232cc7b865274e6d90e9be40fdd491e Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 3 Mar 2022 00:55:55 +1100 Subject: [PATCH 10/20] feat(group): select() without arguments toggles --- packages/vuetify/src/composables/group.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/vuetify/src/composables/group.ts b/packages/vuetify/src/composables/group.ts index a3022747a30..ff947bfebfe 100644 --- a/packages/vuetify/src/composables/group.ts +++ b/packages/vuetify/src/composables/group.ts @@ -207,39 +207,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] : [] } } From 33a024aea6354eb5da346b704098e01e57696045 Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 3 Mar 2022 02:59:49 +1100 Subject: [PATCH 11/20] refactor: remove useSlideGroup composable --- .../components/VSlideGroup/VSlideGroup.sass | 10 +- .../components/VSlideGroup/VSlideGroup.tsx | 301 ++++++++++++++++-- .../VSlideGroup/VSlideGroupItem.tsx | 6 +- .../src/components/VSlideGroup/helpers.ts | 66 ++++ .../vuetify/src/composables/slideGroup.ts | 201 ------------ 5 files changed, 346 insertions(+), 238 deletions(-) create mode 100644 packages/vuetify/src/components/VSlideGroup/helpers.ts delete mode 100644 packages/vuetify/src/composables/slideGroup.ts diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass index 6ef18660f76..df26a6a80ce 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass @@ -1,5 +1,5 @@ -@use 'sass:math' @forward './variables' +@use 'sass:math' @use 'sass:map' @use '../../styles/settings' @use '../../styles/tools' @@ -53,3 +53,11 @@ .v-slide-group__prev &--disabled pointer-events: none + opacity: var(--v-disabled-opacity) + +.v-slide-group--vertical + &, + .v-slide-group__container, + .v-slide-group__content + flex-direction: column + diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index 7f8dd15e7b2..888314afadc 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -1,24 +1,65 @@ // Styles import './VSlideGroup.sass' +// Components +import { VFadeTransition } from '@/components/transitions' +import { VIcon } from '@/components/VIcon' + // Composables -import { makeGroupProps } from '@/composables/group' -import { useSlideGroup } from '@/composables/slideGroup' +import { makeGroupProps, useGroup } from '@/composables/group' +import { useResizeObserver } from '@/composables/resizeObserver' +import { useRtl } from '@/composables/rtl' import { makeTagProps } from '@/composables/tag' // Utilities -import { defineComponent, useRender } from '@/util' -import { computed } from 'vue' +import { computed, ref, watch, watchEffect } from 'vue' +import { clamp, defineComponent, useRender } from '@/util' +import { bias, calculateCenteredOffset, calculateUpdatedOffset } from './helpers' + +// Types +import type { InjectionKey } from 'vue' +import type { GroupProvide } from '@/composables/group' +import { useDisplay } from '@/composables' + +export const VSlideGroupSymbol: InjectionKey = Symbol.for('vuetify:v-slide-group') export const VSlideGroup = defineComponent({ name: 'VSlideGroup', props: { - symbol: null, + 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({ - mandatory: true, - }), + ...makeGroupProps(), }, emits: { @@ -26,59 +67,253 @@ export const VSlideGroup = defineComponent({ }, setup (props, { slots }) { - const { - containerRef, - contentRef, - contentStyles, - next, - prev, - select, - selected, - isSelected, - } = useSlideGroup(props, props.symbol) + 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(() => { + const sizeProperty = isHorizontal.value ? 'width' : 'height' + + containerSize.value = containerRect.value?.[sizeProperty] ?? 0 + contentSize.value = contentRect.value?.[sizeProperty] ?? 0 + + 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) + } + + function onFocusin (e: FocusEvent) { + 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 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, - prev, - select, - isSelected, + 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(() => ( - { slots.prepend && ( -
- { slots.prepend(slotProps.value) } -
- )} +
scrollTo('prev') } + > + { slots.prev?.(slotProps.value) ?? ( + + + + )} +
{ slots.default?.(slotProps.value) }
- { slots.append && ( -
- { slots.append(slotProps.value) } -
- )} +
scrollTo('next') } + > + { slots.next?.(slotProps.value) ?? ( + + + + )} +
)) return { - selected, + selected: group.selected, + scrollTo, + scrollOffset, } }, }) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx index 40bd802255a..61779c1b2d3 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx @@ -1,6 +1,6 @@ -import { makeGroupItemProps } from '@/composables/group' -import { useSlideGroupItem } from '@/composables/slideGroup' +import { makeGroupItemProps, useGroupItem } from '@/composables/group' import { defineComponent } from '@/util' +import { VSlideGroupSymbol } from './VSlideGroup' export const VSlideGroupItem = defineComponent({ name: 'VSlideGroupItem', @@ -10,7 +10,7 @@ export const VSlideGroupItem = defineComponent({ }, setup (props, { slots }) { - const slideGroupItem = useSlideGroupItem(props) + const slideGroupItem = useGroupItem(props, VSlideGroupSymbol) return () => slots.default?.({ isSelected: slideGroupItem.isSelected.value, 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/composables/slideGroup.ts b/packages/vuetify/src/composables/slideGroup.ts deleted file mode 100644 index 713339e2d4c..00000000000 --- a/packages/vuetify/src/composables/slideGroup.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Utilities -import { computed, ref, watch } from 'vue' -import { useGroup, useGroupItem } from './group' -import { useResizeObserver } from './resizeObserver' - -// Types -import type { InjectionKey } from 'vue' -import type { GroupItemProps, GroupProps, GroupProvide } from './group' -import { useRtl } from '.' - -export const VSlideGroupSymbol: InjectionKey = Symbol.for('vuetify:v-slide-group') - -function bias (val: number) { - const c = 0.501 - const x = Math.abs(val) - return Math.sign(val) * (x / ((1 / c - 2) * (1 - x) + 1)) -} - -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)) - } -} - -export function useSlideGroup ( - props: GroupProps & { - direction?: 'vertical' | 'horizontal' - centerActive?: boolean | undefined - }, - injectKey: InjectionKey = VSlideGroupSymbol -) { - const { isRtl } = useRtl() - const group = useGroup(props, injectKey) - const isOverflowing = ref(false) - const scrollOffset = ref(0) - const containerSize = ref(0) - const contentSize = ref(0) - const contentRef = ref() - const isHorizontal = computed(() => props.direction === 'horizontal') - - const { resizeRef: containerRef } = useResizeObserver(() => { - const sizeProperty = isHorizontal.value ? 'clientWidth' : 'clientHeight' - - containerSize.value = containerRef.value?.[sizeProperty] ?? 0 - contentSize.value = contentRef.value?.[sizeProperty] ?? 0 - - 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[0]) - - // 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 - let firstMove = true - - 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 - firstMove = true - } - - const containerListeners = { - onTouchend, - onTouchmove, - onTouchstart, - } - - 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' : '', - } - }) - - return { ...group, containerRef, contentRef, contentStyles, containerListeners } -} - -export function useSlideGroupItem (props: GroupItemProps, injectKey: InjectionKey = VSlideGroupSymbol) { - return useGroupItem(props, injectKey) -} From bbbfb745c24a6b31733e7d35ec2907343ef6cf16 Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 3 Mar 2022 03:17:29 +1100 Subject: [PATCH 12/20] feat: add more tabs props back --- .../vuetify/src/components/VTabs/VTabs.tsx | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index f01cdfd29b9..b9378d90167 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -8,7 +8,6 @@ import { VSlideGroup } from '@/components/VSlideGroup' // Composables import { provideDefaults } from '@/composables/defaults' import { makeDensityProps, useDensity } from '@/composables/density' -import { makeGroupProps } from '@/composables/group' import { makeTagProps } from '@/composables/tag' // Utilities @@ -50,35 +49,20 @@ export const VTabs = defineComponent({ 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(), - // backgroundColor: String, - // centered: 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, - // }, }, setup (props, { slots, attrs }) { @@ -90,6 +74,7 @@ export const VTabs = defineComponent({ stacked: toRef(props, 'stacked'), color: toRef(props, 'color'), fixed: toRef(props, 'fixedTabs'), + sliderColor: toRef(props, 'sliderColor'), }, }) @@ -100,6 +85,9 @@ export const VTabs = defineComponent({ `v-tabs--${props.direction}`, { 'v-tabs--align-with-title': props.alignWithTitle, + 'v-tabs--centered': props.centered, + 'v-tabs--grow': props.grow, + 'v-tabs--right': props.right, }, densityClasses.value, ]} From 5a328ea70d2c0067f8de0d1ff01808b7b802bc55 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 2 Mar 2022 16:20:38 -0600 Subject: [PATCH 13/20] fix(VSlideGroup): remove implicit white-space inheritence --- packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass index df26a6a80ce..5a0f8680061 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass @@ -42,6 +42,9 @@ transition: 0.2s all settings.$standard-easing white-space: nowrap + > * + white-space: initial + .v-slide-group__container contain: content display: flex @@ -60,4 +63,3 @@ .v-slide-group__container, .v-slide-group__content flex-direction: column - From 84ef9a70baac03a31de9999cb6eb89bab940f40f Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 3 Mar 2022 16:52:57 +1100 Subject: [PATCH 14/20] feat(VTabs): implement missing props --- .../components/VSlideGroup/VSlideGroup.sass | 24 ++--- .../components/VSlideGroup/VSlideGroup.tsx | 64 +++++++------- .../vuetify/src/components/VTabs/VTab.sass | 3 + .../vuetify/src/components/VTabs/VTab.tsx | 22 ++--- .../vuetify/src/components/VTabs/VTabs.sass | 87 ++++++------------- .../vuetify/src/components/VTabs/VTabs.tsx | 4 +- .../src/components/VTabs/_variables.scss | 4 +- packages/vuetify/src/composables/group.ts | 4 +- 8 files changed, 86 insertions(+), 126 deletions(-) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass index 5a0f8680061..40cf64e5c21 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass @@ -9,19 +9,6 @@ .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 @@ -34,6 +21,11 @@ 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 @@ -52,12 +44,6 @@ overflow: hidden // Modifiers -.v-slide-group__next, -.v-slide-group__prev - &--disabled - pointer-events: none - opacity: var(--v-disabled-opacity) - .v-slide-group--vertical &, .v-slide-group__container, diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index 888314afadc..155f9f802dc 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -80,10 +80,12 @@ export const VSlideGroup = defineComponent({ const { resizeRef: contentRef, contentRect } = useResizeObserver() watchEffect(() => { + if (!containerRect.value || !contentRect.value) return + const sizeProperty = isHorizontal.value ? 'width' : 'height' - containerSize.value = containerRect.value?.[sizeProperty] ?? 0 - contentSize.value = contentRect.value?.[sizeProperty] ?? 0 + containerSize.value = containerRect.value[sizeProperty] + contentSize.value = contentRect.value[sizeProperty] isOverflowing.value = containerSize.value + 1 < contentSize.value }) @@ -234,7 +236,7 @@ export const VSlideGroup = defineComponent({ // Always show arrows when // overflowed on desktop default: return ( - mobile.value && + !mobile.value && (isOverflowing.value || Math.abs(scrollOffset.value) > 0) ) } @@ -262,19 +264,21 @@ export const VSlideGroup = defineComponent({ }, ]} > -
scrollTo('prev') } - > - { slots.prev?.(slotProps.value) ?? ( - - - - )} -
+ { hasAffixes.value && ( +
scrollTo('prev') } + > + { slots.prev?.(slotProps.value) ?? ( + + + + )} +
+ )}
-
scrollTo('next') } - > - { slots.next?.(slotProps.value) ?? ( - - - - )} -
+ { hasAffixes.value && ( +
scrollTo('next') } + > + { slots.next?.(slotProps.value) ?? ( + + + + )} +
+ )} )) diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass index 8c071bd6917..8c3032a44ed 100644 --- a/packages/vuetify/src/components/VTabs/VTab.sass +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -2,8 +2,11 @@ @use './variables' as * .v-tab + --v-btn-height: 100% display: inline-flex position: relative + max-width: 360px + min-width: 90px .v-tab__slider position: absolute diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index d987aa13713..0098f628ab4 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -34,6 +34,7 @@ export const VTab = defineComponent({ }, color: String, sliderColor: String, + hideSlider: Boolean, ...makeTagProps(), ...makeRouterProps(), @@ -51,9 +52,6 @@ export const VTab = defineComponent({ VBtn: { block: toRef(props, 'fixed'), color: computed(() => isSelected.value ? props.color : undefined), - height: 'auto', - maxWidth: 360, - minWidth: 90, variant: 'text', }, }, { @@ -120,14 +118,16 @@ export const VTab = defineComponent({ { ...attrs } > { slots.default ? slots.default() : props.title } -
+ { !props.hideSlider && ( +
+ )} ) }) diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index 6c0295fe2f6..f1d9d68f14d 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -13,6 +13,9 @@ @include tools.density('v-tabs', $tabs-density) using ($modifier) height: $tabs-height + $modifier + &.v-tabs--stacked + height: $tabs-stacked-height + $modifier + .v-tabs__container contain: content display: flex @@ -34,72 +37,32 @@ .v-tabs__content, .v-tabs__container flex-direction: row -.v-tabs--align-with-title +.v-tabs--align-with-title:not(.v-slide-group--has-affixes) .v-tab:first-child - margin-inline-start: 40px - -// 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 - +tools.ltr() - margin-left: $tabs-item-align-with-title-margin - - +tools.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 - +tools.ltr() - margin-right: auto - - +tools.rtl() - margin-left: auto - - .v-tabs-bar__content > *:first-child:not(.v-tabs-slider-wrapper), - .v-tabs-slider-wrapper + * - +tools.ltr() - margin-left: auto - - +tools.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 + margin-inline-start: $tabs-item-align-with-title-margin - .v-tab - flex-direction: column-reverse +.v-tabs--fixed-tabs, +.v-tabs--centered + .v-slide-group__content > .v-tab:last-child + margin-inline-end: auto - > *:first-child - margin-bottom: $tabs-icons-and-text-first-tab-margin-bottom + .v-slide-group__content > .v-tab:first-child + margin-inline-start: auto -.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 - +tools.ltr() - margin-left: auto +.v-tabs--grow .v-tab + flex: 1 0 auto + max-width: none - +tools.rtl() - margin-right: auto +.v-tabs--right + .v-tab:first-child + margin-inline-start: auto .v-tab:last-child - +tools.ltr() - margin-right: 0 - - +tools.rtl() - margin-left: 0 + 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.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index b9378d90167..40ceec397aa 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -37,7 +37,6 @@ export const VTabs = defineComponent({ props: { alignWithTitle: Boolean, - centerActive: Boolean, color: String, direction: { type: String as PropType<'horizontal' | 'vertical'>, @@ -75,6 +74,7 @@ export const VTabs = defineComponent({ color: toRef(props, 'color'), fixed: toRef(props, 'fixedTabs'), sliderColor: toRef(props, 'sliderColor'), + hideSlider: toRef(props, 'hideSlider'), }, }) @@ -86,8 +86,10 @@ export const VTabs = defineComponent({ { 'v-tabs--align-with-title': props.alignWithTitle, 'v-tabs--centered': props.centered, + 'v-tabs--fixed-tabs': props.fixedTabs, 'v-tabs--grow': props.grow, 'v-tabs--right': props.right, + 'v-tabs--stacked': props.stacked, }, densityClasses.value, ]} diff --git a/packages/vuetify/src/components/VTabs/_variables.scss b/packages/vuetify/src/components/VTabs/_variables.scss index 71cb5c50057..f3a6fd8cb4b 100644 --- a/packages/vuetify/src/components/VTabs/_variables.scss +++ b/packages/vuetify/src/components/VTabs/_variables.scss @@ -13,7 +13,7 @@ $tab-slider-size: 2px !default; $tabs-bar-background-color: 'cards' !default; $tabs-density: ( 'default': 0, 'comfortable' : -1, 'compact': -3) !default; $tabs-height: 48px !default; -$tabs-icons-and-text-bar-height: 72px !default; +$tabs-stacked-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; @@ -21,4 +21,4 @@ $tabs-item-max-width: 360px !default; $tabs-item-min-width: 90px !default; $tabs-item-padding: 0 16px !default; $tabs-item-vertical-height: $tabs-height !default; -$tabs-item-vertical-icons-and-text-height: $tabs-icons-and-text-bar-height !default; +$tabs-item-vertical-icons-and-text-height: $tabs-stacked-height !default; diff --git a/packages/vuetify/src/composables/group.ts b/packages/vuetify/src/composables/group.ts index da17de71f42..2bd4e1f5aaa 100644 --- a/packages/vuetify/src/composables/group.ts +++ b/packages/vuetify/src/composables/group.ts @@ -2,7 +2,7 @@ import { useProxiedModel } from './proxiedModel' // Utilities -import { computed, inject, onBeforeUnmount, onMounted, provide, reactive, shallowReactive, toRef } from 'vue' +import { computed, inject, onBeforeUnmount, onMounted, provide, reactive, toRef } from 'vue' import { consoleWarn, deepEqual, findChildren, getCurrentInstance, getUid, propsFactory, wrapInArray } from '@/util' // Types @@ -214,7 +214,7 @@ export function useGroup ( if (props.multiple) { const internalValue = selected.value.slice() const index = internalValue.findIndex(v => v === id) - const isSelected = !!~index + const isSelected = ~index value = value ?? !isSelected // We can't remove value if group is From 7f75aa871b3e2be97116642631259e35eb14a142 Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 3 Mar 2022 16:57:21 +1100 Subject: [PATCH 15/20] refactor: remove unused sass variables --- .../vuetify/src/components/VTabs/VTab.sass | 4 ++-- .../vuetify/src/components/VTabs/VTabs.sass | 21 ++++++++--------- .../src/components/VTabs/_variables.scss | 23 ++++--------------- 3 files changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass index 8c3032a44ed..22fce1a8607 100644 --- a/packages/vuetify/src/components/VTabs/VTab.sass +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -5,8 +5,8 @@ --v-btn-height: 100% display: inline-flex position: relative - max-width: 360px - min-width: 90px + max-width: $tab-max-width + min-width: $tab-min-width .v-tab__slider position: absolute diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index f1d9d68f14d..3cfe8f07f58 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -5,7 +5,6 @@ @use '../../styles/tools' @use './variables' as * -// Block .v-tabs display: flex @@ -17,17 +16,17 @@ height: $tabs-stacked-height + $modifier .v-tabs__container - contain: content - display: flex - flex: 1 1 auto - overflow: hidden + contain: content + display: flex + flex: 1 1 auto + overflow: hidden .v-tabs__content - display: flex - flex: 1 0 auto - position: relative - transition: 0.2s all settings.$standard-easing - white-space: nowrap + display: flex + flex: 1 0 auto + position: relative + transition: 0.2s all settings.$standard-easing + white-space: nowrap .v-tabs--vertical .v-tabs__content, .v-tabs__container @@ -39,7 +38,7 @@ .v-tabs--align-with-title:not(.v-slide-group--has-affixes) .v-tab:first-child - margin-inline-start: $tabs-item-align-with-title-margin + margin-inline-start: $tab-align-with-title-margin .v-tabs--fixed-tabs, .v-tabs--centered diff --git a/packages/vuetify/src/components/VTabs/_variables.scss b/packages/vuetify/src/components/VTabs/_variables.scss index f3a6fd8cb4b..71cd9e41f81 100644 --- a/packages/vuetify/src/components/VTabs/_variables.scss +++ b/packages/vuetify/src/components/VTabs/_variables.scss @@ -1,24 +1,11 @@ @use 'sass:math'; @use 'sass:map'; -@use '../../styles/settings'; -@use '../../styles/tools'; -$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-font-size: '14px'; -$tab-font-weight: 'normal'; -$tab-line-height: normal !default; -$tab-slider-size: 2px !default; -$tabs-bar-background-color: 'cards' !default; $tabs-density: ( 'default': 0, 'comfortable' : -1, 'compact': -3) !default; $tabs-height: 48px !default; $tabs-stacked-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-height !default; -$tabs-item-vertical-icons-and-text-height: $tabs-stacked-height !default; + +$tab-align-with-title-margin: 42px !default; +$tab-max-width: 360px !default; +$tab-min-width: 90px !default; +$tab-slider-size: 2px !default; From ed52622b4cdd0a27947042c1a64b69bccb7d2edd Mon Sep 17 00:00:00 2001 From: Kael Date: Thu, 3 Mar 2022 17:22:26 +1100 Subject: [PATCH 16/20] refactor: remove unused styles --- .../vuetify/src/components/VTabs/VTabs.sass | 21 ------------------- .../vuetify/src/components/VTabs/VTabs.tsx | 1 + 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index 3cfe8f07f58..21d35d27543 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -15,27 +15,6 @@ &.v-tabs--stacked height: $tabs-stacked-height + $modifier -.v-tabs__container - contain: content - display: flex - flex: 1 1 auto - overflow: hidden - -.v-tabs__content - display: flex - flex: 1 0 auto - position: relative - transition: 0.2s all settings.$standard-easing - white-space: nowrap - -.v-tabs--vertical - .v-tabs__content, .v-tabs__container - flex-direction: column - -.v-tabs--horizontal - .v-tabs__content, .v-tabs__container - flex-direction: row - .v-tabs--align-with-title:not(.v-slide-group--has-affixes) .v-tab:first-child margin-inline-start: $tab-align-with-title-margin diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index 40ceec397aa..4aad18855bd 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -96,6 +96,7 @@ export const VTabs = defineComponent({ role="tablist" symbol={ VTabsSymbol } mandatory="force" + direction={ props.direction } { ...attrs } > { slots.default ? slots.default() : parsedItems.value.map(item => ( From 51f0d9a1c18bfbe5397b6d5f0a78e8bda43d7ff5 Mon Sep 17 00:00:00 2001 From: Kael Date: Fri, 4 Mar 2022 00:55:35 +1100 Subject: [PATCH 17/20] feat(VSlideGroup): implement keyboard navigation resolves #10416 --- .../components/VSlideGroup/VSlideGroup.tsx | 57 +++++++++++++++++++ .../vuetify/src/components/VTabs/VTab.tsx | 3 + 2 files changed, 60 insertions(+) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index 155f9f802dc..ee14abb813a 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -163,7 +163,10 @@ export const VSlideGroup = defineComponent({ 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 @@ -185,6 +188,55 @@ export const VSlideGroup = defineComponent({ } } + 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 + @@ -263,6 +315,8 @@ export const VSlideGroup = defineComponent({ 'v-slide-group--is-overflowing': isOverflowing.value, }, ]} + tabindex={ (isFocused.value || group.selected.value.length) ? -1 : 0 } + onFocus={ onFocus } > { hasAffixes.value && (
{ slots.default?.(slotProps.value) }
@@ -320,6 +376,7 @@ export const VSlideGroup = defineComponent({ selected: group.selected, scrollTo, scrollOffset, + focus, } }, }) diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index 0098f628ab4..1105ba680bf 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -113,6 +113,9 @@ export const VTab = defineComponent({ 'v-tab', selectedClass.value, ]} + tabindex={ isSelected.value ? 0 : -1 } + role="tab" + aria-selected={ String(isSelected.value) } onClick={ () => !props.disabled && select(!isSelected.value) } { ...btnProps } { ...attrs } From 4884f7be075ff9941d87706f159522731118b335 Mon Sep 17 00:00:00 2001 From: Kael Date: Fri, 4 Mar 2022 01:19:46 +1100 Subject: [PATCH 18/20] fix(VTabs): add vertical styles --- .../vuetify/src/components/VTabs/VTab.sass | 5 ++++ .../vuetify/src/components/VTabs/VTab.tsx | 25 ++++++++++++++----- .../vuetify/src/components/VTabs/VTabs.sass | 6 +++++ .../vuetify/src/components/VTabs/VTabs.tsx | 3 ++- .../src/components/VWindow/VWindow.tsx | 9 +++++-- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass index 22fce1a8607..bc8f5c38f8c 100644 --- a/packages/vuetify/src/components/VTabs/VTab.sass +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -20,3 +20,8 @@ .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.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index 1105ba680bf..cf08fac76e4 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -16,6 +16,9 @@ import { makeThemeProps } from '@/composables/theme' 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', @@ -36,6 +39,11 @@ export const VTab = defineComponent({ sliderColor: String, hideSlider: Boolean, + direction: { + type: String as PropType<'horizontal' | 'vertical'>, + default: 'horizontal', + }, + ...makeTagProps(), ...makeRouterProps(), ...makeGroupItemProps({ @@ -47,6 +55,7 @@ export const VTab = defineComponent({ 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: { @@ -72,18 +81,22 @@ export const VTab = defineComponent({ const prevBox = prevEl.getBoundingClientRect() const nextBox = nextEl.getBoundingClientRect() - const delta = prevBox.x - nextBox.x + 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 ? 'right' - : Math.sign(delta) < 0 ? 'left' + Math.sign(delta) > 0 ? (isHorizontal.value ? 'right' : 'bottom') + : Math.sign(delta) < 0 ? (isHorizontal.value ? 'left' : 'top') : 'center' - const width = Math.abs(delta) + (origin === 'left' ? prevBox.width : nextBox.width) - const scale = width / nextBox.width + 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: [`translateX(${delta}px)`, `translateX(${delta / sigma}px) scaleX(${(scale - 1) / sigma + 1})`, ''], + transform: [`translate${XY}(${delta}px)`, `translate${XY}(${delta / sigma}px) scale${XY}(${(scale - 1) / sigma + 1})`, ''], transformOrigin: Array(3).fill(origin), }, { duration: 225, diff --git a/packages/vuetify/src/components/VTabs/VTabs.sass b/packages/vuetify/src/components/VTabs/VTabs.sass index 21d35d27543..a96aaef8c61 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.sass +++ b/packages/vuetify/src/components/VTabs/VTabs.sass @@ -15,6 +15,12 @@ &.v-tabs--stacked height: $tabs-stacked-height + $modifier + &.v-slide-group--vertical + height: auto + + .v-tab + --v-btn-height: #{$tabs-height} + .v-tabs--align-with-title:not(.v-slide-group--has-affixes) .v-tab:first-child margin-inline-start: $tab-align-with-title-margin diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index 4aad18855bd..afb087e5321 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -70,8 +70,9 @@ export const VTabs = defineComponent({ provideDefaults({ VTab: { - stacked: toRef(props, 'stacked'), color: toRef(props, 'color'), + direction: toRef(props, 'direction'), + stacked: toRef(props, 'stacked'), fixed: toRef(props, 'fixedTabs'), sliderColor: toRef(props, 'sliderColor'), hideSlider: toRef(props, 'hideSlider'), diff --git a/packages/vuetify/src/components/VWindow/VWindow.tsx b/packages/vuetify/src/components/VWindow/VWindow.tsx index 02f5db7605e..4342134b617 100644 --- a/packages/vuetify/src/components/VWindow/VWindow.tsx +++ b/packages/vuetify/src/components/VWindow/VWindow.tsx @@ -22,6 +22,7 @@ import { computed, defineComponent, provide, ref, watch } from 'vue' import type { ComputedRef, InjectionKey, PropType, Ref } from 'vue' import type { GroupItemProvide } from '@/composables/group' import type { TouchHandlers } from '@/directives/touch' +import { useRender } from '@/util' type WindowProvide = { transition: ComputedRef @@ -206,7 +207,7 @@ export const VWindow = defineComponent({ } }) - return () => ( + useRender(() => ( - ) + )) + + return { + group, + } }, }) From a5e7b23f18de8318125a7cd5a843477ba2eaae43 Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 3 Mar 2022 09:16:31 -0600 Subject: [PATCH 19/20] fix(VTab): justify self start when using vertical tabs --- packages/vuetify/src/components/VTabs/VTab.sass | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/vuetify/src/components/VTabs/VTab.sass b/packages/vuetify/src/components/VTabs/VTab.sass index bc8f5c38f8c..689dd17d271 100644 --- a/packages/vuetify/src/components/VTabs/VTab.sass +++ b/packages/vuetify/src/components/VTabs/VTab.sass @@ -8,6 +8,9 @@ max-width: $tab-max-width min-width: $tab-min-width + .v-slide-group--vertical & + justify-content: start + .v-tab__slider position: absolute bottom: 0 From f1512ce7c2f5bc1e8132265c69c82c7074156962 Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 3 Mar 2022 09:30:18 -0600 Subject: [PATCH 20/20] chore(VSlideGroup): code clean-up --- .../src/components/VSlideGroup/VSlideGroup.tsx | 18 +++++++++--------- .../components/VSlideGroup/VSlideGroupItem.tsx | 5 ++++- packages/vuetify/src/components/VTabs/VTab.tsx | 7 ++++--- .../vuetify/src/components/VTabs/VTabs.tsx | 6 +++--- .../vuetify/src/components/VWindow/VWindow.tsx | 2 +- packages/vuetify/src/composables/group.ts | 1 - 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx index ee14abb813a..ccb4999b94e 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx @@ -7,19 +7,19 @@ 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' -import { makeTagProps } from '@/composables/tag' // Utilities -import { computed, ref, watch, watchEffect } from 'vue' -import { clamp, defineComponent, useRender } from '@/util' import { bias, calculateCenteredOffset, calculateUpdatedOffset } from './helpers' +import { clamp, defineComponent, useRender } from '@/util' +import { computed, ref, watch, watchEffect } from 'vue' // Types -import type { InjectionKey } from 'vue' import type { GroupProvide } from '@/composables/group' -import { useDisplay } from '@/composables' +import type { InjectionKey } from 'vue' export const VSlideGroupSymbol: InjectionKey = Symbol.for('vuetify:v-slide-group') @@ -330,9 +330,9 @@ export const VSlideGroup = defineComponent({ - )} + ) }
- )} + ) }
- )} + ) }
- )} + ) } )) diff --git a/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx index 61779c1b2d3..8278ad69215 100644 --- a/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx +++ b/packages/vuetify/src/components/VSlideGroup/VSlideGroupItem.tsx @@ -1,7 +1,10 @@ +// Composables import { makeGroupItemProps, useGroupItem } from '@/composables/group' -import { defineComponent } from '@/util' import { VSlideGroupSymbol } from './VSlideGroup' +// Utilities +import { defineComponent } from '@/util' + export const VSlideGroupItem = defineComponent({ name: 'VSlideGroupItem', diff --git a/packages/vuetify/src/components/VTabs/VTab.tsx b/packages/vuetify/src/components/VTabs/VTab.tsx index cf08fac76e4..0d46fdd2e02 100644 --- a/packages/vuetify/src/components/VTabs/VTab.tsx +++ b/packages/vuetify/src/components/VTabs/VTab.tsx @@ -1,3 +1,4 @@ +// Styles import './VTab.sass' // Components @@ -5,12 +6,12 @@ import { VBtn } from '@/components/VBtn' import { VTabsSymbol } from './VTabs' // Composables -import { useTextColor } from '@/composables/color' -import { provideDefaults } from '@/composables/defaults' 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' @@ -143,7 +144,7 @@ export const VTab = defineComponent({ ]} style={ sliderColorStyles.value } /> - )} + ) } ) }) diff --git a/packages/vuetify/src/components/VTabs/VTabs.tsx b/packages/vuetify/src/components/VTabs/VTabs.tsx index afb087e5321..cfb5c40a98f 100644 --- a/packages/vuetify/src/components/VTabs/VTabs.tsx +++ b/packages/vuetify/src/components/VTabs/VTabs.tsx @@ -2,21 +2,21 @@ import './VTabs.sass' // Components -import { VTab } from './VTab' import { VSlideGroup } from '@/components/VSlideGroup' +import { VTab } from './VTab' // Composables -import { provideDefaults } from '@/composables/defaults' 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 { InjectionKey, PropType } from 'vue' import type { GroupProvide } from '@/composables/group' +import type { InjectionKey, PropType } from 'vue' export type TabItem = string | Record diff --git a/packages/vuetify/src/components/VWindow/VWindow.tsx b/packages/vuetify/src/components/VWindow/VWindow.tsx index 4342134b617..5b00cd80220 100644 --- a/packages/vuetify/src/components/VWindow/VWindow.tsx +++ b/packages/vuetify/src/components/VWindow/VWindow.tsx @@ -17,12 +17,12 @@ 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' import type { GroupItemProvide } from '@/composables/group' import type { TouchHandlers } from '@/directives/touch' -import { useRender } from '@/util' type WindowProvide = { transition: ComputedRef diff --git a/packages/vuetify/src/composables/group.ts b/packages/vuetify/src/composables/group.ts index 2bd4e1f5aaa..e9617999a5b 100644 --- a/packages/vuetify/src/composables/group.ts +++ b/packages/vuetify/src/composables/group.ts @@ -142,7 +142,6 @@ export function useGroup ( ) { let isUnmounted = false const items = reactive([]) - // const refs = shallowReactive([]) const selected = useProxiedModel( props, 'modelValue',