diff --git a/src-next/mixins/bind-event.ts b/src-next/mixins/bind-event.ts new file mode 100644 index 000000000..5a54365c8 --- /dev/null +++ b/src-next/mixins/bind-event.ts @@ -0,0 +1,33 @@ +/** + * Bind event when mounted or activated + */ +import { on, off } from '../utils/dom/event'; + +type BindEventMixinThis = { + binded: boolean; +}; + +type BindEventHandler = (bind: Function, isBind: boolean) => void; + +export function BindEventMixin(handler: BindEventHandler) { + function bind(this: BindEventMixinThis) { + if (!this.binded) { + handler.call(this, on, true); + this.binded = true; + } + } + + function unbind(this: BindEventMixinThis) { + if (this.binded) { + handler.call(this, off, false); + this.binded = false; + } + } + + return { + mounted: bind, + activated: bind, + deactivated: unbind, + beforeDestroy: unbind, + }; +} diff --git a/src-next/mixins/checkbox.js b/src-next/mixins/checkbox.js new file mode 100644 index 000000000..5fc341d67 --- /dev/null +++ b/src-next/mixins/checkbox.js @@ -0,0 +1,148 @@ +/** + * Common part of Checkbox & Radio + */ +import Icon from '../icon'; +import { FieldMixin } from './field'; +import { ChildrenMixin } from './relation'; +import { addUnit } from '../utils'; + +export const CheckboxMixin = ({ parent, bem, role }) => ({ + mixins: [ChildrenMixin(parent), FieldMixin], + + props: { + name: null, + value: null, + disabled: Boolean, + iconSize: [Number, String], + checkedColor: String, + labelPosition: String, + labelDisabled: Boolean, + shape: { + type: String, + default: 'round', + }, + bindGroup: { + type: Boolean, + default: true, + }, + }, + + computed: { + disableBindRelation() { + return !this.bindGroup; + }, + + isDisabled() { + return (this.parent && this.parent.disabled) || this.disabled; + }, + + direction() { + return (this.parent && this.parent.direction) || null; + }, + + iconStyle() { + const checkedColor = + this.checkedColor || (this.parent && this.parent.checkedColor); + + if (checkedColor && this.checked && !this.isDisabled) { + return { + borderColor: checkedColor, + backgroundColor: checkedColor, + }; + } + }, + + tabindex() { + if (this.isDisabled || (role === 'radio' && !this.checked)) { + return -1; + } + + return 0; + }, + }, + + methods: { + onClick(event) { + const { target } = event; + const { icon } = this.$refs; + const iconClicked = icon === target || icon.contains(target); + + if (!this.isDisabled && (iconClicked || !this.labelDisabled)) { + this.toggle(); + + // wait for toggle method to complete + // so we can get the changed value in the click event listener + setTimeout(() => { + this.$emit('click', event); + }); + } else { + this.$emit('click', event); + } + }, + + genIcon() { + const { checked } = this; + const iconSize = this.iconSize || (this.parent && this.parent.iconSize); + + return ( +
+ {this.slots('icon', { checked }) || ( + + )} +
+ ); + }, + + genLabel() { + const slot = this.slots(); + + if (slot) { + return ( + + {slot} + + ); + } + }, + }, + + render() { + const Children = [this.genIcon()]; + + if (this.labelPosition === 'left') { + Children.unshift(this.genLabel()); + } else { + Children.push(this.genLabel()); + } + + return ( +
+ {Children} +
+ ); + }, +}); diff --git a/src-next/mixins/click-outside.js b/src-next/mixins/click-outside.js new file mode 100644 index 000000000..534d026f9 --- /dev/null +++ b/src-next/mixins/click-outside.js @@ -0,0 +1,31 @@ +/** + * Listen to click outside event + */ +import { on, off } from '../utils/dom/event'; + +export const ClickOutsideMixin = (config) => ({ + props: { + closeOnClickOutside: { + type: Boolean, + default: true, + }, + }, + + data() { + const clickOutsideHandler = (event) => { + if (this.closeOnClickOutside && !this.$el.contains(event.target)) { + this[config.method](); + } + }; + + return { clickOutsideHandler }; + }, + + mounted() { + on(document, config.event, this.clickOutsideHandler); + }, + + beforeDestroy() { + off(document, config.event, this.clickOutsideHandler); + }, +}); diff --git a/src-next/mixins/close-on-popstate.js b/src-next/mixins/close-on-popstate.js new file mode 100644 index 000000000..ed98c5c18 --- /dev/null +++ b/src-next/mixins/close-on-popstate.js @@ -0,0 +1,44 @@ +import { on, off } from '../utils/dom/event'; +import { BindEventMixin } from './bind-event'; + +export const CloseOnPopstateMixin = { + mixins: [ + BindEventMixin(function (bind, isBind) { + this.handlePopstate(isBind && this.closeOnPopstate); + }), + ], + + props: { + closeOnPopstate: Boolean, + }, + + data() { + return { + bindStatus: false, + }; + }, + + watch: { + closeOnPopstate(val) { + this.handlePopstate(val); + }, + }, + + methods: { + handlePopstate(bind) { + /* istanbul ignore if */ + if (this.$isServer) { + return; + } + + if (this.bindStatus !== bind) { + this.bindStatus = bind; + const action = bind ? on : off; + action(window, 'popstate', () => { + this.close(); + this.shouldReopen = false; + }); + } + }, + }, +}; diff --git a/src-next/mixins/field.js b/src-next/mixins/field.js new file mode 100644 index 000000000..603cff7da --- /dev/null +++ b/src-next/mixins/field.js @@ -0,0 +1,26 @@ +export const FieldMixin = { + inject: { + vanField: { + default: null, + }, + }, + + watch: { + value() { + const field = this.vanField; + + if (field) { + field.resetValidation(); + field.validateWithTrigger('onChange'); + } + }, + }, + + created() { + const field = this.vanField; + + if (field && !field.children) { + field.children = this; + } + }, +}; diff --git a/src-next/mixins/popup/context.ts b/src-next/mixins/popup/context.ts new file mode 100644 index 000000000..6ab98f914 --- /dev/null +++ b/src-next/mixins/popup/context.ts @@ -0,0 +1,16 @@ +import { OverlayConfig } from './overlay'; + +export type StackItem = { + vm: any; + overlay: any; + config: OverlayConfig; +}; + +export const context = { + zIndex: 2000, + lockCount: 0, + stack: [] as StackItem[], + find(vm: any): StackItem | undefined { + return this.stack.filter((item) => item.vm === vm)[0]; + }, +}; diff --git a/src-next/mixins/popup/index.js b/src-next/mixins/popup/index.js new file mode 100644 index 000000000..c2c97d40b --- /dev/null +++ b/src-next/mixins/popup/index.js @@ -0,0 +1,220 @@ +// Context +import { context } from './context'; +import { + openOverlay, + closeOverlay, + updateOverlay, + removeOverlay, +} from './overlay'; + +// Utils +import { on, off, preventDefault } from '../../utils/dom/event'; +import { removeNode } from '../../utils/dom/node'; +import { getScroller } from '../../utils/dom/scroll'; + +// Mixins +import { TouchMixin } from '../touch'; +import { PortalMixin } from '../portal'; +import { CloseOnPopstateMixin } from '../close-on-popstate'; + +export const popupMixinProps = { + // whether to show popup + value: Boolean, + // whether to show overlay + overlay: Boolean, + // overlay custom style + overlayStyle: Object, + // overlay custom class name + overlayClass: String, + // whether to close popup when click overlay + closeOnClickOverlay: Boolean, + // z-index + zIndex: [Number, String], + // prevent body scroll + lockScroll: { + type: Boolean, + default: true, + }, + // whether to lazy render + lazyRender: { + type: Boolean, + default: true, + }, +}; + +export function PopupMixin(options = {}) { + return { + mixins: [ + TouchMixin, + CloseOnPopstateMixin, + PortalMixin({ + afterPortal() { + if (this.overlay) { + updateOverlay(); + } + }, + }), + ], + + props: popupMixinProps, + + data() { + return { + inited: this.value, + }; + }, + + computed: { + shouldRender() { + return this.inited || !this.lazyRender; + }, + }, + + watch: { + value(val) { + const type = val ? 'open' : 'close'; + this.inited = this.inited || this.value; + this[type](); + + if (!options.skipToggleEvent) { + this.$emit(type); + } + }, + + overlay: 'renderOverlay', + }, + + mounted() { + if (this.value) { + this.open(); + } + }, + + /* istanbul ignore next */ + activated() { + if (this.shouldReopen) { + this.$emit('input', true); + this.shouldReopen = false; + } + }, + + beforeDestroy() { + this.removeLock(); + removeOverlay(this); + + if (this.getContainer) { + removeNode(this.$el); + } + }, + + /* istanbul ignore next */ + deactivated() { + if (this.value) { + this.close(); + this.shouldReopen = true; + } + }, + + methods: { + open() { + /* istanbul ignore next */ + if (this.$isServer || this.opened) { + return; + } + + // cover default zIndex + if (this.zIndex !== undefined) { + context.zIndex = this.zIndex; + } + + this.opened = true; + this.renderOverlay(); + this.addLock(); + }, + + addLock() { + if (this.lockScroll) { + on(document, 'touchstart', this.touchStart); + on(document, 'touchmove', this.onTouchMove); + + if (!context.lockCount) { + document.body.classList.add('van-overflow-hidden'); + } + context.lockCount++; + } + }, + + removeLock() { + if (this.lockScroll && context.lockCount) { + context.lockCount--; + off(document, 'touchstart', this.touchStart); + off(document, 'touchmove', this.onTouchMove); + + if (!context.lockCount) { + document.body.classList.remove('van-overflow-hidden'); + } + } + }, + + close() { + if (!this.opened) { + return; + } + + closeOverlay(this); + this.opened = false; + this.removeLock(); + this.$emit('input', false); + }, + + onTouchMove(event) { + this.touchMove(event); + const direction = this.deltaY > 0 ? '10' : '01'; + const el = getScroller(event.target, this.$el); + const { scrollHeight, offsetHeight, scrollTop } = el; + let status = '11'; + + /* istanbul ignore next */ + if (scrollTop === 0) { + status = offsetHeight >= scrollHeight ? '00' : '01'; + } else if (scrollTop + offsetHeight >= scrollHeight) { + status = '10'; + } + + /* istanbul ignore next */ + if ( + status !== '11' && + this.direction === 'vertical' && + !(parseInt(status, 2) & parseInt(direction, 2)) + ) { + preventDefault(event, true); + } + }, + + renderOverlay() { + if (this.$isServer || !this.value) { + return; + } + + this.$nextTick(() => { + this.updateZIndex(this.overlay ? 1 : 0); + + if (this.overlay) { + openOverlay(this, { + zIndex: context.zIndex++, + duration: this.duration, + className: this.overlayClass, + customStyle: this.overlayStyle, + }); + } else { + closeOverlay(this); + } + }); + }, + + updateZIndex(value = 0) { + this.$el.style.zIndex = ++context.zIndex + value; + }, + }, + }; +} diff --git a/src-next/mixins/popup/overlay.ts b/src-next/mixins/popup/overlay.ts new file mode 100644 index 000000000..1d5d86f6a --- /dev/null +++ b/src-next/mixins/popup/overlay.ts @@ -0,0 +1,77 @@ +import Overlay from '../../overlay'; +import { context } from './context'; +import { mount } from '../../utils/functional'; +import { removeNode } from '../../utils/dom/node'; + +export type OverlayConfig = { + zIndex?: number; + className?: string; + customStyle?: string | object[] | object; +}; + +const defaultConfig: OverlayConfig = { + className: '', + customStyle: {}, +}; + +function mountOverlay(vm: any) { + return mount(Overlay, { + on: { + // close popup when overlay clicked & closeOnClickOverlay is true + click() { + vm.$emit('click-overlay'); + + if (vm.closeOnClickOverlay) { + if (vm.onClickOverlay) { + vm.onClickOverlay(); + } else { + vm.close(); + } + } + }, + }, + }); +} + +export function updateOverlay(vm: any): void { + const item = context.find(vm); + + if (item) { + const el = vm.$el; + const { config, overlay } = item; + + if (el && el.parentNode) { + el.parentNode.insertBefore(overlay.$el, el); + } + + Object.assign(overlay, defaultConfig, config, { + show: true, + }); + } +} + +export function openOverlay(vm: any, config: OverlayConfig): void { + const item = context.find(vm); + if (item) { + item.config = config; + } else { + const overlay = mountOverlay(vm); + context.stack.push({ vm, config, overlay }); + } + + updateOverlay(vm); +} + +export function closeOverlay(vm: any): void { + const item = context.find(vm); + if (item) { + item.overlay.show = false; + } +} + +export function removeOverlay(vm: any) { + const item = context.find(vm); + if (item) { + removeNode(item.overlay.$el); + } +} diff --git a/src-next/mixins/popup/type.ts b/src-next/mixins/popup/type.ts new file mode 100644 index 000000000..5944f8932 --- /dev/null +++ b/src-next/mixins/popup/type.ts @@ -0,0 +1,13 @@ +export type GetContainer = () => Element; + +export type PopupMixinProps = { + value: boolean; + zIndex: string | number; + overlay?: boolean; + lockScroll: boolean; + lazyRender: boolean; + overlayClass?: any; + overlayStyle?: object | object[]; + getContainer?: string | GetContainer; + closeOnClickOverlay?: boolean; +}; diff --git a/src-next/mixins/portal.js b/src-next/mixins/portal.js new file mode 100644 index 000000000..3ed88ef2d --- /dev/null +++ b/src-next/mixins/portal.js @@ -0,0 +1,47 @@ +function getElement(selector) { + if (typeof selector === 'string') { + return document.querySelector(selector); + } + + return selector(); +} + +export function PortalMixin({ ref, afterPortal }) { + return { + props: { + getContainer: [String, Function], + }, + + watch: { + getContainer: 'portal', + }, + + mounted() { + if (this.getContainer) { + this.portal(); + } + }, + + methods: { + portal() { + const { getContainer } = this; + const el = ref ? this.$refs[ref] : this.$el; + + let container; + if (getContainer) { + container = getElement(getContainer); + } else if (this.$parent) { + container = this.$parent.$el; + } + + if (container && container !== el.parentNode) { + container.appendChild(el); + } + + if (afterPortal) { + afterPortal.call(this); + } + }, + }, + }; +} diff --git a/src-next/mixins/relation.js b/src-next/mixins/relation.js new file mode 100644 index 000000000..b3f6cb37e --- /dev/null +++ b/src-next/mixins/relation.js @@ -0,0 +1,69 @@ +import { sortChildren } from '../utils/vnodes'; + +export function ChildrenMixin(parent, options = {}) { + const indexKey = options.indexKey || 'index'; + + return { + inject: { + // TODO: disableBindRelation + parent: { + from: parent, + default: null, + }, + }, + + computed: { + [indexKey]() { + this.bindRelation(); + + if (this.parent) { + return this.parent.children.indexOf(this); + } + + return null; + }, + }, + + mounted() { + this.bindRelation(); + }, + + beforeDestroy() { + if (this.parent) { + this.parent.children = this.parent.children.filter( + (item) => item !== this + ); + } + }, + + methods: { + bindRelation() { + if (!this.parent || this.parent.children.indexOf(this) !== -1) { + return; + } + + const children = [...this.parent.children, this]; + + sortChildren(children, this.parent); + + this.parent.children = children; + }, + }, + }; +} + +export function ParentMixin(parent) { + return { + provide() { + return { + [parent]: this, + }; + }, + + data() { + return { + children: [], + }; + }, + }; +} diff --git a/src-next/mixins/slots.js b/src-next/mixins/slots.js new file mode 100644 index 000000000..70449074c --- /dev/null +++ b/src-next/mixins/slots.js @@ -0,0 +1,18 @@ +/** + * Use scopedSlots in Vue 2.6+ + * downgrade to slots in lower version + */ +export const SlotsMixin = { + methods: { + slots(name = 'default', props) { + const { $slots, $scopedSlots } = this; + const scopedSlot = $scopedSlots[name]; + + if (scopedSlot) { + return scopedSlot(props); + } + + return $slots[name]; + }, + }, +}; diff --git a/src-next/mixins/touch.js b/src-next/mixins/touch.js new file mode 100644 index 000000000..45f9190da --- /dev/null +++ b/src-next/mixins/touch.js @@ -0,0 +1,61 @@ +import { on } from '../utils/dom/event'; + +const MIN_DISTANCE = 10; + +function getDirection(x, y) { + if (x > y && x > MIN_DISTANCE) { + return 'horizontal'; + } + + if (y > x && y > MIN_DISTANCE) { + return 'vertical'; + } + + return ''; +} + +export const TouchMixin = { + data() { + return { direction: '' }; + }, + + methods: { + touchStart(event) { + this.resetTouchStatus(); + this.startX = event.touches[0].clientX; + this.startY = event.touches[0].clientY; + }, + + touchMove(event) { + const touch = event.touches[0]; + this.deltaX = touch.clientX - this.startX; + this.deltaY = touch.clientY - this.startY; + this.offsetX = Math.abs(this.deltaX); + this.offsetY = Math.abs(this.deltaY); + this.direction = + this.direction || getDirection(this.offsetX, this.offsetY); + }, + + resetTouchStatus() { + this.direction = ''; + this.deltaX = 0; + this.deltaY = 0; + this.offsetX = 0; + this.offsetY = 0; + }, + + // avoid Vue 2.6 event bubble issues by manually binding events + // https://github.com/youzan/vant/issues/3015 + bindTouchEvent(el) { + const { onTouchStart, onTouchMove, onTouchEnd } = this; + + on(el, 'touchstart', onTouchStart); + on(el, 'touchmove', onTouchMove); + + if (onTouchEnd) { + on(el, 'touchend', onTouchEnd); + on(el, 'touchcancel', onTouchEnd); + } + }, + }, +}; diff --git a/src-next/row/index.less b/src-next/row/index.less new file mode 100644 index 000000000..3858f4d68 --- /dev/null +++ b/src-next/row/index.less @@ -0,0 +1,41 @@ +@import '../style/var'; + +.van-row { + &::after { + display: table; + clear: both; + content: ''; + } + + &--flex { + display: flex; + + &::after { + display: none; + } + } + + &--justify-center { + justify-content: center; + } + + &--justify-end { + justify-content: flex-end; + } + + &--justify-space-between { + justify-content: space-between; + } + + &--justify-space-around { + justify-content: space-around; + } + + &--align-center { + align-items: center; + } + + &--align-bottom { + align-items: flex-end; + } +}