├── test ├── src │ ├── autogrid │ │ ├── index.css │ │ └── index.jsx │ ├── image-picker │ │ ├── index.css │ │ └── index.jsx │ ├── dialog │ │ ├── index.module.css │ │ └── index.jsx │ ├── privacy │ │ ├── index.css │ │ └── index.jsx │ ├── scrollview │ │ ├── index.css │ │ └── index.jsx │ ├── clickable │ │ ├── index.css │ │ └── index.jsx │ ├── index.jsx │ ├── indicator │ │ └── index.jsx │ ├── overlay │ │ └── index.jsx │ ├── index.css │ ├── loading │ │ └── index.jsx │ ├── ago │ │ └── index.jsx │ ├── carouse-notice │ │ └── index.jsx │ ├── index │ │ └── index.jsx │ ├── toast │ │ └── index.jsx │ ├── countdown │ │ └── index.jsx │ └── alert │ │ └── index.jsx ├── jsconfig.json ├── .gitignore ├── index.html ├── vite.config.js ├── package.json ├── eslint.config.js ├── README.md └── public │ └── vite.svg ├── .vscode └── settings.json ├── .npmignore ├── src ├── Indicator │ ├── style.ts │ └── index.tsx ├── Effect │ ├── useUpdate.ts │ ├── useTick.ts │ ├── useInterval.ts │ ├── useWindowResize.ts │ └── useViewport.ts ├── utils │ ├── uniqKey.ts │ ├── defaultScroll.ts │ ├── dom.tsx │ ├── jsonp.ts │ ├── tick.ts │ ├── wait.ts │ ├── calendarTable.ts │ ├── ago.ts │ ├── cssUtil.ts │ ├── is.ts │ ├── Countdown.ts │ ├── createApp.tsx │ └── request.ts ├── context.ts ├── ScrollView │ ├── style.ts │ └── index.tsx ├── CarouselNotice │ ├── style.ts │ └── index.tsx ├── AutoGrid │ ├── style.ts │ └── index.tsx ├── Ago │ └── index.tsx ├── SafeArea │ └── index.tsx ├── Loading │ ├── style.ts │ ├── index.tsx │ └── Wrapper.tsx ├── Alert │ ├── index.tsx │ ├── style.ts │ └── Wrapper.tsx ├── Toast │ ├── index.tsx │ ├── Toast.tsx │ └── style.ts ├── Flex │ ├── index.tsx │ ├── Row.tsx │ └── Col.tsx ├── Dialog │ ├── index.tsx │ ├── Wrapper.tsx │ └── style.ts ├── index.ts ├── Countdowner │ └── index.tsx ├── Overlay │ └── index.tsx ├── Clickable │ └── index.tsx └── Container │ └── index.tsx ├── LICENSE ├── .gitignore ├── package.json ├── tsconfig.json └── README.md /test/src/autogrid/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/src/image-picker/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "editor.tabSize": 2 4 | } -------------------------------------------------------------------------------- /test/src/dialog/index.module.css: -------------------------------------------------------------------------------- 1 | .contentBox { 2 | width: 7.5rem * 0.8; 3 | height: 4rem; 4 | background: goldenrod; 5 | } 6 | -------------------------------------------------------------------------------- /test/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["../build/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /scripts/ 2 | /examples/ 3 | /.vscode/ 4 | node_modules/ 5 | tsconfig.json 6 | package-lock.json 7 | /.npmignore 8 | /.gitignore 9 | /src/ 10 | .DS_Store -------------------------------------------------------------------------------- /test/src/privacy/index.css: -------------------------------------------------------------------------------- 1 | .Privacy { 2 | .item { 3 | border-bottom: 1px solid #f0f0f0; 4 | strong { 5 | color: blue; 6 | } 7 | i { 8 | font-size: .12rem; 9 | color: #af2d07; 10 | font-style: normal; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/src/scrollview/index.css: -------------------------------------------------------------------------------- 1 | .ScrollView { 2 | height: 400px; 3 | background-color: #f4f5f6; 4 | padding: 0 0.1rem; 5 | border: 1px solid #000; 6 | .content { 7 | height: 500px; 8 | background-image: linear-gradient(to top, #fff, green); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Indicator/style.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@emotion/react'; 2 | 3 | /** 4 | * 获取转圈每一条bar的过渡动画 5 | * @param color 6 | */ 7 | export function getBarChangeKeyFrames(color: string) { 8 | return keyframes` 9 | from { 10 | fill: ${color}; 11 | } 12 | to { 13 | fill: transparent; 14 | } 15 | `; 16 | } 17 | -------------------------------------------------------------------------------- /src/Effect/useUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from "react"; 2 | 3 | const updateReducer = (num: number): number => (num + 1) % 1_000_000; 4 | 5 | /** 6 | * 返回一个函数,调用该函数,组件会刷新一次 7 | * @returns 8 | */ 9 | export function useUpdate(): () => void { 10 | const [, update] = useReducer(updateReducer, 0); 11 | 12 | return update; 13 | } 14 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/utils/uniqKey.ts: -------------------------------------------------------------------------------- 1 | let keyIndex = 0; 2 | let last = Date.now(); 3 | 4 | /** 5 | * 生成一个全局唯一的key 6 | * @returns 7 | */ 8 | export function uniqKey() { 9 | keyIndex += 1; 10 | let now = Date.now(); 11 | if (now !== last && keyIndex > 1e9) { 12 | keyIndex = 0; 13 | } 14 | const key = now.toString(36) + keyIndex.toString(36); 15 | last = now; 16 | return key; 17 | } 18 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | test 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Effect/useTick.ts: -------------------------------------------------------------------------------- 1 | import { tick } from '../utils/tick'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | /** 5 | * 逐帧执行的ticker 6 | * @param frame 帧函数 7 | * @param interval 执行间隔 8 | */ 9 | export function useTick(frame: () => void) { 10 | const framer = useRef(frame); 11 | framer.current = frame; 12 | useEffect(() => { 13 | const stop = tick(() => framer.current()); 14 | return () => stop(); 15 | }, []); 16 | } 17 | -------------------------------------------------------------------------------- /test/src/clickable/index.css: -------------------------------------------------------------------------------- 1 | .Touchable { 2 | padding: 0.1rem; 3 | user-select: none; 4 | .btntest { 5 | margin-top: 0.5rem; 6 | background-color: gray; 7 | padding: 0.1rem; 8 | > div { 9 | padding: 0.1rem; 10 | margin: 0.1rem; 11 | background-color: #f0f0f0; 12 | color: red; 13 | } 14 | } 15 | 16 | .as { 17 | margin-top: 0.2rem; 18 | background-color: gray; 19 | padding: 0.1rem; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Effect/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useInterval(callback: () => void, delay?: number | null) { 4 | const savedCallback = useRef<() => void>(callback); 5 | savedCallback.current = callback; 6 | 7 | useEffect(() => { 8 | if (delay !== null) { 9 | const interval = setInterval(() => savedCallback.current(), delay || 0); 10 | return () => clearInterval(interval); 11 | } 12 | }, [delay]); 13 | } 14 | -------------------------------------------------------------------------------- /test/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "../build/index"), 11 | react: path.resolve(__dirname, "node_modules/react"), 12 | "react-dom": path.resolve(__dirname, "node_modules/react-dom"), 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | export interface ContextValue { 2 | // 最大文档尺寸 3 | maxDocWidth: number; 4 | // 最小文档尺寸 5 | minDocWidth: number; 6 | } 7 | 8 | let context: ContextValue = { 9 | maxDocWidth: 576, 10 | minDocWidth: 312, 11 | }; 12 | 13 | /** 14 | * 设置环境变量的值 15 | * @param envValue 16 | */ 17 | export function setContextValue(value: Partial) { 18 | if (typeof context === "object") { 19 | context = { ...context, ...value }; 20 | } 21 | } 22 | 23 | /** 24 | * 获取环境变量值 25 | * @param key 26 | * @returns 27 | */ 28 | export function getContextValue(key?: keyof ContextValue) { 29 | if (key && typeof key === "string") { 30 | return context[key]; 31 | } 32 | return context; 33 | } 34 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.1", 14 | "react-dom": "^19.1.1" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.36.0", 18 | "@types/react": "^19.1.16", 19 | "@types/react-dom": "^19.1.9", 20 | "@vitejs/plugin-react": "^5.0.4", 21 | "eslint": "^9.36.0", 22 | "eslint-plugin-react-hooks": "^5.2.0", 23 | "eslint-plugin-react-refresh": "^0.4.22", 24 | "globals": "^16.4.0", 25 | "vite": "^7.1.7" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/src/autogrid/index.jsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import React from "react"; 3 | import { AutoGrid } from "@"; 4 | 5 | export default function Index() { 6 | return ( 7 |
8 |

简单测试

9 | 10 |
3143214112
11 | {[1, 2, 3]}, . :1 12 | 2 13 | 3 14 | {false} 15 | {null} 16 | {undefined} 17 | {0} 18 | {[]} 19 | {""} 20 | {true} 21 | {() => <>123} 22 |

最后一个

23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /test/src/index.jsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import React from 'react'; 3 | import { createApp, history } from '@'; 4 | import Home from './index/index'; 5 | 6 | createApp({ 7 | target: "#root", 8 | // maxDocWidth: 10000, 9 | async render(pathname) { 10 | let page = await import(`./${pathname}/index.jsx`); 11 | if (pathname === 'index') { 12 | return ; 13 | } 14 | return ( 15 | <> 16 |
17 | 24 |
25 |
26 | 27 |
28 | 29 | ); 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src/ScrollView/style.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme } from "@emotion/react"; 2 | import { adaptive } from "../utils/cssUtil"; 3 | 4 | export const style: Record> = { 5 | container: { 6 | overflow: "auto", 7 | height: "100%", 8 | WebkitOverflowScrolling: "touch", 9 | }, 10 | loading: [ 11 | adaptive({ 12 | paddingTop: 15, 13 | paddingBottom: 15, 14 | }), 15 | { 16 | "> div": adaptive({ 17 | width: 30, 18 | height: 30, 19 | }), 20 | "> p": [ 21 | { 22 | color: "#666", 23 | lineHeight: 1, 24 | }, 25 | adaptive({ 26 | marginLeft: 16, 27 | fontSize: 24, 28 | }), 29 | ], 30 | }, 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /test/src/indicator/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Indicator } from "@"; 3 | 4 | export default function Index () { 5 | return ( 6 |
7 |

常规圆角条

8 | 9 |

直角条

10 | 11 |

绿色条

12 | 13 |

转圈速度变慢的绿色条

14 | 15 |

20条

16 | 17 |

条宽度变大

18 | 19 |

条宽度和高度一致

20 | 21 |

指示器尺寸变小

22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/CarouselNotice/style.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation, keyframes, Theme } from "@emotion/react"; 2 | import { adaptive } from "../utils/cssUtil"; 3 | 4 | export const Bubble = keyframes` 5 | from { 6 | transform: translateY(0); 7 | } 8 | to { 9 | transform: translateY(-50%); 10 | } 11 | `; 12 | 13 | export const style: Record> = { 14 | box: [ 15 | { 16 | position: "relative", 17 | overflow: "hidden", 18 | transition: "all 200ms", 19 | }, 20 | adaptive({ 21 | height: 80, 22 | }), 23 | ], 24 | wrapper: { 25 | position: "absolute", 26 | left: 0, 27 | top: 0, 28 | width: "100%", 29 | height: "200%", 30 | }, 31 | item: { 32 | width: "100%", 33 | height: "50%", 34 | display: "flex", 35 | alignItems: "center", 36 | fontSize: "initial", 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/AutoGrid/style.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | 3 | export const style = { 4 | itemBoxStyle: css({ 5 | float: "left", 6 | position: "relative", 7 | "&:last-child": { 8 | marginRight: 0, 9 | }, 10 | }), 11 | itemBoxSquare: css({ 12 | "&:after,&::after": { 13 | content: "''", 14 | display: "block", 15 | height: 0, 16 | // 这个属性是设置块为方形的关键 17 | paddingBottom: "100%", 18 | }, 19 | }), 20 | itemNull: css({ 21 | visibility: "hidden", 22 | }), 23 | itemInnerStyle: css({ 24 | position: "absolute", 25 | left: 0, 26 | right: 0, 27 | bottom: 0, 28 | top: 0, 29 | }), 30 | 31 | // 行样式 32 | rowStyle: css({ 33 | width: "100%", 34 | "&:after,&::after": { 35 | content: "''", 36 | display: "table", 37 | height: 0, 38 | clear: "both" 39 | } 40 | }) 41 | }; 42 | -------------------------------------------------------------------------------- /test/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import { defineConfig, globalIgnores } from 'eslint/config' 6 | 7 | export default defineConfig([ 8 | globalIgnores(['dist']), 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | extends: [ 12 | js.configs.recommended, 13 | reactHooks.configs['recommended-latest'], 14 | reactRefresh.configs.vite, 15 | ], 16 | languageOptions: { 17 | ecmaVersion: 2020, 18 | globals: globals.browser, 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | ecmaFeatures: { jsx: true }, 22 | sourceType: 'module', 23 | }, 24 | }, 25 | rules: { 26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 27 | }, 28 | }, 29 | ]) 30 | -------------------------------------------------------------------------------- /test/src/overlay/index.jsx: -------------------------------------------------------------------------------- 1 | import { Overlay } from "@"; 2 | import { useState } from "react"; 3 | 4 | export default function Index () { 5 | const [show1, setShow1] = useState(false); 6 | const [show2, setShow2] = useState(false); 7 | 8 | return ( 9 | <> 10 |

11 | 12 |

13 |

14 | 17 |

18 | 19 | {show1 && ( 20 | setShow1(false)}> 21 |
Overlay显示在虚拟DOM中插入的位置
22 |
23 | )} 24 | {show2 && ( 25 | setShow2(false)}> 26 |
Overlay显示在最外面,默认body下新创建的DOM中
27 |
28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /test/src/index.css: -------------------------------------------------------------------------------- 1 | 2 | .backHome { 3 | padding: 0.3rem; 4 | border-bottom: 1px solid #ccc; 5 | } 6 | .Home { 7 | margin: 0; 8 | padding: 0; 9 | list-style-type: none; 10 | .item, 11 | .item-disable { 12 | font-size: 0.36rem; 13 | padding: 0.2rem 0.3rem; 14 | position: relative; 15 | border-bottom: 1px solid #f5f5f5; 16 | svg { 17 | width: 0.48rem; 18 | position: absolute; 19 | top: 50%; 20 | right: 0.3rem; 21 | transform: translateY(-50%); 22 | } 23 | } 24 | .item-disable { 25 | color: #ddd; 26 | svg { 27 | path { 28 | fill: #ddd; 29 | } 30 | } 31 | } 32 | .item { 33 | svg { 34 | path { 35 | fill: #666; 36 | } 37 | } 38 | &:active { 39 | color: #007bff; 40 | opacity: 0.5; 41 | svg path { 42 | fill: #007bff; 43 | } 44 | } 45 | } 46 | } 47 | .demo { 48 | padding: 0.3rem; 49 | } -------------------------------------------------------------------------------- /test/src/loading/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { showLoading, showLoadingAtLeast } from "@"; 3 | 4 | export default function Index () { 5 | return ( 6 |
7 |

普通加载(5秒后自动关闭)

8 | 16 |

普通加载带提示文字(5秒后自动关闭)

17 | 25 |

至少展示2000毫秒

26 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/Ago/index.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { ago, AgoValue } from '../utils/ago'; 3 | 4 | export interface AgoProps 5 | extends React.HTMLProps { 6 | // 将要格式化显示的日期,任意的dayjs识别的格式 7 | date?: dayjs.ConfigType; 8 | // 是否显示为块div,默认为span 9 | block?: boolean; 10 | // 定制内容 11 | renderContent?: (result: AgoValue) => React.ReactNode; 12 | } 13 | 14 | export function Ago(props: AgoProps) { 15 | const { date = dayjs(), block = false, renderContent, ...attrs } = props; 16 | const agoValue = ago(date); 17 | 18 | // 格式化内容 19 | let content: string | React.ReactNode = agoValue.format; 20 | if (typeof renderContent === 'function') { 21 | content = renderContent(agoValue); 22 | } 23 | 24 | // 是否显示为块元素 25 | if (block) { 26 | return
)}>{content}
; 27 | } 28 | 29 | // 默认显示行内元素 30 | return {content}; 31 | } 32 | -------------------------------------------------------------------------------- /test/src/ago/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Ago } from "@"; 3 | 4 | export default function Index() { 5 | return ( 6 |
7 |

2019-11-2

8 | 9 |
10 |

2020-06-02

11 | 12 |
13 |

2018/06/02

14 | 15 |
16 |

2018/07/06 12:04:36

17 | 18 |
19 |

Date.now()

20 | 21 |
22 |

new Date("2012-07-16 12:30:06")

23 | 24 |
25 |

(时间戳:毫秒)1583314718595

26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/SafeArea/index.tsx: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme } from "@emotion/react"; 2 | import { useViewport } from "../Effect/useViewport"; 3 | 4 | export interface SafeAreaProps extends React.HTMLProps { 5 | children?: React.ReactNode; 6 | // 目前只支持常见的顶部和底部 7 | type?: "top" | "bottom"; 8 | } 9 | 10 | export function SafeArea(props: SafeAreaProps) { 11 | const { children, type = "bottom", ...extra } = props; 12 | 13 | useViewport({ viewportFit: "cover" }); 14 | 15 | let boxCss: Interpolation = {}; 16 | if (type === "top") { 17 | boxCss = { 18 | height: [ 19 | `constant(safe-area-inset-top, 0)`, 20 | `env(safe-area-inset-top, 0)`, 21 | ], 22 | }; 23 | } else if (type === "bottom") { 24 | boxCss = { 25 | height: [ 26 | `constant(safe-area-inset-bottom, 0)`, 27 | `env(safe-area-inset-bottom, 0)`, 28 | ], 29 | }; 30 | } 31 | 32 | return ( 33 |
34 | {children} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /test/src/privacy/index.jsx: -------------------------------------------------------------------------------- 1 | // import './index.css'; 2 | // import React from 'react'; 3 | // import { PrivacyContent } from '@'; 4 | 5 | // export default function () { 6 | // return ( 7 | //
8 | //
9 | //

10 | // 13456567657(手机号) 11 | //
12 | // 包含模式:从第3位到第7位 13 | //

14 | //

15 | // 16 | // 13456567657 17 | // 18 | //

19 | //
20 | //
21 | //

22 | // 420281199509164579(身份证号) 23 | //
24 | // 排除模式:排除开头5位和结尾3位 25 | //

26 | //

27 | // 28 | // 420281199509164579 29 | // 30 | //

31 | //
32 | //
33 | // ); 34 | // } 35 | -------------------------------------------------------------------------------- /src/utils/defaultScroll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 检测是否支持passive事件绑定 3 | */ 4 | export let passiveSupported = false; 5 | try { 6 | window.addEventListener( 7 | 'test', 8 | () => undefined, 9 | Object.defineProperty({}, 'passive', { 10 | get: function () { 11 | passiveSupported = true; 12 | }, 13 | }), 14 | ); 15 | // eslint-disable-next-line no-empty 16 | } catch (err) {} 17 | 18 | /** 19 | * 触摸移动事件处理器 20 | */ 21 | const touchMoveHandler = (event: TouchEvent) => { 22 | event.preventDefault(); 23 | }; 24 | 25 | /** 26 | * 禁用和启用默认滚动 27 | */ 28 | export const defaultScroll = { 29 | disable() { 30 | document.documentElement.addEventListener( 31 | 'touchmove', 32 | touchMoveHandler, 33 | passiveSupported ? { capture: false, passive: false } : false, 34 | ); 35 | }, 36 | enable() { 37 | document.documentElement.removeEventListener( 38 | 'touchmove', 39 | touchMoveHandler, 40 | passiveSupported ? { capture: false } : false, 41 | ); 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 joye 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Loading/style.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation, keyframes, Theme } from "@emotion/react"; 2 | import { adaptive } from "../utils/cssUtil"; 3 | 4 | export const LoadingShow = keyframes` 5 | from { 6 | opacity: 0; 7 | } 8 | to { 9 | opacity: 1; 10 | } 11 | `; 12 | export const LoadingHide = keyframes` 13 | from { 14 | opacity: 1; 15 | } 16 | to { 17 | opacity: 0; 18 | } 19 | `; 20 | 21 | export const style: Record> = { 22 | boxCommon: adaptive({ 23 | backgroundColor: `rgba(0, 0, 0, .8)`, 24 | borderRadius: 16, 25 | }), 26 | box: adaptive({ 27 | width: 160, 28 | height: 160, 29 | }), 30 | boxShow: { 31 | animation: `${LoadingShow} 200ms`, 32 | }, 33 | boxHide: { 34 | animation: `${LoadingHide} 200ms`, 35 | }, 36 | boxWithExtra: [ 37 | adaptive({ padding: 30 }), 38 | { 39 | "> div:first-of-type": adaptive({ 40 | width: 48, 41 | height: 48, 42 | }), 43 | }, 44 | ], 45 | hint: adaptive({ 46 | color: "#f5f5f5dd", 47 | whiteSpace: "nowrap", 48 | fontSize: 28, 49 | marginLeft: 20, 50 | }), 51 | }; 52 | -------------------------------------------------------------------------------- /src/utils/dom.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | export interface PortalDOM { 5 | element: HTMLDivElement; 6 | mount: (component: React.ReactNode) => void; 7 | unmount: () => void; 8 | } 9 | 10 | /** 11 | * 12 | * 组件可以通过函数的第一个参数传递进去 13 | * 14 | * @param point HTMLElement 挂载点,如果未指定,则挂载点为body 15 | * @returns CreatePortalDOMResult 16 | */ 17 | export function createPortalDOM(point?: HTMLElement): PortalDOM { 18 | const container = document.createElement('div'); 19 | let mountPoint: HTMLElement = document.body; 20 | if (point instanceof HTMLElement) { 21 | mountPoint = point; 22 | } 23 | mountPoint.appendChild(container); 24 | const root = createRoot(container); 25 | 26 | return { 27 | element: container, 28 | mount(component) { 29 | root.render(component); 30 | }, 31 | unmount() { 32 | root.unmount(); 33 | if (container instanceof HTMLDivElement) { 34 | if (typeof container.remove === 'function') { 35 | container.remove(); 36 | } else { 37 | mountPoint.removeChild(container); 38 | } 39 | } 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/jsonp.ts: -------------------------------------------------------------------------------- 1 | let JSONP_INDEX = 1; 2 | declare const window: Window & { 3 | [key: string]: any; 4 | }; 5 | /** 6 | * 发送jsonp请求 7 | * @param url 8 | * @param callbackName 9 | */ 10 | export async function jsonp( 11 | url: string, 12 | callbackName: string = 'callback', 13 | ): Promise { 14 | return new Promise((resolve, reject) => { 15 | // 生成全局唯一的 16 | JSONP_INDEX += 1; 17 | const funcName = 'jsonp_' + Date.now().toString(36) + '_' + JSONP_INDEX; 18 | 19 | // 修正以//开头的链接,添加跟location相同的schema 20 | if (/^\/\//.test(url)) { 21 | url = window.location.protocol + url; 22 | } 23 | const urlObject = new URL(url); 24 | urlObject.searchParams.set(callbackName, funcName); 25 | 26 | // 创建全局script 27 | const script = document.createElement('script'); 28 | script.src = urlObject.href; 29 | document.body.appendChild(script); 30 | script.onerror = (error) => { 31 | reject(error); 32 | }; 33 | 34 | // 创建全局函数 35 | window[funcName] = (result: any) => { 36 | resolve(result); 37 | // 删除全局函数 38 | delete window[funcName]; 39 | // 删除临时脚本 40 | script.remove(); 41 | }; 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /test/src/clickable/index.jsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import React from 'react'; 3 | import { Clickable } from '@'; 4 | 5 | export default function Index () { 6 | return ( 7 |
8 | 9 | 可点击元素 10 | 内嵌可点击元素 11 | 内嵌可点击元素 bubble=false 12 | 13 | 内嵌 activeStyle 14 | 15 | 19 | 内嵌 activeStyle bubble=false 20 | 21 | 22 | 23 | 27 | activeStyle 28 | 29 | 30 | 35 | activeStyle(禁用) disable=true 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## React Compiler 11 | 12 | The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). 13 | 14 | ## Expanding the ESLint configuration 15 | 16 | If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. 17 | -------------------------------------------------------------------------------- /src/Alert/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { showDialog } from '../Dialog'; 3 | import { AlertWrapper, AlertWrapperProps } from './Wrapper'; 4 | import isPlainObject from 'lodash/isPlainObject'; 5 | import omit from 'lodash/omit'; 6 | 7 | export interface ShowAlertOption extends AlertWrapperProps { 8 | // 是否显示遮罩,主要是提供一个便捷选项 9 | showMask?: boolean; 10 | } 11 | 12 | /** 13 | * 显示弹框 14 | * @param option 15 | */ 16 | export function showAlert(option: React.ReactNode | ShowAlertOption) { 17 | let config: ShowAlertOption; 18 | 19 | if (React.isValidElement(option) || !isPlainObject(option)) { 20 | config = { 21 | title: option as React.ReactNode, 22 | }; 23 | } else { 24 | config = option as ShowAlertOption; 25 | } 26 | 27 | // 组件选项 28 | const props: AlertWrapperProps = omit(config, ['showMask']); 29 | props.onCancel = async () => { 30 | await closeDialog(); 31 | config.onCancel?.(); 32 | }; 33 | props.onConfirm = async () => { 34 | await closeDialog(); 35 | config.onConfirm?.(); 36 | }; 37 | 38 | // 创建对话框 39 | const closeDialog = showDialog({ 40 | showMask: typeof config.showMask === 'undefined' ? true : !!config.showMask, 41 | content: , 42 | }); 43 | 44 | // 返回关闭逻辑 45 | return closeDialog; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/tick.ts: -------------------------------------------------------------------------------- 1 | export type StopTick = () => void; 2 | 3 | /** 4 | * 逐帧执行的工具函数,返回一个方法,调用该方法,停止执行 5 | * @param callback 6 | * @param interval 7 | */ 8 | export function tick(callback: () => void, interval?: number): StopTick { 9 | // 执行状态,是否正在执行 10 | let isRunning: boolean; 11 | 12 | let frame: () => void; 13 | let frameId: number; 14 | 15 | // 设置了tick的间隔 16 | if (interval && typeof interval === 'number') { 17 | let lastTick = Date.now(); 18 | frame = () => { 19 | if (!isRunning) { 20 | return; 21 | } 22 | frameId = requestAnimationFrame(frame); 23 | const now = Date.now(); 24 | 25 | // 每次间隔频率逻辑上保持一致,即使帧频不一致 26 | if (now - lastTick >= interval) { 27 | // 本次tick的时间为上次的时间加上频率间隔 28 | lastTick = lastTick + interval; 29 | callback(); 30 | } 31 | }; 32 | } 33 | // 没有设置tick的间隔 34 | else { 35 | frame = () => { 36 | if (!isRunning) { 37 | return; 38 | } 39 | frameId = requestAnimationFrame(frame); 40 | // 没有设置interval时,每帧都执行 41 | callback(); 42 | }; 43 | } 44 | 45 | // 开始执行 46 | isRunning = true; 47 | frameId = requestAnimationFrame(frame); 48 | 49 | // 返回一个可以立即停止的函数 50 | return () => { 51 | isRunning = false; 52 | cancelAnimationFrame(frameId); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | build/ 64 | 65 | package-lock.json 66 | yarn.lock 67 | .DS_Store 68 | 69 | /exampless -------------------------------------------------------------------------------- /src/Effect/useWindowResize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | /** 4 | * 窗口尺寸变化时触发(包括屏幕旋转) 5 | * 注意:移动端 resize 事件通常只在旋转屏幕时触发,频率很低,不需要防抖 6 | * 7 | * @param onResize 窗口尺寸变化时的回调函数 8 | */ 9 | export function useWindowResize(onResize: () => void) { 10 | const callbackRef = useRef<() => void>(onResize); 11 | 12 | // 每次渲染都更新回调引用,避免闭包陈旧 13 | callbackRef.current = onResize; 14 | 15 | useEffect(() => { 16 | const callback = () => callbackRef.current(); 17 | 18 | // 监听窗口大小变化 19 | window.addEventListener("resize", callback); 20 | 21 | // 监听屏幕方向变化(使用现代 API) 22 | const mediaQuery = window.matchMedia("(orientation: portrait)"); 23 | 24 | // 现代浏览器使用 change 事件 25 | if (mediaQuery.addEventListener) { 26 | mediaQuery.addEventListener("change", callback); 27 | } else { 28 | // 降级处理:旧浏览器使用 addListener 29 | // @ts-ignore 30 | mediaQuery.addListener(callback); 31 | } 32 | 33 | return () => { 34 | window.removeEventListener("resize", callback); 35 | 36 | // 清理 orientation 监听器 37 | if (mediaQuery.removeEventListener) { 38 | mediaQuery.removeEventListener("change", callback); 39 | } else { 40 | // 降级处理 41 | // @ts-ignore 42 | mediaQuery.removeListener(callback); 43 | } 44 | }; 45 | }, []); // 空依赖数组,因为使用了 ref 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clxx", 3 | "version": "2.1.4", 4 | "description": "Basic JS library for mobile devices", 5 | "main": "./build/index.js", 6 | "module": "./build/index.js", 7 | "directories": { 8 | "example": "example" 9 | }, 10 | "scripts": { 11 | "start": "tsc -w --pretty", 12 | "build": "rimraf ./build && tsc", 13 | "pub": "npm run build && npm publish" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/joye61/clxx.git" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "emotion", 22 | "typescript", 23 | "mobile", 24 | "h5" 25 | ], 26 | "author": "Joye", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/joye61/clxx/issues" 30 | }, 31 | "homepage": "https://github.com/joye61/clxx#readme", 32 | "publishConfig": { 33 | "registry": "https://registry.npmjs.org", 34 | "access": "public" 35 | }, 36 | "dependencies": { 37 | "@emotion/react": "^11.14.0", 38 | "dayjs": "^1.11.18", 39 | "history": "^5.3.0", 40 | "lodash": "^4.17.21" 41 | }, 42 | "peerDependencies": { 43 | "react": "^19.2.0", 44 | "react-dom": "^19.2.0" 45 | }, 46 | "devDependencies": { 47 | "@types/lodash": "^4.17.20", 48 | "@types/react": "^19.2.2", 49 | "@types/react-dom": "^19.2.2", 50 | "csstype": "^3.1.3", 51 | "rimraf": "^6.0.1", 52 | "typescript": "^5.9.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { uniqKey } from '../utils/uniqKey'; 3 | import { createPortalDOM, PortalDOM } from '../utils/dom'; 4 | import { Toast as ToastComponent, ToastProps } from './Toast'; 5 | 6 | /** 7 | * 显示一个全局的轻提示,这个toast不是唯一的 8 | * @param option 可以是一个字符串,也可以是一个React组件 9 | */ 10 | export function showToast(option: React.ReactNode | ToastProps) { 11 | const { mount, unmount } = createPortalDOM(); 12 | let props: ToastProps = {}; 13 | if (React.isValidElement(option) || typeof option !== 'object') { 14 | props.content = option; 15 | } else { 16 | props = option as ToastProps; 17 | } 18 | props.onHide = unmount; 19 | mount(); 20 | } 21 | 22 | /** 23 | * 生成一个全局唯一的Toast 24 | * @param option 25 | */ 26 | let portalDOM: PortalDOM | null = null; 27 | export function showUniqToast(option: React.ReactNode | ToastProps) { 28 | if (!portalDOM) { 29 | portalDOM = createPortalDOM(); 30 | } 31 | let props: ToastProps = {}; 32 | // 默认Toast是唯一的 33 | if (React.isValidElement(option) || typeof option !== 'object') { 34 | props.content = option; 35 | } else { 36 | props = option as ToastProps; 37 | } 38 | 39 | const onHide = () => { 40 | portalDOM?.unmount(); 41 | portalDOM = null; 42 | }; 43 | props.onHide = onHide; 44 | portalDOM.mount(); 45 | } 46 | -------------------------------------------------------------------------------- /test/src/scrollview/index.jsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import React, { useRef, useState } from "react"; 3 | import { ScrollView } from "@"; 4 | 5 | export default function Index() { 6 | const [list, setList] = useState([1]); 7 | const isLoading = useRef(false); 8 | const [showLoading, setShowLoading] = useState(true); 9 | 10 | const loadMore = async () => { 11 | if (!showLoading) return; 12 | 13 | isLoading.current = true; 14 | return new Promise((resolve) => { 15 | window.setTimeout(() => { 16 | const num = list.length + 1; 17 | list.push(num); 18 | setList([...list]); 19 | resolve(); 20 | isLoading.current = false; 21 | if (num > 5) { 22 | setShowLoading(false); 23 | } 24 | }, 800); 25 | }); 26 | }; 27 | 28 | return ( 29 |
30 | { 32 | console.log("reachTop", e); 33 | }} 34 | onReachBottom={(e) => { 35 | console.log("reachBottom", e); 36 | if (!isLoading.current) { 37 | loadMore(); 38 | } 39 | }} 40 | showLoading={showLoading} 41 | > 42 | {list.map((item, index) => { 43 | return ( 44 |
45 | {item} 46 |
47 | ); 48 | })} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /test/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/wait.ts: -------------------------------------------------------------------------------- 1 | import { tick } from './tick'; 2 | 3 | /** 4 | * 直接条件为真或者超时才返回结果 5 | * 6 | * @param condition 检测条件 7 | * @param maxTime 最大等待时长(毫秒) 8 | * 9 | * @returns 返回检测的结果,超时返回false 10 | */ 11 | export async function waitUntil( 12 | condition: () => boolean | Promise, 13 | maxTime?: number 14 | ) { 15 | // 记录检测开始时间 16 | const start = Date.now(); 17 | 18 | // 如果检测条件不为函数,直接返回结果 19 | if (typeof condition !== 'function') { 20 | return !!condition; 21 | } 22 | 23 | // 设置默认检测时间的最大值,如果没有设置,则一直检测 24 | if (!maxTime || typeof maxTime !== 'number') { 25 | maxTime = Infinity; 26 | } 27 | 28 | return new Promise((resolve) => { 29 | const stop = tick(() => { 30 | const now = Date.now(); 31 | const result = condition(); 32 | // 超时返回false 33 | if (now - start >= maxTime!) { 34 | stop(); 35 | resolve(false); 36 | return; 37 | } 38 | // 处理结果 39 | const handle = (res: boolean) => { 40 | if (res) { 41 | stop(); 42 | resolve(true); 43 | } 44 | }; 45 | 46 | // 未超时状态 47 | if (result instanceof Promise) { 48 | result.then(handle); 49 | return; 50 | } 51 | // 普通一般的结果 52 | handle(result); 53 | }); 54 | }); 55 | } 56 | 57 | /** 58 | * 等待固定时间,期间一直阻塞 59 | * @param duration 等待时长(毫秒) 60 | */ 61 | export async function waitFor(duration: number) { 62 | return new Promise((resolve) => { 63 | window.setTimeout(resolve, duration); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /test/src/carouse-notice/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CarouselNotice } from '@'; 3 | 4 | export default function Index () { 5 | return ( 6 |
7 |

轮播条目只有一条(不滚动):

8 | 15 | 16 |

轮播条目为纯文本:

17 | 21 | 22 |

轮播条目为任意React组件:

23 | 26 | 第一条粉色滚动 27 |

, 28 |

29 | 第二条红色文字要小一些 30 |

, 31 |

第3条滚动文字加粗

, 32 |

{ 34 | alert('点击了第4条轮播条目'); 35 | }} 36 | style={{ textDecoration: 'underline' }} 37 | > 38 | 第4条可点击 39 |

, 40 | ]} 41 | style={{ background: '#f0f0f0' }} 42 | /> 43 | 44 |

轮播条目左对齐:

45 | 50 | 51 |

轮播条目右对齐:

52 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { Wrapper, LoadingWrapperProps } from './Wrapper'; 2 | import { createPortalDOM } from '../utils/dom'; 3 | import isPlainObject from 'lodash/isPlainObject'; 4 | 5 | /** 6 | * 显示loading 7 | * @param hint 提示文字 8 | * @param option 9 | * @returns 10 | */ 11 | export function showLoading(hint?: string, option?: LoadingWrapperProps) { 12 | const { mount, unmount } = createPortalDOM(); 13 | let props: LoadingWrapperProps = { hint, status: 'show' }; 14 | if (isPlainObject(option)) { 15 | delete option!.status; 16 | props = { ...props, ...option }; 17 | } 18 | 19 | // 关闭loading 20 | const closeLoading = async () => { 21 | props.status = 'hide'; 22 | props.onHide = unmount; 23 | await mount(); 24 | }; 25 | 26 | // 显示loading 27 | const mountShow = mount(); 28 | 29 | // 关闭loading 30 | return async () => { 31 | await mountShow; 32 | await closeLoading(); 33 | }; 34 | } 35 | 36 | /** 37 | * 显示loading,至少展示atLeast毫秒 38 | * @param atLeast 最小展示时间 39 | * @param hint 提示文字 40 | * @param option 各种可定制的选项 41 | * @returns 42 | */ 43 | export function showLoadingAtLeast( 44 | atLeast = 300, 45 | hint?: string, 46 | option?: LoadingWrapperProps 47 | ) { 48 | const closeLoading = showLoading(hint, option); 49 | // 记录开始展示的时间 50 | const start = Date.now(); 51 | 52 | // 返回一个可关闭的函数 53 | return async () => { 54 | const now = Date.now(); 55 | const diff = now - start; 56 | // 如果当前关闭的时间已经超过最小允许的时间,直接关闭 57 | if (diff >= atLeast) { 58 | await closeLoading(); 59 | return; 60 | } 61 | // 等待剩余的差时间差 62 | await new Promise((resolve) => { 63 | window.setTimeout(resolve, atLeast - diff); 64 | }); 65 | // 时间够了,直接关闭 66 | await closeLoading(); 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /test/src/index/index.jsx: -------------------------------------------------------------------------------- 1 | import { React } from "react"; 2 | import { history } from "@"; 3 | 4 | const pageConfig = [ 5 | { path: "indicator", title: "Indicator菊花转圈指示器", enable: true }, 6 | { path: "countdown", title: "Countdown倒计时", enable: true }, 7 | { path: "alert", title: "showAlert弹框功能", enable: true }, 8 | { path: "dialog", title: "showDialog对话框", enable: true }, 9 | // { path: 'layout', title: '常用布局组件', enable: false }, 10 | { path: "toast", title: "showToast轻提示", enable: true }, 11 | { path: "carouse-notice", title: "CarouseNotice轮播公告", enable: true }, 12 | { path: "loading", title: "showLoading加载", enable: true }, 13 | { path: "scrollview", title: "ScrollView滚动容器", enable: true }, 14 | { path: "ago", title: "Ago多久以前", enable: true }, 15 | { path: "overlay", title: "Overlay通用覆盖层", enable: true }, 16 | { path: "clickable", title: "Clickable可触摸组件", enable: true }, 17 | // { path: 'privacy', title: 'Privacy去标识化', enable: true }, 18 | { path: "autogrid", title: "AutoGrid生成自动对齐的表格", enable: true }, 19 | ]; 20 | 21 | export default function Index() { 22 | return ( 23 | <> 24 |
    25 | {pageConfig.map((item) => { 26 | return ( 27 |
  • { 31 | if (item.enable) { 32 | history.push(`/${item.path}`); 33 | } 34 | }} 35 | > 36 | {item.title} 37 | 38 | 39 | 40 |
  • 41 | ); 42 | })} 43 |
44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/Flex/index.tsx: -------------------------------------------------------------------------------- 1 | import * as CSS from 'csstype'; 2 | 3 | export interface FlexProps extends React.HTMLProps { 4 | children?: React.ReactNode; 5 | alignItems?: CSS.Property.AlignItems; 6 | alignContent?: CSS.Property.AlignContent; 7 | justifyContent?: CSS.Property.JustifyContent; 8 | flexFlow?: CSS.Property.FlexFlow; 9 | flexWrap?: CSS.Property.FlexWrap; 10 | flexDirection?: CSS.Property.FlexDirection; 11 | } 12 | 13 | export interface FlexItemProps extends React.HTMLProps { 14 | children?: React.ReactNode; 15 | alignSelf?: CSS.Property.AlignSelf; 16 | order?: CSS.Property.Order; 17 | flex?: CSS.Property.BoxFlex; 18 | flexGrow?: CSS.Property.FlexGrow; 19 | flexShrink?: CSS.Property.FlexShrink; 20 | flexBasis?: CSS.Property.FlexBasis; 21 | } 22 | 23 | export function Flex(props: FlexProps) { 24 | const { 25 | children, 26 | alignItems = 'center', 27 | alignContent, 28 | justifyContent, 29 | flexFlow, 30 | flexWrap, 31 | flexDirection, 32 | ...extra 33 | } = props; 34 | return ( 35 |
47 | {children} 48 |
49 | ); 50 | } 51 | 52 | export function FlexItem(props: FlexItemProps) { 53 | const { 54 | children, 55 | alignSelf, 56 | order, 57 | flex, 58 | flexGrow, 59 | flexShrink, 60 | flexBasis, 61 | ...extra 62 | } = props; 63 | return ( 64 |
75 | {children} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createPortalDOM } from '../utils/dom'; 3 | import { DialogType } from './style'; 4 | import { WrapperProps, Wrapper } from './Wrapper'; 5 | import isPlainObject from 'lodash/isPlainObject'; 6 | import omit from 'lodash/omit'; 7 | 8 | export interface ShowDialogOption extends WrapperProps { 9 | // 空白处可点击关闭 10 | blankClosable?: boolean; 11 | // 弹框内容 12 | content?: React.ReactNode; 13 | // 弹窗类型 14 | type?: DialogType; 15 | } 16 | 17 | /** 18 | * 显示一个对话框,出现和隐藏都带有动画效果 19 | * @param option 20 | * @returns 21 | */ 22 | export function showDialog(option: React.ReactNode | ShowDialogOption) { 23 | const { mount, unmount } = createPortalDOM(); 24 | 25 | // 生成全部配置 26 | let config: ShowDialogOption = { status: 'show', blankClosable: false }; 27 | if (React.isValidElement(option) || !isPlainObject(option)) { 28 | config.content = option as React.ReactNode; 29 | } else { 30 | config = { ...config, ...(option as ShowDialogOption) }; 31 | } 32 | 33 | // 提取需要单独处理的配置项 34 | const blankClosable = !!config.blankClosable; 35 | const children = config.content; 36 | const onBlankClick = config.onBlankClick; 37 | const onHide = config.onHide; 38 | const props: WrapperProps = omit(config, [ 39 | 'blankClosable', 40 | 'content', 41 | 'onHide', 42 | ]); 43 | 44 | // 关闭弹窗 45 | const closeDialog = async () => { 46 | props.status = 'hide'; 47 | props.onHide = () => { 48 | unmount(); 49 | onHide?.(); 50 | }; 51 | await mount({children}); 52 | }; 53 | 54 | // 空白处可点击关闭 55 | if (blankClosable) { 56 | props.onBlankClick = async (event) => { 57 | await closeDialog(); 58 | onBlankClick?.(event); 59 | }; 60 | } 61 | 62 | // 挂载容器对象 63 | const amountShow = mount({children}); 64 | 65 | return async () => { 66 | // 关闭前确保容器已经被挂载 67 | await amountShow; 68 | await closeDialog(); 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/Flex/Row.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, FlexProps } from '.'; 2 | 3 | export function RowStart(props: FlexProps) { 4 | const { 5 | flexDirection = 'row', 6 | justifyContent = 'flex-start', 7 | ...extra 8 | } = props; 9 | return ( 10 | 15 | ); 16 | } 17 | export function RowEnd(props: FlexProps) { 18 | const { 19 | flexDirection = 'row', 20 | justifyContent = 'flex-end', 21 | ...extra 22 | } = props; 23 | return ( 24 | 29 | ); 30 | } 31 | export function RowCenter(props: FlexProps) { 32 | const { flexDirection = 'row', justifyContent = 'center', ...extra } = props; 33 | return ( 34 | 39 | ); 40 | } 41 | export function RowBetween(props: FlexProps) { 42 | const { 43 | flexDirection = 'row', 44 | justifyContent = 'space-between', 45 | ...extra 46 | } = props; 47 | return ( 48 | 53 | ); 54 | } 55 | export function RowAround(props: FlexProps) { 56 | const { 57 | flexDirection = 'row', 58 | justifyContent = 'space-around', 59 | ...extra 60 | } = props; 61 | return ( 62 | 67 | ); 68 | } 69 | export function RowEvenly(props: FlexProps) { 70 | const { 71 | flexDirection = 'row', 72 | justifyContent = 'space-evenly', 73 | ...extra 74 | } = props; 75 | return ( 76 | 81 | ); 82 | } 83 | 84 | export { RowStart as Row }; 85 | -------------------------------------------------------------------------------- /test/src/toast/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { showToast, showUniqToast } from "@"; 3 | 4 | export default function Index () { 5 | return ( 6 |
7 |

toast内容为纯文本

8 | 15 | 16 |

toast内容为可定制组件

17 | 28 | 29 |

toast内容在顶部显示

30 | 40 | 41 |

toast内容在底部显示

42 | 52 | 53 |

内容非常长的Toast

54 | 63 | 64 |

全局唯一的Toast

65 | 72 |

全局唯一的顶部Toast,永不消失

73 | 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/Flex/Col.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, FlexProps } from '.'; 2 | 3 | export function ColStart(props: FlexProps) { 4 | const { 5 | flexDirection = 'column', 6 | justifyContent = 'flex-start', 7 | ...extra 8 | } = props; 9 | return ( 10 | 15 | ); 16 | } 17 | export function ColEnd(props: FlexProps) { 18 | const { 19 | flexDirection = 'column', 20 | justifyContent = 'flex-end', 21 | ...extra 22 | } = props; 23 | return ( 24 | 29 | ); 30 | } 31 | export function ColCenter(props: FlexProps) { 32 | const { 33 | flexDirection = 'column', 34 | justifyContent = 'center', 35 | ...extra 36 | } = props; 37 | return ( 38 | 43 | ); 44 | } 45 | export function ColBetween(props: FlexProps) { 46 | const { 47 | flexDirection = 'column', 48 | justifyContent = 'space-between', 49 | ...extra 50 | } = props; 51 | return ( 52 | 57 | ); 58 | } 59 | export function ColAround(props: FlexProps) { 60 | const { 61 | flexDirection = 'column', 62 | justifyContent = 'space-around', 63 | ...extra 64 | } = props; 65 | return ( 66 | 71 | ); 72 | } 73 | export function ColEvenly(props: FlexProps) { 74 | const { 75 | flexDirection = 'column', 76 | justifyContent = 'space-evenly', 77 | ...extra 78 | } = props; 79 | return ( 80 | 85 | ); 86 | } 87 | 88 | export { ColStart as Col }; 89 | -------------------------------------------------------------------------------- /src/Alert/style.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme } from "@emotion/react"; 2 | import { adaptive } from "../utils/cssUtil"; 3 | 4 | export const style: Record> = { 5 | container: [ 6 | { 7 | position: "relative", 8 | overflow: "hidden", 9 | backgroundColor: "#fff", 10 | boxShadow: "0 0 2px 0 #00000055", 11 | }, 12 | adaptive({ 13 | borderRadius: 16, 14 | width: 750 * 0.84, 15 | }), 16 | ], 17 | 18 | content: { 19 | position: "relative", 20 | "&:after,&::after": { 21 | content: "''", 22 | position: "absolute", 23 | bottom: 0, 24 | left: 0, 25 | right: 0, 26 | height: "1px", 27 | backgroundColor: "#c0c0c0", 28 | transform: `scale(1, ${1 / window.devicePixelRatio})`, 29 | }, 30 | }, 31 | title: [ 32 | { 33 | textAlign: "center", 34 | lineHeight: 1.4, 35 | color: "#000", 36 | }, 37 | adaptive({ 38 | paddingTop: 50, 39 | paddingLeft: 40, 40 | paddingRight: 40, 41 | paddingBottom: 50, 42 | fontSize: 33, 43 | }), 44 | ], 45 | desc: [ 46 | { 47 | textAlign: "center", 48 | lineHeight: 1.4, 49 | color: "#666", 50 | }, 51 | adaptive({ 52 | paddingTop: 20, 53 | paddingLeft: 40, 54 | paddingRight: 40, 55 | paddingBottom: 50, 56 | fontSize: 29, 57 | }), 58 | ], 59 | btnBox: adaptive({ 60 | position: "relative", 61 | height: 90, 62 | }), 63 | btnBoxWithCancel: { 64 | "&:after,&::after": { 65 | content: "''", 66 | position: "absolute", 67 | top: 0, 68 | bottom: 0, 69 | left: "50%", 70 | marginLeft: "-.5px", 71 | width: "1px", 72 | backgroundColor: "#c0c0c0", 73 | transform: `scale(${1 / devicePixelRatio}, 1)`, 74 | }, 75 | }, 76 | btn: [ 77 | { 78 | flex: 1, 79 | display: "flex", 80 | alignItems: "center", 81 | justifyContent: "center", 82 | userSelect: "none", 83 | letterSpacing: "1px", 84 | }, 85 | adaptive({ 86 | fontSize: 33, 87 | }), 88 | ], 89 | }; 90 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { tick } from './utils/tick'; 2 | export { setContextValue, getContextValue } from './context'; 3 | 4 | export { jsonp } from './utils/jsonp'; 5 | export { uniqKey } from './utils/uniqKey'; 6 | export { ago } from './utils/ago'; 7 | export { 8 | GET, 9 | POST, 10 | sendJSON, 11 | sugarSend, 12 | sendRequest, 13 | buildUrlByOption, 14 | registerHostAlias, 15 | } from './utils/request'; 16 | export { calendarTable } from './utils/calendarTable'; 17 | export { Countdown } from './utils/Countdown'; 18 | export { defaultScroll } from './utils/defaultScroll'; 19 | export { is } from './utils/is'; 20 | export { waitFor, waitUntil } from './utils/wait'; 21 | export { normalizeUnit, splitValue, adaptive } from './utils/cssUtil'; 22 | export { createApp, history, getHistory } from './utils/createApp'; 23 | export { createPortalDOM } from './utils/dom'; 24 | 25 | export { useInterval } from './Effect/useInterval'; 26 | export { useTick } from './Effect/useTick'; 27 | export { useUpdate } from './Effect/useUpdate'; 28 | export { useWindowResize } from './Effect/useWindowResize'; 29 | export { useViewport } from './Effect/useViewport'; 30 | 31 | export { Ago } from './Ago'; 32 | export { Container } from './Container'; 33 | export { Flex, FlexItem } from './Flex'; 34 | export { 35 | Row, 36 | RowAround, 37 | RowBetween, 38 | RowCenter, 39 | RowEnd, 40 | RowEvenly, 41 | RowStart, 42 | } from './Flex/Row'; 43 | export { 44 | Col, 45 | ColAround, 46 | ColBetween, 47 | ColCenter, 48 | ColEnd, 49 | ColEvenly, 50 | ColStart, 51 | } from './Flex/Col'; 52 | export { Countdowner } from './Countdowner'; 53 | export { Overlay } from './Overlay'; 54 | export { showToast, showUniqToast } from './Toast'; 55 | export { showDialog } from './Dialog'; 56 | export { Clickable } from './Clickable'; 57 | export { Indicator } from './Indicator'; 58 | export { showLoading, showLoadingAtLeast } from './Loading'; 59 | export { SafeArea } from './SafeArea'; 60 | export { AutoGrid } from './AutoGrid'; 61 | export { showAlert } from './Alert'; 62 | export { ScrollView } from './ScrollView'; 63 | export { CarouselNotice } from './CarouselNotice'; 64 | -------------------------------------------------------------------------------- /src/Dialog/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, Theme, Interpolation } from "@emotion/react"; 3 | import { Overlay } from "../Overlay"; 4 | import { style, DialogType, AnimationStatus, getAnimation } from "./style"; 5 | 6 | export interface WrapperProps { 7 | // 对话框类型 8 | type?: DialogType; 9 | // 对话框容器的内容 10 | children?: React.ReactNode; 11 | // 对话框打开或者关闭的动画状态 12 | status?: AnimationStatus; 13 | // 对话框完全关闭时触发的回调 14 | onHide?: () => void; 15 | // 是否显示遮罩 16 | showMask?: boolean; 17 | // 遮罩颜色 18 | maskColor?: string; 19 | // 容器被点击时触发 20 | onBlankClick?: (event?: React.MouseEvent) => void; 21 | // 容器的样式 22 | boxStyle?: Interpolation; 23 | // 遮罩样式 24 | maskStyle?: Interpolation; 25 | } 26 | 27 | export function Wrapper(props: WrapperProps) { 28 | const { 29 | type = "center", 30 | status = "show", 31 | children, 32 | onHide, 33 | showMask = true, 34 | maskColor, 35 | maskStyle, 36 | boxStyle, 37 | onBlankClick, 38 | } = props; 39 | const { animation, keyframes } = getAnimation(type, status); 40 | 41 | // 选取特定的类型对应的样式 42 | let boxCss: any[] = [ 43 | style.boxCss, 44 | ["pullUp", "pullDown", "pullLeft", "pullRight"].includes(type) ? style[type as keyof typeof style] : {} 45 | ]; 46 | 47 | // 遮罩的样式 48 | let maskCss: any[] = [ 49 | style.mask, 50 | status === "show" ? style.maskShow : style.maskHide, 51 | maskStyle, 52 | maskColor ? { backgroundColor: maskColor } : {} 53 | ]; 54 | 55 | // 空白处点击 56 | const blankClick = (event: React.MouseEvent) => { 57 | if (event.target === event.currentTarget) { 58 | event.stopPropagation(); 59 | onBlankClick?.(event); 60 | } 61 | }; 62 | 63 | return ( 64 | 71 | {showMask &&
} 72 |
{ 75 | if (status === "hide" && event.animationName === keyframes.name) { 76 | onHide?.(); 77 | } 78 | }} 79 | > 80 | {children} 81 |
82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/Loading/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme } from '@emotion/react'; 2 | import React from 'react'; 3 | import { style, LoadingHide } from './style'; 4 | import { Indicator, IndicatorProps } from '../Indicator'; 5 | import { RowCenter } from '../Flex/Row'; 6 | import { Overlay, OverlayProps } from '../Overlay'; 7 | 8 | export interface LoadingWrapperProps { 9 | // loading的状态 10 | status?: 'show' | 'hide'; 11 | // 是否有额外信息 12 | hint?: React.ReactNode; 13 | // fixcontainer组件的属性 14 | overlay?: OverlayProps; 15 | // indicator组件的属性 16 | indicator?: IndicatorProps; 17 | // 隐藏动画结束时触发 18 | onHide?: () => void; 19 | // 容器样式 20 | containerStyle?: Interpolation; 21 | } 22 | 23 | export function Wrapper(props: LoadingWrapperProps) { 24 | const { 25 | status = 'show', 26 | hint, 27 | overlay, 28 | indicator, 29 | onHide, 30 | containerStyle, 31 | } = props; 32 | 33 | // 覆盖层样式 34 | let overlayProps: OverlayProps = { 35 | centerContent: true, 36 | fullScreen: true, 37 | maskColor: 'transparent', 38 | }; 39 | if (typeof overlay === 'object') { 40 | overlayProps = { ...overlayProps, ...overlay }; 41 | } 42 | 43 | // 指示器样式 44 | let indicatorProps: IndicatorProps = { 45 | barWidth: 5, 46 | barHeight: 25, 47 | barCount: 14, 48 | }; 49 | if (typeof indicator === 'object') { 50 | indicatorProps = { ...indicatorProps, ...indicator }; 51 | } 52 | 53 | // 根据状态设置动画 54 | const animation = status === 'show' ? style.boxShow : style.boxHide; 55 | // 动画结束时触发的函数逻辑 56 | const animationEnd = (event: React.AnimationEvent) => { 57 | if (event.animationName === LoadingHide.name) { 58 | onHide?.(); 59 | } 60 | }; 61 | 62 | let box: React.ReactNode; 63 | if (hint && typeof hint === 'string') { 64 | box = ( 65 | 69 | 70 |
{hint}
71 |
72 | ); 73 | } else { 74 | box = ( 75 | 79 | 80 | 81 | ); 82 | } 83 | 84 | return {box}; 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/calendarTable.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { Dayjs, ConfigType } from "dayjs"; 2 | 3 | /** 4 | * 创建一个月历视图的原始数据表 5 | * @param usefulFormat dayjs构造函数可以识别的任意值 6 | * @param startFromSunday 是否以星期天作为一周的第一天 7 | * @param sizeGuarantee 是否保证生成表格始终有6行 8 | */ 9 | export function calendarTable( 10 | usefulFormat: ConfigType = dayjs(), 11 | startFromSunday = false, 12 | sizeGuarantee = true 13 | ) { 14 | const value = dayjs(usefulFormat); 15 | const startOfMonth = value.startOf("month"); 16 | const endOfMonth = value.endOf("month"); 17 | const monthStartDay = startOfMonth.date(); 18 | const monthEndDay = endOfMonth.date(); 19 | 20 | /** 21 | * 向列表中添加元素 22 | * @param element 23 | */ 24 | const result: Array = []; 25 | const addDayToResult = (day: Dayjs) => { 26 | const len = result.length; 27 | if (len === 0 || result[len - 1].length >= 7) { 28 | result.push([day]); 29 | } else { 30 | result[len - 1].push(day); 31 | } 32 | }; 33 | 34 | if (startFromSunday) { 35 | const monthStartWeekDay = startOfMonth.day(); 36 | const monthEndWeekDay = endOfMonth.day(); 37 | // 1、补足上一个月天数 38 | for (let i = 0; i < monthStartWeekDay; i++) { 39 | addDayToResult(startOfMonth.subtract(monthStartWeekDay - i, "day")); 40 | } 41 | // 2、补足当月的天数 42 | for (let i = monthStartDay; i <= monthEndDay; i++) { 43 | addDayToResult(value.date(i)); 44 | } 45 | // 3、补足下一个月天速 46 | for (let i = monthEndWeekDay + 1; i <= 6; i++) { 47 | addDayToResult(endOfMonth.add(i - monthEndWeekDay, "day")); 48 | } 49 | } else { 50 | const monthStartWeekDay = startOfMonth.day() || 7; 51 | const monthEndWeekDay = endOfMonth.day() || 7; 52 | 53 | // 1、补足上一个月天数 54 | for (let i = 1; i < monthStartWeekDay; i++) { 55 | addDayToResult(startOfMonth.subtract(monthStartWeekDay - i, "day")); 56 | } 57 | 58 | // 2、补足当月的天数 59 | for (let i = monthStartDay; i <= monthEndDay; i++) { 60 | addDayToResult(value.date(i)); 61 | } 62 | 63 | // 3、补足下一个月天速 64 | for (let i = monthEndWeekDay + 1; i <= 7; i++) { 65 | addDayToResult(endOfMonth.add(i - monthEndWeekDay, "day")); 66 | } 67 | } 68 | 69 | // 4、如果保证了表格的尺寸,且结果只有5行 70 | if (result.length < 6 && sizeGuarantee) { 71 | const remain = (6 - result.length) * 7; 72 | const lastDayOfTable = result[result.length - 1][6]; 73 | for (let i = 1; i <= remain; i++) { 74 | addDayToResult(lastDayOfTable.add(i, "day")); 75 | } 76 | } 77 | 78 | return result; 79 | } 80 | -------------------------------------------------------------------------------- /test/src/countdown/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Countdown, Countdowner } from "@"; 3 | 4 | export default function Index () { 5 | const [show1, setShow1] = useState(""); 6 | const [show2, setShow2] = useState(""); 7 | const [show2Closed, setShow2Close] = useState(false); 8 | const [show3, setShow3] = useState(""); 9 | 10 | useEffect(() => { 11 | const cd1 = new Countdown({ 12 | remain: 1000, 13 | format: "his", 14 | onUpdate(result) { 15 | setShow1(() => `${result.h}时${result.i}分${result.s}秒`); 16 | }, 17 | }); 18 | cd1.start(); 19 | 20 | const cd2 = new Countdown({ 21 | remain: 10, 22 | format: "s", 23 | onUpdate(result) { 24 | setShow2(() => `${result.s}秒`); 25 | }, 26 | onEnd() { 27 | setShow2Close(() => true); 28 | }, 29 | }); 30 | cd2.start(); 31 | 32 | const cd3 = new Countdown({ 33 | remain: 259205, 34 | format: "dhis", 35 | onUpdate(result) { 36 | setShow3(() => `${result.d}天${result.h}时${result.i}分${result.s}秒`); 37 | }, 38 | }); 39 | cd3.start(); 40 | 41 | return () => { 42 | cd1.stop(); 43 | cd2.stop(); 44 | cd3.stop(); 45 | }; 46 | }, []); 47 | 48 | return ( 49 |
50 |

格式:his,总时长:1000秒

51 |

{show1}

52 |

格式:s,总时长:10秒

53 |

54 | {show2Closed ? ( 55 | 已触发结束事件 56 | ) : ( 57 | show2 58 | )} 59 |

60 |

格式:dhis,总时长:259200秒

61 |

{show3}

62 |
63 |

下面是来自Countdowner组件的

64 | 65 | { 72 | if (key === "i") { 73 | return {n}; 74 | } else { 75 | return {n}; 76 | } 77 | }} 78 | /> 79 | { 83 | return {key} 84 | }} 85 | /> 86 | {console.log("已结束");}} 92 | /> 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/ago.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export type AgoValue = { 4 | // 多久以前数字值 5 | num: number; 6 | // 多久以前单位 7 | unit: 'y' | 'm' | 'd' | 'h' | 'i' | 's'; 8 | // 这是一个默认的格式化中文字符串 9 | format: string; 10 | }; 11 | 12 | /** 13 | * 用于格式化显示:多久以前 14 | * @param date 15 | */ 16 | export function ago(date: dayjs.ConfigType): AgoValue { 17 | const now = dayjs(); 18 | const input = dayjs(date); 19 | const aYearAgo = now.subtract(1, 'year'); 20 | const aMonthAgo = now.subtract(1, 'month'); 21 | const aDayAgo = now.subtract(1, 'day'); 22 | const aHourAgo = now.subtract(1, 'hour'); 23 | const aMinuteAgo = now.subtract(1, 'minute'); 24 | 25 | // 多少年前 26 | if (input.isBefore(aYearAgo)) { 27 | const diff = now.year() - input.year(); 28 | const nYearsAgo = now.subtract(diff, 'year'); 29 | let showNum = diff; 30 | if (input.isAfter(nYearsAgo)) { 31 | showNum = diff - 1; 32 | } 33 | return { 34 | num: showNum, 35 | unit: 'y', 36 | format: `${showNum}年前`, 37 | }; 38 | } 39 | 40 | // 多少月前 41 | if (input.isBefore(aMonthAgo)) { 42 | let showNum = 1; 43 | for (let n = 2; n <= 12; n++) { 44 | const nMonthAgo = now.subtract(n, 'month'); 45 | if (input.isAfter(nMonthAgo)) { 46 | showNum = n - 1; 47 | break; 48 | } 49 | } 50 | return { 51 | num: showNum, 52 | unit: 'm', 53 | format: `${showNum}个月前`, 54 | }; 55 | } 56 | 57 | // 多少天前 58 | if (input.isBefore(aDayAgo)) { 59 | const showNum = Math.floor((now.unix() - input.unix()) / 86400); 60 | return { 61 | num: showNum, 62 | unit: 'd', 63 | format: `${showNum}天前`, 64 | }; 65 | } 66 | 67 | // 多少小时前 68 | if (input.isBefore(aHourAgo)) { 69 | const showNum = Math.floor((now.unix() - input.unix()) / 3600); 70 | return { 71 | num: showNum, 72 | unit: 'h', 73 | format: `${showNum}个小时前`, 74 | }; 75 | } 76 | 77 | // 多少分钟前 78 | if (input.isBefore(aMinuteAgo)) { 79 | const showNum = Math.floor((now.unix() - input.unix()) / 60); 80 | return { 81 | num: showNum, 82 | unit: 'i', 83 | format: `${showNum}分钟前`, 84 | }; 85 | } 86 | 87 | // 多少秒前 88 | const showNum = now.unix() - input.unix(); 89 | let format; 90 | if (showNum > 10) { 91 | format = `${showNum}秒前`; 92 | } else { 93 | format = '刚刚'; 94 | } 95 | return { 96 | num: showNum, 97 | unit: 's', 98 | format, 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /test/src/image-picker/index.jsx: -------------------------------------------------------------------------------- 1 | // import "./index.css"; 2 | // import React from "react"; 3 | // import { ImagePicker, SimpleImagePicker, Loading } from "@"; 4 | 5 | // export default function () { 6 | // return ( 7 | //
8 | //

网格风格的图片选择器(多选)

9 | // { 12 | // // return Haha; 13 | // // }} 14 | // multiple 15 | // cols={4} 16 | // // isSquare={false} 17 | // onHookEachRound={() => { 18 | // // 显示一个loading 19 | // let loading = null; 20 | // const onStartPick = () => { 21 | // loading = new Loading(); 22 | // }; 23 | // const onEndPick = async () => { 24 | // await loading?.close(); 25 | // }; 26 | // return { onStartPick, onEndPick }; 27 | // }} 28 | // onFilesChange={(res) => { 29 | // console.log(res); 30 | // }} 31 | // /> 32 | 33 | //

网格风格的自定义选取按钮的图片选择器(多选)

34 | // { 40 | // console.log(disabled); 41 | // return ; 42 | // }} 43 | // /> 44 | 45 | //

普通选择器(多选,结果需要自己处理)

46 | // { 49 | // // 显示一个loading 50 | // let loading = null; 51 | // const onStartPick = () => { 52 | // loading = new Loading(); 53 | // }; 54 | // const onEndPick = async () => { 55 | // await loading?.close(); 56 | // }; 57 | // return { onStartPick, onEndPick }; 58 | // }} 59 | // onFilesChange={(res) => { 60 | // console.log(res); 61 | // }} 62 | // /> 63 | //

自定义选取按钮普通选择器(单选,结果需要自己处理)

64 | // { 67 | // return ; 68 | // }} 69 | // onFilesChange={(res) => { 70 | // console.log(res); 71 | // }} 72 | // /> 73 | //

限制最大宽度(单选,结果需要自己处理)

74 | // { 77 | // return ; 78 | // }} 79 | // onFilesChange={(res) => { 80 | // console.log(res); 81 | // }} 82 | // loadImageOption={{ 83 | // maxWidth: 10 84 | // }} 85 | // /> 86 | //
87 | // ); 88 | // } 89 | -------------------------------------------------------------------------------- /src/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Interpolation, SerializedStyles, Theme } from "@emotion/react"; 3 | import React, { useState, useEffect } from "react"; 4 | import { style, getAnimation } from "./style"; 5 | 6 | export interface ToastProps 7 | extends Omit, "content"> { 8 | // toast消失动画时触发的回调 9 | onHide?: () => void; 10 | // toast的内容 11 | content?: React.ReactNode; 12 | // toast出现的位置,上|中|下 13 | position?: "top" | "middle" | "bottom"; 14 | // toast在上时相对于顶部偏移 15 | offsetTop?: number; 16 | // toast在下时相对于底部偏移 17 | offsetBottom?: number; 18 | // toast持续时间 19 | duration?: number; 20 | // 默认圆角值 21 | radius?: number; 22 | // 容器样式 23 | containerStyle?: Interpolation; 24 | // 内容样式 25 | contentStyle?: Interpolation; 26 | } 27 | 28 | export function Toast(props: ToastProps) { 29 | const { 30 | content = "", 31 | position = "middle", 32 | duration = 2000, 33 | radius = 16, 34 | offsetTop = 50, 35 | offsetBottom = 50, 36 | onHide = () => undefined, 37 | containerStyle, 38 | contentStyle, 39 | ...attributes 40 | } = props; 41 | 42 | // 初始化显示的动画 43 | const getResult = getAnimation(position, "show"); 44 | const [animation, setAnimation] = useState( 45 | getResult.animation 46 | ); 47 | 48 | useEffect(() => { 49 | const timer = window.setTimeout(() => { 50 | const { animation } = getAnimation(position, "hide"); 51 | setAnimation(animation); 52 | }, duration); 53 | 54 | return () => { 55 | window.clearTimeout(timer); 56 | }; 57 | }, [position, duration]); 58 | 59 | let showContent: any; 60 | const middleStyle = position === "middle" ? style.contentMiddle : undefined; 61 | if (React.isValidElement(content)) { 62 | showContent =
{content}
; 63 | } else { 64 | showContent = ( 65 |

{content}

66 | ); 67 | } 68 | 69 | // toast消失动画结束触发 70 | const animationEnd = (event: React.AnimationEvent) => { 71 | const { keyframes } = getAnimation(position, "hide"); 72 | if (event.animationName === keyframes.name) { 73 | onHide?.(); 74 | } 75 | }; 76 | 77 | let positionStyle: SerializedStyles; 78 | if (position === "top") { 79 | positionStyle = style.top(offsetTop); 80 | } else if (position === "bottom") { 81 | positionStyle = style.bottom(offsetBottom); 82 | } else { 83 | positionStyle = style.middle; 84 | } 85 | 86 | return ( 87 |
92 | {showContent} 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/Alert/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme } from '@emotion/react'; 2 | import { Clickable } from '../Clickable'; 3 | import { Row } from '../Flex/Row'; 4 | import { style } from './style'; 5 | import * as CSS from 'csstype'; 6 | 7 | export interface AlertWrapperProps { 8 | // 标题 9 | title?: React.ReactNode; 10 | // 内容 11 | description?: React.ReactNode; 12 | // 确认按钮 13 | confirm?: React.ReactNode; 14 | // 确认按钮颜色 15 | confirmColor?: CSS.Property.Color; 16 | // 取消按钮 17 | cancel?: React.ReactNode; 18 | // 取消按钮颜色 19 | cancelColor?: CSS.Property.Color; 20 | // 显示取消按钮 21 | showCancel?: boolean; 22 | // 确认回调 23 | onConfirm?: () => void; 24 | // 取消回调 25 | onCancel?: () => void; 26 | 27 | // 可定制的样式 28 | titleStyle?: Interpolation; 29 | descStyle?: Interpolation; 30 | btnStyle?: Interpolation; 31 | confirmStyle?: Interpolation; 32 | cancelStyle?: Interpolation; 33 | } 34 | 35 | export function AlertWrapper(props: AlertWrapperProps) { 36 | const { 37 | title = '提示', 38 | description, 39 | confirm = '确定', 40 | confirmColor = '#007afe', 41 | cancel = '取消', 42 | cancelColor = '#666', 43 | showCancel = false, 44 | onConfirm, 45 | onCancel, 46 | 47 | // 可定制的样式 48 | titleStyle, 49 | descStyle, 50 | btnStyle, 51 | cancelStyle, 52 | confirmStyle, 53 | } = props; 54 | 55 | // 标题样式 56 | let titleCss: Interpolation = [ 57 | style.title, 58 | description ? { paddingBottom: 0 } : {}, 59 | titleStyle 60 | ]; 61 | 62 | // 展示按钮组 63 | let btnBoxCss: Interpolation = [ 64 | style.btnBox, 65 | showCancel ? style.btnBoxWithCancel : {} 66 | ]; 67 | 68 | return ( 69 |
70 |
71 | {/* 标题 */} 72 |
{title}
73 | {/* 描述 */} 74 | {description &&
{description}
} 75 |
76 | 77 | {/* 取消按钮 */} 78 | {showCancel && ( 79 | 86 | {cancel} 87 | 88 | )} 89 | {/* 确认按钮 */} 90 | 97 | {confirm} 98 | 99 | 100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/Indicator/index.tsx: -------------------------------------------------------------------------------- 1 | import { css, Interpolation, Theme } from '@emotion/react'; 2 | import React from 'react'; 3 | import * as CSS from 'csstype'; 4 | import { adaptive, normalizeUnit } from '../utils/cssUtil'; 5 | import { getBarChangeKeyFrames } from './style'; 6 | 7 | export interface IndicatorProps 8 | extends React.DetailedHTMLProps< 9 | React.HTMLAttributes, 10 | HTMLDivElement 11 | > { 12 | // 容器的尺寸 13 | size?: CSS.Property.Width | number; 14 | // bar是否圆角,默认:true 15 | rounded?: boolean; 16 | // bar宽度,默认:7 17 | barWidth?: number; 18 | // bar高度,默认:26 19 | barHeight?: number; 20 | // bar颜色,默认:#fff 21 | barColor?: string; 22 | // bar个数,默认:12 23 | barCount?: number; 24 | // 每转一圈的持续时间,单位毫秒,默认:500ms 25 | duration?: number; 26 | // 容器样式 27 | containerStyle?: Interpolation; 28 | } 29 | 30 | /** 31 | * SVG转圈指示器,一般用作loading效果 32 | * @param props 33 | */ 34 | export function Indicator(props: IndicatorProps) { 35 | const { 36 | size, 37 | rounded = true, 38 | barWidth = 7, 39 | barHeight = 28, 40 | barColor = '#fff', 41 | barCount = 12, 42 | duration = 600, 43 | containerStyle, 44 | ...attributes 45 | } = props; 46 | 47 | const radius = rounded ? barWidth / 2 : 0; 48 | 49 | // 使用 useMemo 缓存 barList,避免每次渲染都重新生成 50 | const barList = React.useMemo(() => { 51 | const bars = []; 52 | for (let i = 0; i < barCount; i++) { 53 | bars.push( 54 | 67 | ); 68 | } 69 | return bars; 70 | }, [barCount, barWidth, barHeight, radius, duration]); 71 | 72 | // 使用 useMemo 缓存样式,避免每次都重新计算 73 | const style = React.useMemo>(() => [ 74 | { 75 | fontSize: 0, 76 | }, 77 | typeof size !== 'undefined' ? { 78 | width: normalizeUnit(size), 79 | height: normalizeUnit(size), 80 | } : adaptive({ 81 | width: 60, 82 | height: 60, 83 | }) 84 | ], [size]); 85 | 86 | const svgStyle = React.useMemo(() => css({ 87 | width: '100%', 88 | height: '100%', 89 | rect: { 90 | fill: 'transparent', 91 | animationName: getBarChangeKeyFrames(barColor), 92 | animationDuration: `${duration}ms`, 93 | animationTimingFunction: 'linear', 94 | animationIterationCount: 'infinite', 95 | }, 96 | }), [barColor, duration]); 97 | 98 | return ( 99 |
100 | 101 | {barList} 102 | 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/AutoGrid/index.tsx: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme } from '@emotion/react'; 2 | import React, { useCallback } from 'react'; 3 | import CSS from 'csstype'; 4 | import { style } from './style'; 5 | import { normalizeUnit } from '../utils/cssUtil'; 6 | 7 | export interface AutoGridOption extends React.HTMLProps { 8 | children?: React.ReactNode; 9 | // 容器的样式 10 | containerStyle?: Interpolation; 11 | // 元素之间空白槽的宽度 12 | gap?: CSS.Property.Width; 13 | // 列的数目 14 | cols?: number; 15 | // 是否显示正方形 16 | isSquare?: boolean; 17 | // 元素容器的样式 18 | itemStyle?: Interpolation; 19 | } 20 | 21 | /** 22 | * 自动化生成表格 23 | * @param props 24 | */ 25 | export function AutoGrid(props: AutoGridOption) { 26 | const { 27 | children, 28 | cols: rawCols = 1, 29 | gap: rawGap = 0, 30 | isSquare = false, 31 | itemStyle, 32 | containerStyle, 33 | ...extra 34 | } = props; 35 | 36 | // 规范化数字单位 37 | const cols = +rawCols; 38 | const gap = normalizeUnit(rawGap) as string; 39 | 40 | // 获取表格数据 41 | const getGridData = useCallback(() => { 42 | // 生成一个能创建表格的二维数组 43 | let list: Array = []; 44 | React.Children.forEach(children, (child) => { 45 | if (child !== null) { 46 | if (list.length === 0 || list[list.length - 1].length >= cols) { 47 | list.push([]); 48 | } 49 | list[list.length - 1].push(child); 50 | } 51 | }); 52 | return list; 53 | }, [children]); 54 | 55 | // 元素的最终样式 56 | const finalItemBoxStyle: Interpolation = [ 57 | style.itemBoxStyle, 58 | { 59 | marginRight: gap, 60 | width: `calc((100% - ${cols - 1} * ${gap}) / ${cols})`, 61 | }, 62 | ]; 63 | 64 | /** 65 | * 显示内容 66 | */ 67 | const showContent = () => { 68 | const gridData = getGridData(); 69 | return gridData.map((row, rowIndex) => { 70 | // 每行的槽样式,最后一行没有 71 | let finalRowStyle: Interpolation = [ 72 | style.rowStyle, 73 | rowIndex !== gridData.length - 1 ? { marginBottom: gap } : {} 74 | ]; 75 | 76 | return ( 77 |
78 | {row.map((item, colIndex) => { 79 | let finalCss: Interpolation = [ 80 | ...finalItemBoxStyle, 81 | itemStyle 82 | ]; 83 | 84 | if (isSquare) { 85 | return ( 86 |
87 |
{item}
88 |
89 | ); 90 | } else { 91 | return ( 92 |
93 | {item} 94 |
95 | ); 96 | } 97 | })} 98 |
99 | ); 100 | }); 101 | }; 102 | 103 | return ( 104 |
105 | {showContent()} 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/utils/cssUtil.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation, Theme, css } from '@emotion/react'; 2 | import { ContextValue, getContextValue } from '../context'; 3 | /** 4 | * 匹配所有的CSS数值类型的值 5 | */ 6 | // eslint-disable-next-line no-useless-escape 7 | export const CSSValueReg = /^((?:\-)?(?:\d+\.?|\.\d+|\d+\.\d+))([a-zA-Z%]*)$/; 8 | 9 | /** 10 | * 标准化长度值单位 11 | * @param value 长度值 12 | * @param defaultUnit 默认长度值单位 13 | */ 14 | export function normalizeUnit(value?: number | string, defaultUnit = 'px') { 15 | if (typeof value === 'number') { 16 | return value + defaultUnit; 17 | } 18 | 19 | if (typeof value === 'string') { 20 | const result = value.match(CSSValueReg); 21 | if (Array.isArray(result)) { 22 | return result[2] 23 | ? parseFloat(value) + result[2] 24 | : parseFloat(value) + defaultUnit; 25 | } 26 | } 27 | 28 | return value; 29 | } 30 | 31 | /** 32 | * CSS值的对象表示 33 | */ 34 | export interface SplitedValue { 35 | num: number; 36 | unit: string; 37 | } 38 | 39 | /** 40 | * 41 | * 提取数字和单位,以下都是合理的CSS数值 42 | * 43 | * 123 44 | * 123px 45 | * .98px 46 | * 98.2px 47 | * -98rem 48 | * -0.98rem 49 | * 98. 50 | * 51 | * @param value 52 | * @param defaultUnit 53 | */ 54 | export function splitValue( 55 | value: number | string, 56 | defaultUnit = 'px' 57 | ): SplitedValue { 58 | if (typeof value === 'number') { 59 | return { num: value, unit: defaultUnit }; 60 | } 61 | 62 | if (typeof value === 'string') { 63 | const result = value.match(CSSValueReg); 64 | if (Array.isArray(result)) { 65 | return { num: parseFloat(result[1]), unit: result[2] || defaultUnit }; 66 | } 67 | } 68 | 69 | throw new Error('Invalid numeric format'); 70 | } 71 | 72 | /** 73 | * 生成自适应的样式,仅供库内部使用 74 | * 所有内部组件的默认设计尺寸约定为750 75 | * 76 | * @param style 77 | * @returns 78 | */ 79 | export function adaptive(style: Record>) { 80 | const ctx = getContextValue() as ContextValue; 81 | const max: Interpolation = {}; 82 | const min: Interpolation = {}; 83 | const normal: Interpolation = {}; 84 | for (let name in style) { 85 | let value = style[name]; 86 | if (typeof value !== 'number') { 87 | normal[name] = value as any; 88 | } else if ( 89 | [ 90 | 'flex', 91 | 'flexGrow', 92 | 'flexShrink', 93 | 'lineHeight', 94 | 'fontWeight', 95 | 'zIndex', 96 | ].includes(name) && 97 | typeof value === 'number' 98 | ) { 99 | normal[name] = value as any; 100 | } else { 101 | normal[name] = (value * 100) / 750 + 'vw'; 102 | max[name] = (value * ctx.maxDocWidth) / 750 + 'px'; 103 | min[name] = (value * ctx.minDocWidth) / 750 + 'px'; 104 | } 105 | } 106 | return css({ 107 | ...normal, 108 | [`@media (min-width: ${ctx.maxDocWidth}px)`]: max, 109 | [`@media (max-width: ${ctx.minDocWidth}px)`]: min, 110 | }); 111 | } -------------------------------------------------------------------------------- /src/Effect/useViewport.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import isPlainObject from "lodash/isPlainObject"; 3 | 4 | export interface ViewportAttr { 5 | // 宽度 6 | width: "device-width" | number | string; 7 | // 高度 8 | height: "device-height" | number | string; 9 | // 最大缩放 10 | maximumScale: number; 11 | // 最小缩放 12 | minimumScale: number; 13 | // 是否可缩放 14 | userScalable: "yes" | "no"; 15 | // 初始缩放比例 16 | initialScale: number; 17 | // 视口匹配 18 | viewportFit: "cover" | "contain" | "auto"; 19 | } 20 | 21 | export const metaContent = { 22 | /** 23 | * 解析meta的content字段 24 | * @param content 25 | * @returns 26 | */ 27 | parse(content: string): Record { 28 | content = content.replace(/\s+|,$/g, ""); 29 | if (!content) return {}; 30 | const parts = content.split(","); 31 | const output: Record = {}; 32 | for (let part of parts) { 33 | const arr = part.split("="); 34 | output[arr[0]] = arr[1]; 35 | } 36 | return output; 37 | }, 38 | 39 | /** 40 | * 生成meta的content字段 41 | * @param data 42 | * @returns 43 | */ 44 | stringify(data: Record): string { 45 | const parts: string[] = []; 46 | for (let key in data) { 47 | const part = `${key}=${data[key]}`; 48 | parts.push(part); 49 | } 50 | return parts.join(", "); 51 | }, 52 | }; 53 | 54 | export function useViewport(attr?: Partial) { 55 | let config: Partial = { 56 | width: "device-width", 57 | initialScale: 1, 58 | userScalable: "no", 59 | viewportFit: "cover", 60 | }; 61 | if (isPlainObject(attr)) { 62 | config = { ...config, ...attr }; 63 | } 64 | useEffect(() => { 65 | // 确保viewport的合法逻辑 66 | let meta: HTMLMetaElement | null = document.querySelector("meta[name='viewport']"); 67 | if (!meta) { 68 | meta = document.createElement("meta"); 69 | meta.name = "viewport"; 70 | document.head.prepend(meta); 71 | } 72 | 73 | const content: Record = metaContent.parse(meta.content || ""); 74 | if (config.width) { 75 | content.width = config.width; 76 | } 77 | if (config.height) { 78 | content.height = config.height; 79 | } 80 | if (config.maximumScale) { 81 | content["maximum-scale"] = config.maximumScale; 82 | } 83 | if (config.minimumScale) { 84 | content["minimum-scale"] = config.minimumScale; 85 | } 86 | if (config.initialScale) { 87 | content["initial-scale"] = config.initialScale; 88 | } 89 | if (config.userScalable) { 90 | content["user-scalable"] = config.userScalable; 91 | } 92 | if (config.viewportFit) { 93 | content["viewport-fit"] = config.viewportFit; 94 | } 95 | 96 | meta.content = metaContent.stringify(content); 97 | }, [ 98 | config.width, 99 | config.height, 100 | config.initialScale, 101 | config.maximumScale, 102 | config.minimumScale, 103 | config.userScalable, 104 | config.viewportFit, 105 | ]); 106 | } 107 | -------------------------------------------------------------------------------- /test/src/dialog/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { showDialog, RowCenter } from "@"; 3 | import style from "./index.module.css"; 4 | 5 | export default function Index() { 6 | return ( 7 |
8 |

