Browse Source

feat(组件): 增加playground与基本kankan sdk 到playground

gemercheung 2 years ago
parent
commit
e66b2dbc2d

+ 12 - 2
package.json

@@ -10,8 +10,9 @@
     "docs": "doctoc --title '**Table of content**' README.md",
     "clean": "pnpm run -r clean",
     "build": "pnpm run -r build",
-    "test": "pnpm run -r test",
-    "lint": "eslint --ext js,ts . --fix",
+    "test": "vitest",
+    "test:coverage": "vitest --coverage",
+    "lint": "eslint --ext js,ts,tsx . --fix",
     "commit": "git cz",
     "preinstall": "npx only-allow pnpm",
     "postinstall": "husky install",
@@ -22,6 +23,12 @@
   "peerDependencies": {
     "vue": "^3.2.0"
   },
+  "dependencies": {
+    "@vueuse/core": "^9.1.0",
+    "lodash": "^4.17.21",
+    "lodash-es": "^4.17.21",
+    "lodash-unified": "^1.0.2"
+  },
   "devDependencies": {
     "@changesets/cli": "^2.24.4",
     "@commitlint/cli": "^17.1.2",
@@ -29,6 +36,8 @@
     "@vitejs/plugin-vue": "^3.1.0",
     "@vitejs/plugin-vue-jsx": "^2.0.1",
     "@vue/test-utils": "^2.0.2",
+    "@types/jsdom": "^16.2.14",
+    "@types/node": "*",
     "commitizen": "^4.2.5",
     "commitlint-config-cz": "^0.13.3",
     "cz-customizable": "^7.0.0",
@@ -42,6 +51,7 @@
     "eslint-plugin-vue": "^8.7.1",
     "husky": "^8.0.1",
     "jest": "^29.0.3",
+    "jsdom": "16.4.0",
     "lint-staged": "^13.0.3",
     "resize-observer-polyfill": "^1.5.1",
     "typescript": "~4.7.4",

+ 20 - 9
packages/components/audio/__tests__/audio.test.tsx

@@ -1,14 +1,25 @@
-// import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
-// import Audio from '../src/audio.vue';
-// import type { VNode } from 'vue'
+import { describe, expect, test } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+import Audio from '../src/audio.vue';
+// import type { VNode } from 'vue';
 
 // const _mount = (render: () => VNode) => {
 //     return mount(render, { attachTo: document.body })
 //   }
 
-// describe('Audio.vue', () => {
-//     test('render test', async () => {
-//         const wrapper = _mount(() => <Audio>{AXIOM}</Audio>)
-//         await nextTick()
-//     }
-// });
+describe('Audio.vue', () => {
+  //   test('render test', async () => {
+  //     const AudioSrc = '';
+  //     const wrapper = mount(() => <Audio src={AudioSrc}></Audio>);
+  //     await nextTick();
+  //   });
+
+  test('play', async () => {
+    const radio = ref('');
+    const wrapper = mount(() => <Audio v-model={radio.value} />);
+    await wrapper.trigger('click');
+    expect(radio.value).toBe('');
+    expect(wrapper.classes()).toContain('is-disabled');
+  });
+});

+ 119 - 0
packages/utils/dom/aria.ts

@@ -0,0 +1,119 @@
+const FOCUSABLE_ELEMENT_SELECTORS = `a[href],button:not([disabled]),button:not([hidden]),:not([tabindex="-1"]),input:not([disabled]),input:not([type="hidden"]),select:not([disabled]),textarea:not([disabled])`;
+
+/**
+ * Determine if the testing element is visible on screen no matter if its on the viewport or not
+ */
+export const isVisible = (element: HTMLElement) => {
+  if (process.env.NODE_ENV === 'test') return true;
+  const computed = getComputedStyle(element);
+  // element.offsetParent won't work on fix positioned
+  // WARNING: potential issue here, going to need some expert advices on this issue
+  return computed.position === 'fixed' ? false : element.offsetParent !== null;
+};
+
+export const obtainAllFocusableElements = (element: HTMLElement): HTMLElement[] => {
+  return Array.from(element.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENT_SELECTORS)).filter(
+    (item: HTMLElement) => isFocusable(item) && isVisible(item),
+  );
+};
+
+/**
+ * @desc Determine if target element is focusable
+ * @param element {HTMLElement}
+ * @returns {Boolean} true if it is focusable
+ */
+export const isFocusable = (element: HTMLElement): boolean => {
+  if (
+    element.tabIndex > 0 ||
+    (element.tabIndex === 0 && element.getAttribute('tabIndex') !== null)
+  ) {
+    return true;
+  }
+  // HTMLButtonElement has disabled
+  if ((element as HTMLButtonElement).disabled) {
+    return false;
+  }
+
+  switch (element.nodeName) {
+    case 'A': {
+      // casting current element to Specific HTMLElement in order to be more type precise
+      return (
+        !!(element as HTMLAnchorElement).href && (element as HTMLAnchorElement).rel !== 'ignore'
+      );
+    }
+    case 'INPUT': {
+      return !(
+        (element as HTMLInputElement).type === 'hidden' ||
+        (element as HTMLInputElement).type === 'file'
+      );
+    }
+    case 'BUTTON':
+    case 'SELECT':
+    case 'TEXTAREA': {
+      return true;
+    }
+    default: {
+      return false;
+    }
+  }
+};
+
+/**
+ * @desc Set Attempt to set focus on the current node.
+ * @param element
+ *          The node to attempt to focus on.
+ * @returns
+ *  true if element is focused.
+ */
+export const attemptFocus = (element: HTMLElement): boolean => {
+  if (!isFocusable(element)) {
+    return false;
+  }
+  // Remove the old try catch block since there will be no error to be thrown
+  element.focus?.();
+  return document.activeElement === element;
+};
+
+/**
+ * Trigger an event
+ * mouseenter, mouseleave, mouseover, keyup, change, click, etc.
+ * @param  {HTMLElement} elm
+ * @param  {String} name
+ * @param  {*} opts
+ */
+export const triggerEvent = function (
+  elm: HTMLElement,
+  name: string,
+  ...opts: Array<boolean>
+): HTMLElement {
+  let eventName: string;
+
+  if (name.includes('mouse') || name.includes('click')) {
+    eventName = 'MouseEvents';
+  } else if (name.includes('key')) {
+    eventName = 'KeyboardEvent';
+  } else {
+    eventName = 'HTMLEvents';
+  }
+  const evt = document.createEvent(eventName);
+
+  evt.initEvent(name, ...opts);
+  elm.dispatchEvent(evt);
+  return elm;
+};
+
+export const isLeaf = (el: HTMLElement) => !el.getAttribute('aria-owns');
+
+export const getSibling = (el: HTMLElement, distance: number, elClass: string) => {
+  const { parentNode } = el;
+  if (!parentNode) return null;
+  const siblings = parentNode.querySelectorAll(elClass);
+  const index = Array.prototype.indexOf.call(siblings, el);
+  return siblings[index + distance] || null;
+};
+
+export const focusNode = (el: HTMLElement) => {
+  if (!el) return;
+  el.focus();
+  !isLeaf(el) && el.click();
+};

