Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
17 changes: 10 additions & 7 deletions example/src/domain/machinery/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,25 @@ 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: inputRef } = useFormField(field)
return <InputBase type='text' {...{ name, label, state, eventHandlers, inputRef }} />
}

export function FormNumberInput({ field, label }) {
const { name, state, eventHandlers } = useNumberFormField(field)
const { name, state, eventHandlers, ref: inputRef } = 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, inputRef }} />
}

export function FormCheckbox({ field, label }) {
const { name, state, eventHandlers } = useBooleanFormField(field)
const { name, state, eventHandlers, ref: inputRef } = useBooleanFormField(field)
const { value } = state
console.log(`[${name}] render checkbox field`)
return (
<LabelAndError {...{ name, label, state }}>
<input
id={name}
ref={inputRef}
type='checkbox'
checked={value || false}
{...{ name }}
Expand All @@ -35,7 +36,7 @@ export function FormCheckbox({ field, label }) {
}

export function FormCheckboxGroupField({ field, options, label }) {
const { name, state, eventHandlers: { onChange, ...eventHandlers } } = useFormField(field)
const { name, state, eventHandlers: { onChange, ...eventHandlers }, ref: inputRef } = useFormField(field)
const { value } = state

console.log(`[${name}] render checkbox group field`)
Expand All @@ -49,6 +50,7 @@ export function FormCheckboxGroupField({ field, options, label }) {
<label htmlFor={id}>{option.label}</label>
<input
type='checkbox'
ref={inputRef}
value={option.value}
checked={Array.isArray(value) && value.includes(option.value)}
onChange={handleChangeFor(option.value)}
Expand Down Expand Up @@ -133,13 +135,14 @@ export function FormObjectField({ field, render }) {
)
}

function InputBase({ type, name, label, state, eventHandlers }) {
function InputBase({ type, name, label, state, eventHandlers, inputRef }) {
const { value } = state
console.log(`[${name}] render ${type} field`)
return (
<LabelAndError {...{ name, label, state }}>
<input
id={name}
ref={inputRef}
value={value === undefined ? '' : value}
{...{ name, type }}
{...eventHandlers}
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)


### 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()
}

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