9 | 26 | 27 | ), 28 | }); 29 | }} 30 | > 31 | 普通弹框,没有遮罩,空白可关闭 32 | 33 |

34 |

35 | 46 | 47 | ), 48 | }); 49 | }} 50 | > 51 | 从底部上拉出,没有遮罩,空白可关闭 52 | 53 |

54 |

55 | 62 | 63 | ), 64 | }); 65 | }} 66 | > 67 | 从顶部下拉出,有遮罩,空白不可关闭 68 | 69 |

70 |

71 | 78 | 79 | ), 80 | }); 81 | }} 82 | > 83 | 左侧往右侧拉出,有遮罩,空白不可关闭 84 | 85 |

86 |

87 | 94 | 95 | ), 96 | }); 97 | }} 98 | > 99 | 右侧往左侧拉出,有遮罩,空白不可关闭 100 | 101 |

102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /test/src/alert/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { showAlert, RowCenter } from "@"; 3 | 4 | export default function Index () { 5 | return ( 6 |
7 |

8 | 25 |

26 |

27 | 38 |

39 |

40 | 56 |

57 | 58 |

59 | 80 |

81 |

82 | 108 |

109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/Countdowner/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Interpolation, Theme } from '@emotion/react'; 3 | import { 4 | Countdown, 5 | CountdownOption, 6 | CountdownValue, 7 | CountdownValueIndex, 8 | } from '../utils/Countdown'; 9 | import { RowStart } from '../Flex/Row'; 10 | 11 | export interface CountdownerOption 12 | extends CountdownOption, 13 | React.HTMLProps { 14 | // 数字之间的分隔符 15 | separator?: React.ReactNode; 16 | // 分隔符的样式 17 | separatorStyle?: Interpolation; 18 | // 包裹容器的样式 19 | containerStyle?: Interpolation; 20 | // 数字的样式 21 | numberStyle?: Interpolation; 22 | // 数字的渲染组件 23 | renderNumber?: (value: number, key?: string) => React.ReactNode; 24 | // 渲染分隔符 25 | renderSeparator?: (value: number, key?: string) => React.ReactNode; 26 | } 27 | 28 | export function Countdowner(props: CountdownerOption) { 29 | let { 30 | remain = 0, 31 | separator = ':', 32 | format = 'his', 33 | onUpdate, 34 | onEnd, 35 | separatorStyle, 36 | containerStyle, 37 | numberStyle, 38 | renderNumber, 39 | renderSeparator, 40 | ...extra 41 | } = props; 42 | 43 | const [value, setValue] = useState(null); 44 | 45 | // 使用 ref 保存最新的回调,避免频繁重建倒计时实例 46 | const callbacksRef = React.useRef({ onUpdate, onEnd }); 47 | callbacksRef.current = { onUpdate, onEnd }; 48 | 49 | let content: Array = []; 50 | if (value && typeof value === 'object') { 51 | for (let i = 0; i < format.length; i++) { 52 | // 渲染数字进组件 53 | const key = format[i] as CountdownValueIndex; 54 | const num = value![key]!; 55 | let numberComponent: React.ReactNode; 56 | if (typeof renderNumber === 'function') { 57 | numberComponent = renderNumber(num, key); 58 | } else { 59 | // 默认以span包围,且数字不足10的时候有前置0 60 | numberComponent = ( 61 | {num < 10 ? `0${num}` : num} 62 | ); 63 | } 64 | content.push({numberComponent}); 65 | 66 | // 添加分隔符,最后一个数字不需要分隔符 67 | if (i !== format.length - 1) { 68 | let separatorComponent: React.ReactNode; 69 | if (typeof renderSeparator === 'function') { 70 | separatorComponent = renderSeparator(num, key); 71 | } else { 72 | separatorComponent = separator ? ( 73 | {separator} 74 | ) : null; 75 | } 76 | content.push( 77 | {separatorComponent} 78 | ); 79 | } 80 | } 81 | } 82 | 83 | useEffect(() => { 84 | let instance: Countdown | null = new Countdown({ 85 | format, 86 | remain, 87 | onUpdate(current) { 88 | setValue(current); 89 | // 使用 ref 中的最新回调 90 | callbacksRef.current.onUpdate?.(current); 91 | }, 92 | onEnd() { 93 | // 使用 ref 中的最新回调 94 | callbacksRef.current.onEnd?.(); 95 | }, 96 | }); 97 | instance.start(); 98 | 99 | // 执行清理逻辑 100 | return () => { 101 | instance!.stop(); 102 | instance = null; 103 | }; 104 | }, [format, remain]); // ← 移除 onUpdate 和 onEnd 依赖 105 | 106 | return ( 107 | 108 | {content} 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/Overlay/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { getContextValue } from "../context"; 4 | import { ContextValue } from "../context"; 5 | import { useWindowResize } from "../Effect/useWindowResize"; 6 | 7 | export interface OverlayProps extends React.HTMLProps { 8 | // 挂载元素的子元素 9 | children?: React.ReactNode; 10 | // 挂载位置,是否挂载到外部 11 | outside?: boolean; 12 | // 内容是否居中,默认居中 13 | centerContent?: boolean; 14 | // 是否全屏,默认全屏 15 | fullScreen?: boolean; 16 | // 遮罩的颜色,只有fullScreen状态生效,默认rgba(0, 0, 0, .6) 17 | maskColor?: string; 18 | } 19 | 20 | /** 21 | * 覆盖层,可以作为通用遮罩 22 | * @param props 23 | * @returns 24 | */ 25 | export function Overlay(props: OverlayProps) { 26 | const { 27 | children, 28 | outside = false, 29 | centerContent = true, 30 | fullScreen = true, 31 | maskColor = "rgba(0, 0, 0, .6)", 32 | ...extra 33 | } = props; 34 | 35 | const [mount, setMount] = useState(null); 36 | const [innerWidth, setInnerWidth] = useState(window.innerWidth); 37 | 38 | // 这里是为了修复一个非挂载状态触发resize事件的bug 39 | const isUnmount = useRef(false); 40 | useEffect(() => { 41 | return () => { 42 | isUnmount.current = true; 43 | }; 44 | }, []); 45 | 46 | useLayoutEffect(() => { 47 | if (outside) { 48 | const div = document.createElement("div"); 49 | document.body.appendChild(div); 50 | setMount(div); 51 | 52 | return () => { 53 | div.remove(); 54 | }; 55 | } 56 | }, [outside]); 57 | 58 | // 页面大小变化时,innerWidth 也会更新 59 | useWindowResize(() => { 60 | if (!isUnmount.current) { 61 | setInnerWidth(window.innerWidth); 62 | } 63 | }); 64 | 65 | const ctx = getContextValue() as ContextValue; 66 | 67 | // 使用 useMemo 缓存样式计算,避免每次渲染都重新计算 68 | const style = useMemo(() => { 69 | const styles: any[] = []; 70 | 71 | // 如果是全屏,设置全屏样式 72 | if (fullScreen) { 73 | // 获取宽度 74 | let width = innerWidth; 75 | if (width >= ctx.maxDocWidth) { 76 | width = ctx.maxDocWidth; 77 | } else if (width <= ctx.minDocWidth) { 78 | width = ctx.minDocWidth; 79 | } 80 | styles.push({ 81 | zIndex: 9999, 82 | position: "fixed", 83 | top: 0, 84 | left: "50%", 85 | marginLeft: `-${width / 2}px`, 86 | width: `${width}px`, 87 | height: "100%", 88 | maxWidth: `${ctx.maxDocWidth}px`, 89 | minWidth: `${ctx.minDocWidth}px`, 90 | backgroundColor: maskColor, 91 | }); 92 | } 93 | 94 | // 如果内容居中,设置内容居中样式 95 | if (centerContent) { 96 | styles.push({ 97 | display: "flex", 98 | alignItems: "center", 99 | justifyContent: "center", 100 | }); 101 | } 102 | 103 | return styles; 104 | }, [fullScreen, innerWidth, ctx.maxDocWidth, ctx.minDocWidth, maskColor, centerContent]); 105 | 106 | const content = ( 107 |
108 | {children} 109 |
110 | ); 111 | 112 | /** 113 | * 如果是挂载到当前位置,直接返回 114 | */ 115 | if (!outside) { 116 | return content; 117 | } 118 | 119 | /** 120 | * 如果是挂载到外部,但是挂载点还没准备好,返回空 121 | */ 122 | if (!mount) { 123 | return null; 124 | } 125 | 126 | /** 127 | * 挂载到外部,且挂载点已经准备好 128 | */ 129 | return createPortal(content, mount); 130 | } 131 | -------------------------------------------------------------------------------- /src/Toast/style.ts: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from "@emotion/react"; 2 | import { adaptive } from "../utils/cssUtil"; 3 | import { Keyframes } from "@emotion/serialize"; 4 | 5 | export const middleShowAnimation = keyframes` 6 | from { 7 | opacity: 0; 8 | transform: translateX(-50%) scale(0.9); 9 | } 10 | to { 11 | opacity: 1; 12 | transform: translateX(-50%) scale(1); 13 | } 14 | `; 15 | 16 | export const middleHideAnimation = keyframes` 17 | from { 18 | opacity: 1; 19 | transform: translateX(-50%) scale(1); 20 | } 21 | to { 22 | opacity: 0; 23 | transform: translateX(-50%) scale(0.9); 24 | } 25 | `; 26 | 27 | export const topShowAnimation = keyframes` 28 | from { 29 | opacity: 0; 30 | transform: translate(-50%, -100%); 31 | } 32 | to { 33 | opacity: 1; 34 | transform: translate(-50%, 0); 35 | } 36 | `; 37 | export const topHideAnimation = keyframes` 38 | from { 39 | opacity: 1; 40 | transform: translate(-50%, 0); 41 | } 42 | to { 43 | opacity: 0; 44 | transform: translate(-50%, -100%); 45 | } 46 | `; 47 | export const bottomShowAnimation = keyframes` 48 | from { 49 | opacity: 0; 50 | transform: translate(-50%, 100%); 51 | } 52 | to { 53 | opacity: 1; 54 | transform: translate(-50%, 0); 55 | } 56 | `; 57 | export const bottomHideAnimation = keyframes` 58 | from { 59 | opacity: 1; 60 | transform: translate(-50%, 0); 61 | } 62 | to { 63 | opacity: 0; 64 | transform: translate(-50%, 100%); 65 | } 66 | `; 67 | 68 | /** 69 | * 根据位置和类型获取动画 70 | * @param position 71 | * @param type 72 | * @returns 73 | */ 74 | export function getAnimation(position: "top" | "middle" | "bottom", type: "show" | "hide") { 75 | const animation = { 76 | top: [topShowAnimation, topHideAnimation], 77 | middle: [middleShowAnimation, middleHideAnimation], 78 | bottom: [bottomShowAnimation, bottomHideAnimation], 79 | }; 80 | let keyframes: Keyframes; 81 | if (type === "show") { 82 | keyframes = animation[position][0]; 83 | } else { 84 | keyframes = animation[position][1]; 85 | } 86 | 87 | return { 88 | keyframes, 89 | animation: css({ 90 | animation: `${keyframes} 300ms ease`, 91 | }), 92 | }; 93 | } 94 | 95 | export const style = { 96 | container() { 97 | return css( 98 | { 99 | position: "fixed", 100 | left: "50%", 101 | transform: "translateX(-50%)", 102 | zIndex: 9999, 103 | }, 104 | adaptive({ 105 | maxWidth: 600, 106 | }) 107 | ); 108 | }, 109 | 110 | top(offset: number) { 111 | return adaptive({ top: offset }); 112 | }, 113 | middle: css({ top: "50%" }), 114 | bottom(offset: number) { 115 | return adaptive({ bottom: offset }); 116 | }, 117 | content: (radius?: number) => { 118 | return css( 119 | { 120 | position: "relative", 121 | backgroundColor: "rgba(0, 0, 0, .8)", 122 | color: "#fff", 123 | margin: 0, 124 | whiteSpace: "nowrap", 125 | textOverflow: "ellipsis", 126 | overflow: "hidden", 127 | lineHeight: 1, 128 | }, 129 | adaptive({ 130 | fontSize: 26, 131 | paddingLeft: 30, 132 | paddingRight: 30, 133 | paddingTop: 40, 134 | paddingBottom: 40, 135 | borderRadius: radius ?? 0, 136 | }) 137 | ); 138 | }, 139 | contentMiddle: { 140 | transform: `translateY(-50%)`, 141 | }, 142 | }; 143 | -------------------------------------------------------------------------------- /src/Clickable/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useRef, 4 | useState, 5 | useEffect, 6 | CSSProperties, 7 | } from 'react'; 8 | import { is } from '../utils/is'; 9 | 10 | /** 11 | * 可触摸元素的属性,兼容PC 12 | */ 13 | export interface ClickableProps extends React.HTMLProps { 14 | // 包裹元素的子元素 15 | children?: React.ReactNode; 16 | // 是否允许冒泡 17 | bubble?: boolean; 18 | // 激活时的类 19 | activeClassName?: string; 20 | // 激活时的样式 21 | activeStyle?: React.CSSProperties; 22 | // 禁用点击态行为 23 | disable?: boolean; 24 | } 25 | 26 | export function Clickable(props: Partial) { 27 | let { 28 | children, 29 | bubble = true, 30 | className, 31 | activeClassName, 32 | style, 33 | activeStyle, 34 | disable = false, 35 | ...attrs 36 | } = props; 37 | 38 | // 如果激活样式和激活类都不存在,则设置激活默认样式 39 | // 使用 useMemo 避免每次渲染都创建新对象 40 | const defaultActiveStyle = React.useMemo(() => { 41 | if (!activeClassName && !activeStyle) { 42 | return { opacity: 0.6 }; 43 | } 44 | return activeStyle; 45 | }, [activeClassName, activeStyle]); 46 | 47 | const finalActiveStyle = defaultActiveStyle || activeStyle; 48 | 49 | const touchable = is('touchable'); 50 | const [boxClass, setBoxClass] = useState(className); 51 | const [boxStyle, setBoxStyle] = useState(style); 52 | 53 | // 监控属性的更新 54 | useEffect(() => { 55 | setBoxClass(className); 56 | setBoxStyle(style); 57 | }, [className, style]); 58 | 59 | // 标记是否正处于触摸状态 60 | const touchRef = useRef(false); 61 | const onStart = ( 62 | event: React.TouchEvent | React.MouseEvent 63 | ) => { 64 | if (!touchRef.current) { 65 | touchRef.current = true; 66 | // 阻止冒泡 67 | if (!bubble) { 68 | event.stopPropagation(); 69 | } 70 | // 激活目标样式 71 | if (typeof activeClassName === 'string') { 72 | setBoxClass( 73 | typeof boxClass === 'string' 74 | ? `${boxClass} ${activeClassName}` 75 | : activeClassName 76 | ); 77 | } 78 | if (typeof finalActiveStyle === 'object') { 79 | setBoxStyle( 80 | typeof boxStyle === 'object' 81 | ? { ...boxStyle, ...finalActiveStyle } 82 | : finalActiveStyle 83 | ); 84 | } 85 | } 86 | }; 87 | 88 | // onEnd返回记忆的版本,防止下一个effect中无意义重复执行 89 | const onEnd = useCallback<() => void>(() => { 90 | if (touchRef.current) { 91 | touchRef.current = false; 92 | setBoxClass(className); 93 | setBoxStyle(style); 94 | } 95 | }, [className, style]); 96 | 97 | // PC环境释放逻辑 98 | useEffect(() => { 99 | if (!disable && !touchable) { 100 | const doc = document.documentElement; 101 | doc.addEventListener('mouseup', onEnd); 102 | return () => { 103 | doc.removeEventListener('mouseup', onEnd); 104 | }; 105 | } 106 | }, [disable, touchable, onEnd]); 107 | 108 | const fullAttrs: React.HTMLProps = { 109 | ...attrs, 110 | className: boxClass, 111 | style: boxStyle, 112 | }; 113 | 114 | // 非禁用状态有点击态行为 115 | if (!disable) { 116 | if (touchable) { 117 | // 当前如果是触摸环境 118 | fullAttrs.onTouchStart = onStart; 119 | fullAttrs.onTouchEnd = onEnd; 120 | fullAttrs.onTouchCancel = onEnd; 121 | } else { 122 | fullAttrs.onMouseDown = onStart; 123 | } 124 | } 125 | 126 | return
{children}
; 127 | } 128 | -------------------------------------------------------------------------------- /src/utils/is.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 环境类型定义 3 | */ 4 | export type EnvType = 5 | | "ios" // iOS 平台(iPhone/iPad/iPod) 6 | | "android" // Android 平台 7 | | "wechat" // 微信环境 8 | | "qq" // QQ/QQ浏览器 9 | | "alipay" // 支付宝环境 10 | | "weibo" // 微博环境 11 | | "douyin" // 抖音环境 12 | | "xiaohongshu" // 小红书 13 | | "toutiao" // 今日头条 14 | | "baidu" // 百度 APP 15 | | "touchable"; // 可触摸环境 16 | 17 | // 缓存 UserAgent,避免重复获取 18 | const UA = window.navigator.userAgent; 19 | 20 | // 预编译正则表达式,提升性能 21 | const REGEX_PATTERNS = { 22 | ios: /iPhone|iPad|iPod/i, 23 | // iPadOS 13+ 会显示为 Mac,需要通过 maxTouchPoints 判断 24 | ipad: /Macintosh/i, 25 | android: /Android/i, 26 | wechat: /MicroMessenger/i, 27 | // QQ 有多种形式:手Q、QQ浏览器等 28 | qq: /\s(QQ|MQQBrowser)\//i, 29 | alipay: /AlipayClient/i, 30 | weibo: /Weibo/i, 31 | douyin: /aweme/i, 32 | xiaohongshu: /xhsdiscover/i, 33 | toutiao: /NewsArticle/i, 34 | baidu: /baiduboxapp/i, 35 | } as const; 36 | 37 | // 结果缓存,避免重复判断 38 | const cache = new Map(); 39 | 40 | /** 41 | * 判断是否为触摸设备 42 | * 综合多种方式判断,提高准确性 43 | */ 44 | function isTouchable(): boolean { 45 | // 1. 检查是否支持触摸事件 46 | const hasTouchEvent = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 47 | 48 | // 2. 检查是否为移动设备 UA 49 | const isMobileUA = /Mobile|Android|iPhone|iPad|iPod/i.test(UA); 50 | 51 | // 3. 综合判断 52 | return hasTouchEvent && isMobileUA; 53 | } 54 | 55 | /** 56 | * 判断是否为 iPad(包括 iPadOS 13+ 伪装成 Mac 的情况) 57 | */ 58 | function isIPad(): boolean { 59 | // iPadOS 13+ 显示为 Mac,但有触摸点 60 | if (REGEX_PATTERNS.ipad.test(UA) && navigator.maxTouchPoints > 1) { 61 | return true; 62 | } 63 | return /iPad/i.test(UA); 64 | } 65 | 66 | /** 67 | * 常用的简单环境判断 68 | * @param env 环境类型 69 | * @returns 是否匹配该环境 70 | * 71 | * @example 72 | * ```typescript 73 | * if (is('ios')) { 74 | * // iOS 特定逻辑 75 | * } 76 | * 77 | * if (is('wechat')) { 78 | * // 微信内逻辑 79 | * } 80 | * ``` 81 | */ 82 | export function is(env: EnvType): boolean { 83 | // 从缓存中获取结果 84 | if (cache.has(env)) { 85 | return cache.get(env)!; 86 | } 87 | 88 | let result = false; 89 | 90 | switch (env.toLowerCase()) { 91 | case "ios": 92 | result = REGEX_PATTERNS.ios.test(UA) || isIPad(); 93 | break; 94 | case "android": 95 | result = REGEX_PATTERNS.android.test(UA); 96 | break; 97 | case "wechat": 98 | result = REGEX_PATTERNS.wechat.test(UA); 99 | break; 100 | case "qq": 101 | result = REGEX_PATTERNS.qq.test(UA); 102 | break; 103 | case "alipay": 104 | result = REGEX_PATTERNS.alipay.test(UA); 105 | break; 106 | case "weibo": 107 | result = REGEX_PATTERNS.weibo.test(UA); 108 | break; 109 | case "douyin": 110 | result = REGEX_PATTERNS.douyin.test(UA); 111 | break; 112 | case "xiaohongshu": 113 | result = REGEX_PATTERNS.xiaohongshu.test(UA); 114 | break; 115 | case "toutiao": 116 | result = REGEX_PATTERNS.toutiao.test(UA); 117 | break; 118 | case "baidu": 119 | result = REGEX_PATTERNS.baidu.test(UA); 120 | break; 121 | case "touchable": 122 | result = isTouchable(); 123 | break; 124 | default: 125 | result = false; 126 | } 127 | 128 | // 缓存结果 129 | cache.set(env, result); 130 | return result; 131 | } 132 | 133 | /** 134 | * 清除缓存(用于测试或特殊场景) 135 | */ 136 | export function clearIsCache(): void { 137 | cache.clear(); 138 | } 139 | -------------------------------------------------------------------------------- /src/Dialog/style.ts: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from "@emotion/react"; 2 | import { Keyframes } from "@emotion/serialize"; 3 | 4 | const maskShow = keyframes` 5 | from { 6 | opacity: 0; 7 | } 8 | to { 9 | opacity: 1; 10 | } 11 | `; 12 | export const maskHide = keyframes` 13 | from { 14 | opacity: 1; 15 | } 16 | to { 17 | opacity: 0; 18 | } 19 | `; 20 | const pullUpShow = keyframes` 21 | from { 22 | transform: translateY(100%); 23 | } 24 | to { 25 | transform: translateY(0); 26 | } 27 | `; 28 | const pullUpHide = keyframes` 29 | from { 30 | transform: translateY(0); 31 | } 32 | to { 33 | transform: translateY(100%); 34 | } 35 | `; 36 | const pullDownShow = keyframes` 37 | from { 38 | transform: translateY(-100%); 39 | } 40 | to { 41 | transform: translateY(0); 42 | } 43 | `; 44 | const pullDownHide = keyframes` 45 | from { 46 | transform: translateY(0); 47 | } 48 | to { 49 | transform: translateY(-100%); 50 | } 51 | `; 52 | const pullLeftShow = keyframes` 53 | from { 54 | transform: translateX(100%); 55 | } 56 | to { 57 | transform: translateX(0); 58 | } 59 | `; 60 | const pullLeftHide = keyframes` 61 | from { 62 | transform: translateX(0); 63 | } 64 | to { 65 | transform: translateX(100%); 66 | } 67 | `; 68 | const pullRightShow = keyframes` 69 | from { 70 | transform: translateX(-100%); 71 | } 72 | to { 73 | transform: translateX(0); 74 | } 75 | `; 76 | const pullRightHide = keyframes` 77 | from { 78 | transform: translateX(0); 79 | } 80 | to { 81 | transform: translateX(-100%); 82 | } 83 | `; 84 | const centerShow = keyframes` 85 | from { 86 | transform: scale(0.8); 87 | opacity: 0; 88 | } 89 | to { 90 | transform: scale(1); 91 | opacity: 1; 92 | } 93 | `; 94 | const centerHide = keyframes` 95 | from { 96 | transform: scale(1); 97 | opacity: 1; 98 | } 99 | to { 100 | transform: scale(0.8); 101 | opacity: 0; 102 | } 103 | `; 104 | 105 | export type DialogType = 106 | | "center" 107 | | "pullUp" 108 | | "pullDown" 109 | | "pullLeft" 110 | | "pullRight"; 111 | 112 | export type AnimationStatus = "show" | "hide"; 113 | 114 | export function getAnimation(type: DialogType, status: AnimationStatus) { 115 | const animation = { 116 | center: [centerShow, centerHide], 117 | pullUp: [pullUpShow, pullUpHide], 118 | pullDown: [pullDownShow, pullDownHide], 119 | pullLeft: [pullLeftShow, pullLeftHide], 120 | pullRight: [pullRightShow, pullRightHide], 121 | }; 122 | 123 | let keyframes: Keyframes; 124 | if (status === "show") { 125 | keyframes = animation[type][0]; 126 | } else { 127 | keyframes = animation[type][1]; 128 | } 129 | 130 | return { 131 | keyframes, 132 | animation: css({ 133 | animation: `${keyframes} 300ms ease`, 134 | }), 135 | }; 136 | } 137 | 138 | export const style = { 139 | maskShow: css({ 140 | animation: `${maskShow} 300ms ease`, 141 | }), 142 | maskHide: css({ 143 | animation: `${maskHide} 300ms ease`, 144 | }), 145 | mask: css({ 146 | zIndex: 1, 147 | position: "absolute", 148 | left: 0, 149 | bottom: 0, 150 | width: "100%", 151 | height: "100%", 152 | backgroundColor: "rgba(0, 0, 0, 0.6)", 153 | }), 154 | boxCss: css({ 155 | zIndex: 2, 156 | }), 157 | pullUp: css({ 158 | position: "absolute", 159 | left: 0, 160 | bottom: 0, 161 | width: "100%", 162 | }), 163 | pullDown: css({ 164 | position: "absolute", 165 | left: 0, 166 | top: 0, 167 | width: "100%", 168 | }), 169 | pullLeft: css({ 170 | position: "absolute", 171 | right: 0, 172 | top: 0, 173 | height: "100%", 174 | }), 175 | pullRight: css({ 176 | position: "absolute", 177 | left: 0, 178 | top: 0, 179 | height: "100%", 180 | }), 181 | }; 182 | -------------------------------------------------------------------------------- /src/utils/Countdown.ts: -------------------------------------------------------------------------------- 1 | import { tick } from './tick'; 2 | 3 | export type CountdownValueIndex = 'd' | 'h' | 'i' | 's'; 4 | export type CountdownValue = { 5 | [key in CountdownValueIndex]?: number; 6 | }; 7 | export type UpdateCallback = (value: CountdownValue) => void; 8 | 9 | export interface CountdownOption { 10 | // 倒计时剩余时间 11 | remain?: number | string; 12 | // 更新时触发 13 | onUpdate?: UpdateCallback; 14 | // 结束时触发 15 | onEnd?: () => void; 16 | // 格式dhis 17 | format?: string; 18 | } 19 | 20 | export class Countdown { 21 | /** 22 | * 倒计时的剩余时间,单位为秒 23 | */ 24 | total = 0; 25 | remain = 0; 26 | 27 | /** 28 | * 当前倒计时的格式 29 | * d:天 30 | * h:时 31 | * i:分 32 | * s:秒 33 | */ 34 | format = ['d', 'h', 'i', 's']; 35 | 36 | // 逐帧tick 37 | _stopTick?: () => void; 38 | // 每次更新时都会调用 39 | _onUpdate?: UpdateCallback; 40 | // 结束时触发调用 41 | _onEnd?: () => void; 42 | 43 | constructor(option: CountdownOption) { 44 | if (typeof option.remain === 'number' && option.remain >= 0) { 45 | this.total = this.remain = option.remain; 46 | } 47 | 48 | // 倒计时需要展示的时间格式 49 | if (typeof option.format === 'string') { 50 | const parts = option.format.split(''); 51 | const output: string[] = []; 52 | this.format.forEach((item) => { 53 | if (parts.includes(item)) { 54 | output.push(item); 55 | } 56 | }); 57 | this.format = output; 58 | } else { 59 | // 设置默认的倒计时格式 60 | this.format = ['h', 'i', 's']; 61 | } 62 | 63 | this._onUpdate = option.onUpdate; 64 | this._onEnd = option.onEnd; 65 | } 66 | 67 | onUpdate(callback: UpdateCallback) { 68 | this._onUpdate = callback; 69 | } 70 | 71 | onEnd(callback: () => void) { 72 | this._onEnd = callback; 73 | } 74 | 75 | start() { 76 | // 如果倒计时时间不够,直接返回 77 | if (this.remain <= 0) { 78 | this._stopTick?.(); 79 | this._onUpdate?.(this.formatValue()); 80 | this._onEnd?.(); 81 | return; 82 | } 83 | 84 | // 初始化立即触发一次更新 85 | this._onUpdate?.(this.formatValue()); 86 | 87 | // 记录倒计时开启时的时间 88 | const start = Date.now(); 89 | 90 | // 使用 1000ms 间隔,避免每帧都执行(性能优化) 91 | this._stopTick = tick(() => { 92 | // 获取倒计时已经持续的时间 93 | const duration = Math.floor((Date.now() - start) / 1000); 94 | const currentRemain = this.total - duration; 95 | 96 | // 倒计时结束 97 | if (currentRemain <= 0) { 98 | this.remain = 0; 99 | this._stopTick?.(); 100 | this._onUpdate?.(this.formatValue()); 101 | this._onEnd?.(); 102 | return; 103 | } 104 | 105 | // 调用更新,这里是防止一秒以内多次反复渲染 106 | if (currentRemain !== this.remain) { 107 | this.remain = currentRemain; 108 | this._onUpdate?.(this.formatValue()); 109 | } 110 | }, 1000); // ← 添加 1000ms 间隔 111 | } 112 | 113 | // 停止倒计时 114 | stop() { 115 | this.total = this.remain; 116 | this._stopTick?.(); 117 | } 118 | 119 | /** 120 | * 格式化每次更新的值 121 | * 注意:format 的顺序决定了如何分配时间 122 | * 例如:format='his' 时,72小时会显示为 72:00:00 123 | * format='dhis' 时,72小时会显示为 3:0:00:00(3天) 124 | */ 125 | formatValue(): CountdownValue { 126 | let remainTime = this.remain; 127 | const result: CountdownValue = {}; 128 | 129 | this.format.forEach((key) => { 130 | switch (key) { 131 | case 'd': 132 | result.d = Math.floor(remainTime / 86400); 133 | remainTime = remainTime - result.d * 86400; 134 | break; 135 | case 'h': 136 | result.h = Math.floor(remainTime / 3600); 137 | remainTime = remainTime - result.h * 3600; 138 | break; 139 | case 'i': 140 | result.i = Math.floor(remainTime / 60); 141 | remainTime = remainTime - result.i * 60; 142 | break; 143 | case 's': 144 | result.s = remainTime; 145 | break; 146 | default: 147 | break; 148 | } 149 | }); 150 | return result; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/CarouselNotice/index.tsx: -------------------------------------------------------------------------------- 1 | import { Interpolation, css, Theme } from '@emotion/react'; 2 | import * as CSS from 'csstype'; 3 | import { useState, useEffect } from 'react'; 4 | import { useInterval } from '../Effect/useInterval'; 5 | import { style, Bubble } from './style'; 6 | 7 | export interface CarouselNoticeOption 8 | extends React.DetailedHTMLProps< 9 | React.HTMLAttributes, 10 | HTMLDivElement 11 | > { 12 | // 需要滚动的列表 13 | list: Array; 14 | // 滚动容器的宽度 15 | width: CSS.Property.Width; 16 | // 滚动容器的高度 17 | height: CSS.Property.Height; 18 | // 滚动的内容水平对齐,默认center 19 | justify: 'start' | 'center' | 'end'; 20 | // 每一次冒泡持续时间(单位毫秒),默认200ms 21 | duration: number; 22 | // 每一轮冒泡切换的时间间(单位毫秒),默认3000ms 23 | interval: number; 24 | // 容器样式 25 | containerStyle?: Interpolation; 26 | // 内部容器样式 27 | wrapperStyle?: Interpolation; 28 | // 条目样式 29 | itemStyle?: Interpolation; 30 | } 31 | 32 | /** 33 | * 滚动循环轮播公告 34 | * @param props 35 | */ 36 | export function CarouselNotice(props: Partial) { 37 | const { 38 | width, 39 | height, 40 | justify = 'center', 41 | interval = 3000, 42 | duration = 200, 43 | list = [], 44 | containerStyle, 45 | wrapperStyle, 46 | itemStyle, 47 | ...attrs 48 | } = props; 49 | 50 | const [current, setCurrent] = useState(0); 51 | const [animation, setAnimation] = useState(false); 52 | 53 | /** 54 | * 一旦列表发生更新时,触发的逻辑 55 | */ 56 | useEffect(() => { 57 | setCurrent(0); 58 | setAnimation(false); 59 | }, [list]); 60 | 61 | /** 62 | * 每隔多少秒更新一次动画 63 | */ 64 | useInterval( 65 | () => { 66 | setAnimation(true); 67 | }, 68 | list.length > 1 ? interval : null 69 | ); 70 | 71 | /** 72 | * 当前显示的两条数据,只用两条数据来轮播 73 | */ 74 | const showContent = () => { 75 | const justifyStyle: Interpolation = {}; 76 | if (justify === 'center') { 77 | justifyStyle.justifyContent = 'center'; 78 | } else if (justify === 'start') { 79 | justifyStyle.justifyContent = 'flex-start'; 80 | } else if (justify === 'end') { 81 | justifyStyle.justifyContent = 'flex-end'; 82 | } else { 83 | justifyStyle.justifyContent = 'center'; 84 | } 85 | 86 | const itemCss = [style.item, justifyStyle]; 87 | 88 | if (list.length === 1) { 89 | return ( 90 |
91 | {list[0]} 92 |
93 | ); 94 | } 95 | 96 | const showList: Array = []; 97 | if (current === list.length - 1) { 98 | showList.push( 99 |
100 | {list[list.length - 1]} 101 |
102 | ); 103 | showList.push( 104 |
105 | {list[0]} 106 |
107 | ); 108 | } else { 109 | showList.push( 110 |
111 | {list[current]} 112 |
113 | ); 114 | showList.push( 115 |
116 | {list[current + 1]} 117 |
118 | ); 119 | } 120 | return showList; 121 | }; 122 | 123 | /** 124 | * 获取动画 125 | */ 126 | const getAnimation = () => { 127 | if (!animation || list.length <= 1) { 128 | return null; 129 | } 130 | return css({ 131 | animationName: Bubble, 132 | animationTimingFunction: 'linear', 133 | animationDuration: `${duration}ms`, 134 | }); 135 | }; 136 | 137 | /** 138 | * 一轮动画结束时触发下一轮 139 | */ 140 | const animationEnd = () => { 141 | let newIndex = current + 1; 142 | if (current >= list.length - 1) { 143 | newIndex = 0; 144 | } 145 | setCurrent(newIndex); 146 | setAnimation(false); 147 | }; 148 | 149 | return ( 150 | Array.isArray(list) && 151 | list.length > 0 && ( 152 |
153 |
157 | {showContent()} 158 |
159 |
160 | ) 161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/Container/index.tsx: -------------------------------------------------------------------------------- 1 | import { Global, Interpolation, Theme } from "@emotion/react"; 2 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 3 | import { ContextValue, getContextValue } from "../context"; 4 | import { useWindowResize } from "../Effect/useWindowResize"; 5 | import { useViewport } from "../Effect/useViewport"; 6 | 7 | export interface ContainerProps { 8 | // 用户自定义的全局样式 9 | globalStyle?: Interpolation; 10 | // 容器包裹的子元素 11 | children?: React.ReactNode; 12 | // 设计尺寸 13 | designWidth?: number; 14 | } 15 | 16 | /** 17 | * 自适应容器 18 | * @param props 19 | */ 20 | export function Container(props: ContainerProps) { 21 | // 来自全局的环境变量 22 | const { minDocWidth, maxDocWidth } = getContextValue() as ContextValue; 23 | // 获取环境变量 24 | const { designWidth = 750, globalStyle, children } = props; 25 | 26 | // 计算根字体尺寸的函数(使用 useCallback 避免重复创建) 27 | const calculateFontSize = useCallback( 28 | (width: number): number => { 29 | let targetWidth = width; 30 | if (width >= maxDocWidth) { 31 | targetWidth = maxDocWidth; 32 | } else if (width <= minDocWidth) { 33 | targetWidth = minDocWidth; 34 | } 35 | return (targetWidth * 100) / designWidth; 36 | }, 37 | [designWidth, minDocWidth, maxDocWidth] 38 | ); 39 | 40 | // 基准字体尺寸(初始化时计算一次) 41 | const [baseFontSize, setBaseFontSize] = useState(() => 42 | calculateFontSize(window.innerWidth) 43 | ); 44 | 45 | // 是否已完成初始化(包括字体缩放修正) 46 | const [isInitialized, setIsInitialized] = useState(false); 47 | 48 | // 字体缩放修正逻辑(处理浏览器字体设置影响) 49 | // 使用 useLayoutEffect 在 DOM 更新后立即同步执行,避免闪烁 50 | useEffect(() => { 51 | // 只在未初始化时检查一次 52 | if (isInitialized) return; 53 | 54 | // 延迟到下一帧检查,确保 DOM 已经应用了 baseFontSize 55 | requestAnimationFrame(() => { 56 | const computedSize = parseFloat( 57 | window.getComputedStyle(document.documentElement).fontSize 58 | ); 59 | 60 | // 如果计算出的字体大小与期望不符(说明被浏览器字体设置影响了) 61 | // 使用较大的容差值,避免浮点数精度问题导致的无限循环 62 | if ( 63 | typeof computedSize === "number" && 64 | computedSize > 0 && 65 | Math.abs(computedSize - baseFontSize) > 1 // 容差 1px,避免过度敏感 66 | ) { 67 | // 计算浏览器的字体缩放比例 68 | const scaleFactor = computedSize / baseFontSize; 69 | 70 | // 通过反向缩放修正字体大小 71 | // 例如:期望 50px,实际 60px(1.2倍),则设置 50/1.2 ≈ 41.67px 72 | const correctedSize = 73 | Math.round((baseFontSize / scaleFactor) * 10) / 10; 74 | 75 | // 只修正一次,然后标记为已初始化 76 | setBaseFontSize(correctedSize); 77 | setIsInitialized(true); 78 | } else { 79 | // 字体大小正确,直接标记为已初始化 80 | setIsInitialized(true); 81 | } 82 | }); 83 | }, [baseFontSize, isInitialized]); 84 | 85 | // 页面大小变化时,基准字体同步变化 86 | // 使用 requestAnimationFrame 批量处理,避免频繁更新 87 | useWindowResize(() => { 88 | requestAnimationFrame(() => { 89 | const newFontSize = calculateFontSize(window.innerWidth); 90 | if (newFontSize !== baseFontSize) { 91 | setBaseFontSize(newFontSize); 92 | } 93 | }); 94 | }); 95 | 96 | // 设置meta, 确保viewport的合法逻辑 97 | useViewport(); 98 | 99 | // 页面初始化逻辑 100 | useEffect(() => { 101 | // 激活iOS上的:active伪类 102 | const activable = () => {}; 103 | document.body.addEventListener("touchstart", activable, { passive: true }); 104 | 105 | return () => { 106 | document.body.removeEventListener("touchstart", activable); 107 | }; 108 | }, []); 109 | 110 | // 使用 useMemo 缓存媒体查询样式,避免每次渲染都重新计算 111 | const mediaQueryStyles = useMemo( 112 | () => ({ 113 | [`@media (min-width: ${maxDocWidth}px)`]: { 114 | html: { 115 | fontSize: `${(100 * maxDocWidth) / designWidth}px`, 116 | }, 117 | }, 118 | [`@media (max-width: ${minDocWidth}px)`]: { 119 | html: { 120 | fontSize: `${(100 * minDocWidth) / designWidth}px`, 121 | }, 122 | }, 123 | }), 124 | [designWidth, minDocWidth, maxDocWidth] 125 | ); 126 | 127 | return ( 128 | 129 | 153 | {isInitialized ? children : null} 154 | 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /src/utils/createApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { 4 | createBrowserHistory, 5 | createHashHistory, 6 | createMemoryHistory, 7 | History, 8 | } from "history"; 9 | import { Container, ContainerProps } from "../Container"; 10 | import { ContextValue, setContextValue } from "../context"; 11 | import pick from "lodash/pick"; 12 | 13 | export type RouterMode = "browser" | "hash" | "memory"; 14 | export type AwaitValue = T | Promise; 15 | 16 | export interface CreateAppOption 17 | extends Omit, 18 | ContextValue { 19 | // 页面组件加载前触发的钩子函数 20 | onBefore?: (pathname: string) => AwaitValue; 21 | // 页面组件加载后触发的钩子函数 22 | onAfter?: (pathname: string) => AwaitValue; 23 | // 返回加载中占位组件 24 | loading?: (pathname: string) => AwaitValue; 25 | // 根据路径加载并返回页面组件 26 | render?: (pathname: string) => AwaitValue; 27 | // 页面未找到时的错误处理 28 | notFound?: (pathname: string) => AwaitValue; 29 | // 路由模式 30 | mode?: RouterMode; 31 | // 默认路由路径 32 | default?: string; 33 | // 挂载目标元素(选择器或 DOM 元素) 34 | target: string | HTMLElement; 35 | } 36 | 37 | // 存储历史记录对象 38 | export let history: null | History = null; 39 | // 获取历史记录对象 40 | export function getHistory(mode: RouterMode = "browser") { 41 | if (history === null) { 42 | const createMap: Record History> = { 43 | browser: createBrowserHistory, 44 | hash: createHashHistory, 45 | memory: createMemoryHistory, 46 | }; 47 | history = createMap[mode](); 48 | } 49 | return history; 50 | } 51 | 52 | /** 53 | * 创建带路由的APP对象,全局对象,绝大部分情况下只需要调用一次 54 | * @param option CreateAppOption 55 | */ 56 | export async function createApp(option: CreateAppOption) { 57 | // 设置默认的路由方式 58 | if ( 59 | !option.mode || 60 | ["browser", "hash", "memory"].indexOf(option.mode) === -1 61 | ) { 62 | option.mode = "browser"; 63 | } 64 | // 设置默认路由路径 65 | if (!option.default) { 66 | option.default = "/index"; 67 | } 68 | 69 | // 这里是为了确保历史记录对象在组件渲染之前一定存在 70 | history = getHistory(option.mode); 71 | 72 | // 提取关键数据 73 | const context: ContextValue = pick(option, ["minDocWidth", "maxDocWidth"]); 74 | const containerProps: ContainerProps = pick(option, [ 75 | "designWidth", 76 | "globalStyle", 77 | ]); 78 | const { onBefore, onAfter, loading, render, notFound } = option; 79 | 80 | // 设置上下文属性 81 | setContextValue(context); 82 | 83 | // 规范化路径:移除首尾斜杠 84 | const PATH_TRIM_REGEX = /^\/*|\/*$/g; 85 | const normalizePath = (path: string): string => { 86 | const normalized = path.replace(PATH_TRIM_REGEX, ""); 87 | return normalized || option.default!.replace(PATH_TRIM_REGEX, ""); 88 | }; 89 | 90 | /** 91 | * 全局APP组件对象 92 | * @returns 93 | */ 94 | const App = () => { 95 | const [page, setPage] = useState(null); 96 | 97 | /** 98 | * 加载并渲染页面 99 | */ 100 | const loadAndRenderPage = useCallback( 101 | async (pathname: string) => { 102 | const normalizedPath = normalizePath(pathname); 103 | 104 | // 如果有 loading 占位符,先显示 105 | if (typeof loading === "function") { 106 | setPage(await loading(normalizedPath)); 107 | } 108 | 109 | // 页面加载前钩子 110 | await onBefore?.(normalizedPath); 111 | 112 | // 加载并显示页面 113 | if (typeof render === "function") { 114 | const pageContent = await render(normalizedPath); 115 | 116 | // 如果返回 null/undefined,视为页面未找到 117 | if (pageContent === null || pageContent === undefined) { 118 | if (typeof notFound === "function") { 119 | setPage(await notFound(normalizedPath)); 120 | } else { 121 | // 默认 404 页面 122 | setPage(
Not Found: {normalizedPath}
); 123 | } 124 | return; 125 | } 126 | 127 | setPage(pageContent); 128 | } 129 | 130 | // 页面加载后钩子 131 | await onAfter?.(normalizedPath); 132 | }, 133 | [onBefore, onAfter, loading, render, notFound] 134 | ); 135 | 136 | /** 137 | * 监听路由变化 138 | */ 139 | useEffect(() => { 140 | // 监听页面变化,一旦变化渲染新页面 141 | const unlisten = history!.listen(({ location }) => { 142 | loadAndRenderPage(location.pathname); 143 | }); 144 | 145 | // 初始化时渲染当前路径对应的页面 146 | loadAndRenderPage(history!.location.pathname); 147 | 148 | // 卸载时,取消监听 149 | return unlisten; 150 | }, [loadAndRenderPage]); 151 | 152 | return {page}; 153 | }; 154 | 155 | // 获取挂载对象 156 | let mount: HTMLElement | null = null; 157 | if (typeof option.target === "string") { 158 | mount = document.querySelector(option.target); 159 | } else if (option.target instanceof HTMLElement) { 160 | mount = option.target; 161 | } else { 162 | throw new Error("No mounted object is specified"); 163 | } 164 | 165 | const root = createRoot(mount!); 166 | root.render(); 167 | } 168 | -------------------------------------------------------------------------------- /src/ScrollView/index.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Interpolation, jsx, SerializedStyles, Theme } from "@emotion/react"; 3 | import * as CSS from "csstype"; 4 | import { useCallback, useLayoutEffect, useRef, useState } from "react"; 5 | import { Indicator } from "../Indicator"; 6 | import { RowCenter } from "../Flex/Row"; 7 | import { style } from "./style"; 8 | 9 | // 经过特别计算的滚动事件参数 10 | export interface ScrollEvent { 11 | containerHeight: number; 12 | contentHeight: number; 13 | scrollTop: number; 14 | maxScroll: number; 15 | direction: "upward" | "downward"; 16 | rawEvent?: React.UIEvent; 17 | } 18 | 19 | export interface ScrollViewProps extends Omit, "onScroll"> { 20 | // 滚动的内容 21 | children?: React.ReactNode; 22 | // 容器的高度,默认100% 23 | height?: CSS.Property.Height; 24 | // 触顶事件的阈值,默认为50像素 25 | reachTopThreshold?: number; 26 | // 触顶事件 27 | onReachTop?: (event: ScrollEvent) => void; 28 | // 触底事件发生的阈值,默认为50像素 29 | reachBottomThreshold?: number; 30 | // 触底事件 31 | onReachBottom?: (event: ScrollEvent) => void; 32 | // 是否显示loading 33 | showLoading?: boolean; 34 | // loading内容 35 | loadingContent?: React.ReactNode; 36 | // 滚动事件 37 | onScroll?: (event: ScrollEvent) => void; 38 | // 滚动事件节流时间(毫秒),默认 16ms(约60fps) 39 | scrollThrottle?: number; 40 | // 容器样式 41 | containerStyle?: SerializedStyles; 42 | // 包裹容器样式 43 | wrapperStyle?: SerializedStyles; 44 | // 默认的loading样式 45 | loadingStyle?: SerializedStyles; 46 | } 47 | 48 | export function ScrollView(props: ScrollViewProps) { 49 | const { 50 | children, 51 | height, 52 | reachTopThreshold = 50, 53 | onReachTop, 54 | reachBottomThreshold = 50, 55 | onReachBottom, 56 | showLoading = true, 57 | loadingContent, 58 | onScroll, 59 | scrollThrottle = 16, 60 | containerStyle, 61 | wrapperStyle, 62 | loadingStyle, 63 | ...attrs 64 | } = props; 65 | 66 | // 容器高度 67 | const heightStyle: Interpolation = {}; 68 | if (height) { 69 | heightStyle.height = height; 70 | } 71 | 72 | // 滚动容器 73 | const container = useRef(null); 74 | 75 | // 当前滚动到顶部的距离 76 | const lastScrollTop = useRef(0); 77 | 78 | // 防止重复触发的标记 79 | const hasReachedTop = useRef(false); 80 | const hasReachedBottom = useRef(false); 81 | 82 | // 节流控制 83 | const throttleTimer = useRef(undefined); 84 | const lastCallTime = useRef(0); 85 | 86 | // 使用 ref 保存最新的回调函数,避免闭包陈旧 87 | const callbacksRef = useRef({ 88 | onScroll, 89 | onReachTop, 90 | onReachBottom, 91 | }); 92 | 93 | // 每次渲染都更新 ref 中的回调 94 | callbacksRef.current = { 95 | onScroll, 96 | onReachTop, 97 | onReachBottom, 98 | }; 99 | 100 | // container是否有滚动条 101 | const [hasScrollBar, setHasScrollBar] = useState(false); 102 | 103 | // 检查是否有滚动条 104 | const checkScrollBar = useCallback(() => { 105 | if (container.current) { 106 | const hasScroll = container.current.scrollHeight > container.current.clientHeight; 107 | setHasScrollBar(hasScroll); 108 | } 109 | }, []); 110 | 111 | // 使用 ResizeObserver 监听内容高度变化 112 | useLayoutEffect(() => { 113 | const containerEl = container.current; 114 | if (!containerEl) return; 115 | 116 | // 初始检查 117 | checkScrollBar(); 118 | 119 | // 使用 ResizeObserver 监听尺寸变化 120 | const resizeObserver = new ResizeObserver(() => { 121 | checkScrollBar(); 122 | }); 123 | 124 | // 观察容器和内容 125 | resizeObserver.observe(containerEl); 126 | if (containerEl.firstElementChild) { 127 | resizeObserver.observe(containerEl.firstElementChild); 128 | } 129 | 130 | return () => { 131 | resizeObserver.disconnect(); 132 | }; 133 | }, [checkScrollBar]); 134 | 135 | // 滚动回调(带节流) 136 | const scrollCallback = useCallback((rawEvent: React.UIEvent) => { 137 | const now = Date.now(); 138 | 139 | // 节流控制 140 | if (scrollThrottle > 0 && now - lastCallTime.current < scrollThrottle) { 141 | // 清除之前的定时器 142 | if (throttleTimer.current) { 143 | clearTimeout(throttleTimer.current); 144 | } 145 | 146 | // 设置新的定时器,确保最后一次调用会被执行 147 | throttleTimer.current = window.setTimeout(() => { 148 | handleScroll(rawEvent); 149 | }, scrollThrottle); 150 | 151 | return; 152 | } 153 | 154 | lastCallTime.current = now; 155 | handleScroll(rawEvent); 156 | }, [scrollThrottle, reachTopThreshold, reachBottomThreshold]); 157 | 158 | // 实际的滚动处理逻辑 159 | const handleScroll = useCallback((rawEvent: React.UIEvent) => { 160 | const box = container.current; 161 | if (!box) return; 162 | 163 | // 已经滚动的距离 164 | const scrollTop = box.scrollTop; 165 | // 滚动容器的包含滚动内容的高度 166 | const contentHeight = box.scrollHeight; 167 | // 滚动容器的视口高度 168 | const containerHeight = Math.min(box.clientHeight, box.offsetHeight); 169 | // 最大可滚动距离 170 | const maxScroll = contentHeight - containerHeight; 171 | 172 | // 计算滚动方向 173 | const direction: "upward" | "downward" = scrollTop > lastScrollTop.current ? "downward" : "upward"; 174 | 175 | // 生成滚动事件参数 176 | const event: ScrollEvent = { 177 | containerHeight, 178 | contentHeight, 179 | maxScroll, 180 | scrollTop, 181 | direction, 182 | rawEvent, 183 | }; 184 | 185 | // 调用通用滚动事件(使用 ref 中的最新回调) 186 | callbacksRef.current.onScroll?.(event); 187 | 188 | // 触顶逻辑(防止重复触发) 189 | if (direction === "upward" && scrollTop <= reachTopThreshold) { 190 | if (!hasReachedTop.current) { 191 | hasReachedTop.current = true; 192 | hasReachedBottom.current = false; // 重置触底标记 193 | callbacksRef.current.onReachTop?.(event); 194 | } 195 | } else if (scrollTop > reachTopThreshold) { 196 | hasReachedTop.current = false; 197 | } 198 | 199 | // 触底逻辑(防止重复触发) 200 | if (direction === "downward" && scrollTop >= maxScroll - reachBottomThreshold) { 201 | if (!hasReachedBottom.current) { 202 | hasReachedBottom.current = true; 203 | hasReachedTop.current = false; // 重置触顶标记 204 | callbacksRef.current.onReachBottom?.(event); 205 | } 206 | } else if (scrollTop < maxScroll - reachBottomThreshold) { 207 | hasReachedBottom.current = false; 208 | } 209 | 210 | // 更新scrollTop上次的值 211 | lastScrollTop.current = scrollTop; 212 | }, [reachTopThreshold, reachBottomThreshold]); 213 | 214 | // 清理节流定时器 215 | useLayoutEffect(() => { 216 | return () => { 217 | if (throttleTimer.current) { 218 | clearTimeout(throttleTimer.current); 219 | } 220 | }; 221 | }, []); 222 | 223 | // loading内容 224 | let showLoadingContent: React.ReactNode = null; 225 | if (showLoading) { 226 | if (!loadingContent) { 227 | showLoadingContent = ( 228 | 229 | 230 |