+ 19 - 0
packages/utils/dom/event.ts

@@ -0,0 +1,19 @@
+export const composeEventHandlers = <E>(
+  theirsHandler?: (event: E) => boolean | void,
+  oursHandler?: (event: E) => void,
+  { checkForDefaultPrevented = true } = {},
+) => {
+  const handleEvent = (event: E) => {
+    const shouldPrevent = theirsHandler?.(event);
+
+    if (checkForDefaultPrevented === false || !shouldPrevent) {
+      return oursHandler?.(event);
+    }
+  };
+  return handleEvent;
+};
+
+type WhenMouseHandler = (e: PointerEvent) => any;
+export const whenMouse = (handler: WhenMouseHandler): WhenMouseHandler => {
+  return (e: PointerEvent) => (e.pointerType === 'mouse' ? handler(e) : undefined);
+};

+ 5 - 0
packages/utils/dom/index.ts

@@ -0,0 +1,5 @@
+export * from './aria';
+export * from './event';
+export * from './position';
+export * from './scroll';
+export * from './style';

+ 60 - 0
packages/utils/dom/position.ts

@@ -0,0 +1,60 @@
+import { isClient } from '@vueuse/core';
+
+export const isInContainer = (el?: Element, container?: Element | Window): boolean => {
+  if (!isClient || !el || !container) return false;
+
+  const elRect = el.getBoundingClientRect();
+
+  let containerRect: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
+  if (container instanceof Element) {
+    containerRect = container.getBoundingClientRect();
+  } else {
+    containerRect = {
+      top: 0,
+      right: window.innerWidth,
+      bottom: window.innerHeight,
+      left: 0,
+    };
+  }
+  return (
+    elRect.top < containerRect.bottom &&
+    elRect.bottom > containerRect.top &&
+    elRect.right > containerRect.left &&
+    elRect.left < containerRect.right
+  );
+};
+
+export const getOffsetTop = (el: HTMLElement) => {
+  let offset = 0;
+  let parent = el;
+
+  while (parent) {
+    offset += parent.offsetTop;
+    parent = parent.offsetParent as HTMLElement;
+  }
+
+  return offset;
+};
+
+export const getOffsetTopDistance = (el: HTMLElement, containerEl: HTMLElement) => {
+  return Math.abs(getOffsetTop(el) - getOffsetTop(containerEl));
+};
+
+export const getClientXY = (event: MouseEvent | TouchEvent) => {
+  let clientX: number;
+  let clientY: number;
+  if (event.type === 'touchend') {
+    clientY = (event as TouchEvent).changedTouches[0].clientY;
+    clientX = (event as TouchEvent).changedTouches[0].clientX;
+  } else if (event.type.startsWith('touch')) {
+    clientY = (event as TouchEvent).touches[0].clientY;
+    clientX = (event as TouchEvent).touches[0].clientX;
+  } else {
+    clientY = (event as MouseEvent).clientY;
+    clientX = (event as MouseEvent).clientX;
+  }
+  return {
+    clientX,
+    clientY,
+  };
+};

+ 91 - 0
packages/utils/dom/scroll.ts

