diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 6274d24..e17b771 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -72,6 +72,7 @@
+
diff --git a/src/components/forms/Form.stories.jsx b/src/components/forms/Form.stories.jsx
index 5bb0239..f974674 100644
--- a/src/components/forms/Form.stories.jsx
+++ b/src/components/forms/Form.stories.jsx
@@ -12,6 +12,7 @@ import { FormCheckboxGroup } from "./FormCheckboxGroup"
import { FormRow } from "./FormRow"
import { Button } from "../inputs/Button"
import { Parenthesis } from "../panels/Parenthesis"
+import { useFormState } from "../../hooks/useValidatedState"
export default {
@@ -22,53 +23,99 @@ export default {
}
-export const Form = props => (
-
-
-
-
- Enter the details of your characters below.
-
-
-
-
-
-
-
-
-
-
-
-
- Throw fireball
- Shoot a magic missile
- Save character
-
-
-)
+export const Form = props => {
+ const username = useFormState("", val => {
+ if(val === "") return undefined
+ return true
+ })
+
+ const password = useFormState("", val => {
+ if(val === "") return undefined
+ if(val.length < 8) return false
+ return true
+ })
+
+ const name = useFormState("", val => {
+ if(val === "") return undefined
+ return true
+ })
+
+ const backstory = useFormState("", val => {
+ if(val === "") return undefined
+ if(val.split("\n").length < 3) return false
+ return true
+ })
+
+ const gender = useFormState(null, val => {
+ if(val === null) return undefined
+ return true
+ })
+
+ const level = useFormState(1, val => {
+ if(val < 1) return false
+ if(val > 20) return false
+ return true
+ })
+
+ const alignment = useFormState([], val => {
+ if(val.length === 0) return undefined
+ return true
+ })
+
+ const classes = useFormState([], val => {
+ if(val.length === 0) return undefined
+ if(val.length > level.value) return false
+ return true
+ })
+
+ return (
+
+
+
+
+ Enter the details of your characters below.
+
+
+
+
+
+
+
+
+
+
+
+
+ Throw fireball
+ Shoot a magic missile
+ Save character
+
+
+ )
+}
Form.args = {}
diff --git a/src/components/forms/FormPair.tsx b/src/components/forms/FormPair.tsx
index 72ed94d..9b86185 100644
--- a/src/components/forms/FormPair.tsx
+++ b/src/components/forms/FormPair.tsx
@@ -23,13 +23,13 @@ export function FormPair({id, label, input, validity, bluelibClassNames, customC
}
let validityClass = ""
- if(validity === "running") {
+ if(validity === null) {
validityClass = "color-yellow"
}
- else if(validity === "ok") {
+ else if(validity === true) {
validityClass = "color-lime"
}
- else if(validity === "error") {
+ else if(validity === false) {
validityClass = "color-red"
}
diff --git a/src/hooks/usePromise.js b/src/hooks/usePromise.js
new file mode 100644
index 0000000..3d82ded
--- /dev/null
+++ b/src/hooks/usePromise.js
@@ -0,0 +1,19 @@
+import * as React from "react"
+import {useCallback, useMemo} from "react"
+
+
+/**
+ * Hook similar to {@link useCallback} that converts a syncronous function into an asyncronous one.
+ *
+ * @todo Improve this docstring.
+ * @todo I have no idea of how to write this in TypeScript.
+ * @param func The function to convert.
+ */
+export function usePromise(func) {
+ return useMemo(
+ () => {
+ return async (...args) => await func(...args)
+ },
+ [func]
+ )
+}
\ No newline at end of file
diff --git a/src/hooks/useValidatedState.ts b/src/hooks/useValidatedState.ts
new file mode 100644
index 0000000..7468fc2
--- /dev/null
+++ b/src/hooks/useValidatedState.ts
@@ -0,0 +1,64 @@
+import * as React from "react"
+import {useState} from "react"
+import {Validator, Validity} from "../types"
+import {usePromise} from "./usePromise";
+
+
+/**
+ * The return type of the {@link useFormState} hook.
+ */
+export type FormState = {
+ value: T,
+ onSimpleChange: React.Dispatch>,
+ validity: Validity,
+}
+
+
+/**
+ * Hook similar to {@link useState} for handling the validation of form fields.
+ *
+ * @param def - Default value for the state.
+ * @param validator - The {@link Validator} to apply.
+ */
+export function useFormState(def: T, validator: Validator): FormState {
+ const [value, setValue]
+ = React.useState(def)
+
+ const [validity, setValidity]
+ = React.useState(null)
+
+ const trueValidator
+ = usePromise(validator)
+
+ const validate
+ = React.useCallback(
+ async (value: T, abort: AbortSignal) => {
+ setValidity(null)
+ let result: Validity
+ try {
+ result = await trueValidator(value, abort)
+ }
+ catch(_) {
+ result = false
+ }
+ if(!abort.aborted) {
+ setValidity(result)
+ }
+ },
+ []
+ )
+
+ React.useEffect(
+ () => {
+ const abort = new AbortController()
+ validate(value, abort.signal)
+
+ return () => {
+ abort.abort()
+ }
+ },
+ [validate, value]
+ )
+
+ return {value, onSimpleChange: setValue, validity}
+}
diff --git a/src/types.ts b/src/types.ts
index 4fb31ad..664ea02 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -21,4 +21,24 @@ export interface BluelibHTMLProps extends BluelibPr
export type InputValue = readonly string[] | string | number | undefined
-export type Validity = null | "running" | "ok" | "error"
+
+
+/**
+ * An optionally async function that checks if a value is acceptable for a certain form field or not.
+ *
+ * See {@link Validity} to see the acceptable return values of the function.
+ *
+ * An {@link AbortSignal} is passed to the function to allow it to handle teardowns, allowing it to stop HTTP requests if the previous value is torn down.
+ */
+export type Validator = (value: T, abort: AbortSignal) => Promise | Validity
+
+/**
+ * The possible return values of a {@link Validator}.
+ *
+ * - `true` means that the value is acceptable for the field;
+ * - `false` means that the value contains an error and should not be accepted;
+ * - `undefined` means that the value has no meaning and wasn't checked (such as in the case of an empty form field);
+ * - `null` means that the value is in progress of being checked.
+ */
+export type Validity = boolean | null | undefined
+