数据加载中...

231 |
232 | ); 233 | } else { 234 | showLoadingContent = loadingContent; 235 | } 236 | } 237 | 238 | return ( 239 |
240 |
241 | {children} 242 |
243 | {hasScrollBar && showLoadingContent} 244 |
245 | ); 246 | } 247 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import isPlainObject from "lodash/isPlainObject"; 2 | import omit from "lodash/omit"; 3 | import isArrayBuffer from "lodash/isArrayBuffer"; 4 | import isTypedArray from "lodash/isTypedArray"; 5 | import { uniqKey } from "./uniqKey"; 6 | 7 | /** 8 | * normal: 普通的请求,参数都附加在URL后面,data为JSON对象或字符串 9 | * text: 请求体为text,data为字符串 10 | * form: 请求体为FormData,data为JSON对象或FormData 11 | * json: 请求体为JSON字符串,data为JSON对象 12 | * blob: 请求体为blob原始二进制,data只能为Blob 13 | * params: 请求体为URLSearchParams,data为JSON对象或URLSearchParams 14 | * buffer: 请求体data只能为ArrayBuffer、TypeArray、DataView 15 | */ 16 | export type SendType = "normal" | "text" | "form" | "json" | "blob" | "params" | "buffer"; 17 | 18 | // 发送的数据类型 19 | export type SendDataType = BodyInit | Record; 20 | 21 | /** 22 | * 请求对象参数 23 | */ 24 | export interface RequestOption extends RequestInit { 25 | // 请求的URL地址 26 | url?: string; 27 | // 发送类型 28 | sendType?: SendType; 29 | // 请求的数据 30 | data?: SendDataType; 31 | // 禁用URL地址缓存 32 | disableUrlCache?: boolean; 33 | // ajax请求携带当前页面url的参数 34 | transmitPageParam?: boolean; 35 | // 超时时间,默认30秒 36 | timeout?: number; 37 | } 38 | 39 | export type BuildUrlOption = Pick< 40 | RequestOption, 41 | "url" | "data" | "disableUrlCache" | "transmitPageParam" | "sendType" 42 | >; 43 | 44 | export type StandardAjaxResult = { 45 | code: number; 46 | message?: string; 47 | data?: any; 48 | }; 49 | 50 | // 用来全局存储host别名映射 51 | let hostAliasMap: Record = {}; 52 | 53 | /** 54 | * 注册Host别名 55 | * @param aliasMap 56 | * @returns 57 | */ 58 | export async function registerHostAlias(aliasMap: Record) { 59 | if (!isPlainObject(aliasMap)) return; 60 | hostAliasMap = { ...hostAliasMap, ...aliasMap }; 61 | } 62 | 63 | /** 64 | * 通过ajax选项构建Url 65 | * @param option 66 | */ 67 | export function buildUrlByOption(option: BuildUrlOption) { 68 | let config: BuildUrlOption = { 69 | url: "", 70 | sendType: "normal", 71 | data: {}, 72 | disableUrlCache: false, 73 | transmitPageParam: false, 74 | }; 75 | if (isPlainObject(option)) { 76 | config = { ...config, ...option }; 77 | } 78 | if (!isPlainObject(config.data)) { 79 | config.data = {}; 80 | } 81 | 82 | let url = config.url || ""; 83 | const parts = url.split("@"); 84 | // 如果URL中有@符号,按照别名模式处理 85 | if (parts.length === 2) { 86 | let host = hostAliasMap[parts[0]]; 87 | if (host && typeof host === "string") { 88 | /** 89 | * 这一步两个作用 90 | * 1、删除前置 https:// 91 | * 2、删除结尾的反斜杠 / 92 | */ 93 | host = host.replace(/^(https?\:)?\/{2}|\/*$/g, ""); 94 | host = window.location.protocol + "//" + host; 95 | url = host + "/" + parts[1].replace(/^\/*/, ""); 96 | } 97 | } 98 | 99 | // 创建url对象 100 | let urlObject: URL; 101 | if (/^http/.test(url)) { 102 | urlObject = new URL(url); 103 | } else { 104 | // 这里是请求当前页面host的逻辑 105 | const { origin, pathname } = window.location; 106 | urlObject = new URL(url, origin + pathname); 107 | } 108 | 109 | // 透传当前页面的参数 110 | if (config.transmitPageParam) { 111 | const pageUrl = new URL(window.location.href); 112 | pageUrl.searchParams.forEach((value, key) => { 113 | urlObject.searchParams.append(key, value); 114 | }); 115 | } 116 | 117 | // 如果是normal请求,将data中的数据作为url的查询字符串 118 | if (config.sendType?.toLowerCase() === "normal") { 119 | if (isPlainObject(config.data)) { 120 | const data = config.data as Record; 121 | for (let key in data) { 122 | urlObject.searchParams.append(key, data[key]); 123 | } 124 | } else if (typeof config.data === "string") { 125 | const params = new URLSearchParams(config.data); 126 | params.forEach((value, key) => { 127 | urlObject.searchParams.append(key, value); 128 | }); 129 | } 130 | } 131 | 132 | // 如果禁用了URL缓存,添加去缓存参数 133 | if (config.disableUrlCache) { 134 | urlObject.searchParams.append("__c", uniqKey()); 135 | } 136 | 137 | // 返回URL地址 138 | return urlObject.toString(); 139 | } 140 | 141 | /** 142 | * 解析请求选项 143 | * @param option 144 | * @returns 145 | */ 146 | export function parseRequestOption(option: RequestOption) { 147 | let config: RequestOption = { 148 | url: "", 149 | sendType: "normal", 150 | method: "GET", 151 | disableUrlCache: false, 152 | transmitPageParam: false, 153 | timeout: 30000, 154 | }; 155 | config.sendType = config.sendType!.toLowerCase() as SendType; 156 | config.method = config.method?.toUpperCase(); 157 | 158 | // 传递过来的参数覆盖默认值 159 | if (isPlainObject(option)) { 160 | config = { 161 | ...config, 162 | ...option, 163 | }; 164 | } 165 | 166 | /** 167 | * 以下场景请求方法默认为POST 168 | * 1、调用方没有传递请求方法 169 | * 2、请求类型不为normal 170 | */ 171 | if ((config.method === "GET" || !config.method) && config.sendType !== "normal") { 172 | config.method = "POST"; 173 | } 174 | 175 | // 如果没有设置headers,确保请求头为对象 176 | if (!config.headers) { 177 | config.headers = {}; 178 | } 179 | 180 | // 生成请求的URL 181 | const url = buildUrlByOption(option); 182 | switch (config.sendType) { 183 | case "normal": 184 | config.body = null; 185 | break; 186 | case "text": 187 | if (typeof config.data === "string") { 188 | config.body = config.data; 189 | } 190 | break; 191 | case "form": 192 | if (isPlainObject(config.data)) { 193 | const body = new FormData(); 194 | const data = config.data as Record; 195 | for (let key in data) { 196 | body.append(key, data[key]); 197 | } 198 | config.body = body; 199 | } else if (config.data instanceof FormData) { 200 | config.body = config.data; 201 | } 202 | break; 203 | case "json": 204 | if (isPlainObject(config.data)) { 205 | (config.headers as Record)["Content-Type"] = "application/json"; 206 | config.body = JSON.stringify(config.data); 207 | } 208 | break; 209 | case "blob": 210 | if (config.data instanceof Blob) { 211 | config.body = config.data; 212 | } 213 | break; 214 | case "params": 215 | if (isPlainObject(config.data)) { 216 | const body = new URLSearchParams(); 217 | const data = config.data as Record; 218 | for (let key in data) { 219 | body.append(key, data[key]); 220 | } 221 | config.body = body; 222 | } else if (config.data instanceof URLSearchParams) { 223 | config.body = config.data; 224 | } 225 | break; 226 | case "buffer": 227 | if ( 228 | isArrayBuffer(config.data) || 229 | isTypedArray(config.data) || 230 | config.data instanceof DataView 231 | ) { 232 | config.body = config.data as BodyInit; 233 | } 234 | break; 235 | default: 236 | break; 237 | } 238 | 239 | const fetchOption: RequestInit = omit(config, [ 240 | "url", 241 | "sendType", 242 | "data", 243 | "disableUrlCache", 244 | "transmitPageParam", 245 | "timeout", 246 | ]); 247 | 248 | return { url, fetchOption, timeout: config.timeout ?? 30000 }; 249 | } 250 | 251 | /** 252 | * 发送正常Ajax请求,这个函数带有超时逻辑 253 | * @param url 254 | * @param option 255 | */ 256 | export async function sendRequest(option: RequestOption) { 257 | const { url, fetchOption, timeout } = parseRequestOption(option); 258 | const controller = new AbortController(); 259 | 260 | return Promise.race([ 261 | // 网络请求 262 | fetch(url, { ...fetchOption, signal: controller.signal }) 263 | .then((response) => { 264 | return response.json(); 265 | }) 266 | .then((result: T) => { 267 | return result; 268 | }) 269 | .catch((error) => { 270 | // 如果是主动取消的请求,返回超时错误 271 | if (error.name === 'AbortError') { 272 | const result: StandardAjaxResult = { 273 | code: -10001, 274 | message: "Network request timeout", 275 | }; 276 | return result; 277 | } 278 | const result: StandardAjaxResult = { 279 | code: -10000, 280 | message: "An exception occurred in the network request", 281 | }; 282 | return result; 283 | }), 284 | // 超时逻辑 285 | new Promise((resolve) => { 286 | window.setTimeout(() => { 287 | controller.abort(); // 取消请求 288 | const result: StandardAjaxResult = { 289 | code: -10001, 290 | message: "Network request timeout", 291 | }; 292 | resolve(result); 293 | }, timeout ?? 30000); 294 | }), 295 | ]); 296 | } 297 | 298 | /** 299 | * sendRequest方法的糖式调用 300 | * @param sendType 301 | * @param url 302 | * @param data 303 | * @param option 304 | * @returns 305 | */ 306 | export async function sugarSend( 307 | sendType: SendType, 308 | url: string, 309 | data: SendDataType, 310 | option?: RequestOption 311 | ) { 312 | let config: RequestOption = { 313 | url, 314 | data, 315 | sendType, 316 | }; 317 | if (isPlainObject(option)) { 318 | config = { ...config, ...omit(option, ["url", "data", "sendType"]) }; 319 | } 320 | return sendRequest(config); 321 | } 322 | 323 | /** 324 | * 简单直观的GET请求 325 | * @param url 326 | * @param data 327 | * @param option 328 | * @returns 329 | */ 330 | export async function GET( 331 | url: string, 332 | data: SendDataType, 333 | option?: RequestOption 334 | ) { 335 | return sugarSend("normal", url, data, option); 336 | } 337 | 338 | /** 339 | * 简单直观的POST请求 340 | * @param url 341 | * @param data 342 | * @param option 343 | * @returns 344 | */ 345 | export async function POST( 346 | url: string, 347 | data: SendDataType, 348 | option?: RequestOption 349 | ) { 350 | return sugarSend("form", url, data, option); 351 | } 352 | 353 | /** 354 | * 简单直观的发送json字符串 355 | * @param url 356 | * @param data 357 | * @param option 358 | * @returns 359 | */ 360 | export async function sendJSON( 361 | url: string, 362 | data: SendDataType, 363 | option?: RequestOption 364 | ) { 365 | return sugarSend("json", url, data, option); 366 | } 367 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "DOM", 17 | "ESNext" 18 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 19 | "jsx": "react-jsx" /* Specify what JSX code is generated. */, 20 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 21 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 22 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 23 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 24 | "jsxImportSource": "@emotion/react", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 25 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 26 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 27 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 28 | 29 | /* Modules */ 30 | "module": "ES6" /* Specify what module code is generated. */, 31 | "rootDir": "./src" /* Specify the root folder within your source files. */, 32 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, 34 | // "paths": {} /* Specify a set of entries that re-map imports to additional lookup locations. */, 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "resolveJsonModule": true, /* Enable importing .json files */ 40 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 41 | 42 | /* JavaScript Support */ 43 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 44 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 45 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 46 | 47 | /* Emit */ 48 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 49 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 50 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 51 | "sourceMap": false /* Create source map files for emitted JavaScript files. */, 52 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 53 | "outDir": "./build" /* Specify an output folder for all emitted files. */, 54 | "removeComments": false /* Disable emitting comments. */, 55 | // "noEmit": true, /* Disable emitting files from a compilation. */ 56 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 57 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 58 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 59 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 62 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 63 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 64 | "newLine": "LF" /* Set the newline character for emitting files. */, 65 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 66 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 67 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 68 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 69 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 70 | 71 | /* Interop Constraints */ 72 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 77 | 78 | /* Type Checking */ 79 | "strict": true /* Enable all strict type-checking options. */, 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 81 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 86 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 87 | "alwaysStrict": true /* Ensure 'use strict' is always emitted. */, 88 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["src/**/*"] 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLXX 2 | 3 |