@@ -0,0 +1,91 @@
+import { isClient } from '@vueuse/core';
+import { getStyle } from './style';
+
+export const isScroll = (el: HTMLElement, isVertical?: boolean): boolean => {
+  if (!isClient) return false;
+
+  const key = (
+    {
+      undefined: 'overflow',
+      true: 'overflow-y',
+      false: 'overflow-x',
+    } as const
+  )[String(isVertical)]!;
+  const overflow = getStyle(el, key);
+  return ['scroll', 'auto', 'overlay'].some((s) => overflow.includes(s));
+};
+
+export const getScrollContainer = (
+  el: HTMLElement,
+  isVertical?: boolean,
+): Window | HTMLElement | undefined => {
+  if (!isClient) return;
+
+  let parent: HTMLElement = el;
+  while (parent) {
+    if ([window, document, document.documentElement].includes(parent)) return window;
+
+    if (isScroll(parent, isVertical)) return parent;
+
+    parent = parent.parentNode as HTMLElement;
+  }
+
+  return parent;
+};
+
+let scrollBarWidth: number;
+export const getScrollBarWidth = (namespace: string): number => {
+  if (!isClient) return 0;
+  if (scrollBarWidth !== undefined) return scrollBarWidth;
+
+  const outer = document.createElement('div');
+  outer.className = `${namespace}-scrollbar__wrap`;
+  outer.style.visibility = 'hidden';
+  outer.style.width = '100px';
+  outer.style.position = 'absolute';
+  outer.style.top = '-9999px';
+  document.body.appendChild(outer);
+
+  const widthNoScroll = outer.offsetWidth;
+  outer.style.overflow = 'scroll';
+
+  const inner = document.createElement('div');
+  inner.style.width = '100%';
+  outer.appendChild(inner);
+
+  const widthWithScroll = inner.offsetWidth;
+  outer.parentNode?.removeChild(outer);
+  scrollBarWidth = widthNoScroll - widthWithScroll;
+
+  return scrollBarWidth;
+};
+
+/**
+ * Scroll with in the container element, positioning the **selected** element at the top
+ * of the container
+ */
+export function scrollIntoView(container: HTMLElement, selected: HTMLElement): void {
+  if (!isClient) return;
+
+  if (!selected) {
+    container.scrollTop = 0;
+    return;
+  }
+
+  const offsetParents: HTMLElement[] = [];
+  let pointer = selected.offsetParent;
+  while (pointer !== null && container !== pointer && container.contains(pointer)) {
+    offsetParents.push(pointer as HTMLElement);
+    pointer = (pointer as HTMLElement).offsetParent;
+  }
+  const top = selected.offsetTop + offsetParents.reduce((prev, curr) => prev + curr.offsetTop, 0);
+  const bottom = top + selected.offsetHeight;
+  const viewRectTop = container.scrollTop;
+  const viewRectBottom = viewRectTop + container.clientHeight;
+
+  if (top < viewRectTop) {
+    container.scrollTop = top;
+  } else if (bottom > viewRectBottom) {
+    container.scrollTop = bottom - container.clientHeight;
+  }
+}

+ 76 - 0
packages/utils/dom/style.ts

@@ -0,0 +1,76 @@
+import { isClient } from '@vueuse/core';
+import { isNumber, isObject, isString } from '../types';
+import { camelize } from '../strings';
+import { entriesOf, keysOf } from '../objects';
+import { debugWarn } from '../error';
+import type { CSSProperties } from 'vue';
+
+const SCOPE = 'utils/dom/style';
+
+export const classNameToArray = (cls = '') => cls.split(' ').filter((item) => !!item.trim());
+
+export const hasClass = (el: Element, cls: string): boolean => {
+  if (!el || !cls) return false;
+  if (cls.includes(' ')) throw new Error('className should not contain space.');
+  return el.classList.contains(cls);
+};
+
+export const addClass = (el: Element, cls: string) => {
+  if (!el || !cls.trim()) return;
+  el.classList.add(...classNameToArray(cls));
+};
+
+export const removeClass = (el: Element, cls: string) => {
+  if (!el || !cls.trim()) return;
+  el.classList.remove(...classNameToArray(cls));
+};
+
+export const getStyle = (element: HTMLElement, styleName: keyof CSSProperties): string => {
+  if (!isClient || !element || !styleName) return '';
+
+  let key = camelize(styleName);
+  if (key === 'float') key = 'cssFloat';
+  try {
+    const style = (element.style as any)[key];
+    if (style) return style;
+    const computed: any = document.defaultView?.getComputedStyle(element, '');
+    return computed ? computed[key] : '';
+  } catch {
+    return (element.style as any)[key];
+  }
+};
+
+export const setStyle = (
+  element: HTMLElement,
+  styleName: CSSProperties | keyof CSSProperties,
+  value?: string | number,
+) => {
+  if (!element || !styleName) return;
+
+  if (isObject(styleName)) {
+    entriesOf(styleName).forEach(([prop, value]) => setStyle(element, prop, value));
+  } else {
+    const key: any = camelize(styleName);
+    element.style[key] = value as any;
+  }
+};
+
+export const removeStyle = (element: HTMLElement, style: CSSProperties | keyof CSSProperties) => {
+  if (!element || !style) return;
+
+  if (isObject(style)) {
+    keysOf(style).forEach((prop) => removeStyle(element, prop));
+  } else {
+    setStyle(element, style, '');
+  }
+};
+
+export function addUnit(value?: string | number, defaultUnit = 'px') {
+  if (!value) return '';
+  if (isString(value)) {
+    return value;
+  } else if (isNumber(value)) {
+    return `${value}${defaultUnit}`;
+  }
+  debugWarn(SCOPE, 'binding value must be a string or number');
+}

+ 22 - 0
packages/utils/error.ts

