0.1.3 #4
@@ -3,6 +3,7 @@ export * from "./useCallbackPromise.js"
|
|||||||
export * from "./useCallbackSync.js"
|
export * from "./useCallbackSync.js"
|
||||||
export * from "./useContext.js"
|
export * from "./useContext.js"
|
||||||
export * from "./useEffect.js"
|
export * from "./useEffect.js"
|
||||||
|
export * from "./useInput.js"
|
||||||
export * from "./useLayoutEffect.js"
|
export * from "./useLayoutEffect.js"
|
||||||
export * from "./useMemo.js"
|
export * from "./useMemo.js"
|
||||||
export * from "./useOnce.js"
|
export * from "./useOnce.js"
|
||||||
|
|||||||
59
packages/effect-fc/src/hooks/Hooks/useInput.ts
Normal file
59
packages/effect-fc/src/hooks/Hooks/useInput.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { type Duration, Effect, flow, Option, ParseResult, Ref, Schema, Stream, SubscriptionRef, Types } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useCallbackSync } from "./useCallbackSync.js"
|
||||||
|
import { useFork } from "./useFork.js"
|
||||||
|
import { useOnce } from "./useOnce.js"
|
||||||
|
import { useSubscribeRefs } from "./useSubscribeRefs.js"
|
||||||
|
|
||||||
|
|
||||||
|
export namespace useInput {
|
||||||
|
export interface Options<A, R> {
|
||||||
|
readonly ref: SubscriptionRef.SubscriptionRef<A>
|
||||||
|
readonly schema: Schema.Schema<Types.NoInfer<A>, string, R>
|
||||||
|
readonly debounce?: Duration.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly value: string
|
||||||
|
readonly onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
readonly error: Option.Option<ParseResult.ParseError>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInput: {
|
||||||
|
<A, R>(options: useInput.Options<A, R>): Effect.Effect<useInput.Result, ParseResult.ParseError, R>
|
||||||
|
} = Effect.fnUntraced(function* <A, R>(options: useInput.Options<A, R>) {
|
||||||
|
const internalRef = yield* useOnce(() => options.ref.pipe(
|
||||||
|
Effect.andThen(Schema.encode(options.schema)),
|
||||||
|
Effect.andThen(SubscriptionRef.make),
|
||||||
|
))
|
||||||
|
const [error, setError] = React.useState(Option.none<ParseResult.ParseError>())
|
||||||
|
|
||||||
|
yield* useFork(() => Effect.all([
|
||||||
|
Stream.runForEach(options.ref, upstreamValue =>
|
||||||
|
Effect.andThen(internalRef, internalValue =>
|
||||||
|
upstreamValue !== internalValue
|
||||||
|
? Effect.andThen(Schema.encode(options.schema)(upstreamValue), v => Ref.set(internalRef, v))
|
||||||
|
: Effect.void
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
Stream.runForEach(
|
||||||
|
options.debounce ? Stream.debounce(internalRef, options.debounce) : internalRef,
|
||||||
|
flow(
|
||||||
|
Schema.decode(options.schema),
|
||||||
|
Effect.andThen(v => Ref.set(options.ref, v)),
|
||||||
|
Effect.andThen(() => setError(Option.none())),
|
||||||
|
Effect.catchTag("ParseError", e => Effect.sync(() => setError(Option.some(e)))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
], { concurrency: "unbounded" }), [options.ref, options.schema, options.debounce, internalRef])
|
||||||
|
|
||||||
|
const [value] = yield* useSubscribeRefs(internalRef)
|
||||||
|
const onChange = yield* useCallbackSync((e: React.ChangeEvent<HTMLInputElement>) => Ref.set(
|
||||||
|
internalRef,
|
||||||
|
e.target.value,
|
||||||
|
), [internalRef])
|
||||||
|
|
||||||
|
return { value, onChange, error }
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as Domain from "@/domain"
|
import * as Domain from "@/domain"
|
||||||
import { Box, Button, Flex, IconButton, TextArea } from "@radix-ui/themes"
|
import { Box, Button, Callout, Flex, IconButton, Text, TextArea } from "@radix-ui/themes"
|
||||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
import { Chunk, Effect, Match, Option, Ref, Runtime, SubscriptionRef } from "effect"
|
import { Chunk, Effect, Match, Option, ParseResult, Ref, Runtime, Schema, SubscriptionRef } from "effect"
|
||||||
import { Component, Memoized } from "effect-fc"
|
import { Component, Memoized } from "effect-fc"
|
||||||
import { Hooks } from "effect-fc/hooks"
|
import { Hooks } from "effect-fc/hooks"
|
||||||
import { SubscriptionSubRef } from "effect-fc/types"
|
import { SubscriptionSubRef } from "effect-fc/types"
|
||||||
@@ -40,15 +40,26 @@ export class Todo extends Component.make(function* Todo(props: TodoProps) {
|
|||||||
] as const),
|
] as const),
|
||||||
), [props._tag, props.index])
|
), [props._tag, props.index])
|
||||||
|
|
||||||
const [content, size] = yield* Hooks.useSubscribeRefs(contentRef, state.sizeRef)
|
const [size] = yield* Hooks.useSubscribeRefs(state.sizeRef)
|
||||||
|
const contentInput = yield* Hooks.useInput({ ref: contentRef, schema: Schema.Any })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
{Option.isSome(contentInput.error) &&
|
||||||
|
<Callout.Root color="red">
|
||||||
|
<Callout.Text>
|
||||||
|
{ParseResult.ArrayFormatter.formatErrorSync(contentInput.error.value).map(e => <>
|
||||||
|
<Text>• {e.message}</Text><br />
|
||||||
|
</>)}
|
||||||
|
</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
}
|
||||||
|
|
||||||
<Flex direction="row" align="center" gap="2">
|
<Flex direction="row" align="center" gap="2">
|
||||||
<Box flexGrow="1">
|
<Box flexGrow="1">
|
||||||
<TextArea
|
<TextArea
|
||||||
value={content}
|
value={contentInput.value}
|
||||||
onChange={e => Runtime.runSync(runtime)(Ref.set(contentRef, e.target.value))}
|
onChange={contentInput.onChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user