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;
+ }
+}