@@ -0,0 +1,22 @@
+import { isString } from './types';
+
+class ElementPlusError extends Error {
+  constructor(m: string) {
+    super(m);
+    this.name = 'ElementPlusError';
+  }
+}
+
+export function throwError(scope: string, m: string): never {
+  throw new ElementPlusError(`[${scope}] ${m}`);
+}
+
+export function debugWarn(err: Error): void;
+export function debugWarn(scope: string, message: string): void;
+export function debugWarn(scope: string | Error, message?: string): void {
+  if (process.env.NODE_ENV !== 'production') {
+    const error: Error = isString(scope) ? new ElementPlusError(`[${scope}] ${message}`) : scope;
+    // eslint-disable-next-line no-console
+    console.warn(error);
+  }
+}

+ 22 - 0
packages/utils/objects.ts

@@ -0,0 +1,22 @@
+import { get, set } from 'lodash-unified';
+import type { Entries } from 'type-fest';
+import type { Arrayable } from '.';
+
+export const keysOf = <T>(arr: T) => Object.keys(arr) as Array<keyof T>;
+export const entriesOf = <T>(arr: T) => Object.entries(arr) as Entries<T>;
+export { hasOwn } from '@vue/shared';
+
+export const getProp = <T = any>(
+  obj: Record<string, any>,
+  path: Arrayable<string>,
+  defaultValue?: any,
+): { value: T } => {
+  return {
+    get value() {
+      return get(obj, path, defaultValue);
+    },
+    set value(val: any) {
+      set(obj, path, val);
+    },
+  };
+};

+ 22 - 0
packages/utils/types.ts

@@ -0,0 +1,22 @@
+import { isArray, isObject } from '@vue/shared';
+import { isNil } from 'lodash-unified';
+
+export { isArray, isFunction, isObject, isString, isDate, isPromise, isSymbol } from '@vue/shared';
+export { isBoolean, isNumber } from '@vueuse/core';
+export { isVNode } from 'vue';
+
+export const isUndefined = (val: any): val is undefined => val === undefined;
+
+export const isEmpty = (val: unknown) =>
+  (!val && val !== 0) ||
+  (isArray(val) && val.length === 0) ||
+  (isObject(val) && !Object.keys(val).length);
+
+export const isElement = (e: unknown): e is Element => {
+  if (typeof Element === 'undefined') return false;
+  return e instanceof Element;
+};
+
+export const isPropAbsent = (prop: unknown): prop is null | undefined => {
+  return isNil(prop);
+};

+ 32 - 0
packages/utils/vue/global-node.ts

@@ -0,0 +1,32 @@
+import { isClient } from '@vueuse/core';
+
+const globalNodes: HTMLElement[] = [];
+let target: HTMLElement = !isClient ? (undefined as any) : document.body;
+
+export function createGlobalNode(id?: string) {
+  const el = document.createElement('div');
+  if (id !== undefined) {
+    el.setAttribute('id', id);
+  }
+
+  target.appendChild(el);
+  globalNodes.push(el);
+
+  return el;
+}
+
+export function removeGlobalNode(el: HTMLElement) {
+  globalNodes.splice(globalNodes.indexOf(el), 1);
+  el.remove();
+}
+
+export function changeGlobalNodesTarget(el: HTMLElement) {
+  if (el === target) return;
+
+  target = el;
+  globalNodes.forEach((el) => {
+    if (el.contains(target) === false) {
+      target.appendChild(el);
+    }
+  });
+}

+ 40 - 0
packages/utils/vue/icon.ts

@@ -0,0 +1,40 @@
+import {
+  CircleCheck,
+  CircleClose,
+  CircleCloseFilled,
+  Close,
+  InfoFilled,
+  Loading,
+  SuccessFilled,
+  WarningFilled,
+} from '@element-plus/icons-vue';
+import { definePropType } from './props';
+
+import type { Component } from 'vue';
+
+export const iconPropType = definePropType<string | Component>([String, Object, Function]);
+
+export const CloseComponents = {
+  Close,
+};
+
+export const TypeComponents = {
+  Close,
+  SuccessFilled,
+  InfoFilled,
+  WarningFilled,
+  CircleCloseFilled,
+};
+
+export const TypeComponentsMap = {
+  success: SuccessFilled,
+  warning: WarningFilled,
+  error: CircleCloseFilled,
+  info: InfoFilled,
+};
+
+export const ValidateComponentsMap = {
+  validating: Loading,
+  success: CircleCheck,
+  error: CircleClose,
+};

+ 6 - 1
packages/utils/vue/index.ts

@@ -1 +1,6 @@
-export * from './install';
+export * from './global-node';
+export * from './install';
+export * from './props';
+export * from './refs';
+export * from './typescript';
+export * from './vnode';

+ 42 - 42
packages/utils/vue/install.ts

