Skip to content

Commit 0a59e57

Browse files
committed
✨ add @coven/pair
1 parent e0d0527 commit 0a59e57

18 files changed

+345
-5
lines changed

@coven/pair/README.md

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<img alt="Coven Engineering Pair logo" src="https://raw.githubusercontent.com/covenengineering/libraries/main/@coven/pair/logo.svg" height="108" />
2+
3+
[![JSR](https://jsr.io/badges/@coven/pair)](https://jsr.io/@coven/pair)
4+
[![JSR Score](https://jsr.io/badges/@coven/pair/score)](https://jsr.io/@coven/pair/score)
5+
6+
🧩 [Paired hook pattern](https://lou.cx/articles/the-paired-hook-pattern)
7+
helper.
8+
9+
## Examples
10+
11+
### Preact
12+
13+
```tsx
14+
import { useState } from "preact";
15+
import { pair } from "@coven/pair/preact";
16+
17+
const useCount = (initialCount) => {
18+
const [count, setCount] = useState(initialCount);
19+
20+
return { onClick: () => setCount(count + 1), children: count };
21+
};
22+
23+
const PairedCount = pair(useCount);
24+
25+
const Component = ({ array = [] }) => (
26+
<ul>
27+
{array.map((key) => (
28+
<PairedCount key={key}>
29+
{(usePairedCount) => {
30+
const props = usePairedCount(key);
31+
32+
return (
33+
<li>
34+
<button type="button" {...props} />
35+
</li>
36+
);
37+
}}
38+
</PairedCount>
39+
))}
40+
</ul>
41+
);
42+
```
43+
44+
### React
45+
46+
```tsx
47+
import { useState } from "react";
48+
import { pair } from "@coven/pair/react";
49+
50+
const useCount = (initialCount) => {
51+
const [count, setCount] = useState(initialCount);
52+
53+
return { onClick: () => setCount(count + 1), children: count };
54+
};
55+
56+
const PairedCount = pair(useCount);
57+
58+
const Component = ({ array = [] }) => (
59+
<ul>
60+
{array.map((key) => (
61+
<PairedCount key={key}>
62+
{(usePairedCount) => {
63+
const props = usePairedCount(key);
64+
65+
return (
66+
<li>
67+
<button type="button" {...props} />
68+
</li>
69+
);
70+
}}
71+
</PairedCount>
72+
))}
73+
</ul>
74+
);
75+
```
76+
77+
## Other links
78+
79+
- [Coverage](https://coveralls.io/github/covenengineering/libraries).

@coven/pair/deno.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@coven/pair",
3+
"version": "0.0.5",
4+
"exports": {
5+
".": "./mod.ts",
6+
"./preact": "./preact/mod.ts",
7+
"./react": "./react/mod.ts"
8+
}
9+
}

@coven/pair/logo.svg

+1
Loading

@coven/pair/mod.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * as preact from "./preact/mod.ts";
2+
export * as react from "./react/mod.ts";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { PairedRenderFunction } from "./PairedRenderFunction.ts";
2+
3+
/**
4+
* Paired component properties (just children with the paired hook render function).
5+
*
6+
* @category Internal
7+
*/
8+
export type PairedComponentProperties<
9+
Hook extends (...attributes: never) => unknown,
10+
> = {
11+
/**
12+
* Children has to be a function, and the argument is the paired hook.
13+
*/
14+
readonly children: PairedRenderFunction<Hook>;
15+
};
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Unary } from "@coven/types";
2+
import type { h } from "preact";
3+
4+
/**
5+
* Function that receives the paired hook and must return a `VNode`.
6+
*
7+
* @category Internal
8+
*/
9+
export type PairedRenderFunction<
10+
Hook extends (...attributes: never) => unknown,
11+
> = Unary<
12+
[hook: Hook],
13+
ReturnType<typeof h>
14+
>;

@coven/pair/preact/mod.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { pair } from "./pair.ts";
2+
export type { PairedComponentProperties } from "./PairedComponentProperties.ts";
3+
export type { PairedRenderFunction } from "./PairedRenderFunction.ts";

@coven/pair/preact/pair.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { FunctionComponent } from "preact";
2+
import type { PairedComponentProperties } from "./PairedComponentProperties.ts";
3+
4+
/**
5+
* Creates a component with a function children that has the given hook in context.
6+
*
7+
* @example
8+
* ```tsx
9+
* const useCount = initialCount => {
10+
* const [count, setCount] = useState(initialCount);
11+
*
12+
* return { onClick: () => setCount(count + 1), children: count };
13+
* };
14+
*
15+
* const PairedCount = pair(useCount);
16+
*
17+
* const Component = ({ array = [] }) => (
18+
* <ul>
19+
* {array.map(key => (
20+
* <PairedCount key={key}>
21+
* {usePairedCount => {
22+
* const props = usePairedCount(key);
23+
*
24+
* return (
25+
* <li>
26+
* <button
27+
* type="button"
28+
* {...props}
29+
* />
30+
* </li>
31+
* );
32+
* }}
33+
* </PairedCount>
34+
* ))}
35+
* </ul>
36+
* );
37+
* ```
38+
* @param hook Hook to be paired.
39+
* @returns Component that expects a function as children with the paired hook.
40+
*/
41+
export const pair = <Hook extends (...attributes: never) => unknown>(
42+
hook: Hook,
43+
): FunctionComponent<PairedComponentProperties<Hook>> => {
44+
const PairedComponent = ((properties) => properties.children(hook)) as
45+
& FunctionComponent<
46+
PairedComponentProperties<Hook>
47+
>
48+
& { displayName: string };
49+
50+
return (
51+
(PairedComponent.displayName = `paired(${hook.name})`), PairedComponent
52+
);
53+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { PairedRenderFunction } from "./PairedRenderFunction.ts";
2+
3+
/**
4+
* Paired component properties (just children with the paired hook render function).
5+
*
6+
* @category Internal
7+
*/
8+
export type PairedComponentProperties<
9+
Hook extends (...attributes: never) => unknown,
10+
> = {
11+
/**
12+
* Children has to be a function, and the argument is the paired hook.
13+
*/
14+
readonly children: PairedRenderFunction<Hook>;
15+
};
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Unary } from "@coven/types";
2+
import type { createElement } from "react";
3+
4+
/**
5+
* Function that receives the paired hook and must return a `ReactElement`.
6+
*
7+
* @category Internal
8+
*/
9+
export type PairedRenderFunction<
10+
Hook extends (...attributes: never) => unknown,
11+
> = Unary<
12+
[hook: Hook],
13+
ReturnType<typeof createElement>
14+
>;

@coven/pair/react/mod.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { pair } from "./pair.ts";
2+
export type { PairedComponentProperties } from "./PairedComponentProperties.ts";
3+
export type { PairedRenderFunction } from "./PairedRenderFunction.ts";

@coven/pair/react/pair.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { FunctionComponent } from "react";
2+
import { pair as preactPair } from "../preact/pair.ts";
3+
import type { PairedComponentProperties } from "./PairedComponentProperties.ts";
4+
5+
/**
6+
* Creates a component with a function children that has the given hook in context.
7+
*
8+
* @example
9+
* ```tsx
10+
* const useCount = initialCount => {
11+
* const [count, setCount] = useState(initialCount);
12+
*
13+
* return { onClick: () => setCount(count + 1), children: count };
14+
* };
15+
*
16+
* const PairedCount = pair(useCount);
17+
*
18+
* const Component = ({ array = [] }) => (
19+
* <ul>
20+
* {array.map(key => (
21+
* <PairedCount key={key}>
22+
* {usePairedCount => {
23+
* const props = usePairedCount(key);
24+
*
25+
* return (
26+
* <li>
27+
* <button
28+
* type="button"
29+
* {...props}
30+
* />
31+
* </li>
32+
* );
33+
* }}
34+
* </PairedCount>
35+
* ))}
36+
* </ul>
37+
* );
38+
* ```
39+
* @param hook Hook to be paired.
40+
* @returns Component that expects a function as children with the paired hook.
41+
*/
42+
export const pair = preactPair as <
43+
Hook extends (...attributes: never) => unknown,
44+
>(
45+
hook: Hook,
46+
) => FunctionComponent<PairedComponentProperties<Hook>>;

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ libraries:
1515
build regular expressions.
1616
- 🌪️ [`@coven/iterables`](https://jsr.io/@coven/iterables) — Iteration rituals.
1717
- 💀 [`@coven/math`](https://jsr.io/@coven/math) — Math witchcraft.
18+
- 🧩 [`@coven/pair`](https://jsr.io/@coven/pair) — Paired hook pattern helper.
1819
- 💫 [`@coven/parsers`](https://jsr.io/@coven/parsers) — Parsing charms.
1920
- 🛡️ [`@coven/predicates`](https://jsr.io/@coven/predicates) — Predicate wards.
2021
- 🖌️ [`@coven/terminal`](https://jsr.io/@coven/terminal) — Delightfully simple

TODO.md

+2-3
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@ to `1.0.0` for everything. Now about the actual "to do":
1111
- [x] Move `functional-expression` as `@coven/expression`.
1212
- [x] Move `@lou.codes/cron` as `@coven/cron`.
1313
- [x] Move `@lou.codes/notify` as `@simulcast/core`.
14-
- [ ] Move `react-pair` as `@coven/react-pair`.
15-
- [ ] Move `preact-pair` as `@coven/preact-pair`.
16-
- [ ] Check if is worth it to create `@coven/solid-pair`.
14+
- [x] Move `*-pair` as `@coven/pair`.
15+
- [x] Check if is worth it to create `@coven/solid-pair` (they don't need this).
1716
- [ ] Cleanup tests.
1817
- [ ] Cleanup docs.
1918
- [ ] Start working on new `@simulcast/{name}` libs:

deno.json

+10-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@
2020
"singleQuote": false,
2121
"useTabs": true
2222
},
23-
"imports": { "@std/assert": "jsr:@std/assert@^1.0.6" },
23+
"imports": {
24+
"@std/assert": "jsr:@std/assert@^1.0.6",
25+
"@types/react": "npm:@types/react@^18.3.11",
26+
"@types/react-dom": "npm:@types/react-dom@^18.3.1",
27+
"preact": "npm:preact@^10.24.2",
28+
"preact-render-to-string": "npm:preact-render-to-string@^6.5.11",
29+
"react": "npm:react@^18.3.1",
30+
"react-dom": "npm:react-dom@^18.3.1"
31+
},
2432
"lint": {
2533
"rules": {
2634
"include": [
@@ -135,6 +143,7 @@
135143
"./@coven/expression",
136144
"./@coven/iterables",
137145
"./@coven/math",
146+
"./@coven/pair",
138147
"./@coven/parsers",
139148
"./@coven/predicates",
140149
"./@coven/terminal",

tests/@coven/pair/preact.test.tsx

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/** @jsxImportSource preact */
2+
import { pair, type PairedComponentProperties } from "@coven/pair/preact";
3+
import { assertStrictEquals } from "@std/assert";
4+
import { renderToString } from "preact-render-to-string";
5+
import { useState } from "preact/hooks";
6+
7+
const Render = (usePairedState: typeof useState) => {
8+
const [count, setCount] = usePairedState(0);
9+
10+
return (
11+
<button onClick={() => setCount(count + 1)} type="button">
12+
{count}
13+
</button>
14+
);
15+
};
16+
17+
const Wanted = ({ children }: PairedComponentProperties<typeof useState>) =>
18+
children(useState);
19+
20+
const key = "TEST";
21+
22+
const PairedState = pair(useState);
23+
24+
Deno.test("Paired hook with key returns component wrapping hook and with key", () =>
25+
assertStrictEquals(
26+
renderToString(<PairedState key={key}>{Render}</PairedState>),
27+
renderToString(<Wanted key={key}>{Render}</Wanted>),
28+
));
29+
30+
Deno.test("Paired hook without key returns component wrapping hook and without key", () =>
31+
assertStrictEquals(
32+
renderToString(<PairedState>{Render}</PairedState>),
33+
renderToString(<Wanted>{Render}</Wanted>),
34+
));
35+
36+
Deno.test("Paired hook has a displayName that reflects it's a paired hook", () =>
37+
assertStrictEquals(PairedState.displayName, `paired(${useState.name})`));

tests/@coven/pair/react.test.tsx

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/** @jsxImportSource react */
2+
/** @jsxImportSourceTypes @types/react */
3+
import { pair, type PairedComponentProperties } from "@coven/pair/react";
4+
import { assertStrictEquals } from "@std/assert";
5+
import { useState } from "react";
6+
import { renderToString } from "react-dom/server";
7+
8+
const Render = (usePairedState: typeof useState) => {
9+
const [count, setCount] = usePairedState(0);
10+
11+
return (
12+
<button onClick={() => setCount(count + 1)} type="button">
13+
{count}
14+
</button>
15+
);
16+
};
17+
18+
const Wanted = ({ children }: PairedComponentProperties<typeof useState>) =>
19+
children(useState);
20+
21+
const key = "TEST";
22+
23+
const PairedState = pair(useState);
24+
25+
Deno.test("Paired hook with key returns component wrapping hook and with key", () =>
26+
assertStrictEquals(
27+
renderToString(<PairedState key={key}>{Render}</PairedState>),
28+
renderToString(<Wanted key={key}>{Render}</Wanted>),
29+
));
30+
31+
Deno.test("Paired hook without key returns component wrapping hook and without key", () =>
32+
assertStrictEquals(
33+
renderToString(<PairedState>{Render}</PairedState>),
34+
renderToString(<Wanted>{Render}</Wanted>),
35+
));
36+
37+
Deno.test("Paired hook has a displayName that reflects it's a paired hook", () =>
38+
assertStrictEquals(
39+
PairedState.displayName,
40+
`paired(${useState.name})`,
41+
));

tests/@simulcast/core/emit.test.ts

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ type TestRegistry = { [TEST_EVENT]: never };
88
Deno.test("emit with listeners calls the listeners", () => {
99
let called = false;
1010
emit<TestRegistry>({
11-
// eslint-disable-next-line no-param-reassign
1211
[TEST_EVENT]: [() => (called = true)],
1312
})(TEST_EVENT)();
1413

0 commit comments

Comments
 (0)