diff --git a/packages/control/.eslintrc.js b/packages/control/.eslintrc.js new file mode 100644 index 0000000..42a1f50 --- /dev/null +++ b/packages/control/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: require.resolve('@reskript/config-lint/config/eslint'), +}; diff --git a/packages/control/CHANGELOG.md b/packages/control/CHANGELOG.md new file mode 100644 index 0000000..6ca30c1 --- /dev/null +++ b/packages/control/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 0.1.0 (2021-11-12) + + +### Features + +* control hooks diff --git a/packages/control/README.md b/packages/control/README.md new file mode 100644 index 0000000..b9ce278 --- /dev/null +++ b/packages/control/README.md @@ -0,0 +1,18 @@ +--- +title: README +nav: + title: Hooks + path: /hook +group: + title: Control + path: /control +order: 1 +--- + +# Control + +Provides hooks to control components. + +```shell +npm install @huse/control +``` diff --git a/packages/control/docs/demo/useControl.tsx b/packages/control/docs/demo/useControl.tsx new file mode 100644 index 0000000..44768fc --- /dev/null +++ b/packages/control/docs/demo/useControl.tsx @@ -0,0 +1,76 @@ +import {forwardRef} from 'react'; +import {Modal as AModal, Drawer as ADrawer, Radio, ModalProps, DrawerProps, Steps} from 'antd'; +import {partial} from 'lodash'; +import 'antd/dist/antd.min.css'; +import {useInputValue} from '@huse/input-value'; +import {useRenderTimes} from '@huse/debug'; +import {ControlRef, useControl, useControlSource} from '@huse/control'; + +const items = Array.from({length: 10}, (_, i) => String.fromCodePoint(0x1f600 + i)); + +type Params = [number?, string?]; +type ExtraProps = {icons: typeof items}; +interface ControlMethods { + open(i: number, icon: string): void; + close(): void; +} +type FowardedRef = ControlRef; + +function createViewerMethods(setState): ControlMethods { + return { + close: partial(setState, []), + open(i, content) { + setState([i, content]); + }, + }; +} + +const Modal = forwardRef(function Viewer(props, ref) { + const [[i, icon], {close}] = useControlSource(ref as FowardedRef, createViewerMethods, []); + return ( + 第{i + 1}个} onCancel={close} {...props}> +
+
{icon}
+

0x{icon?.codePointAt(0)?.toString(16).toUpperCase()}

