index.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { use, isDef } from '../utils';
  2. import { raf } from '../utils/raf';
  3. import { on, off } from '../utils/event';
  4. import { TouchMixin } from '../mixins/touch';
  5. import { setScrollTop, getScrollTop, getElementTop, getScrollEventTarget } from '../utils/scroll';
  6. const [sfc, bem] = use('tabs');
  7. const tabBem = use('tab')[1];
  8. export default sfc({
  9. mixins: [TouchMixin],
  10. model: {
  11. prop: 'active'
  12. },
  13. props: {
  14. color: String,
  15. sticky: Boolean,
  16. animated: Boolean,
  17. offsetTop: Number,
  18. swipeable: Boolean,
  19. background: String,
  20. titleActiveColor: String,
  21. titleInactiveColor: String,
  22. ellipsis: {
  23. type: Boolean,
  24. default: true
  25. },
  26. lazyRender: {
  27. type: Boolean,
  28. default: true
  29. },
  30. lineWidth: {
  31. type: Number,
  32. default: null
  33. },
  34. lineHeight: {
  35. type: Number,
  36. default: null
  37. },
  38. active: {
  39. type: [Number, String],
  40. default: 0
  41. },
  42. type: {
  43. type: String,
  44. default: 'line'
  45. },
  46. duration: {
  47. type: Number,
  48. default: 0.3
  49. },
  50. swipeThreshold: {
  51. type: Number,
  52. default: 4
  53. }
  54. },
  55. data() {
  56. return {
  57. tabs: [],
  58. position: '',
  59. curActive: null,
  60. lineStyle: {
  61. backgroundColor: this.color
  62. },
  63. events: {
  64. resize: false,
  65. sticky: false,
  66. swipeable: false
  67. }
  68. };
  69. },
  70. computed: {
  71. // whether the nav is scrollable
  72. scrollable() {
  73. return this.tabs.length > this.swipeThreshold || !this.ellipsis;
  74. },
  75. wrapStyle() {
  76. switch (this.position) {
  77. case 'top':
  78. return {
  79. top: this.offsetTop + 'px',
  80. position: 'fixed'
  81. };
  82. case 'bottom':
  83. return {
  84. top: 'auto',
  85. bottom: 0
  86. };
  87. default:
  88. return null;
  89. }
  90. },
  91. navStyle() {
  92. return {
  93. borderColor: this.color,
  94. background: this.background
  95. };
  96. },
  97. trackStyle() {
  98. if (this.animated) {
  99. return {
  100. left: `${-1 * this.curActive * 100}%`,
  101. transitionDuration: `${this.duration}s`
  102. };
  103. }
  104. }
  105. },
  106. watch: {
  107. active(val) {
  108. if (val !== this.curActive) {
  109. this.correctActive(val);
  110. }
  111. },
  112. color() {
  113. this.setLine();
  114. },
  115. tabs() {
  116. this.correctActive(this.curActive || this.active);
  117. this.scrollIntoView();
  118. this.setLine();
  119. },
  120. curActive() {
  121. this.scrollIntoView();
  122. this.setLine();
  123. // scroll to correct position
  124. if (this.position === 'top' || this.position === 'bottom') {
  125. setScrollTop(window, getElementTop(this.$el) - this.offsetTop);
  126. }
  127. },
  128. sticky() {
  129. this.handlers(true);
  130. },
  131. swipeable() {
  132. this.handlers(true);
  133. }
  134. },
  135. mounted() {
  136. this.onShow();
  137. },
  138. activated() {
  139. this.onShow();
  140. this.setLine();
  141. },
  142. deactivated() {
  143. this.handlers(false);
  144. },
  145. beforeDestroy() {
  146. this.handlers(false);
  147. },
  148. methods: {
  149. onShow() {
  150. this.$nextTick(() => {
  151. this.inited = true;
  152. this.handlers(true);
  153. this.scrollIntoView(true);
  154. });
  155. },
  156. // whether to bind sticky listener
  157. handlers(bind) {
  158. const { events } = this;
  159. const sticky = this.sticky && bind;
  160. const swipeable = this.swipeable && bind;
  161. // listen to window resize event
  162. if (events.resize !== bind) {
  163. events.resize = bind;
  164. (bind ? on : off)(window, 'resize', this.setLine, true);
  165. }
  166. // listen to scroll event
  167. if (events.sticky !== sticky) {
  168. events.sticky = sticky;
  169. this.scrollEl = this.scrollEl || getScrollEventTarget(this.$el);
  170. (sticky ? on : off)(this.scrollEl, 'scroll', this.onScroll, true);
  171. this.onScroll();
  172. }
  173. // listen to touch event
  174. if (events.swipeable !== swipeable) {
  175. events.swipeable = swipeable;
  176. const { content } = this.$refs;
  177. const action = swipeable ? on : off;
  178. action(content, 'touchstart', this.touchStart);
  179. action(content, 'touchmove', this.touchMove);
  180. action(content, 'touchend', this.onTouchEnd);
  181. action(content, 'touchcancel', this.onTouchEnd);
  182. }
  183. },
  184. // watch swipe touch end
  185. onTouchEnd() {
  186. const { direction, deltaX, curActive } = this;
  187. const minSwipeDistance = 50;
  188. /* istanbul ignore else */
  189. if (direction === 'horizontal' && this.offsetX >= minSwipeDistance) {
  190. /* istanbul ignore else */
  191. if (deltaX > 0 && curActive !== 0) {
  192. this.setCurActive(curActive - 1);
  193. } else if (deltaX < 0 && curActive !== this.tabs.length - 1) {
  194. this.setCurActive(curActive + 1);
  195. }
  196. }
  197. },
  198. // adjust tab position
  199. onScroll() {
  200. const scrollTop = getScrollTop(window) + this.offsetTop;
  201. const elTopToPageTop = getElementTop(this.$el);
  202. const elBottomToPageTop =
  203. elTopToPageTop + this.$el.offsetHeight - this.$refs.wrap.offsetHeight;
  204. if (scrollTop > elBottomToPageTop) {
  205. this.position = 'bottom';
  206. } else if (scrollTop > elTopToPageTop) {
  207. this.position = 'top';
  208. } else {
  209. this.position = '';
  210. }
  211. const scrollParams = {
  212. scrollTop,
  213. isFixed: this.position === 'top'
  214. };
  215. this.$emit('scroll', scrollParams);
  216. },
  217. // update nav bar style
  218. setLine() {
  219. const shouldAnimate = this.inited;
  220. this.$nextTick(() => {
  221. const { tabs } = this.$refs;
  222. if (!tabs || !tabs[this.curActive] || this.type !== 'line') {
  223. return;
  224. }
  225. const tab = tabs[this.curActive];
  226. const { lineWidth, lineHeight } = this;
  227. const width = isDef(lineWidth) ? lineWidth : tab.offsetWidth / 2;
  228. const left = tab.offsetLeft + (tab.offsetWidth - width) / 2;
  229. const lineStyle = {
  230. width: `${width}px`,
  231. backgroundColor: this.color,
  232. transform: `translateX(${left}px)`
  233. };
  234. if (shouldAnimate) {
  235. lineStyle.transitionDuration = `${this.duration}s`;
  236. }
  237. if (isDef(lineHeight)) {
  238. const height = `${lineHeight}px`;
  239. lineStyle.height = height;
  240. lineStyle.borderRadius = height;
  241. }
  242. this.lineStyle = lineStyle;
  243. });
  244. },
  245. // correct the value of active
  246. correctActive(active) {
  247. active = +active;
  248. const exist = this.tabs.some(tab => tab.index === active);
  249. const defaultActive = (this.tabs[0] || {}).index || 0;
  250. this.setCurActive(exist ? active : defaultActive);
  251. },
  252. setCurActive(active) {
  253. active = this.findAvailableTab(active, active < this.curActive);
  254. if (isDef(active) && active !== this.curActive) {
  255. this.$emit('input', active);
  256. if (this.curActive !== null) {
  257. this.$emit('change', active, this.tabs[active].title);
  258. }
  259. this.curActive = active;
  260. }
  261. },
  262. findAvailableTab(active, reverse) {
  263. const diff = reverse ? -1 : 1;
  264. let index = active;
  265. while (index >= 0 && index < this.tabs.length) {
  266. if (!this.tabs[index].disabled) {
  267. return index;
  268. }
  269. index += diff;
  270. }
  271. },
  272. // emit event when clicked
  273. onClick(index) {
  274. const { title, disabled } = this.tabs[index];
  275. if (disabled) {
  276. this.$emit('disabled', index, title);
  277. } else {
  278. this.setCurActive(index);
  279. this.$emit('click', index, title);
  280. }
  281. },
  282. // scroll active tab into view
  283. scrollIntoView(immediate) {
  284. const { tabs } = this.$refs;
  285. if (!this.scrollable || !tabs || !tabs[this.curActive]) {
  286. return;
  287. }
  288. const { nav } = this.$refs;
  289. const { scrollLeft, offsetWidth: navWidth } = nav;
  290. const { offsetLeft, offsetWidth: tabWidth } = tabs[this.curActive];
  291. this.scrollTo(nav, scrollLeft, offsetLeft - (navWidth - tabWidth) / 2, immediate);
  292. },
  293. // animate the scrollLeft of nav
  294. scrollTo(el, from, to, immediate) {
  295. if (immediate) {
  296. el.scrollLeft += to - from;
  297. return;
  298. }
  299. let count = 0;
  300. const frames = Math.round((this.duration * 1000) / 16);
  301. const animate = () => {
  302. el.scrollLeft += (to - from) / frames;
  303. /* istanbul ignore next */
  304. if (++count < frames) {
  305. raf(animate);
  306. }
  307. };
  308. animate();
  309. },
  310. // render title slot of child tab
  311. renderTitle(el, index) {
  312. this.$nextTick(() => {
  313. const title = this.$refs.title[index];
  314. title.parentNode.replaceChild(el, title);
  315. });
  316. },
  317. getTabStyle(item, index) {
  318. const style = {};
  319. const { color } = this;
  320. const active = index === this.curActive;
  321. const isCard = this.type === 'card';
  322. // theme color
  323. if (color) {
  324. if (!item.disabled && isCard && !active) {
  325. style.color = color;
  326. }
  327. if (!item.disabled && isCard && active) {
  328. style.backgroundColor = color;
  329. }
  330. if (isCard) {
  331. style.borderColor = color;
  332. }
  333. }
  334. const titleColor = active ? this.titleActiveColor : this.titleInactiveColor;
  335. if (titleColor) {
  336. style.color = titleColor;
  337. }
  338. if (this.scrollable && this.ellipsis) {
  339. style.flexBasis = 88 / this.swipeThreshold + '%';
  340. }
  341. return style;
  342. }
  343. },
  344. render(h) {
  345. const { type, ellipsis, animated, scrollable } = this;
  346. const Nav = this.tabs.map((tab, index) => (
  347. <div
  348. ref="tabs"
  349. refInFor
  350. class={tabBem({
  351. active: index === this.curActive,
  352. disabled: tab.disabled,
  353. complete: !ellipsis
  354. })}
  355. style={this.getTabStyle(tab, index)}
  356. onClick={() => {
  357. this.onClick(index);
  358. }}
  359. >
  360. <span ref="title" refInFor class={{ 'van-ellipsis': ellipsis }}>
  361. {tab.title}
  362. </span>
  363. </div>
  364. ));
  365. return (
  366. <div class={bem([type])}>
  367. <div
  368. ref="wrap"
  369. style={this.wrapStyle}
  370. class={[bem('wrap', { scrollable }), { 'van-hairline--top-bottom': type === 'line' }]}
  371. >
  372. <div ref="nav" class={bem('nav', [type])} style={this.navStyle}>
  373. {this.slots('nav-left')}
  374. {type === 'line' && <div class={bem('line')} style={this.lineStyle} />}
  375. {Nav}
  376. {this.slots('nav-right')}
  377. </div>
  378. </div>
  379. <div ref="content" class={bem('content', { animated })}>
  380. {animated ? (
  381. <div class={bem('track')} style={this.trackStyle}>
  382. {this.slots()}
  383. </div>
  384. ) : (
  385. this.slots()
  386. )}
  387. </div>
  388. </div>
  389. );
  390. }
  391. });