Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/control/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@reskript/config-lint/config/eslint'),
};
11 changes: 11 additions & 0 deletions packages/control/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions packages/control/README.md
Original file line number Diff line number Diff line change
@@ -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
```
76 changes: 76 additions & 0 deletions packages/control/docs/demo/useControl.tsx
Original file line number Diff line number Diff line change
@@ -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<ControlMethods>;

function createViewerMethods(setState): ControlMethods {
return {
close: partial(setState, []),
open(i, content) {
setState([i, content]);
},
};
}

const Modal = forwardRef<ControlMethods, DrawerProps & ExtraProps>(function Viewer(props, ref) {
const [[i, icon], {close}] = useControlSource<Params, ControlMethods>(ref as FowardedRef, createViewerMethods, []);
return (
<AModal visible={!!icon} footer={<>第{i + 1}个</>} onCancel={close} {...props}>
<div style={{textAlign: 'center'}}>
<div style={{fontSize: 120}}>{icon}</div>
<p>0x{icon?.codePointAt(0)?.toString(16).toUpperCase()}</p>
</div>
</AModal>
);
});

const Drawer = forwardRef<ControlMethods, DrawerProps & ExtraProps>(function Viewer(props, ref) {
const [[current], {close}] = useControlSource<Params, ControlMethods>(ref as FowardedRef, createViewerMethods, []);
return (
<ADrawer visible={current !== undefined} footer={null} onClose={close} {...props}>
<Steps current={current} direction="vertical">
{props.icons.map(icon => (<Steps.Step title={<big>{icon}</big>} />))}
</Steps>
</ADrawer>
);
});


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 (
<>
<p>
{items.map((item, i) => <button key={item} onClick={partial(handleClick, i)}>{item}</button>)}
</p>
<p>Click emoji to preview detail. <small>{renderTimes}</small></p>
<div>
<Radio.Group size="small" {...typeProps}>
<Radio.Button value="modal">Modal</Radio.Button>
<Radio.Button value="drawer">Drawer</Radio.Button>
</Radio.Group>
</div>
<MyViewer icons={items} title="Emoji Viewer" />
</>
);
}
33 changes: 33 additions & 0 deletions packages/control/docs/useControl.md
Original file line number Diff line number Diff line change
@@ -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<T> = {readonly $get: (property: string) => any} & Omit<T, '$get'>;
type ControlRef<T> = React.MutableRefObject<T>;

function useControl<P, M = ControlMethods>(
CompIn: React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<M>> | null
): [React.FunctionComponent<React.PropsWithoutRef<P>> | null, ProxyMethods<M>];

function useControlSource<T, M = ControlMethods>(
ref: ControlRef<M>,
deriveMethods: (setData: React.Dispatch<React.SetStateAction<T>>) => M,
initialData: T
): [T, M];
```

<code src="./demo/useControl.tsx">
46 changes: 46 additions & 0 deletions packages/control/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
42 changes: 42 additions & 0 deletions packages/control/src/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -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 <div data-title={title} onClick={increase}>{i}</div>;
});

const Bar = ({c = Foo, setFn}) => {
const [Comp, {decrease, $get}] = useControl(c);
useEffect(
() => {
setFn({decrease, $get});
},
[setFn, decrease, $get]
);
return Comp ? <Comp title="test" /> : null;
};

test('expose and trigger methods', () => {
const fns = {};
const {container} = render(<Bar setFn={o => 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(<Bar c={null} setFn={() => {}} />);
expect(container.querySelector('div')).toBe(null);
});
69 changes: 69 additions & 0 deletions packages/control/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {useImperativeHandle, useRef, useMemo, useState} from 'react';

interface ControlMethods {
[key: string]: (...args: any[]) => any;
}
type ProxyMethods<T> = {readonly $get: (property: string) => any} & Omit<T, '$get'>;
export type ControlRef<T> = React.MutableRefObject<T>;

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<P, M = ControlMethods>(
CompIn: React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<M>> | null
): [React.FunctionComponent<React.PropsWithoutRef<P>> | null, ProxyMethods<M>] {
const ref = useRef({}) as ControlRef<M>;
const methods = useMemo(
() => createMethodsProxy(ref) as ProxyMethods<M>,
[]
);

const CompOut = useMemo(
() => {
if (!CompIn) {
return null;
}
return function CompOut(props) {
return <CompIn {...props} ref={ref} />;
};
},
[CompIn]
);

return [CompOut, methods];
}


export function useControlSource<T, M = ControlMethods>(
ref: ControlRef<M>,
deriveMethods: (setData: React.Dispatch<React.SetStateAction<T>>) => M,
initialData: T
): [T, M] {
const [data, setData] = useState(initialData);
const methods = useMemo(
() => deriveMethods(setData),
[deriveMethods, setData]
);
useImperativeHandle(ref, () => methods, [methods]);
return [data, methods];
}
8 changes: 8 additions & 0 deletions packages/control/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./cjs"
},
"include": ["src"]
}
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.