@@ -1,42 +1,42 @@
-import { NOOP } from '@vue/shared';
-
-import type { App } from 'vue';
-import type { SFCInstallWithContext, SFCWithInstall } from './typescript';
-
-export const withInstall = <T, E extends Record<string, any>>(main: T, extra?: E) => {
-  (main as SFCWithInstall<T>).install = (app): void => {
-    for (const comp of [main, ...Object.values(extra ?? {})]) {
-      app.component(comp.name, comp);
-    }
-  };
-
-  if (extra) {
-    for (const [key, comp] of Object.entries(extra)) {
-      (main as any)[key] = comp;
-    }
-  }
-  return main as SFCWithInstall<T> & E;
-};
-
-export const withInstallFunction = <T>(fn: T, name: string) => {
-  (fn as SFCWithInstall<T>).install = (app: App) => {
-    (fn as SFCInstallWithContext<T>)._context = app._context;
-    app.config.globalProperties[name] = fn;
-  };
-
-  return fn as SFCInstallWithContext<T>;
-};
-
-export const withInstallDirective = <T>(directive: T, name: string) => {
-  (directive as SFCWithInstall<T>).install = (app: App): void => {
-    app.directive(name, directive);
-  };
-
-  return directive as SFCWithInstall<T>;
-};
-
-export const withNoopInstall = <T>(component: T) => {
-  (component as SFCWithInstall<T>).install = NOOP;
-
-  return component as SFCWithInstall<T>;
-};
+import { NOOP } from '@vue/shared';
+
+import type { App } from 'vue';
+import type { SFCInstallWithContext, SFCWithInstall } from './typescript';
+
+export const withInstall = <T, E extends Record<string, any>>(main: T, extra?: E) => {
+  (main as SFCWithInstall<T>).install = (app): void => {
+    for (const comp of [main, ...Object.values(extra ?? {})]) {
+      app.component(comp.name, comp);
+    }
+  };
+
+  if (extra) {
+    for (const [key, comp] of Object.entries(extra)) {
+      (main as any)[key] = comp;
+    }
+  }
+  return main as SFCWithInstall<T> & E;
+};
+
+export const withInstallFunction = <T>(fn: T, name: string) => {
+  (fn as SFCWithInstall<T>).install = (app: App) => {
+    (fn as SFCInstallWithContext<T>)._context = app._context;
+    app.config.globalProperties[name] = fn;
+  };
+
+  return fn as SFCInstallWithContext<T>;
+};
+
+export const withInstallDirective = <T>(directive: T, name: string) => {
+  (directive as SFCWithInstall<T>).install = (app: App): void => {
+    app.directive(name, directive);
+  };
+
+  return directive as SFCWithInstall<T>;
+};
+
+export const withNoopInstall = <T>(component: T) => {
+  (component as SFCWithInstall<T>).install = NOOP;
+
+  return component as SFCWithInstall<T>;
+};

+ 3 - 0
packages/utils/vue/props/index.ts

@@ -0,0 +1,3 @@
+export * from './util';
+export * from './types';
+export * from './runtime';

+ 115 - 0
packages/utils/vue/props/runtime.ts

@@ -0,0 +1,115 @@
+import { warn } from 'vue';
+import { fromPairs } from 'lodash-unified';
+import { isObject } from '../../types';
+import { hasOwn } from '../../objects';
+
+import type { PropType } from 'vue';
+import type {
+  EpProp,
+  EpPropConvert,
+  EpPropFinalized,
+  EpPropInput,
+  EpPropMergeType,
+  IfEpProp,
+  IfNativePropType,
+  NativePropType,
+} from './types';
+
+export const epPropKey = '__epPropKey';
+
+export const definePropType = <T>(val: any): PropType<T> => val;
+
+export const isEpProp = (val: unknown): val is EpProp<any, any, any> =>
+  isObject(val) && !!(val as any)[epPropKey];
+
+/**
+ * @description Build prop. It can better optimize prop types
+ * @description 生成 prop,能更好地优化类型
+ * @example
+  // limited options
+  // the type will be PropType<'light' | 'dark'>
+  buildProp({
+    type: String,
+    values: ['light', 'dark'],
+  } as const)
+  * @example
+  // limited options and other types
+  // the type will be PropType<'small' | 'large' | number>
+  buildProp({
+    type: [String, Number],
+    values: ['small', 'large'],
+    validator: (val: unknown): val is number => typeof val === 'number',
+  } as const)
+  @link see more: https://github.com/element-plus/element-plus/pull/3341
+ */
+export const buildProp = <
+  Type = never,
+  Value = never,
+  Validator = never,
+  Default extends EpPropMergeType<Type, Value, Validator> = never,
+  Required extends boolean = false,
+>(
+  prop: EpPropInput<Type, Value, Validator, Default, Required>,
+  key?: string,
+): EpPropFinalized<Type, Value, Validator, Default, Required> => {
+  // filter native prop type and nested prop, e.g `null`, `undefined` (from `buildProps`)
+  if (!isObject(prop) || isEpProp(prop)) return prop as any;
+
+  const { values, required, default: defaultValue, type, validator } = prop;
+
+  const _validator =
+    values || validator
+      ? (val: unknown) => {
+          let valid = false;
+          let allowedValues: unknown[] = [];
+
+          if (values) {
+            allowedValues = Array.from(values);
+            if (hasOwn(prop, 'default')) {
+              allowedValues.push(defaultValue);
+            }
+            valid ||= allowedValues.includes(val);
+          }
+          if (validator) valid ||= validator(val);
+
+          if (!valid && allowedValues.length > 0) {
+            const allowValuesText = [...new Set(allowedValues)]
+              .map((value) => JSON.stringify(value))
+              .join(', ');
+            warn(
+              `Invalid prop: validation failed${
+                key ? ` for prop "${key}"` : ''
+              }. Expected one of [${allowValuesText}], got value ${JSON.stringify(val)}.`,
+            );
+          }
+          return valid;
+        }
+      : undefined;
+
+  const epProp: any = {
+    type,
+    required: !!required,
+    validator: _validator,
+    [epPropKey]: true,
+  };
+  if (hasOwn(prop, 'default')) epProp.default = defaultValue;
+  return epProp;
+};
+
+export const buildProps = <
+  Props extends Record<
+    string,
+    { [epPropKey]: true } | NativePropType | EpPropInput<any, any, any, any, any>
+  >,
+>(
+  props: Props,
+): {
+  [K in keyof Props]: IfEpProp<
+    Props[K],
+    Props[K],
+    IfNativePropType<Props[K], Props[K], EpPropConvert<Props[K]>>
+  >;
+} =>
+  fromPairs(
+    Object.entries(props).map(([key, option]) => [key, buildProp(option as any, key)]),
+  ) as any;

