diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md index f0cc609..7f0cb1e 100644 --- a/packages/docs/docs/forms.md +++ b/packages/docs/docs/forms.md @@ -171,124 +171,6 @@ Pass `initialEncodedValue` only when the first draft should differ from the encoded target. Otherwise `LensForm.service` obtains the initial draft by encoding the target through the schema. -## Example: local date input, UTC domain value - -The encoded/decoded distinction is especially useful for dates. An HTML -`datetime-local` input produces a wall-clock string such as -`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application -state and APIs usually need an unambiguous UTC instant. - -A schema can own that entire conversion. The following schema interprets the -input in the current time zone and decodes it to `DateTime.Utc`: - -```tsx title="src/DateTimeUtcFromZonedInput.ts" -import { DateTime, Effect, Option, ParseResult, Schema } from "effect" - -class DateTimeUtcFromZoned extends Schema.transformOrFail( - Schema.DateTimeZonedFromSelf, - Schema.DateTimeUtcFromSelf, - { - strict: true, - decode: (input) => ParseResult.succeed(DateTime.toUtc(input)), - encode: DateTime.setZoneCurrent, - }, -) {} - -export class DateTimeUtcFromZonedInput extends Schema.transformOrFail( - Schema.String, - DateTimeUtcFromZoned, - { - strict: true, - decode: (input, _options, ast) => - Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) => - Option.match( - DateTime.makeZoned(input, { - timeZone, - adjustForTimeZone: true, - }), - { - onSome: ParseResult.succeed, - onNone: () => - ParseResult.fail( - new ParseResult.Type( - ast, - input, - "Enter a valid date and time", - ), - ), - }, - ), - ), - encode: (value) => - ParseResult.succeed( - DateTime.formatIsoZoned(value).slice(0, 16), - ), - }, -) {} -``` - -Use it like any other field schema. The form and input work with a string, but -the mutation receives UTC: - -```tsx -import { DateTime, Effect, Schema } from "effect" -import { Component, Form, MutationForm, View } from "effect-view" -import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput" - -const AppointmentSchema = Schema.Struct({ - startsAt: DateTimeUtcFromZonedInput, -}) - -const AppointmentView = Component.make("Appointment")(function* () { - const [form, startsAtField] = yield* Component.useOnMount(() => - Effect.gen(function* () { - const form = yield* MutationForm.service({ - schema: AppointmentSchema, - initialEncodedValue: { startsAt: "" }, - f: ([appointment]) => - Effect.log( - `Saving ${DateTime.formatIso(appointment.startsAt)}`, - ), - }) - - return [form, Form.focusObjectOn(form, "startsAt")] as const - }), - ) - - const startsAt = yield* Form.useInput(startsAtField) - const [canCommit] = yield* View.useAll([form.canCommit]) - const runPromise = yield* Component.useRunPromise() - - return ( -
- ) -}) -``` - -The schema requires `DateTime.CurrentTimeZone`; provide -`DateTime.layerCurrentZoneLocal` in the application runtime. If the current -zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the -UTC instant `2026-07-22T12:30:00.000Z`. - -Encoding works in the other direction. A `LensForm` whose target contains that -UTC instant presents `2026-07-22T14:30` to the local input. The component never -manually parses dates, applies offsets, or maintains a second representation; -the schema defines both directions and the form keeps them synchronized. - ## Focus into subforms The root `MutationForm` or `LensForm` represents the complete schema. UI @@ -308,6 +190,16 @@ const contactForm = Form.focusObjectOn(form, "contact") const emailField = Form.focusObjectOn(contactForm, "email") ``` +Form focus helpers also have a dual API. Pass only the key or index to get a curried function. Curried helpers can be chained +with `pipe` to focus through a deeper path: + +```tsx +const emailField = form.pipe( + Form.focusObjectOn("contact"), + Form.focusObjectOn("email"), +) +``` + `displayNameField`, `ageField`, and `emailField` can each be passed to the input component responsible for that field. Focusing can also be repeated: `contactForm` represents the complete contact section, and `emailField` @@ -463,3 +355,121 @@ They expose the schema pipeline as reactive Lenses and Views: The form model itself is Effect code. Effect View adds the React-facing hooks, including `Form.useInput` and `Form.useOptionalInput`, while your own component library remains responsible for rendering controls and layout. + +## Example: local date input, UTC domain value + +The encoded/decoded distinction is especially useful for dates. An HTML +`datetime-local` input produces a wall-clock string such as +`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application +state and APIs usually need an unambiguous UTC instant. + +A schema can own that entire conversion. The following schema interprets the +input in the current time zone and decodes it to `DateTime.Utc`: + +```tsx title="src/DateTimeUtcFromZonedInput.ts" +import { DateTime, Effect, Option, ParseResult, Schema } from "effect" + +class DateTimeUtcFromZoned extends Schema.transformOrFail( + Schema.DateTimeZonedFromSelf, + Schema.DateTimeUtcFromSelf, + { + strict: true, + decode: (input) => ParseResult.succeed(DateTime.toUtc(input)), + encode: DateTime.setZoneCurrent, + }, +) {} + +export class DateTimeUtcFromZonedInput extends Schema.transformOrFail( + Schema.String, + DateTimeUtcFromZoned, + { + strict: true, + decode: (input, _options, ast) => + Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) => + Option.match( + DateTime.makeZoned(input, { + timeZone, + adjustForTimeZone: true, + }), + { + onSome: ParseResult.succeed, + onNone: () => + ParseResult.fail( + new ParseResult.Type( + ast, + input, + "Enter a valid date and time", + ), + ), + }, + ), + ), + encode: (value) => + ParseResult.succeed( + DateTime.formatIsoZoned(value).slice(0, 16), + ), + }, +) {} +``` + +Use it like any other field schema. The form and input work with a string, but +the mutation receives UTC: + +```tsx +import { DateTime, Effect, Schema } from "effect" +import { Component, Form, MutationForm, View } from "effect-view" +import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput" + +const AppointmentSchema = Schema.Struct({ + startsAt: DateTimeUtcFromZonedInput, +}) + +const AppointmentView = Component.make("Appointment")(function* () { + const [form, startsAtField] = yield* Component.useOnMount(() => + Effect.gen(function* () { + const form = yield* MutationForm.service({ + schema: AppointmentSchema, + initialEncodedValue: { startsAt: "" }, + f: ([appointment]) => + Effect.log( + `Saving ${DateTime.formatIso(appointment.startsAt)}`, + ), + }) + + return [form, Form.focusObjectOn(form, "startsAt")] as const + }), + ) + + const startsAt = yield* Form.useInput(startsAtField) + const [canCommit] = yield* View.useAll([form.canCommit]) + const runPromise = yield* Component.useRunPromise() + + return ( + + ) +}) +``` + +The schema requires `DateTime.CurrentTimeZone`; provide +`DateTime.layerCurrentZoneLocal` in the application runtime. If the current +zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the +UTC instant `2026-07-22T12:30:00.000Z`. + +Encoding works in the other direction. A `LensForm` whose target contains that +UTC instant presents `2026-07-22T14:30` to the local input. The component never +manually parses dates, applies offsets, or maintains a second representation; +the schema defines both directions and the form keeps them synchronized. diff --git a/packages/docs/docs/state-management.md b/packages/docs/docs/state-management.md index 9d4366d..5e6fcfa 100644 --- a/packages/docs/docs/state-management.md +++ b/packages/docs/docs/state-management.md @@ -219,6 +219,11 @@ interface UserProfile { readonly name: string readonly email: string readonly role: string + readonly contact: { + readonly address: { + readonly city: string + } + } } class ProfileState extends Context.Service< @@ -237,6 +242,11 @@ class ProfileState extends Context.Service< name: "", email: "", role: "reader", + contact: { + address: { + city: "", + }, + }, }), ) const name = Lens.focusObjectOn(profile, "name") @@ -269,6 +279,19 @@ Updating the focused `name` Lens through `Lens.useState` updates the parent `profile` Lens. The focused `role` Lens is only read, so it stays on the simpler `View.useAll` path. +Lens focus helpers have a dual API. They can be called in data-first form, such +as `Lens.focusObjectOn(profile, "name")`, or in curried form with only the key +or index. The curried form composes naturally with `pipe` for deeper paths: + +```tsx +// profileLens is a Lens.Lens