Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
7 changes: 5 additions & 2 deletions example/src/domain/Full.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { object, array, useForm, useFormFieldValue, snapshot } from '@kaliber/forms'
import { object, array, useForm, useFormFieldValue, snapshot, focusFirstError } from '@kaliber/forms'
import { optional, required, minLength, error, email } from '@kaliber/forms/validation'
import { FormFieldValue, FormFieldsValues, FormFieldValid } from '@kaliber/forms/components'
import { date, ifParentHasValue, ifFormHasValue } from './machinery/validation'
Expand Down Expand Up @@ -78,7 +78,10 @@ export function Full() {
)

function handleSubmit(snapshot) {
if (snapshot.invalid) return
if (snapshot.invalid) {
focusFirstError(form)
return
}
setSubmitted(snapshot.value)
}

Expand Down
16 changes: 8 additions & 8 deletions example/src/domain/machinery/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ export function FormValues({ form }) {
}

export function FormTextInput({ field, label }) {
const { name, state, eventHandlers } = useFormField(field)
return <InputBase type='text' {...{ name, label, state, eventHandlers }} />
const { name, state, eventHandlers, ref } = useFormField(field)
return <InputBase type='text' {...{ name, label, state, eventHandlers, ref }} />
}
Comment thread
AlbertSmit marked this conversation as resolved.

export function FormNumberInput({ field, label }) {
const { name, state, eventHandlers } = useNumberFormField(field)
const { name, state, eventHandlers, ref } = useNumberFormField(field)
// We use type='text' to show `number` validation
return <InputBase type='text' {...{ name, label, state, eventHandlers }} />
return <InputBase type='text' {...{ name, label, state, eventHandlers, ref }} />
}