+
+
+ ); +}); + +const Drawer = forwardRef(function Viewer(props, ref) { + const [[current], {close}] = useControlSource(ref as FowardedRef, createViewerMethods, []); + return ( + + + {props.icons.map(icon => ({icon}} />))} + + + ); +}); + + +export default function Demo() { + const typeProps = useInputValue('modal'); + const [MyViewer, {open: openViewer}] = useControl<(ModalProps | DrawerProps) & ExtraProps, ControlMethods>( + typeProps.value === 'modal' ? Modal : Drawer + ); + const handleClick = i => openViewer(i, items[i]); + + const renderTimes = useRenderTimes(); + + return ( + <> +

+ {items.map((item, i) => )} +

+

Click emoji to preview detail. {renderTimes}

+
+ + Modal + Drawer + +
+ + + ); +} diff --git a/packages/control/docs/useControl.md b/packages/control/docs/useControl.md new file mode 100644 index 0000000..94eb597 --- /dev/null +++ b/packages/control/docs/useControl.md @@ -0,0 +1,33 @@ +--- +title: useControl +nav: + title: Hooks + path: /hook +group: + title: Control + path: /control +order: 3 +--- + +# useControl + +Take control of given component which exposes its methods to ref by `useControlSource` or `useImperativeHandle`. +So you can encapsulate all related codes into component and update inner states from outside. You may take advantage of it to improve components' maintainability and reduce unnecessary refresh of parent elements. + +```typescript +interface ControlMethods {[key: string]: (...args: any[]) => any} +type ProxyMethods = {readonly $get: (property: string) => any} & Omit; +type ControlRef = React.MutableRefObject; + +function useControl( + CompIn: React.ForwardRefExoticComponent & React.RefAttributes> | null +): [React.FunctionComponent> | null, ProxyMethods]; + +function useControlSource( + ref: ControlRef, + deriveMethods: (setData: React.Dispatch>) => M, + initialData: T +): [T, M]; +``` + + diff --git a/packages/control/package.json b/packages/control/package.json new file mode 100644 index 0000000..060cf45 --- /dev/null +++ b/packages/control/package.json @@ -0,0 +1,46 @@ +{ + "name": "@huse/control", + "version": "0.1.0", + "keywords": [ + "react", + "hooks" + ], + "homepage": "https://github.com/ecomfe/react-hooks/tree/master/packages/control", + "bugs": { + "url": "https://github.com/ecomfe/react-hooks/issues" + }, + "license": "MIT", + "main": "cjs/index.js", + "module": "es/index.js", + "types": "es/index.d.ts", + "files": [ + "cjs", + "es", + "src" + ], + "scripts": { + "build": "rm -rf es cjs && tsc & tsc --module ESNext --outDir ./es", + "build-check": "tsc", + "lint": "skr lint --strict src demo", + "test": "skr test --coverage --target=react" + }, + "devDependencies": { + "@reskript/cli": "^1.10.1", + "@reskript/cli-lint": "^1.10.1", + "@reskript/cli-test": "^1.10.1", + "@reskript/config-lint": "^1.10.1", + "@testing-library/react": "^12.0.0", + "@types/react": "^17.0.14", + "antd": "^4.16.8", + "react": "^17.0.0", + "react-dom": "^17.0.0", + "typescript": "^4.3.5" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.com" + } +} diff --git a/packages/control/src/__tests__/index.test.js b/packages/control/src/__tests__/index.test.js new file mode 100644 index 0000000..f68efbc --- /dev/null +++ b/packages/control/src/__tests__/index.test.js @@ -0,0 +1,42 @@ +/* eslint-disable no-empty-function, react/jsx-no-bind */ +import {forwardRef, useEffect} from 'react'; +import {render, fireEvent, act} from '@testing-library/react'; +import {useControl, useControlSource} from '../index'; + + +const Foo = forwardRef(({title}, ref) => { + const [i, {increase}] = useControlSource(ref, setState => ({ + increase: () => setState(v => v + 1), + decrease: () => setState(v => v - 1), + }), 0); + return
{i}
; +}); + +const Bar = ({c = Foo, setFn}) => { + const [Comp, {decrease, $get}] = useControl(c); + useEffect( + () => { + setFn({decrease, $get}); + }, + [setFn, decrease, $get] + ); + return Comp ? : null; +}; + +test('expose and trigger methods', () => { + const fns = {}; + const {container} = render( Object.assign(fns, o)} />); + const el = container.querySelector('div'); + expect(el.innerHTML).toBe('0'); + fireEvent.click(el); + expect(el.innerHTML).toBe('1'); + act(() => fns.decrease()); + expect(el.innerHTML).toBe('0'); + expect(typeof fns.$get('increase')).toBe('function'); + expect(fns.$get('abc')).toBe(undefined); +}); + +test('component not exist', () => { + const {container} = render( {}} />); + expect(container.querySelector('div')).toBe(null); +}); diff --git a/packages/control/src/index.tsx b/packages/control/src/index.tsx new file mode 100644 index 0000000..bff9123 --- /dev/null +++ b/packages/control/src/index.tsx @@ -0,0 +1,69 @@ +import {useImperativeHandle, useRef, useMemo, useState} from 'react'; + +interface ControlMethods { + [key: string]: (...args: any[]) => any; +} +type ProxyMethods = {readonly $get: (property: string) => any} & Omit; +export type ControlRef = React.MutableRefObject; + +function createMethodsProxy(ref) { + return new Proxy( + { + $get(property: string) { + return ref.current[property]; + }, + }, + { + get(target, property: string) { + if (target[property]) { + return target[property]; + } + const method = (...args) => { + const fn = ref.current[property]; + return fn && fn(...args); + }; + target[property] = method; + return method; + }, + } + ); +} + +export function useControl( + CompIn: React.ForwardRefExoticComponent & React.RefAttributes> | null +): [React.FunctionComponent> | null, ProxyMethods] { + const ref = useRef({}) as ControlRef; + const methods = useMemo( + () => createMethodsProxy(ref) as ProxyMethods, + [] + ); + + const CompOut = useMemo( + () => { + if (!CompIn) { + return null; + } + return function CompOut(props) { + return ; + }; + }, + [CompIn] + ); + + return [CompOut, methods]; +} + + +export function useControlSource( + ref: ControlRef, + deriveMethods: (setData: React.Dispatch>) => M, + initialData: T +): [T, M] { + const [data, setData] = useState(initialData); + const methods = useMemo( + () => deriveMethods(setData), + [deriveMethods, setData] + ); + useImperativeHandle(ref, () => methods, [methods]); + return [data, methods]; +} diff --git a/packages/control/tsconfig.json b/packages/control/tsconfig.json new file mode 100644 index 0000000..a6bced8 --- /dev/null +++ b/packages/control/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./cjs" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 670c175..0450440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,30 @@ importers: react-test-renderer: 17.0.2_react@17.0.2 typescript: 4.3.5 + packages/control: + specifiers: + '@reskript/cli': ^1.10.1 + '@reskript/cli-lint': ^1.10.1 + '@reskript/cli-test': ^1.10.1 + '@reskript/config-lint': ^1.10.1 + '@testing-library/react': ^12.0.0 + '@types/react': ^17.0.14 + antd: ^4.16.8 + react: ^17.0.0 + react-dom: ^17.0.0 + typescript: ^4.3.5 + devDependencies: + '@reskript/cli': 1.10.1 + '@reskript/cli-lint': 1.10.1_react@17.0.2+typescript@4.3.5 + '@reskript/cli-test': 1.10.1_react-dom@17.0.2+react@17.0.2 + '@reskript/config-lint': 1.10.1_react@17.0.2+typescript@4.3.5 + '@testing-library/react': 12.0.0_react-dom@17.0.2+react@17.0.2 + '@types/react': 17.0.14 + antd: 4.16.8_react-dom@17.0.2+react@17.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + typescript: 4.3.5 + packages/debounce: specifiers: '@reskript/cli': ^1.10.1 @@ -8798,6 +8822,7 @@ packages: /encoding/0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + requiresBuild: true dependencies: iconv-lite: 0.6.3 dev: true @@ -9932,6 +9957,7 @@ packages: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + requiresBuild: true dev: true optional: true @@ -10843,6 +10869,7 @@ packages: resolution: {integrity: sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=} engines: {node: '>=0.10.0'} hasBin: true + requiresBuild: true dev: true optional: true @@ -13356,6 +13383,7 @@ packages: resolution: {integrity: sha512-ZTq6WYkN/3782H1393me3utVYdq2XyqNUFBsprEE3VMAT0+hP/cItpnITpqsY6ep2yeFE4Tqtqwc74VqUlUYtw==} engines: {node: '>= 4.4.x'} hasBin: true + requiresBuild: true dependencies: debug: 3.2.7 iconv-lite: 0.4.24 @@ -18835,6 +18863,7 @@ packages: resolution: {integrity: sha512-57H3ACYFXeo1IaZ1w02sfA71wI60MGco/IQFjOqK+WtKoprh7Go2/yvd2HPtoJILO2Or84ncLccI4xoHMTSbGg==} engines: {node: '>=0.8.0'} hasBin: true + requiresBuild: true dev: true optional: true