4 | 轻量级 React 移动端组件库 5 |

6 | 7 |

8 | 基于 React 19 + TypeScript + Emotion 构建的现代化移动端 UI 组件库 9 |

10 | 11 |

12 | npm version 13 | npm downloads 14 | license 15 |

16 | 17 | --- 18 | 19 | ## ✨ 特性 20 | 21 | - 🎯 **专为移动端设计** - 完美适配移动端交互和体验 22 | - 📱 **响应式布局** - 基于 rem 的自适应方案,支持多种屏幕尺寸 23 | - 🎨 **CSS-in-JS** - 使用 Emotion 实现样式隔离和动态样式 24 | - 🔧 **TypeScript** - 完整的类型定义,提供更好的开发体验 25 | - ⚡ **高性能** - 优化的渲染逻辑和事件处理 26 | - 🎪 **函数式调用** - Toast、Dialog、Loading 等支持命令式调用 27 | - 🚀 **现代化** - 支持 React 19,使用最新的 Hooks API 28 | - 📦 **轻量级** - 按需加载,tree-shaking 友好 29 | 30 | --- 31 | 32 | ## 📦 安装 33 | 34 | ```bash 35 | # npm 36 | npm install clxx 37 | 38 | # yarn 39 | yarn add clxx 40 | 41 | # pnpm 42 | pnpm add clxx 43 | ``` 44 | 45 | ### Peer Dependencies 46 | 47 | ```json 48 | { 49 | "react": "^19.2.0", 50 | "react-dom": "^19.2.0" 51 | } 52 | ``` 53 | 54 | --- 55 | 56 | ## 🚀 快速开始 57 | 58 | ### 基础使用 59 | 60 | ```tsx 61 | import React from 'react'; 62 | import { Container, showToast } from 'clxx'; 63 | 64 | function App() { 65 | return ( 66 | 67 | 70 | 71 | ); 72 | } 73 | 74 | export default App; 75 | ``` 76 | 77 | ### 使用路由创建应用 78 | 79 | ```tsx 80 | import { createApp } from 'clxx'; 81 | 82 | createApp({ 83 | target: '#root', 84 | designWidth: 750, 85 | routeMethod: 'hash', 86 | renderPage: async (pathname) => { 87 | // 动态加载页面组件 88 | const Page = await import(`./pages${pathname}`); 89 | return ; 90 | }, 91 | }); 92 | ``` 93 | 94 | --- 95 | 96 | ## 📚 组件文档 97 | 98 | ### 🎨 布局组件 99 | 100 | #### Container - 容器组件 101 | 102 | 全局根容器,提供移动端自适应、rem 布局和初始化逻辑。 103 | 104 | ```tsx 105 | import { Container } from 'clxx'; 106 | 107 | 112 | 113 | 114 | ``` 115 | 116 | **特性**: 117 | - ✅ 自动 rem 适配 118 | - ✅ 处理浏览器字体缩放 119 | - ✅ 防止初始化闪烁 120 | - ✅ 支持自定义全局样式 121 | 122 | --- 123 | 124 | #### Flex 布局 125 | 126 | 提供快捷的 Flex 布局组件。 127 | 128 | ```tsx 129 | import { Row, RowCenter, RowBetween, Col, ColCenter } from 'clxx'; 130 | 131 | // 水平布局 132 | 133 |
居中对齐
134 |
135 | 136 | 137 |
两端对齐
138 |
139 | 140 | // 垂直布局 141 | 142 |
垂直居中
143 |
144 | ``` 145 | 146 | **可用组件**: 147 | - `Row` / `RowStart` - 水平布局(左对齐) 148 | - `RowCenter` - 水平居中 149 | - `RowEnd` - 水平右对齐 150 | - `RowBetween` - 两端对齐 151 | - `RowAround` - 周围分布 152 | - `RowEvenly` - 均匀分布 153 | - `Col` / `ColStart` / `ColCenter` / `ColEnd` / `ColBetween` / `ColAround` / `ColEvenly` - 对应的垂直布局 154 | 155 | --- 156 | 157 | #### AutoGrid - 自动网格 158 | 159 | 自动计算并排列网格布局。 160 | 161 | ```tsx 162 | import { AutoGrid } from 'clxx'; 163 | 164 | 169 |
Item 1
170 |
Item 2
171 |
Item 3
172 |
173 | ``` 174 | 175 | --- 176 | 177 | #### SafeArea - 安全区域 178 | 179 | 处理手机刘海屏、底部横条等安全区域。 180 | 181 | ```tsx 182 | import { SafeArea } from 'clxx'; 183 | 184 | 顶部安全区域 185 | 底部安全区域 186 | ``` 187 | 188 | --- 189 | 190 | ### 🎭 交互组件 191 | 192 | #### Clickable - 点击态 193 | 194 | 提供点击反馈效果的容器组件。 195 | 196 | ```tsx 197 | import { Clickable } from 'clxx'; 198 | 199 | console.log('clicked')} 203 | > 204 | 点击我 205 | 206 | ``` 207 | 208 | --- 209 | 210 | #### Overlay - 遮罩层 211 | 212 | 全屏或局部遮罩层组件。 213 | 214 | ```tsx 215 | import { Overlay } from 'clxx'; 216 | 217 | 223 |
遮罩内容
224 |
225 | ``` 226 | 227 | --- 228 | 229 | #### ScrollView - 滚动容器 230 | 231 | 带触底、触顶检测的滚动容器。 232 | 233 | ```tsx 234 | import { ScrollView } from 'clxx'; 235 | 236 | { 240 | console.log('到底了', e); 241 | }} 242 | onReachTop={(e) => { 243 | console.log('到顶了', e); 244 | }} 245 | > 246 |
滚动内容
247 |
248 | ``` 249 | 250 | --- 251 | 252 | ### 💬 反馈组件 253 | 254 | #### Toast - 轻提示 255 | 256 | ```tsx 257 | import { showToast, showUniqToast } from 'clxx'; 258 | 259 | // 基础用法 260 | showToast('操作成功'); 261 | 262 | // 完整配置 263 | showToast({ 264 | content: '操作成功', 265 | position: 'middle', // top | middle | bottom 266 | duration: 2000, 267 | offsetTop: 50, 268 | }); 269 | 270 | // 全局唯一 Toast(新的会替换旧的) 271 | showUniqToast('只显示一个'); 272 | ``` 273 | 274 | --- 275 | 276 | #### Dialog - 对话框 277 | 278 | ```tsx 279 | import { showDialog } from 'clxx'; 280 | 281 | const close = showDialog({ 282 | content:
对话框内容
, 283 | type: 'center', // center | pullUp | pullDown | pullLeft | pullRight 284 | blankClosable: true, // 点击空白处关闭 285 | showMask: true, // 显示遮罩 286 | }); 287 | 288 | // 手动关闭 289 | close(); 290 | ``` 291 | 292 | --- 293 | 294 | #### Alert - 警告框 295 | 296 | ```tsx 297 | import { showAlert } from 'clxx'; 298 | 299 | showAlert({ 300 | title: '提示', 301 | description: '确定要删除吗?', 302 | showCancel: true, 303 | onConfirm: () => console.log('确认'), 304 | onCancel: () => console.log('取消'), 305 | }); 306 | ``` 307 | 308 | --- 309 | 310 | #### Loading - 加载中 311 | 312 | ```tsx 313 | import { showLoading, showLoadingAtLeast } from 'clxx'; 314 | 315 | // 基础用法 316 | const close = showLoading('加载中...'); 317 | // 关闭 318 | close(); 319 | 320 | // 至少显示指定时间(避免闪烁) 321 | const close = showLoadingAtLeast(300, '加载中...'); 322 | // 即使立即调用 close(),也会至少显示 300ms 323 | close(); 324 | ``` 325 | 326 | --- 327 | 328 | ### 🎪 展示组件 329 | 330 | #### Indicator - 加载指示器 331 | 332 | ```tsx 333 | import { Indicator } from 'clxx'; 334 | 335 | 341 | ``` 342 | 343 | --- 344 | 345 | #### Ago - 相对时间 346 | 347 | 显示相对时间(多久以前)。 348 | 349 | ```tsx 350 | import { Ago } from 'clxx'; 351 | 352 | 353 | // 输出:刚刚 354 | 355 | `${value.num}${value.unit}前`} 358 | /> 359 | ``` 360 | 361 | --- 362 | 363 | #### Countdowner - 倒计时 364 | 365 | ```tsx 366 | import { Countdowner } from 'clxx'; 367 | 368 | console.log('倒计时结束')} 373 | /> 374 | ``` 375 | 376 | --- 377 | 378 | #### CarouselNotice - 滚动公告 379 | 380 | ```tsx 381 | import { CarouselNotice } from 'clxx'; 382 | 383 | 389 | ``` 390 | 391 | --- 392 | 393 | ## 🛠️ 工具函数 394 | 395 | ### 网络请求 396 | 397 | ```tsx 398 | import { GET, POST, sendJSON, jsonp } from 'clxx'; 399 | 400 | // GET 请求 401 | const data = await GET('/api/users', { page: 1 }); 402 | 403 | // POST 请求(FormData) 404 | const result = await POST('/api/login', { 405 | username: 'admin', 406 | password: '123456' 407 | }); 408 | 409 | // 发送 JSON 410 | const result = await sendJSON('/api/data', { data: {...} }); 411 | 412 | // JSONP 请求 413 | const data = await jsonp('https://api.example.com/data'); 414 | ``` 415 | 416 | **高级配置**: 417 | ```tsx 418 | import { sendRequest, registerHostAlias } from 'clxx'; 419 | 420 | // 注册 Host 别名 421 | registerHostAlias({ 422 | api: 'https://api.example.com', 423 | cdn: 'https://cdn.example.com' 424 | }); 425 | 426 | // 使用别名 427 | await GET('api@/users'); // 实际请求:https://api.example.com/users 428 | 429 | // 完整配置 430 | const result = await sendRequest({ 431 | url: '/api/data', 432 | method: 'POST', 433 | sendType: 'json', 434 | data: { key: 'value' }, 435 | timeout: 5000, 436 | disableUrlCache: true, 437 | }); 438 | ``` 439 | 440 | --- 441 | 442 | ### 时间处理 443 | 444 | ```tsx 445 | import { ago, calendarTable, Countdown, waitFor, waitUntil } from 'clxx'; 446 | 447 | // 相对时间 448 | const result = ago('2024-01-01'); 449 | console.log(result.format); // "3个月前" 450 | 451 | // 日历表格 452 | const table = calendarTable('2024-10', false, true); 453 | // 返回 6x7 的日期数组 454 | 455 | // 倒计时器 456 | const countdown = new Countdown({ 457 | remain: 3600, 458 | format: 'his', 459 | onUpdate: (value) => console.log(value), 460 | onEnd: () => console.log('结束'), 461 | }); 462 | countdown.start(); 463 | 464 | // 等待指定时间 465 | await waitFor(1000); 466 | 467 | // 等待条件满足 468 | await waitUntil(() => document.querySelector('.loaded'), 5000); 469 | ``` 470 | 471 | --- 472 | 473 | ### 工具类 474 | 475 | ```tsx 476 | import { 477 | tick, 478 | uniqKey, 479 | defaultScroll, 480 | createPortalDOM, 481 | is, 482 | normalizeUnit, 483 | adaptive 484 | } from 'clxx'; 485 | 486 | // 逐帧执行 487 | const stop = tick(() => { 488 | console.log('每帧执行'); 489 | }, 16); // 可选的间隔时间 490 | stop(); // 停止 491 | 492 | // 生成唯一 Key 493 | const key = uniqKey(); // "1730123456789_1" 494 | 495 | // 禁用/启用滚动 496 | defaultScroll.disable(); 497 | defaultScroll.enable(); 498 | 499 | // 创建 Portal DOM 500 | const portal = createPortalDOM(); 501 | portal.mount(); 502 | portal.unmount(); 503 | 504 | // 环境判断 505 | if (is('ios')) { /* iOS 设备 */ } 506 | if (is('android')) { /* Android 设备 */ } 507 | if (is('mobile')) { /* 移动设备 */ } 508 | if (is('touchable')) { /* 支持触摸 */ } 509 | 510 | // 单位标准化 511 | normalizeUnit(100); // "100px" 512 | normalizeUnit('50rem'); // "50rem" 513 | normalizeUnit(0.5, '%'); // "0.5%" 514 | ``` 515 | 516 | --- 517 | 518 | ## 🎯 自定义 Hooks 519 | 520 | ```tsx 521 | import { 522 | useInterval, 523 | useTick, 524 | useUpdate, 525 | useWindowResize, 526 | useViewport 527 | } from 'clxx'; 528 | 529 | // 定时器 Hook 530 | useInterval(() => { 531 | console.log('每秒执行'); 532 | }, 1000); 533 | 534 | // 逐帧执行 Hook 535 | useTick(() => { 536 | console.log('每帧执行'); 537 | }); 538 | 539 | // 强制更新 540 | const update = useUpdate(); 541 | update(); // 触发组件重新渲染 542 | 543 | // 窗口大小变化 544 | useWindowResize(() => { 545 | console.log('窗口大小改变'); 546 | }, 100); // 防抖 100ms 547 | 548 | // Viewport 设置 549 | useViewport({ 550 | width: 'device-width', 551 | initialScale: 1, 552 | userScalable: 'no', 553 | }); 554 | ``` 555 | 556 | --- 557 | 558 | ## 🎨 样式工具 559 | 560 | ```tsx 561 | import { css } from '@emotion/react'; 562 | import { adaptive, normalizeUnit, splitValue } from 'clxx'; 563 | 564 | // 自适应样式(基于 750 设计稿) 565 | const styles = adaptive({ 566 | width: 375, // 自动转换为 vw + 媒体查询 567 | height: 200, 568 | padding: 20, 569 | fontSize: 28, 570 | }); 571 | 572 | // 拆分数值和单位 573 | const { num, unit } = splitValue('100px'); 574 | console.log(num, unit); // 100, "px" 575 | ``` 576 | 577 | --- 578 | 579 | ## ⚙️ 配置 580 | 581 | ### 全局配置 582 | 583 | ```tsx 584 | import { setContextValue } from 'clxx'; 585 | 586 | // 设置文档宽度范围 587 | setContextValue({ 588 | maxDocWidth: 576, // 最大宽度 589 | minDocWidth: 312, // 最小宽度 590 | }); 591 | ``` 592 | 593 | --- 594 | 595 | ## 📱 最佳实践 596 | 597 | ### 1. 项目入口配置 598 | 599 | ```tsx 600 | import React from 'react'; 601 | import ReactDOM from 'react-dom/client'; 602 | import { Container } from 'clxx'; 603 | import App from './App'; 604 | 605 | const root = ReactDOM.createRoot(document.getElementById('root')); 606 | 607 | root.render( 608 | 612 | 613 | 614 | ); 615 | ``` 616 | 617 | ### 2. 使用路由 618 | 619 | ```tsx 620 | import { createApp, setContextValue } from 'clxx'; 621 | 622 | // 配置全局上下文 623 | setContextValue({ 624 | maxDocWidth: 576, 625 | minDocWidth: 312, 626 | }); 627 | 628 | // 创建应用 629 | createApp({ 630 | target: '#root', 631 | designWidth: 750, 632 | routeMethod: 'hash', 633 | 634 | // 加载前 635 | onBeforeRenderPage: async (pathname) => { 636 | console.log('准备加载:', pathname); 637 | }, 638 | 639 | // 加载中 640 | onLoadingPage: () =>
Loading...
, 641 | 642 | // 渲染页面 643 | renderPage: async (pathname) => { 644 | const modules = { 645 | '/': () => import('./pages/Home'), 646 | '/about': () => import('./pages/About'), 647 | }; 648 | 649 | const loadModule = modules[pathname] || modules['/']; 650 | const module = await loadModule(); 651 | return ; 652 | }, 653 | 654 | // 加载后 655 | onAfterRenderPage: (pathname) => { 656 | document.title = pathname; 657 | }, 658 | }); 659 | ``` 660 | 661 | ### 3. 性能优化建议 662 | 663 | ```tsx 664 | // 1. 使用防抖优化 resize 665 | 666 | 667 | // 2. 使用 showUniqToast 避免多个 Toast 堆叠 668 | showUniqToast('只显示最新的'); 669 | 670 | // 3. Loading 至少显示一定时间避免闪烁 671 | const close = showLoadingAtLeast(300, '加载中...'); 672 | 673 | // 4. ScrollView 使用合适的阈值 674 | 678 | ``` 679 | 680 | --- 681 | 682 | ## 🔧 TypeScript 支持 683 | 684 | 所有组件和函数都提供完整的 TypeScript 类型定义: 685 | 686 | ```tsx 687 | import type { 688 | ContainerProps, 689 | ToastProps, 690 | DialogProps, 691 | OverlayProps, 692 | // ... 更多类型 693 | } from 'clxx'; 694 | 695 | // 自定义扩展 696 | import { showToast } from 'clxx'; 697 | 698 | const myShowToast = (message: string) => { 699 | showToast({ 700 | content: message, 701 | duration: 3000, 702 | position: 'middle', 703 | }); 704 | }; 705 | ``` 706 | 707 | --- 708 | 709 | ## 📋 浏览器支持 710 | 711 | - iOS Safari 10+ 712 | - Android Chrome 5.0+ 713 | - 现代浏览器(Chrome, Firefox, Safari, Edge) 714 | 715 | --- 716 | 717 | ## 🤝 贡献 718 | 719 | 欢迎提交 Issue 和 Pull Request! 720 | 721 | --- 722 | 723 | ## 📄 License 724 | 725 | [MIT](./LICENSE) 726 | 727 | --- 728 | 729 | ## 🔗 相关链接 730 | 731 | - [GitHub](https://github.com/joye61/clxx) 732 | - [NPM](https://www.npmjs.com/package/clxx) 733 | - [Issues](https://github.com/joye61/clxx/issues) 734 | 735 | --- 736 | 737 | ## 📝 更新日志 738 | 739 | ### v2.1.4 (2025-10-28) 740 | 741 | #### 🎉 新增 742 | - 新增 `useUpdate`、`useWindowResize`、`useViewport` Hooks 导出 743 | - Container 组件新增 `resizeDelay` 配置项 744 | 745 | #### 🐛 Bug 修复 746 | - 修复 Overlay 组件 resize 性能问题 747 | - 修复 Toast 组件定时器清理错误 748 | - 修复 Countdowner 依赖数组缺失 749 | - 修复 SafeArea 类型定义 750 | - 修复字体缩放可能导致的无限循环 751 | 752 | #### ⚡ 性能优化 753 | - Overlay 组件添加 resize 防抖 754 | - Container 组件优化初始化逻辑 755 | - useWindowResize 使用现代 API 替代废弃的 orientationchange 756 | - 优化网络请求超时逻辑,使用 AbortController 757 | 758 | #### 🔨 代码改进 759 | - 移除不必要的 lodash/round 依赖 760 | - 统一 Hooks 导出风格 761 | - 改进类型定义准确性 762 | - 优化事件监听器使用 passive 选项 763 | 764 | --- 765 | 766 |

767 | Made with ❤️ by Joye 768 |

769 | --------------------------------------------------------------------------------