export function FormCheckbox({ field, label }) {
const { name, state, eventHandlers } = useBooleanFormField(field)
const { name, state, eventHandlers, ref } = useBooleanFormField(field)
const { value } = state
console.log(`[${name}] render checkbox field`)
return (
Expand All @@ -27,7 +27,7 @@ export function FormCheckbox({ field, label }) {
id={name}
type='checkbox'
checked={value || false}
{...{ name }}
{...{ ref, name }}
{...eventHandlers}
/>
</LabelAndError>
Expand Down Expand Up @@ -133,15 +133,15 @@ export function FormObjectField({ field, render }) {
)
}

function InputBase({ type, name, label, state, eventHandlers }) {
function InputBase({ type, name, label, state, eventHandlers, ref }) {
const { value } = state
console.log(`[${name}] render ${type} field`)
return (
<LabelAndError {...{ name, label, state }}>
<input
id={name}
value={value === undefined ? '' : value}
{...{ name, type }}
{...{ ref, name, type }}
{...eventHandlers}
/>
</LabelAndError>
Expand Down
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ export {
export {
array, object,
} from './src/schema'
export {
FormErrorRegion,
focusFirstError,
} from './src/a11y'
40 changes: 40 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ _See the example for use cases_
- [FormFieldValue](#FormFieldValue)
- [FormFieldsValues](#FormFieldsValues)
- [FormFieldValid](#FormFieldValid)
- [FormErrorRegion](#FormErrorRegion)
- [Accessibility](#accessibility)
- [focusFirstError](#focusFirstError)
Comment thread
AlbertSmit marked this conversation as resolved.
Outdated


### Hooks
Expand Down Expand Up @@ -176,6 +179,7 @@ const {
name, // the fully qualified name of the form field
state, // 'object' that contains the form field state
eventHandlers, // 'object' that contains handlers which can be used by form elements
ref, // 'object' that contains the ref for the form field element
} = useFormField(field)
```

Expand All @@ -194,6 +198,7 @@ const {
|`- onBlur` | Handler for `onBlur` events|
|`- onFocus` | Handler for `onFocus` events|
|`- onChange` | handler for `onChange` events, accepts DOM event or value|
|`ref` | A ref object `{ current: null }` that should be passed to the form field element to support `focusFirstError` (when used). Note that this is only available for basic fields. Objects and arrays do not have a ref, as `focusFirstError` will traverse them to find the first invalid basic field.|

#### useNumberFormField

Expand Down Expand Up @@ -444,6 +449,41 @@ Because this is such a common usecase, we provide several of these components.
)}>
```

#### FormErrorRegion

A visually hidden live region that announces form errors to screen readers.

| Props | |
|---------|--------------------------------------------------------------------------------------|
|`form` | The form object returned by `useForm`. |
|`renderError` | (Optional) A function to render each error. Defaults to rendering the error message or id. |

##### Example

```jsx
<FormErrorRegion form={form} />
```

### Accessibility

#### focusFirstError

Focuses the first invalid field in the form. This is useful to call when a form submission fails due to validation errors.

```js
import { focusFirstError } from '@kaliber/forms'

// ...

function handleSubmit(snapshot) {
if (snapshot.invalid) {
focusFirstError(form)
return
}
// ...
}
```

## Missing feature?

If the library has a constraint that prevents you from implementing a specific feature (outside of the library) start a conversation.
Expand Down
86 changes: 86 additions & 0 deletions src/a11y.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useFormFieldSnapshot } from './hooks'

/**
* A visually hidden live region that announces form errors to screen readers.
*/
export function FormErrorRegion({ form, renderError = defaultRenderError }) {
const snapshot = useFormFieldSnapshot(form)
const errors = flattenErrors(snapshot.error)

return (
<div style={visuallyHiddenStyle}>
<div aria-live="polite" role="status">
{errors.map((error, i) => renderError(error, i))}
</div>
</div>
)
}

/**
* Focus the first invalid field in the form.
* @param {object} form - The form object returned by useForm
*/
export function focusFirstError(form) {
const errorFields = findAllErrorFields(form)
const sortedFields = errorFields.sort(byDomOrder)
const firstErrorField = getFirstItem(sortedFields)

firstErrorField?.ref.current.focus()
}
Comment thread
AlbertSmit marked this conversation as resolved.

function findAllErrorFields(field) {
const state = field.state.get()

if (field.type === 'basic' && state.error && field.ref?.current) return [field]

return [
...(field.fields ? Object.values(field.fields) : []),
...(state.children || [])
].flatMap(findAllErrorFields)
}

function byDomOrder(a, b) {
const nodeA = a.ref.current
const nodeB = b.ref.current
if (!nodeA || !nodeB) return 0
return nodeAPrecedesNodeB(nodeA, nodeB) ? -1 : 1
}

function nodeAPrecedesNodeB(nodeA, nodeB) {
return nodeB.compareDocumentPosition(nodeA) & Node.DOCUMENT_POSITION_PRECEDING
}

function getFirstItem(array) {
return array?.[0] ?? undefined
}

function flattenErrors(errorTree) {
if (!errorTree) return []

if (typeof errorTree !== 'object' || (!errorTree.self && !errorTree.children)) {
return errorTree ? [errorTree] : []
}

const selfErrors = errorTree.self ? [errorTree.self] : []
const childErrors = errorTree.children
? Object.values(errorTree.children).flatMap(flattenErrors)
: []

return [...selfErrors, ...childErrors]
}

function defaultRenderError(error, key) {
return <div key={key}>{error.message || error.id || String(error)}</div>
}

const visuallyHiddenStyle = {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: '0'
}
22 changes: 22 additions & 0 deletions src/fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ function createArrayFormField({ name, initialValue = [], field }) {
}

function createBasicFormField({ name, initialValue, field }) {
const ref = createStableRef()

const initialFormFieldState = deriveFormFieldState({ value: initialValue })
const internalState = createState(initialFormFieldState)
Expand All @@ -158,6 +159,7 @@ function createBasicFormField({ name, initialValue, field }) {
return {
type: 'basic',
name,
ref,
validate(context) {
if (validate) validate(value.get(), context)
},
Expand Down Expand Up @@ -237,3 +239,23 @@ function bindValidate(f, state) {
function addParent(context, parent) {
return { ...context, parents: [...context.parents, parent] }
}

/**
* Creates a stable ref object for use outside of React components.
*
* This is equivalent to what React.useRef() returns, but can be called
* outside of React's component lifecycle. It works because:
*
* 1. A React ref is just an object with a `current` property
* 2. React.useRef() only guarantees stable identity across re-renders
* 3. Since field objects are created once and persist, a plain object
* achieves the same stability
*
* When passed to a DOM element's `ref` prop, React will assign the
* DOM node to the `current` property automatically.
*
* @returns {{ current: null }} A ref-compatible object
*/
function createStableRef() {
return { current: null }
}
12 changes: 6 additions & 6 deletions src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,17 @@ export function useFormFieldsValues(fields) {

export function useFormField(field) {
if (!field) throw new Error('No field was passed in')
const { name, eventHandlers } = field
const { name, eventHandlers, ref } = field
const state = useFormFieldState(field.state)

return { name, state, eventHandlers }
return { name, state, eventHandlers, ref }
}

export function useNumberFormField(field) {
const { name, state, eventHandlers: { onChange, ...originalEventHandlers } } = useFormField(field)
const { name, state, eventHandlers: { onChange, ...originalEventHandlers }, ref } = useFormField(field)
const eventHandlers = { ...originalEventHandlers, onChange: handleChange }

return { name, state, eventHandlers }
return { name, state, eventHandlers, ref }

function handleChange(e) {
const userValue = e.target.value
Expand All @@ -119,10 +119,10 @@ export function useNumberFormField(field) {
}

export function useBooleanFormField(field) {
const { name, state, eventHandlers: { onChange, ...originalEventHandlers } } = useFormField(field)
const { name, state, eventHandlers: { onChange, ...originalEventHandlers }, ref } = useFormField(field)
const eventHandlers = { ...originalEventHandlers, onChange: handleChange }

return { name, state, eventHandlers }
return { name, state, eventHandlers, ref }

function handleChange(e) {
onChange(e.target.checked)
Expand Down