+ 161 - 0
packages/utils/vue/props/types.ts

@@ -0,0 +1,161 @@
+import type { epPropKey } from './runtime';
+import type { ExtractPropTypes, PropType } from 'vue';
+import type { IfNever, UnknownToNever, WritableArray } from './util';
+
+type Value<T> = T[keyof T];
+
+/**
+ * Extract the type of a single prop
+ *
+ * 提取单个 prop 的参数类型
+ *
+ * @example
+ * ExtractPropType<{ type: StringConstructor }> => string | undefined
+ * ExtractPropType<{ type: StringConstructor, required: true }> => string
+ * ExtractPropType<{ type: BooleanConstructor }> => boolean
+ */
+export type ExtractPropType<T extends object> = Value<
+  ExtractPropTypes<{
+    key: T;
+  }>
+>;
+
+/**
+ * Extracts types via `ExtractPropTypes`, accepting `PropType<T>`, `XXXConstructor`, `never`...
+ *
+ * 通过 `ExtractPropTypes` 提取类型,接受 `PropType<T>`、`XXXConstructor`、`never`...
+ *
+ * @example
+ * ResolvePropType<BooleanConstructor> => boolean
+ * ResolvePropType<PropType<T>> => T
+ **/
+export type ResolvePropType<T> = IfNever<
+  T,
+  never,
+  ExtractPropType<{
+    type: WritableArray<T>;
+    required: true;
+  }>
+>;
+
+/**
+ * Merge Type, Value, Validator types
+ * 合并 Type、Value、Validator 的类型
+ *
+ * @example
+ * EpPropMergeType<StringConstructor, '1', 1> =>  1 | "1" // ignores StringConstructor
+ * EpPropMergeType<StringConstructor, never, number> =>  string | number
+ */
+export type EpPropMergeType<Type, Value, Validator> =
+  | IfNever<UnknownToNever<Value>, ResolvePropType<Type>, never>
+  | UnknownToNever<Value>
+  | UnknownToNever<Validator>;
+
+/**
+ * Handling default values for input (constraints)
+ *
+ * 处理输入参数的默认值(约束)
+ */
+export type EpPropInputDefault<Required extends boolean, Default> = Required extends true
+  ? never
+  : Default extends Record<string, unknown> | Array<any>
+  ? () => Default
+  : (() => Default) | Default;
+
+/**
+ * Native prop types, e.g: `BooleanConstructor`, `StringConstructor`, `null`, `undefined`, etc.
+ *
+ * 原生 prop `类型,BooleanConstructor`、`StringConstructor`、`null`、`undefined` 等
+ */
+export type NativePropType =
+  | ((...args: any) => any)
+  | { new (...args: any): any }
+  | undefined
+  | null;
+export type IfNativePropType<T, Y, N> = [T] extends [NativePropType] ? Y : N;
+
+/**
+ * input prop `buildProp` or `buildProps` (constraints)
+ *
+ * prop 输入参数(约束)
+ *
+ * @example
+ * EpPropInput<StringConstructor, 'a', never, never, true>
+ * ⬇️
+ * {
+    type?: StringConstructor | undefined;
+    required?: true | undefined;
+    values?: readonly "a"[] | undefined;
+    validator?: ((val: any) => boolean) | ((val: any) => val is never) | undefined;
+    default?: undefined;
+  }
+ */
+export type EpPropInput<
+  Type,
+  Value,
+  Validator,
+  Default extends EpPropMergeType<Type, Value, Validator>,
+  Required extends boolean,
+> = {
+  type?: Type;
+  required?: Required;
+  values?: readonly Value[];
+  validator?: ((val: any) => val is Validator) | ((val: any) => boolean);
+  default?: EpPropInputDefault<Required, Default>;
+};
+
+/**
+ * output prop `buildProp` or `buildProps`.
+ *
+ * prop 输出参数。
+ *
+ * @example
+ * EpProp<'a', 'b', true>
+ * ⬇️
+ * {
+    readonly type: PropType<"a">;
+    readonly required: true;
+    readonly validator: ((val: unknown) => boolean) | undefined;
+    readonly default: "b";
+    __epPropKey: true;
+  }
+ */
+export type EpProp<Type, Default, Required> = {
+  readonly type: PropType<Type>;
+  readonly required: [Required] extends [true] ? true : false;
+  readonly validator: ((val: unknown) => boolean) | undefined;
+  [epPropKey]: true;
+} & IfNever<Default, unknown, { readonly default: Default }>;
+
+/**
+ * Determine if it is `EpProp`
+ */
+export type IfEpProp<T, Y, N> = T extends { [epPropKey]: true } ? Y : N;
+
+/**
+ * Converting input to output.
+ *
+ * 将输入转换为输出
+ */
+export type EpPropConvert<Input> = Input extends EpPropInput<
+  infer Type,
+  infer Value,
+  infer Validator,
+  any,
+  infer Required
+>
+  ? EpPropFinalized<Type, Value, Validator, Input['default'], Required>
+  : never;
+
+/**
+ * Finalized conversion output
+ *
+ * 最终转换 EpProp
+ */
+export type EpPropFinalized<Type, Value, Validator, Default, Required> = EpProp<
+  EpPropMergeType<Type, Value, Validator>,
+  UnknownToNever<Default>,
+  Required
+>;
+
+export {};

