diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a68ecb6 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,17 @@ +*.sh +node_modules +*.md +*.woff +*.ttf +.vscode +.idea +*/dist +/public +/docs +.husky +.local +/bin +Dockerfile +.npmrc +config +*.config.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..0865207 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,11 @@ +{ + "plugins": ["prettier"], + "extends": ["taro", "plugin:react/jsx-runtime", "plugin:prettier/recommended"], + "rules": { + "react-hooks/exhaustive-deps": 0, + "prettier/prettier": 2, + "@typescript-eslint/no-unused-vars": 2, + "import/no-commonjs": 0, + "@typescript-eslint/no-shadow": 0 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0820734 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +.DS_Store +.history +.idea +node_modules +package-lock.json +*/dist +lib +*.zip + +#packages +/packages/hooks +/packages/utils + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor directornd files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ce242c1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +/dist/* +.local +.output.js +/node_modules/** + +**/*.svg +**/*.sh + +/public/* + +.md +.npmrc +*/dist diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f088add --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true, + "quoteProps": "as-needed", + "bracketSpacing": true, + "trailingComma": "all", + "jsxSingleQuote": true, + "arrowParens": "always", + "insertPragma": false, + "requirePragma": false, + "proseWrap": "never", + "endOfLine": "auto" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ba9705 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +## **ygp-taro-react-design** + +基于taro react的跨端ui库 + +目录结构 + +```json +taro-demo 组件库小程序demo +packages 包文件夹 + components 组件 + hooks 钩子函数 + utils 工具类 + styles 样式 +site 文档静态站点 + +``` + +###### **组件列表** diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..c192b34 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,27 @@ +module.exports = { + ignores: [(commit) => commit.includes('init')], + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [1, 'always'], + 'header-max-length': [2, 'always', 108], + 'subject-empty': [2, 'never'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'feat', //新增功能 + 'fix', //缺陷修复 + 'perf', //优化相关 + 'style', //格式 + 'docs', //文档 + 'test', //测试 + 'refactor', //功能重构 + 'build', //版本构建 + 'chore', //构建过程或辅助工具的变动。 + 'revert', //回滚到上一个版本 + ], + ], + }, +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e5539b2 --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "ygp-taro-react-design", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev:weapp": "cd taro-demo && npm run dev:weapp", + "dev:h5": "cd taro-demo && npm run dev:h5", + "build": "cd packages && npm run build", + "publish": "cd packages && npm run publish", + "prepare": "husky install" + }, + "repository": { + "type": "git", + "url": "http://gitlab.yigongpin.net/ygp-bciscm-frontend/ygp-taro-react-design.git" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.7.7", + "@tarojs/components": "3.4.3", + "@tarojs/plugin-framework-react": "3.4.3", + "@tarojs/react": "3.4.3", + "@tarojs/runtime": "3.4.3", + "@tarojs/taro": "3.4.3", + "async-validator": "^4.2.5", + "classnames": "^2.3.1", + "lodash": "^4.17.21", + "rc-field-form": "^1.27.1", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "devDependencies": { + "@babel/core": "^7.8.0", + "@babel/eslint-parser": "^7.18.9", + "@commitlint/cli": "^17.1.2", + "@commitlint/config-conventional": "^17.1.0", + "@rollup/plugin-typescript": "^8.3.3", + "@tarojs/mini-runner": "3.4.3", + "@tarojs/webpack-runner": "3.4.3", + "@types/react": "^17.0.2", + "@types/uuid": "^8.3.4", + "@types/webpack-env": "^1.13.6", + "@typescript-eslint/eslint-plugin": "^5.6.0", + "@typescript-eslint/parser": "^5.6.0", + "babel-preset-taro": "3.4.3", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^8.23.0", + "eslint-config-prettier": "^8.5.0", + "eslint-config-taro": "^3.4.3", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-react": "^7.31.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-taro": "^3.3.20", + "fs-extra": "^8.1.0", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", + "prettier": "^2.7.1", + "rimraf": "^2.7.1", + "rollup": "^2.79.1", + "rollup-plugin-copy": "^3.4.0", + "rollup-plugin-typescript2": "^0.34.1", + "stylelint": "^14.4.0", + "tslib": "^2.4.0", + "typescript": "^4.8.4" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint --fix", + "prettier --write", + "eslint" + ], + "*.less": [ + "prettier --write" + ] + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} \ No newline at end of file diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..3d422d2 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,3 @@ +## **ygp-taro-react-design** + +基于taro react的跨端ui库 diff --git a/packages/build/after-build.js b/packages/build/after-build.js new file mode 100644 index 0000000..a5537c2 --- /dev/null +++ b/packages/build/after-build.js @@ -0,0 +1,11 @@ +import fs from 'fs-extra' +import { resolveFile, modules } from './scripts.js' + +const refs = modules.map((dir) => `/// `) +const dtsPath = resolveFile('dist/index.d.ts') +const dts = await fs.readFileSync(dtsPath, 'utf-8').split(/\r\n|\n|\r/gm) +const newDts = refs.concat(dts).join('\r\n') + +fs.writeFileSync(dtsPath, newDts) + +console.info('success!') diff --git a/packages/build/before-build.js b/packages/build/before-build.js new file mode 100644 index 0000000..d4ce330 --- /dev/null +++ b/packages/build/before-build.js @@ -0,0 +1,11 @@ +import fs from 'fs-extra' +import { resolveFile } from './scripts.js' + +const Package = await fs.readJSON('package.json') +const files = Package.files + +// 删除文件夹 +files.forEach((f) => { + const folderPath = resolveFile(f) + fs.removeSync(folderPath) +}) diff --git a/packages/build/scripts.js b/packages/build/scripts.js new file mode 100644 index 0000000..3dbf1c5 --- /dev/null +++ b/packages/build/scripts.js @@ -0,0 +1,7 @@ +import path from 'path' + +const cwd = process.cwd() + +export const modules = ['hooks'] + +export const resolveFile = (filePath) => path.join(cwd, filePath) diff --git a/packages/package.json b/packages/package.json new file mode 100644 index 0000000..bf6b48f --- /dev/null +++ b/packages/package.json @@ -0,0 +1,31 @@ +{ + "name": "taro-react-form", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "typings": "dist/index.d.ts", + "scripts": { + "build": "node build/before-build.js && rollup -c rollup.config.js && node build/after-build.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/RickTam/taro-react-form.git" + }, + "publishConfig": { + "registry": "http://localhost:4873/" + }, + "files": [ + "dist", + "hooks", + "utils" + ], + "author": "tanyufeng", + "license": "ISC", + "dependencies": { + "async-validator": "^4.2.5", + "classnames": "^2.3.1", + "rc-field-form": "^1.27.1" + } +} diff --git a/packages/rollup.config.js b/packages/rollup.config.js new file mode 100644 index 0000000..2bef208 --- /dev/null +++ b/packages/rollup.config.js @@ -0,0 +1,66 @@ +import RollupTypescript from 'rollup-plugin-typescript2' +import RollupCopy from 'rollup-plugin-copy' +import { resolveFile, modules } from './build/scripts.js' + +const externalPackages = [ + 'classnames', + 'react', + 'react-dom', + '@tarojs/components', + '@tarojs/runtime', + '@tarojs/taro', + '@tarojs/react', + 'rc-field-form', +] + +const genConfig = (module) => { + return { + input: resolveFile(`src/${module}/index.ts`), + output: [ + { + file: resolveFile(`${module}/index.js`), + format: 'esm', + }, + ], + plugins: [ + RollupTypescript({ + tsconfig: resolveFile(`src/${module}/tsconfig.json`), + }), + ], + } +} + +export default [ + ...modules.map((m) => genConfig(m)), + { + input: resolveFile('src/index.ts'), + output: [ + { + file: resolveFile('dist/index.js'), + format: 'esm', + sourcemap: false, + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + ], + external: externalPackages, + extensions: ['json', 'js', 'ts'], + plugins: [ + RollupTypescript({ + clean: true, + tsconfig: resolveFile('tsconfig.json'), + }), + RollupCopy({ + verbose: true, + targets: [ + { + src: resolveFile('src/styles'), + dest: resolveFile('dist'), + }, + ], + }), + ], + }, +] diff --git a/packages/src/components/button/index.tsx b/packages/src/components/button/index.tsx new file mode 100644 index 0000000..d8f4994 --- /dev/null +++ b/packages/src/components/button/index.tsx @@ -0,0 +1,214 @@ +import { CSSProperties, FC } from 'react' +import Taro from '@tarojs/taro' +import { ButtonProps as TaroButtonProps } from '@tarojs/components/types/Button' +import { View, Button as TaroButton, Form } from '@tarojs/components' +import { BaseEventOrig, CommonEvent, ITouchEvent } from '@tarojs/components/types/common' +import classNames from 'classnames' +import { isFunction } from '../../utils/is' + +export interface ButtonProps extends Omit { + /** + *按钮类型 + * @defalt default + */ + type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' + /** + *自定义classname + */ + className?: string + /** + * 是否填充背景 + * @default false + */ + fill?: boolean + /** + * 自动充满父容器 + * @default false + */ + full?: boolean + /** + * 按钮尺寸大小 + */ + size?: 'large' | 'normal' | 'small' | 'mini' + /** + * 是否圆角 + * @default normal + */ + round?: string + /** + * 按钮颜色 + */ + color?: string + /** + * 按钮填充颜色 + */ + fillColor?: string + /** + * 是否使用边框 + */ + border?: boolean + /** + * 边框颜色 + */ + borderColor?: string + /** + * 按钮自定义圆角 + */ + radius?: number + /** + * 自定义样式对象 + */ + style?: CSSProperties + /** + * 设置按钮为禁用态(不可点击) + */ + disabled?: boolean +} + +const Button: FC = (props) => { + const isWEB = Taro.getEnv() === Taro.ENV_TYPE.WEB + const isWEAPP = Taro.getEnv() === Taro.ENV_TYPE.WEAPP + + const { + type = 'default', + size = 'normal', + className, + radius = 0, + fill, + full, + lang, + round, + disabled, + formType, + openType, + color, + border, + borderColor, + fillColor, + style, + sessionFrom, + sendMessageTitle, + sendMessagePath, + sendMessageImg, + showMessageCard, + appParameter, + } = props + + const rootClassName = [`ygp-button`, `ygp-button--${type}`] + const classObject = { + [`ygp-button--${size}`]: size, + [`ygp-button--disabled`]: disabled, + [`ygp-button--full`]: full, + [`ygp-button--${round}`]: round, + [`ygp-button--fill`]: fill, + [`ygp-button--${type}--border`]: border && type, + // 'slc-button__no-border': (fill || fillColor) && !borderColor, + } + + const onClick = (event: ITouchEvent) => { + if (!props.disabled) { + isFunction(props.onClick) && props.onClick(event) + } + } + + const onGetUserInfo = (event: CommonEvent) => { + isFunction(props.onGetUserInfo) && props.onGetUserInfo(event) + } + + const onContact = (event: BaseEventOrig) => { + isFunction(props.onContact) && props.onContact(event) + } + + const onGetPhoneNumber = (event: CommonEvent) => { + isFunction(props.onGetPhoneNumber) && props.onGetPhoneNumber(event) + } + + const onError = (event: CommonEvent) => { + isFunction(props.onError) && props.onError(event) + } + + const onOpenSetting = (event: CommonEvent) => { + isFunction(props.onOpenSetting) && props.onOpenSetting(event) + } + + const onSumit = (event: CommonEvent) => { + if (isWEAPP || isWEB) { + // @ts-ignore + // eslint-disable-next-line no-undef + $scope.triggerEvent('submit', event.detail, { + bubbles: true, + composed: true, + }) + } + } + + const onReset = (event: CommonEvent) => { + if (isWEAPP || isWEB) { + // @ts-ignore + // eslint-disable-next-line no-undef + $scope.triggerEvent('reset', event.detail, { + bubbles: true, + composed: true, + }) + } + } + + const selfColor: any = { color, borderColor, fillColor } + if (fillColor && border) { + selfColor.color = fillColor + selfColor.borderColor = fillColor + selfColor.fillColor = 'none' + } + if (fillColor && !border) { + selfColor.color = '#fff' + selfColor.border = 'none' + } + const borderColorObj = selfColor.borderColor ? { 'border-color': selfColor.borderColor } : {} + const background = selfColor.fillColor ? { background: selfColor.fillColor } : {} + const mergedStyle = { + ...style, + 'border-radius': radius, + color: selfColor.color, + ...borderColorObj, + ...background, + } + const webButton = + + const button = ( + + ) + + return ( + + {isWEB && !disabled && webButton} + {isWEAPP && !disabled && ( +
+ {button} +
+ )} + {props.children} +
+ ) +} + +export default Button diff --git a/packages/src/components/cascader/index.tsx b/packages/src/components/cascader/index.tsx new file mode 100644 index 0000000..3ff42f7 --- /dev/null +++ b/packages/src/components/cascader/index.tsx @@ -0,0 +1,180 @@ +import { FC, useEffect, useState, useCallback, useMemo } from 'react' +import { View, ScrollView } from '@tarojs/components' +import classnames from 'classnames' +import { isArray } from '../../utils/is' +import Popup from '../popup' +import type { Props as PopupProps } from '../popup' + +type CascaderOptionType = { + label? + value? + children? + [key: string]: any +} + +export interface CascaderProps + extends Omit { + value?: (string | number)[] + className?: string + options: CascaderOptionType[] + fieldMaps?: CascaderOptionType + autoClose?: boolean + poppable?: boolean + onChange?: ( + value: (string | number)[] | undefined, + selectedOptions: CascaderOptionType[], + currentOption: Object, + ) => void +} + +const Cascader: FC = (props) => { + const { + value, + className, + options, + fieldMaps, + poppable = true, + autoClose = false, + visible, + onClose, + onChange, + } = props + const [selected, setSeleted] = useState(undefined) + const [renderOptions, setRenderOptions] = useState([]) + const [hasChild, setHasChild] = useState(true) + + const mergedFieldMaps = useMemo(() => { + return { + label: 'label', + value: 'value', + children: 'children', + ...fieldMaps, + } + }, []) + + useEffect(() => { + if (visible) { + if (value?.length && value.every((v) => v)) { + const selectedOpts = getSelected(value, options) + setRenderOptions(selectedOpts[selectedOpts.length - 2][mergedFieldMaps.children]) + setSeleted(selectedOpts) + setHasChild(false) + } else { + setRenderOptions(options) + setHasChild(true) + setSeleted(undefined) + } + } + }, [visible, value]) + + const getSelected = useCallback((val, data) => { + let arr: any = [] + val?.forEach((el) => { + let option = data.find((item) => item[mergedFieldMaps.value] === el) + if (option) { + arr.push(option) + if (option[mergedFieldMaps.children]) data = option[mergedFieldMaps.children] + } + }) + return arr + }, []) + + useEffect(() => { + setRenderOptions(options) + }, []) + + const onClick = useCallback( + (item) => { + let selectedOpts = selected || [] + const children = item[mergedFieldMaps.children] + if (children && isArray(children) && children.length) { + setRenderOptions(children) + setHasChild(true) + selectedOpts = selectedOpts.concat([item]) + } else { + let lastIndex = selectedOpts.length - 1 + let lastEl = selectedOpts[lastIndex] + let lastChildren = lastEl[mergedFieldMaps.children] + if (lastChildren && isArray(lastChildren) && lastChildren.length) { + selectedOpts = selectedOpts?.concat([item]) + } else { + selectedOpts.splice(lastIndex, 1, item) + } + setHasChild(false) + const val = selectedOpts.map((e) => e[mergedFieldMaps.value]) + onChange?.(val, selectedOpts, item) + autoClose && onClose?.() + } + setSeleted(selectedOpts) + }, + [onChange, selected, autoClose], + ) + + const onTabClick = useCallback( + (index) => { + if (index === 0) { + setRenderOptions(options) + setSeleted(undefined) + setHasChild(true) + } else { + // @ts-ignore + const children = selected[index - 1][mergedFieldMaps.children] + setRenderOptions(children as CascaderOptionType[]) + setSeleted(selected?.splice(0, index)) + setHasChild(true) + } + }, + [options, selected], + ) + + const cascaderClassName = useMemo(() => classnames('ygp-cascader', className), [className]) + + const renderChild = useMemo(() => { + return ( + + + {selected?.map((item, index) => { + return ( + onTabClick(index)} + > + {item[mergedFieldMaps.label]} + + ) + })} + {hasChild && 请选择} + + + + {renderOptions.map((item) => { + return ( + onClick(item)} + > + {item[mergedFieldMaps.label]} + + ) + })} + + + + ) + }, [selected, mergedFieldMaps, hasChild, renderOptions]) + + return poppable ? ( + + {renderChild} + + ) : ( + renderChild + ) +} +Cascader.displayName = 'Cascader' + +export default Cascader diff --git a/packages/src/components/cell/index.tsx b/packages/src/components/cell/index.tsx new file mode 100644 index 0000000..5fe4c10 --- /dev/null +++ b/packages/src/components/cell/index.tsx @@ -0,0 +1,60 @@ +import { View, Image } from '@tarojs/components' +import { FC, useCallback, useMemo } from 'react' +import { ITouchEvent } from '@tarojs/components/types/common' +import classNames from 'classnames' + +export type CellProps = { + className?: string + title?: string + desc?: string + arrow?: boolean + border?: boolean + onClick?: (event: ITouchEvent) => void +} + +const Cell: FC = (props) => { + const { children, className = '', title, desc, border = false, arrow = true, onClick } = props + const cellClassName = useMemo( + () => + classNames( + 'ygp-cell', + + className, + ), + [className], + ) + + const handleClick = useCallback( + (event: ITouchEvent) => { + onClick?.(event) + }, + [onClick], + ) + + return ( + + + + + {title} + + + + {children ? children : desc} + {arrow ? ( + + ) : null} + + + + ) +} + +export default Cell diff --git a/packages/src/components/checkbox/index.tsx b/packages/src/components/checkbox/index.tsx new file mode 100644 index 0000000..21b0256 --- /dev/null +++ b/packages/src/components/checkbox/index.tsx @@ -0,0 +1,88 @@ +import { View } from '@tarojs/components' +import { CSSProperties, FC, useCallback, useMemo } from 'react' +import classNames from 'classnames' + +export type CheckOptionType = { label?; value?; [key: string]: any } + +export type CheckboxProps = { + /** value 选中激活的key */ + value: (string | number)[] + /** 指定一行几个元素 */ + columnNum?: 2 | 3 | 4 + /** 是否多选,默认:false */ + isMultiple?: boolean + /** 选项列表 */ + options: CheckOptionType[] + /** 自定义激活属性 */ + activeStyle?: CSSProperties + /** 字段映射关系, 可部分覆盖 */ + fieldMaps?: Partial + /** 选择改变触发方法 */ + onChange: (active?: (number | string)[], option?: CheckOptionType) => void +} +/** + * 按钮形式的checkbox + */ +const CheckBox: FC = (props) => { + const { + isMultiple = false, + options, + onChange, + value = [], + columnNum = 4, + activeStyle, + fieldMaps, + } = props + + const mergedFieldMaps = useMemo(() => { + return { + label: 'label', + value: 'value', + ...fieldMaps, + } + }, [fieldMaps]) + + const itemClick = useCallback( + (item) => { + if (!value.some((ac) => ac == item[mergedFieldMaps.value])) { + if (!isMultiple) { + onChange([item[mergedFieldMaps.value]], item) + } else { + onChange([...value, item[mergedFieldMaps.value]], item) + } + } else { + let arr: (string | number)[] = [] + for (let i = 0; i < value.length; i++) { + if (value[i] == item[mergedFieldMaps.value]) { + arr = [...value] + arr.splice(i, 1) + break + } + } + onChange(arr, item) + } + }, + [value], + ) + + return ( + + {options?.map((item) => ( + ac == item[mergedFieldMaps.value]) ? activeStyle : '' + } + className={classNames('check-item', `check-item-row-${columnNum}`, { + 'item-active': value.length && value.some((ac) => ac == item[mergedFieldMaps.value]), + })} + onClick={() => itemClick(item)} + > + {item[mergedFieldMaps.label]} + + ))} + + ) +} + +export default CheckBox diff --git a/packages/src/components/config-provider/index.tsx b/packages/src/components/config-provider/index.tsx new file mode 100644 index 0000000..f1455ff --- /dev/null +++ b/packages/src/components/config-provider/index.tsx @@ -0,0 +1,21 @@ +import { createContext, FC } from 'react' + +export type ConfigProviderProps = { + uploadOptions?: { + getTokens?: (...arg) => Promise + getPrivateUrls?: (...arg) => Promise + getBciscmPrivateUrl?: (...arg) => Promise + } + [key: string]: any +} + +export const ConfigContext = createContext({}) + +ConfigContext.displayName = 'ConfigContext' + +const ConfigProvider: FC = (props) => { + const { children, ...rest } = props + return {children} +} + +export default ConfigProvider diff --git a/packages/src/components/date-picker/index.tsx b/packages/src/components/date-picker/index.tsx new file mode 100644 index 0000000..774f52d --- /dev/null +++ b/packages/src/components/date-picker/index.tsx @@ -0,0 +1,281 @@ +import Taro from '@tarojs/taro' +import { View, PickerView, PickerViewColumn } from '@tarojs/components' +import { FC, useCallback, useEffect, useState, useMemo } from 'react' +import Button from '../button' +import FooterArea from '../footer-area' +import Popup from '../popup' +import Tabs, { TabItemType } from '../tabs' +import Utils from '../../utils/date' +import type { Props as PopupProps } from '../popup' +import { isNumber } from '../../utils/is' + +type dateType = string | Date + +export interface DatePickerProps extends Omit { + /** 选中的数据 */ + value?: dateType | dateType[] + /** 是否数据改变自动关闭弹窗 */ + autoClose?: boolean + /** 时间选择的类型,单选或范围选择, 默认为单个 */ + mode?: 'one' | 'range' + /** 数据改变的回调 */ + onChange?: (date: string | string[]) => void + /** 点击确认的回调事件 */ + onConfirm?: (date: string | string[]) => void +} + +enum RangeDateEnum { + start, + end, +} + +const DatePicker: FC = (props) => { + const { value, height = '50%', mode = 'one', autoClose, onClose, onConfirm, onChange } = props + const [date, setDate] = useState(undefined) + const [activeKey, setActivekey] = useState(RangeDateEnum.start) + + const years = useMemo(() => { + const years: number[] = [] + // 获取当前年份 + let currentYear = new Date().getFullYear() + // 十年前 + let minYear = currentYear - 10 + // 十年后 + let maxYear = currentYear + 10 + while (minYear < maxYear) { + years.push(minYear) + minYear++ + } + return years + }, []) + + const months = useMemo(() => Array.from({ length: 12 }).map((_, index) => index + 1), []) + + const getDays = useCallback( + (type?) => { + let curYear + let curMonth + if (mode === 'range') { + if (date?.[type]) { + curYear = years[date[type][0]] + curMonth = months[date[type][1]] + } else { + curYear = years[0] + curMonth = months[0] + } + } else { + if (date) { + curYear = years[(date as number[])[0]] + curMonth = months[(date as number[])[1]] + } else { + curYear = years[0] + curMonth = months[0] + } + } + + const monthDay = Utils.getMonthDays(curYear + '', curMonth + '') + return Array.from({ length: monthDay }).map((_, index) => index + 1) + }, + [date], + ) + + useEffect(() => { + if (value) { + if (mode === 'range') { + let start = date2Index(value[RangeDateEnum.start]) + let end = date2Index(value[RangeDateEnum.end]) + setDate([start, end]) + setActivekey(RangeDateEnum.start) + } else { + setDate(date2Index(value)) + } + } else { + setDate(undefined) + setActivekey(RangeDateEnum.start) + } + }, [value]) + + const date2Index = useCallback((date) => { + const newDate = new Date(date) + const yearIdx = years.findIndex((item) => item === newDate.getFullYear()) + const monthIdx = months.findIndex((item) => item === newDate.getMonth() + 1) + const dayIdx = getDays().findIndex((item) => item === newDate.getDate()) + return [yearIdx, monthIdx, dayIdx] + }, []) + + const index2Str = useCallback((index: number[], type?) => { + return `${years[index[0]]}-${Utils.getNumTwoBit(months[index[1]])}-${Utils.getNumTwoBit( + getDays(type)[index[2]], + )}` + }, []) + + const confirm = useCallback(() => { + let datestr + if (date) { + datestr = index2Str(date as number[]) + } else { + datestr = date + } + onConfirm?.(datestr) + onChange?.(datestr) + autoClose && onClose?.() + }, [date]) + + const rangeConfirm = useCallback(() => { + if (activeKey === RangeDateEnum.start) { + if (date?.[RangeDateEnum.start]) { + setActivekey(RangeDateEnum.end) + } else { + Taro.showToast({ + title: '请选择开始时间', + icon: 'none', + }) + } + } else { + // 如果没选择开始时间, 回到选择开始时间 + if (!date?.[RangeDateEnum.start]) { + setActivekey(RangeDateEnum.start) + return + } + if (date?.[RangeDateEnum.end]) { + let startDate = index2Str(date[RangeDateEnum.start] as number[], RangeDateEnum.start) + let endDate = index2Str(date[RangeDateEnum.end] as number[], RangeDateEnum.end) + if (new Date(startDate).valueOf() > new Date(endDate).valueOf()) { + Taro.showToast({ + title: '结束时间不能小于开始时间', + icon: 'none', + }) + } else { + onConfirm?.([startDate, endDate]) + onChange?.([startDate, endDate]) + autoClose && onClose?.() + } + } else { + Taro.showToast({ + title: '请选择结束时间', + icon: 'none', + }) + } + } + }, [date, activeKey]) + + const onPickerChange = useCallback( + (e, type) => { + if (isNumber(type)) { + if (type === RangeDateEnum.start) { + setDate([e.detail.value]) + } + if (type === RangeDateEnum.end) { + setDate([date?.[RangeDateEnum.start], e.detail.value]) + } + } else { + setDate(e.detail.value) + } + }, + [date], + ) + + const renderPickerView = useCallback( + (type?) => { + const value = (mode === 'one' ? date : date?.[type]) as number[] + const days = getDays(type) + return ( + + { + onPickerChange(e, type) + }} + > + <> + {/* 年 */} + + {years?.map((item) => ( + + {item}年 + + ))} + + {/* 月 */} + + {months?.map((item) => ( + + {item}月 + + ))} + + + {days?.map((item) => ( + + {item}日 + + ))} + + + + + ) + }, + [years, months, date], + ) + + const tabItems: TabItemType[] = useMemo(() => { + return [ + { + label: date?.[RangeDateEnum.start] + ? index2Str(date?.[RangeDateEnum.start] as number[], RangeDateEnum.start) + : '开始时间', + key: RangeDateEnum.start, + children: renderPickerView(RangeDateEnum.start), + }, + { + label: date?.[RangeDateEnum.end] + ? index2Str(date?.[RangeDateEnum.end] as number[], RangeDateEnum.end) + : '结束时间', + key: RangeDateEnum.end, + children: renderPickerView(RangeDateEnum.end), + }, + ] + }, [date]) + + const onTabChange = useCallback((e) => { + setActivekey(e.key) + }, []) + + return ( + + + {mode === 'one' ? ( + renderPickerView() + ) : ( + + )} + + + {mode === 'one' && ( + + )} + {mode === 'range' && ( + + )} + + + + ) +} + +DatePicker.displayName = 'DatePicker' + +export default DatePicker diff --git a/packages/src/components/field/Field.tsx b/packages/src/components/field/Field.tsx new file mode 100644 index 0000000..7d6ccd9 --- /dev/null +++ b/packages/src/components/field/Field.tsx @@ -0,0 +1,116 @@ +import { Input, InputProps, CommonEventFunction } from '@tarojs/components' +import { FC } from '@tarojs/taro' +import { CSSProperties, ReactElement, ReactNode } from 'react' +import { FieldLayout, ValueAlign } from '../form/context' +import { isNumber } from '../../utils/is' +import FieldWrap from './FieldWrap' + +export interface FieldProps extends InputProps { + type?: any + /** 自定义className */ + className?: string + /** label文案 */ + label?: string + /** label宽度 */ + labelWidth?: number + /** label对齐方式 */ + labelAlign?: ValueAlign + /** value对齐方式 */ + valueAlign?: ValueAlign + /** 只读状态 */ + readonly?: boolean + /** 是否需要清除功能 */ + clear?: boolean + /** 是否必填 */ + required?: boolean + /** 如果为true,则渲染可点击区域和箭头 */ + isLink?: boolean + /** 数据是否出错 */ + error?: boolean + /** 错误文案 */ + errorMsg?: string + /** label插槽 */ + labelSlot?: ReactNode + /** 右边插槽 */ + rightSlot?: ReactNode + /** value区域插槽 */ + valueSlot?: ReactNode + /** 自定义样式 */ + style?: CSSProperties + /** 数字精度, type为number或digit时才生效 */ + precision?: number + /** 最小值, type为number或digit时才生效 */ + min?: number + /** 最大值, type为number或digit时才生效 */ + max?: number + /** 布局,默认:horizontal */ + layout?: FieldLayout + /** 是否需要点击hover,默认:true */ + clickHover?: boolean + /** 自定义点击hover样式 */ + hoverClass?: string + /** 点击hover展示到消失的持续时间(单位毫秒),默认600ms*/ + hoverSustainTime?: number + /** 字段间的下边距, 默认false*/ + gap?: boolean + /** value改变时的回调 */ + // onChange?: CommonEventFunction + // TODO 根据类型推导 先解决报错 后面再处理 + onChange?: Function + /** 点击清除按钮的回调 */ + onClear?: CommonEventFunction + /** 点击field区域的回调 */ + onClick?: CommonEventFunction +} + +const Field: FC = (props): ReactElement => { + const { type, min, max, precision, readonly } = props + return ( + + {(fieldProps) => { + const { value, onChange, onBlur, ...restFieldProps } = fieldProps + const handelBlur = (e) => { + // 如果是数字类型 + if (e.detail.value && (type === 'number' || type === 'digit')) { + const num = Number(e.detail.value) + if (Number.isFinite(num)) { + if (isNumber(min) && num < min) { + e.detail.value = min + '' + } else if (isNumber(max) && num > max) { + e.detail.value = max + '' + } else { + if (isNumber(precision)) { + e.detail.value = Number(num.toFixed(precision)) + } else { + e.detail.value = num + } + } + } else { + e.detail.value = undefined + } + onChange?.(e) + } + onBlur?.(e) + } + return ( + <> + {readonly ? ( + value + ) : ( + + )} + + ) + }} + + ) +} + +export default Field diff --git a/packages/src/components/field/FieldWrap.tsx b/packages/src/components/field/FieldWrap.tsx new file mode 100644 index 0000000..590f21c --- /dev/null +++ b/packages/src/components/field/FieldWrap.tsx @@ -0,0 +1,293 @@ +import { + FC, + ReactElement, + ReactNode, + useCallback, + useMemo, + isValidElement, + cloneElement, + useState, +} from 'react' +import { View, Label, Image } from '@tarojs/components' +import classNames from 'classnames' +import { isArray, isFunction, isDate, isNullOrUnDef } from '../../utils/is' +import { FieldProps } from './Field' +import Utils from '../../utils/date' + +interface FieldWrapType extends Omit { + options?: any[] + renderLinkChild?: boolean + onChange?: Function + [key: string]: any +} + +const FieldWrap: FC = (props) => { + const { + value, + label, + className, + required, + readonly, + labelWidth = 80, + isLink, + error, + errorMsg, + placeholder, + labelSlot, + rightSlot, + valueSlot, + style, + layout = 'horizontal', + labelAlign = 'left', + valueAlign = layout === 'horizontal' ? 'right' : 'left', + clickHover = true, + hoverClass, + hoverSustainTime = 300, + children, + clear = true, + onChange, + onClear, + onClick, + // picker相关 + options, + renderLinkChild, + fieldMaps, + mode, + gap, + ...restFieldProps + } = props + + const [visible, setVisible] = useState(false) + + const mergedFieldMaps = useMemo(() => { + return { + label: 'label', + value: 'value', + children: 'children', + ...fieldMaps, + } + }, [fieldMaps]) + + const fieldClassName = useMemo(() => { + return classNames( + 'ygp-field', + { + 'ygp-field-error': error, + 'ygp-field-required': required, + 'ygp-field-gap': gap, + }, + className, + ) + }, [className, error, required]) + + const labelStyle = useMemo(() => { + return { + width: `${labelWidth}px`, + textAlign: labelAlign, + } + }, [labelAlign, labelWidth]) + + const valueStyle = useMemo(() => { + return { + textAlign: valueAlign, + } + }, [valueAlign]) + + const rightContent = useMemo(() => { + if (!rightSlot) { + return false + } + if (isFunction(rightSlot)) { + return rightSlot() + } + return rightSlot + }, [rightSlot]) + + const valueContent = useMemo(() => { + if (valueSlot) { + if (isFunction(valueSlot)) { + return valueSlot(value) + } else { + return valueSlot + } + } else { + return false + } + }, [valueSlot, value]) + + const labelCotent = useMemo(() => { + if (labelSlot) { + if (isFunction(labelSlot)) { + return labelSlot() + } else { + return labelSlot + } + } else { + return label + } + }, [label, labelSlot]) + + const handleClick = useCallback( + (e) => { + if (!readonly) { + onClick?.(e) + setVisible(true) + } + }, + [readonly, children], + ) + + const handleClear = useCallback((e) => { + // @ts-ignore + onChange?.(undefined) + onClear?.(e) + }, []) + + const getCascaderValue = useCallback((val, data) => { + let text = '' + isArray(val) && + val?.forEach((el) => { + let option = data.find((item) => item[mergedFieldMaps.value] === el) + if (option) { + text += option[mergedFieldMaps.label] + if (option[mergedFieldMaps.children]) data = option[mergedFieldMaps.children] + } + }) + return text + }, []) + + const getValue = useCallback(() => { + let val: any = value + // 如果有options,匹配name + if (!isNullOrUnDef(val)) { + // @ts-ignore + switch (children.type.displayName) { + case 'Cascader': + val = getCascaderValue(value, options) + break + case 'Picker': + let option = options?.find((item) => item[mergedFieldMaps.value] === value) + val = option?.[mergedFieldMaps.label] + break + case 'DatePicker': + // 时间范围选择 + if (mode === 'range') { + let start = val[0] + let end = val[1] + val = [ + isDate(start) ? Utils.date2Str(start) : start, + isDate(end) ? Utils.date2Str(end) : end, + ].join('~') + } else { + // 判断是否是日期类型, 如果是日期类型转为字符串 + if (isDate(value)) { + val = Utils.date2Str(value) + } + } + break + } + } + return readonly ? val : val || placeholder + }, [readonly, options, value, mode]) + + const renderLink = useCallback<() => ReactNode>( + () => ( + + + {getValue()} + + {!readonly && ( + + )} + + ), + [value, placeholder, readonly, renderLinkChild, visible, options], + ) + + const renderChild = () => { + const childProps = { + value, + placeholder, + readonly, + onChange, + options, + visible, + onClose: () => { + setVisible(false) + }, + fieldMaps, + mode, + ...restFieldProps, + } + if (isFunction(children)) { + return children(childProps) + } else if (isValidElement(children)) { + return cloneElement(children as ReactElement, { ...childProps, ...children.props }) + } else { + console.warn('children of FieldWrap is not valid ReactElement.') + } + } + + const renderFieldContent = () => { + let childNode: ReactNode + // 如果是link状态, 直接渲染link样式 + if (isLink) { + childNode = renderLink() + } else { + childNode = renderChild() + } + return childNode + } + + return ( + + + + + + + + + {valueContent ? valueContent : renderFieldContent()} + + {clear && !readonly && !isNullOrUnDef(value) && ( + + )} + {rightContent && ( + {rightContent} + )} + + {error && {errorMsg || ''}} + + + {renderLinkChild && renderChild()} + + ) +} + +export default FieldWrap diff --git a/packages/src/components/field/index.tsx b/packages/src/components/field/index.tsx new file mode 100644 index 0000000..9ee13d5 --- /dev/null +++ b/packages/src/components/field/index.tsx @@ -0,0 +1,6 @@ +import Field from './Field' +import FieldWrap from './FieldWrap' +import type { FieldProps } from './Field' + +export { FieldProps, FieldWrap } +export default Field diff --git a/packages/src/components/footer-area/index.tsx b/packages/src/components/footer-area/index.tsx new file mode 100644 index 0000000..cd10f87 --- /dev/null +++ b/packages/src/components/footer-area/index.tsx @@ -0,0 +1,44 @@ +import { CSSProperties, FC, useMemo } from 'react' +import { View } from '@tarojs/components' +import classNames from 'classnames' +import { useSafeBottom } from '../../hooks' + +type FooterButton = { + // id + id?: string + // 自定义class + className?: string + // 背景是否透明, 默认false + transparent?: boolean + // 自定义style + style?: CSSProperties +} + +const FooterArea: FC = (props) => { + const { children, className, transparent = false, style, id } = props + const { safeAreaPadding: paddingBottom, safeBottom } = useSafeBottom() + + const classes = useMemo( + () => + classNames('ygp-footer-area', { + 'ygp-footer-area-transparent': transparent, + }), + [transparent], + ) + + const fixedClasses = useMemo(() => classNames('ygp-footer-area-fixed', className), []) + + return ( + + + {children} + + + ) +} + +export default FooterArea diff --git a/packages/src/components/form/Form.tsx b/packages/src/components/form/Form.tsx new file mode 100644 index 0000000..036c246 --- /dev/null +++ b/packages/src/components/form/Form.tsx @@ -0,0 +1,266 @@ +import { forwardRef, ForwardRefRenderFunction, Fragment, useImperativeHandle, useMemo } from 'react' +import RcForm, { FormInstance, useForm } from 'rc-field-form' +import type { FormProps as RcFormProps } from 'rc-field-form/es/Form' +import { FieldProps as RcFieldProps } from 'rc-field-form/es/Field' +import { InputProps } from '@tarojs/components' +import classNames from 'classnames' +import FieldWrap from '../field/FieldWrap' +import FormItem, { FormItemProps } from './FormItem' +import { FieldLayout, FormContext, ValueAlign } from './context' +import Field, { FieldProps } from '../field' +import Textarea, { TextareaProps } from '../textarea' +import Swtich from '../switch' +import Picker, { PickerProps } from '../picker' +import Checkbox, { CheckboxProps } from '../checkbox' +import Cascader, { CascaderProps } from '../cascader' +import DatePicker, { DatePickerProps } from '../date-picker' + +export type BaseFormItem = InputProps & + RcFieldProps & + FieldProps & + FormItemProps & + TextareaProps & + Partial & + Partial & + Partial & + Partial +export interface FormItemType extends Omit { + type?: + | InputProps['type'] + | 'textarea' + | 'switch' + | 'upload' + | 'picker' + | 'cascader' + | 'checkbox' + | 'date' + //TODO 根据组件类型去推导onChange的类型 + onChange?: Function +} +export interface FormProps extends Omit, 'form'> { + name?: string + /** 自定义className */ + className?: string + /** 只读状态 */ + readonly?: boolean + /** label宽度 */ + labelWidth?: number + /** value对齐方式 */ + valueAlign?: ValueAlign + /** useForm的返回值 */ + form?: FormInstance + /** 表单配置 */ + items?: FormItemType[] + /** 布局,默认:horizontal */ + layout?: FieldLayout + /** 是否需要点击hover,默认:true */ + clickHover?: boolean + /** 自定义点击hover样式*/ + hoverClass?: string + /** 点击hover展示到消失的持续时间(单位毫秒),默认600ms*/ + hoverSustainTime?: number +} + +const InternalForm: ForwardRefRenderFunction = (props, ref) => { + const { + className = '', + readonly, + name, + labelWidth, + valueAlign, + layout, + clickHover, + hoverClass, + hoverSustainTime, + form, + items, + children, + // rc-field-form默认容器是form标签, + // taro某些环境没有form模版导致form整体渲染失败,具体还不清楚为什么某些环境没有form模版 + // 把容器改为view做兼容处理 + component = 'view', + ...restFormProps + } = props + + const [wrapForm] = useForm(form) + + useImperativeHandle(ref, () => wrapForm) + + const formClassName = classNames('ygp-form', className) + + const formContextValue = useMemo(() => { + return { + name, + readonly, + valueAlign, + labelWidth, + form: wrapForm, + layout, + clickHover, + hoverClass, + hoverSustainTime, + items, + } + }, [ + name, + readonly, + layout, + clickHover, + hoverClass, + hoverSustainTime, + valueAlign, + labelWidth, + wrapForm, + items, + ]) + + // 通过配置渲染表单 + const renderFormItem = () => { + return ( + + {items?.map((item) => { + const { + name: formItemName, + type, + trigger, + required, + isLink, + validateTrigger, + ...restItemProps + } = item + let { rules, placeholder } = item + let msgPrefix = '请输入' + + switch (type) { + case 'checkbox': + case 'switch': + case 'cascader': + case 'picker': + case 'date': + msgPrefix = '请选择' + break + case 'upload': + msgPrefix = '请上传' + break + } + + if (!placeholder) { + placeholder = msgPrefix + } + + if (required) { + if ( + rules && + !rules.every( + (rule) => rule && typeof rule === 'object' && rule.required && !rule.warningOnly, + ) + ) { + // @ts-ignore + rules = rules.concat([{ required: true, message: `${msgPrefix}${item.label || ''}` }]) + } else { + rules = [{ required: true, message: `${msgPrefix}${item.label || ''}` }] + } + } + let formItemChild + const formItemChildProp = { + ...restItemProps, + placeholder, + isLink, + type, + } + + switch (type) { + case 'date': + formItemChild = ( + + {/* @ts-ignore */} + + + ) + break + case 'checkbox': + formItemChild = ( + + {/* @ts-ignore */} + + + ) + break + case 'switch': + formItemChild = ( + + {(feildProps) => ( + + )} + + ) + break + case 'cascader': + formItemChild = ( + + {/* @ts-ignore */} + + + ) + break + case 'picker': + formItemChild = ( + + {/* @ts-ignore */} + + + ) + break + case 'textarea': + formItemChild = ( + +