wd-tabs.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. <template>
  2. <template v-if="sticky">
  3. <wd-sticky-box>
  4. <view
  5. :class="`wd-tabs ${customClass} ${innerSlidable ? 'is-slide' : ''} ${mapNum < children.length && mapNum !== 0 ? 'is-map' : ''}`"
  6. :style="customStyle"
  7. >
  8. <wd-sticky :offset-top="offsetTop">
  9. <view class="wd-tabs__nav wd-tabs__nav--sticky">
  10. <view class="wd-tabs__nav--wrap">
  11. <scroll-view :scroll-x="innerSlidable" scroll-with-animation :scroll-left="state.scrollLeft">
  12. <view class="wd-tabs__nav-container">
  13. <view
  14. @click="handleSelect(index)"
  15. v-for="(item, index) in children"
  16. :key="index"
  17. :class="`wd-tabs__nav-item ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`"
  18. :style="state.activeIndex === index ? (color ? 'color:' + color : '') : inactiveColor ? 'color:' + inactiveColor : ''"
  19. >
  20. <wd-badge v-if="item.badgeProps" v-bind="item.badgeProps">
  21. <text class="wd-tabs__nav-item-text">{{ item.title }}</text>
  22. </wd-badge>
  23. <text v-else class="wd-tabs__nav-item-text">{{ item.title }}</text>
  24. <view class="wd-tabs__line wd-tabs__line--inner" v-if="state.activeIndex === index && state.useInnerLine"></view>
  25. </view>
  26. <view class="wd-tabs__line" :style="state.lineStyle"></view>
  27. </view>
  28. </scroll-view>
  29. </view>
  30. <view class="wd-tabs__map" v-if="mapNum < children.length && mapNum !== 0">
  31. <view :class="`wd-tabs__map-btn ${state.animating ? 'is-open' : ''}`" @click="toggleMap">
  32. <view :class="`wd-tabs__map-arrow ${state.animating ? 'is-open' : ''}`">
  33. <wd-icon name="arrow-down" />
  34. </view>
  35. </view>
  36. <view class="wd-tabs__map-header" :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1;' : ''}`">
  37. {{ mapTitle || translate('all') }}
  38. </view>
  39. <view :class="`wd-tabs__map-body ${state.animating ? 'is-open' : ''}`" :style="state.mapShow ? '' : 'display:none'">
  40. <view class="wd-tabs__map-nav-item" v-for="(item, index) in children" :key="index" @click="handleSelect(index)">
  41. <view
  42. :class="`wd-tabs__map-nav-btn ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`"
  43. :style="
  44. state.activeIndex === index
  45. ? color
  46. ? 'color:' + color + ';border-color:' + color
  47. : ''
  48. : inactiveColor
  49. ? 'color:' + inactiveColor
  50. : ''
  51. "
  52. >
  53. {{ item.title }}
  54. </view>
  55. </view>
  56. </view>
  57. </view>
  58. </view>
  59. </wd-sticky>
  60. <view class="wd-tabs__container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
  61. <view :class="['wd-tabs__body', animated ? 'is-animated' : '']" :style="bodyStyle">
  62. <slot />
  63. </view>
  64. </view>
  65. <view
  66. class="wd-tabs__mask"
  67. :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1;' : ''}`"
  68. @click="toggleMap"
  69. ></view>
  70. </view>
  71. </wd-sticky-box>
  72. </template>
  73. <template v-else>
  74. <view :class="`wd-tabs ${customClass} ${innerSlidable ? 'is-slide' : ''} ${mapNum < children.length && mapNum !== 0 ? 'is-map' : ''}`">
  75. <view class="wd-tabs__nav">
  76. <view class="wd-tabs__nav--wrap">
  77. <scroll-view :scroll-x="innerSlidable" scroll-with-animation :scroll-left="state.scrollLeft">
  78. <view class="wd-tabs__nav-container">
  79. <view
  80. v-for="(item, index) in children"
  81. @click="handleSelect(index)"
  82. :key="index"
  83. :class="`wd-tabs__nav-item ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`"
  84. :style="state.activeIndex === index ? (color ? 'color:' + color : '') : inactiveColor ? 'color:' + inactiveColor : ''"
  85. >
  86. <wd-badge custom-class="wd-tabs__nav-item-badge" v-if="item.badgeProps" v-bind="item.badgeProps">
  87. <text class="wd-tabs__nav-item-text">{{ item.title }}</text>
  88. </wd-badge>
  89. <text v-else class="wd-tabs__nav-item-text">{{ item.title }}</text>
  90. <view class="wd-tabs__line wd-tabs__line--inner" v-if="state.activeIndex === index && state.useInnerLine"></view>
  91. </view>
  92. <view class="wd-tabs__line" :style="state.lineStyle"></view>
  93. </view>
  94. </scroll-view>
  95. </view>
  96. <view class="wd-tabs__map" v-if="mapNum < children.length && mapNum !== 0">
  97. <view class="wd-tabs__map-btn" @click="toggleMap">
  98. <view :class="`wd-tabs__map-arrow ${state.animating ? 'is-open' : ''}`">
  99. <wd-icon name="arrow-down" />
  100. </view>
  101. </view>
  102. <view class="wd-tabs__map-header" :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1;' : ''}`">
  103. {{ mapTitle || translate('all') }}
  104. </view>
  105. <view :class="`wd-tabs__map-body ${state.animating ? 'is-open' : ''}`" :style="state.mapShow ? '' : 'display:none'">
  106. <view class="wd-tabs__map-nav-item" v-for="(item, index) in children" :key="index" @click="handleSelect(index)">
  107. <view :class="`wd-tabs__map-nav-btn ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`">
  108. {{ item.title }}
  109. </view>
  110. </view>
  111. </view>
  112. </view>
  113. </view>
  114. <view class="wd-tabs__container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
  115. <view :class="['wd-tabs__body', animated ? 'is-animated' : '']" :style="bodyStyle">
  116. <slot />
  117. </view>
  118. </view>
  119. <view class="wd-tabs__mask" :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1' : ''}`" @click="toggleMap"></view>
  120. </view>
  121. </template>
  122. </template>
  123. <script lang="ts">
  124. export default {
  125. name: 'wd-tabs',
  126. options: {
  127. addGlobalClass: true,
  128. virtualHost: true,
  129. styleIsolation: 'shared'
  130. }
  131. }
  132. </script>
  133. <script lang="ts" setup>
  134. import wdIcon from '../wd-icon/wd-icon.vue'
  135. import wdSticky from '../wd-sticky/wd-sticky.vue'
  136. import wdStickyBox from '../wd-sticky-box/wd-sticky-box.vue'
  137. import { computed, getCurrentInstance, onMounted, watch, nextTick, reactive, type CSSProperties, type ComponentInstance } from 'vue'
  138. import { addUnit, checkNumRange, debounce, getRect, isDef, isNumber, isString, objToStyle } from '../common/util'
  139. import { useTouch } from '../composables/useTouch'
  140. import { TABS_KEY, tabsProps, type TabsExpose } from './types'
  141. import { useChildren } from '../composables/useChildren'
  142. import { useTranslate } from '../composables/useTranslate'
  143. const $item = '.wd-tabs__nav-item'
  144. const $itemText = '.wd-tabs__nav-item-text'
  145. const $container = '.wd-tabs__nav-container'
  146. const props = defineProps(tabsProps)
  147. const emit = defineEmits(['change', 'disabled', 'click', 'update:modelValue'])
  148. const { translate } = useTranslate('tabs')
  149. const state = reactive({
  150. activeIndex: 0, // 选中值的索引,默认第一个
  151. lineStyle: 'display:none;', // 激活项边框线样式
  152. useInnerLine: false, // 是否使用内部激活项边框线,当外部激活下划线未成功渲染时显示内部定位的
  153. inited: false, // 是否初始化
  154. animating: false, // 是否动画中
  155. mapShow: false, // map的开关
  156. scrollLeft: 0 // scroll-view偏移量
  157. })
  158. const { children, linkChildren } = useChildren(TABS_KEY)
  159. linkChildren({ state, props })
  160. const { proxy } = getCurrentInstance() as any
  161. const touch = useTouch()
  162. const innerSlidable = computed(() => {
  163. return props.slidable === 'always' || children.length > props.slidableNum
  164. })
  165. const bodyStyle = computed(() => {
  166. if (!props.animated) {
  167. return ''
  168. }
  169. return objToStyle({
  170. left: -100 * state.activeIndex + '%',
  171. 'transition-duration': props.duration + 'ms',
  172. '-webkit-transition-duration': props.duration + 'ms'
  173. })
  174. })
  175. const getTabName = (tab: ComponentInstance<any>, index: number) => {
  176. return isDef(tab.name) ? tab.name : index
  177. }
  178. /**
  179. * 更新激活项
  180. * @param value 激活值
  181. * @param init 是否已初始化
  182. * @param setScroll // 是否设置scroll-view滚动
  183. */
  184. const updateActive = (value: number | string = 0, init: boolean = false, setScroll: boolean = true) => {
  185. // 没有tab子元素,不执行任何操作
  186. if (children.length === 0) return
  187. value = getActiveIndex(value)
  188. // 被禁用,不执行任何操作
  189. if (children[value].disabled) return
  190. state.activeIndex = value
  191. if (setScroll) {
  192. updateLineStyle(init === false)
  193. scrollIntoView()
  194. }
  195. setActiveTab()
  196. }
  197. /**
  198. * @description 修改选中的tab Index
  199. * @param {String |Number } value - radio绑定的value或者tab索引,默认值0
  200. * @param {Boolean } init - 是否伴随初始化操作
  201. */
  202. const setActive = debounce(updateActive, 100, { leading: true })
  203. watch(
  204. () => props.modelValue,
  205. (newValue) => {
  206. if (!isNumber(newValue) && !isString(newValue)) {
  207. console.error('[wot ui] error(wd-tabs): the type of value should be number or string')
  208. }
  209. // 保证不为非空字符串,小于0的数字
  210. if (newValue === '' || !isDef(newValue)) {
  211. // eslint-disable-next-line quotes
  212. console.error("[wot ui] error(wd-tabs): tabs's value cannot be '' null or undefined")
  213. }
  214. if (typeof newValue === 'number' && newValue < 0) {
  215. // eslint-disable-next-line quotes
  216. console.error("[wot ui] error(wd-tabs): tabs's value cannot be less than zero")
  217. }
  218. },
  219. {
  220. immediate: true,
  221. deep: true
  222. }
  223. )
  224. watch(
  225. () => props.modelValue,
  226. (newValue) => {
  227. const index = getActiveIndex(newValue)
  228. setActive(newValue, false, index !== state.activeIndex)
  229. },
  230. {
  231. immediate: false,
  232. deep: true
  233. }
  234. )
  235. watch(
  236. () => children.length,
  237. () => {
  238. if (state.inited) {
  239. nextTick(() => {
  240. setActive(props.modelValue)
  241. })
  242. }
  243. }
  244. )
  245. watch(
  246. () => props.slidableNum,
  247. (newValue) => {
  248. checkNumRange(newValue, 'slidableNum')
  249. }
  250. )
  251. watch(
  252. () => props.mapNum,
  253. (newValue) => {
  254. checkNumRange(newValue, 'mapNum')
  255. }
  256. )
  257. onMounted(() => {
  258. state.inited = true
  259. nextTick(() => {
  260. updateActive(props.modelValue, true)
  261. state.useInnerLine = true
  262. })
  263. })
  264. function toggleMap() {
  265. if (state.mapShow) {
  266. state.animating = false
  267. setTimeout(() => {
  268. state.mapShow = false
  269. }, 300)
  270. } else {
  271. state.mapShow = true
  272. setTimeout(() => {
  273. state.animating = true
  274. }, 100)
  275. }
  276. }
  277. /**
  278. * 更新 underline的偏移量
  279. * @param animation 是否开启动画
  280. */
  281. async function updateLineStyle(animation: boolean = true) {
  282. if (!state.inited) return
  283. const { autoLineWidth, lineWidth, lineHeight } = props
  284. try {
  285. const lineStyle: CSSProperties = {}
  286. if (isDef(lineWidth)) {
  287. lineStyle.width = addUnit(lineWidth)
  288. } else {
  289. if (autoLineWidth) {
  290. const textRects = await getRect($itemText, true, proxy)
  291. const textWidth = Number(textRects[state.activeIndex].width)
  292. lineStyle.width = addUnit(textWidth)
  293. }
  294. }
  295. if (isDef(lineHeight)) {
  296. lineStyle.height = addUnit(lineHeight)
  297. lineStyle.borderRadius = `calc(${addUnit(lineHeight)} / 2)`
  298. }
  299. const rects = await getRect($item, true, proxy)
  300. const rect = rects[state.activeIndex]
  301. let left = rects.slice(0, state.activeIndex).reduce((prev, curr) => prev + Number(curr.width), 0) + Number(rect.width) / 2
  302. if (left) {
  303. lineStyle.transform = `translateX(${left}px) translateX(-50%)`
  304. if (animation) {
  305. lineStyle.transition = 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);'
  306. }
  307. state.useInnerLine = false
  308. state.lineStyle = objToStyle(lineStyle)
  309. }
  310. } catch (error) {
  311. console.error('[wot ui] error(wd-tabs): update line style failed', error)
  312. }
  313. }
  314. function setActiveTab() {
  315. if (!state.inited) return
  316. const name = getTabName(children[state.activeIndex], state.activeIndex)
  317. if (name !== props.modelValue) {
  318. emit('change', {
  319. index: state.activeIndex,
  320. name: name
  321. })
  322. emit('update:modelValue', name)
  323. }
  324. }
  325. function scrollIntoView() {
  326. if (!state.inited) return
  327. Promise.all([getRect($item, true, proxy), getRect($container, false, proxy)]).then(([navItemsRects, navRect]) => {
  328. // 选中元素
  329. const selectItem = navItemsRects[state.activeIndex]
  330. // 选中元素之前的节点的宽度总和
  331. const offsetLeft = (navItemsRects as any).slice(0, state.activeIndex).reduce((prev: any, curr: any) => prev + curr.width, 0)
  332. // scroll-view滑动到selectItem的偏移量
  333. const left = offsetLeft - ((navRect as any).width - Number(selectItem.width)) / 2
  334. if (left === state.scrollLeft) {
  335. state.scrollLeft = left + Math.random() / 10000
  336. } else {
  337. state.scrollLeft = left
  338. }
  339. })
  340. }
  341. /**
  342. * @description 单击tab的处理
  343. * @param index
  344. */
  345. function handleSelect(index: number) {
  346. if (index === undefined) return
  347. const { disabled } = children[index]
  348. const name = getTabName(children[index], index)
  349. if (disabled) {
  350. emit('disabled', {
  351. index,
  352. name
  353. })
  354. return
  355. }
  356. state.mapShow && toggleMap()
  357. setActive(index)
  358. emit('click', {
  359. index,
  360. name
  361. })
  362. }
  363. function onTouchStart(event: any) {
  364. if (!props.swipeable) return
  365. touch.touchStart(event)
  366. }
  367. function onTouchMove(event: any) {
  368. if (!props.swipeable) return
  369. touch.touchMove(event)
  370. }
  371. function onTouchEnd() {
  372. if (!props.swipeable) return
  373. const { direction, deltaX, offsetX } = touch
  374. const minSwipeDistance = 50
  375. if (direction.value === 'horizontal' && offsetX.value >= minSwipeDistance) {
  376. if (deltaX.value > 0 && state.activeIndex !== 0) {
  377. setActive(state.activeIndex - 1)
  378. } else if (deltaX.value < 0 && state.activeIndex !== children.length - 1) {
  379. setActive(state.activeIndex + 1)
  380. }
  381. }
  382. }
  383. function getActiveIndex(value: number | string) {
  384. // name代表的索引超过了children长度的边界,自动用0兜底
  385. if (isNumber(value) && value >= children.length) {
  386. // eslint-disable-next-line prettier/prettier
  387. console.error('[wot ui] warning(wd-tabs): the type of tabs\' value is Number shouldn\'t be less than its children')
  388. value = 0
  389. }
  390. // 如果是字符串直接匹配,匹配不到用0兜底
  391. if (isString(value)) {
  392. const index = children.findIndex((item) => item.name === value)
  393. value = index === -1 ? 0 : index
  394. }
  395. return value
  396. }
  397. defineExpose<TabsExpose>({
  398. setActive,
  399. scrollIntoView,
  400. updateLineStyle
  401. })
  402. </script>
  403. <style lang="scss" scoped>
  404. @import './index.scss';
  405. </style>