+ 10 - 0
packages/utils/vue/props/util.ts

@@ -0,0 +1,10 @@
+export type Writable<T> = { -readonly [P in keyof T]: T[P] };
+export type WritableArray<T> = T extends readonly any[] ? Writable<T> : T;
+
+export type IfNever<T, Y = true, N = false> = [T] extends [never] ? Y : N;
+
+export type IfUnknown<T, Y, N> = [unknown] extends [T] ? Y : N;
+
+export type UnknownToNever<T> = IfUnknown<T, never, T>;
+
+export {};

+ 17 - 0
packages/utils/vue/refs.ts

@@ -0,0 +1,17 @@
+import { isFunction } from '../types';
+
+import type { ComponentPublicInstance, Ref } from 'vue';
+
+export type RefSetter = (el: Element | ComponentPublicInstance | undefined) => void;
+
+export const composeRefs = (...refs: (Ref<HTMLElement | undefined> | RefSetter)[]) => {
+  return (el: Element | ComponentPublicInstance | null) => {
+    refs.forEach((ref) => {
+      if (isFunction(ref)) {
+        ref(el as Element | ComponentPublicInstance);
+      } else {
+        ref.value = el as HTMLElement | undefined;
+      }
+    });
+  };
+};

+ 143 - 0
packages/utils/vue/vnode.ts

@@ -0,0 +1,143 @@
+import { Comment, Fragment, Text, createBlock, createCommentVNode, isVNode, openBlock } from 'vue';
+import { camelize, isArray } from '@vue/shared';
+import { hasOwn } from '../objects';
+import { debugWarn } from '../error';
+import type { VNode, VNodeArrayChildren, VNodeChild, VNodeNormalizedChildren } from 'vue';
+
+const SCOPE = 'utils/vue/vnode';
+
+export enum PatchFlags {
+  TEXT = 1,
+  CLASS = 2,
+  STYLE = 4,
+  PROPS = 8,
+  FULL_PROPS = 16,
+  HYDRATE_EVENTS = 32,
+  STABLE_FRAGMENT = 64,
+  KEYED_FRAGMENT = 128,
+  UNKEYED_FRAGMENT = 256,
+  NEED_PATCH = 512,
+  DYNAMIC_SLOTS = 1024,
+  HOISTED = -1,
+  BAIL = -2,
+}
+
+export type VNodeChildAtom = Exclude<VNodeChild, Array<any>>;
+export type RawSlots = Exclude<VNodeNormalizedChildren, Array<any> | null | string>;
+
+export function isFragment(node: VNode): boolean;
+export function isFragment(node: unknown): node is VNode;
+export function isFragment(node: unknown): node is VNode {
+  return isVNode(node) && node.type === Fragment;
+}
+
+export function isText(node: VNode): boolean;
+export function isText(node: unknown): node is VNode;
+export function isText(node: unknown): node is VNode {
+  return isVNode(node) && node.type === Text;
+}
+
+export function isComment(node: VNode): boolean;
+export function isComment(node: unknown): node is VNode;
+export function isComment(node: unknown): node is VNode {
+  return isVNode(node) && node.type === Comment;
+}
+
+const TEMPLATE = 'template';
+export function isTemplate(node: VNode): boolean;
+export function isTemplate(node: unknown): node is VNode;
+export function isTemplate(node: unknown): node is VNode {
+  return isVNode(node) && node.type === TEMPLATE;
+}
+
+/**
+ * determine if the element is a valid element type rather than fragments and comment e.g. <template> v-if
+ * @param node {VNode} node to be tested
+ */
+export function isValidElementNode(node: VNode): boolean;
+export function isValidElementNode(node: unknown): node is VNode;
+export function isValidElementNode(node: unknown): node is VNode {
+  return isVNode(node) && !isFragment(node) && !isComment(node);
+}
+
+/**
+ * get a valid child node (not fragment nor comment)
+ * @param node {VNode} node to be searched
+ * @param depth {number} depth to be searched
+ */
+function getChildren(
+  node: VNodeNormalizedChildren | VNodeChild,
+  depth: number,
+): VNodeNormalizedChildren | VNodeChild {
+  if (isComment(node)) return;
+  if (isFragment(node) || isTemplate(node)) {
+    return depth > 0 ? getFirstValidNode(node.children, depth - 1) : undefined;
+  }
+  return node;
+}
+
+export const getFirstValidNode = (nodes: VNodeNormalizedChildren, maxDepth = 3) => {
+  if (Array.isArray(nodes)) {
+    return getChildren(nodes[0], maxDepth);
+  } else {
+    return getChildren(nodes, maxDepth);
+  }
+};
+
+export function renderIf(condition: boolean, ...args: Parameters<typeof createBlock>) {
+  return condition ? renderBlock(...args) : createCommentVNode('v-if', true);
+}
+
+export function renderBlock(...args: Parameters<typeof createBlock>) {
+  return openBlock(), createBlock(...args);
+}
+
+export const getNormalizedProps = (node: VNode) => {
+  if (!isVNode(node)) {
+    debugWarn(SCOPE, '[getNormalizedProps] must be a VNode');
+    return {};
+  }
+
+  const raw = node.props || {};
+  const type = (isVNode(node.type) ? node.type.props : undefined) || {};
+  const props: Record<string, any> = {};
+
+  Object.keys(type).forEach((key) => {
+    if (hasOwn(type[key], 'default')) {
+      props[key] = type[key].default;
+    }
+  });
+
+  Object.keys(raw).forEach((key) => {
+    props[camelize(key)] = raw[key];
+  });
+
+  return props;
+};
+
+export const ensureOnlyChild = (children: VNodeArrayChildren | undefined) => {
+  if (!isArray(children) || children.length > 1) {
+    throw new Error('expect to receive a single Vue element child');
+  }
+  return children[0];
+};
+
+export type FlattenVNodes = Array<VNodeChildAtom | RawSlots>;
+
+export const flattedChildren = (
+  children: FlattenVNodes | VNode | VNodeNormalizedChildren,
+): FlattenVNodes => {
+  const vNodes = isArray(children) ? children : [children];
+  const result: FlattenVNodes = [];
+
+  vNodes.forEach((child) => {
+    if (isArray(child)) {
+      result.push(...flattedChildren(child));
+    } else if (isVNode(child) && isArray(child.children)) {
+      result.push(...flattedChildren(child.children));
+    } else {
+      result.push(child);
+    }
+  });
+  return result;
+};

+ 3 - 0
playground/index.html

@@ -5,6 +5,9 @@
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>kankan component</title>
+    <script src="//4dkk.4dage.com/v4/sdk/4.2.2/kankan-sdk-deps.js"></script>
+    <script src="//4dkk.4dage.com/v4/sdk/4.2.2/kankan-sdk.js"></script>
+ 
   </head>
   <body>
     <div id="app"></div>

+ 2 - 0
playground/package.json

@@ -9,6 +9,8 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@kankan/components": "workspace:^1.0.0",
+    "@kankan/sdk": "4.3.0-alpha.10",
     "vue": "^3.2.37"
   },
   "devDependencies": {

+ 50 - 12
playground/src/App.vue

@@ -1,19 +1,57 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+  import { onMounted } from 'vue';
+  import { KKAudio } from '@kankan/components';
+  // import { h } from 'vue';
+  // import * as KanKanSDK from '@kankan/sdk';
+  // console.log('KKAudio', KanKanSDK);
+
+  onMounted(async () => {
+    const KanKan = (window as any).KanKan;
+    const app = new KanKan({
+      // dom: '#scene',
+      num: 'KJ-JYo2ZZyKKJ',
+    });
+    app.mount('#scene').render();
+
+    // console.log('TagView', await TagView);
+    // const el = h(KKAudio, {
+    //   src: 'http://samplelib.com/lib/preview/mp3/sample-3s.mp3',
+    // });
+    // app.use(KKAudio);
+
+    console.log(app);
+
+    // console.log('kankan', KKAudio);
+    // const res = await kankan.use(KKAudio.render());
+    // console.log('11', res);
+    // app.use('TagEditor');
+
+    const TagView = await app.use('TagView', {
+      render: (data) => {
+        console.log('data', data.type);
+      },
+    });
+    TagView.on('click', (event) => {
+      console.log('event', event);
+      debugger;
+    });
+
+    console.log('TagView', TagView);
+    // .then(function () {
+    //   console.log(arguments);
+    // });
+  });
+</script>
 
 <template>
-  <div></div>
+  <div id="scene">
+    <KKAudio src="http://samplelib.com/lib/preview/mp3/sample-3s.mp3" />
+  </div>
 </template>
 
 <style scoped>
-  .logo {
-    height: 6em;
-    padding: 1.5em;
-    will-change: filter;
-  }
-  .logo:hover {
-    filter: drop-shadow(0 0 2em #646cffaa);
-  }
-  .logo.vue:hover {
-    filter: drop-shadow(0 0 2em #42b883aa);
+  #scene {
+    width: 100vw;
+    height: 100vh;
   }
 </style>

+ 5 - 61
playground/src/style.css

@@ -15,67 +15,11 @@
   -webkit-text-size-adjust: 100%;
 }
 
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-a:hover {
-  color: #535bf2;
-}
-
+html,
 body {
+  width: 100%;
+  height: 100%;
   margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
-  min-height: 100vh;
-}
-
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-button:hover {
-  border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
-  padding: 2em;
-}
-
-#app {
-  max-width: 1280px;
-  margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
-}
-
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #ffffff;
-  }
-  a:hover {
-    color: #747bff;
-  }
-  button {
-    background-color: #f9f9f9;
-  }
+  overflow: hidden;
+  padding: 0;
 }

File diff suppressed because it is too large
+ 688 - 54
pnpm-lock.yaml


+ 18 - 0
tsconfig.node.json

@@ -0,0 +1,18 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "composite": true,
+    "lib": ["ESNext"],
+    "types": ["node"],
+    "skipLibCheck": true
+  },
+  "include": [
+    // "internal/**/*",
+    // "internal/**/*.json",
+    // "scripts/**/*",
+    // "packages/theme-chalk/*",
+    // "packages/element-plus/version.ts",
+    // "packages/element-plus/package.json"
+  ],
+  "exclude": ["**/__tests__/**", "**/tests/**", "**/dist"]
+}