@coopdigital/react 1.27.1 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Button/Button.js +1 -1
- package/dist/components/Card/Card.js +1 -1
- package/dist/components/Checkbox/Checkbox.d.ts +3 -3
- package/dist/components/Checkbox/Checkbox.js +5 -3
- package/dist/components/Combobox/Combobox.js +6 -3
- package/dist/components/DatePicker/DatePicker.js +5 -3
- package/dist/components/Dialog/Dialog.js +1 -1
- package/dist/components/Expandable/Expandable.js +1 -1
- package/dist/components/Field/Field.context.js +5 -6
- package/dist/components/Field/Field.d.ts +2 -2
- package/dist/components/Field/Field.js +40 -20
- package/dist/components/FieldMarkers/Error.js +10 -3
- package/dist/components/FieldMarkers/Label.js +1 -5
- package/dist/components/FieldMarkers/fieldmarkers-utils.js +13 -0
- package/dist/components/Fieldset/Fieldset.context.js +7 -0
- package/dist/components/Fieldset/Fieldset.d.ts +3 -1
- package/dist/components/Fieldset/Fieldset.js +34 -7
- package/dist/components/Pill/Pill.js +1 -1
- package/dist/components/Popover/Popover.js +1 -1
- package/dist/components/Radio/Radio.d.ts +1 -1
- package/dist/components/Radio/Radio.js +5 -3
- package/dist/components/Searchbox/Searchbox.js +2 -1
- package/dist/components/Select/Select.d.ts +3 -3
- package/dist/components/Select/Select.js +5 -3
- package/dist/components/Tag/Tag.js +1 -1
- package/dist/components/TextInput/TextInput.d.ts +3 -3
- package/dist/components/TextInput/TextInput.js +7 -3
- package/dist/components/Textarea/Textarea.d.ts +3 -3
- package/dist/components/Textarea/Textarea.js +5 -3
- package/dist/components/ValidationSummary/ValidationSummary.context.js +10 -0
- package/dist/components/ValidationSummary/ValidationSummary.d.ts +1 -8
- package/dist/components/ValidationSummary/ValidationSummary.js +36 -17
- package/dist/components/ValidationSummary/validationsummary-utils.d.ts +11 -0
- package/dist/components/ValidationSummary/validationsummary-utils.js +29 -6
- package/dist/hooks/useSlots.js +2 -26
- package/dist/types/index.d.ts +15 -0
- package/dist/utils/classes.d.ts +6 -0
- package/dist/utils/classes.js +13 -0
- package/dist/utils/index.d.ts +3 -7
- package/dist/utils/index.js +3 -17
- package/dist/utils/slots.d.ts +9 -0
- package/dist/utils/slots.js +37 -0
- package/package.json +3 -3
- package/src/components/Checkbox/Checkbox.tsx +8 -5
- package/src/components/Combobox/Combobox.context.ts +1 -0
- package/src/components/Combobox/Combobox.tsx +7 -3
- package/src/components/DatePicker/DatePicker.tsx +5 -3
- package/src/components/Field/Field.context.ts +14 -8
- package/src/components/Field/Field.tsx +39 -20
- package/src/components/FieldMarkers/Error.tsx +2 -1
- package/src/components/FieldMarkers/Label.tsx +0 -7
- package/src/components/FieldMarkers/fieldmarkers-utils.ts +26 -0
- package/src/components/Fieldset/Fieldset.context.ts +13 -0
- package/src/components/Fieldset/Fieldset.tsx +39 -5
- package/src/components/Radio/Radio.tsx +6 -3
- package/src/components/Select/Select.tsx +7 -4
- package/src/components/TextInput/TextInput.tsx +9 -8
- package/src/components/Textarea/Textarea.tsx +8 -7
- package/src/components/ValidationSummary/ValidationSummary.context.ts +18 -0
- package/src/components/ValidationSummary/ValidationSummary.tsx +38 -24
- package/src/components/ValidationSummary/validationsummary-utils.ts +81 -13
- package/src/hooks/useSlots.ts +1 -41
- package/src/utils/classes.ts +14 -0
- package/src/utils/index.ts +2 -24
- package/src/utils/slots.ts +63 -0
|
@@ -5,13 +5,14 @@ import clsx from "clsx"
|
|
|
5
5
|
import React from "react"
|
|
6
6
|
|
|
7
7
|
import { Error as BaseError, type ErrorProps } from "@/components/FieldMarkers/Error"
|
|
8
|
+
import { getMarkerIds, type Markers } from "@/components/FieldMarkers/fieldmarkers-utils"
|
|
8
9
|
import { Hint as BaseHint, type HintProps } from "@/components/FieldMarkers/Hint"
|
|
9
10
|
import { Label as BaseLabel, type LabelProps } from "@/components/FieldMarkers/Label"
|
|
11
|
+
import { useFieldset } from "@/components/Fieldset/Fieldset.context"
|
|
10
12
|
import { useId } from "@/hooks/useId"
|
|
11
|
-
import {
|
|
12
|
-
import { getSlotProp } from "@/utils"
|
|
13
|
+
import { forEachSlot, getSlotProp } from "@/utils"
|
|
13
14
|
|
|
14
|
-
import { FieldConfig, FieldContext } from "./Field.context"
|
|
15
|
+
import { FieldConfig, FieldContext, useField } from "./Field.context"
|
|
15
16
|
|
|
16
17
|
interface FieldProps extends HTMLAttributes<HTMLDivElement> {
|
|
17
18
|
/** Specify whether the Field should render inside a box. */
|
|
@@ -22,7 +23,7 @@ interface FieldProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
22
23
|
children?: React.ReactNode
|
|
23
24
|
/** Specify additional CSS classes. */
|
|
24
25
|
classname?: string
|
|
25
|
-
/** Specify the component error state. */
|
|
26
|
+
/** Specify the component error state. Inherits the error state of a Fieldset with `group` set when not specified. */
|
|
26
27
|
error?: boolean
|
|
27
28
|
/** Specify whether the Field should show the left-hand error bar if it is in an error state. */
|
|
28
29
|
hideErrorBar?: boolean
|
|
@@ -34,32 +35,38 @@ const Field = ({
|
|
|
34
35
|
boxed = false,
|
|
35
36
|
children,
|
|
36
37
|
className,
|
|
37
|
-
error
|
|
38
|
+
error: errorProp,
|
|
38
39
|
hideErrorBar = false,
|
|
39
40
|
ref,
|
|
40
41
|
...props
|
|
41
42
|
}: FieldProps): JSX.Element => {
|
|
42
43
|
const internalId = useId()
|
|
44
|
+
const fieldset = useFieldset()
|
|
45
|
+
const error = errorProp ?? fieldset?.error ?? false
|
|
43
46
|
|
|
44
47
|
const controlProps: FieldConfig = {
|
|
45
48
|
disabled: false,
|
|
46
49
|
id: internalId,
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
52
|
+
// The Hint and Error rendered in the Field, for the aria-describedby of the control
|
|
53
|
+
const markers: Markers = {}
|
|
54
|
+
|
|
55
|
+
forEachSlot(children, (slot, child) => {
|
|
56
|
+
if (slot.field) {
|
|
57
|
+
controlProps.inline = slot.inline ?? false
|
|
58
|
+
controlProps.disabled = getSlotProp<boolean>(child, "disabled") ?? false
|
|
59
|
+
controlProps.id = getSlotProp<string>(child, "id") ?? controlProps.id
|
|
60
|
+
controlProps.suffix = slot.suffix ?? undefined
|
|
61
|
+
} else if (slot.name === "FieldHint") {
|
|
62
|
+
markers.hint = { id: getSlotProp<string>(child, "id") }
|
|
63
|
+
} else if (slot.name === "FieldError") {
|
|
64
|
+
markers.error = { id: getSlotProp<string>(child, "id") }
|
|
60
65
|
}
|
|
61
66
|
})
|
|
62
67
|
|
|
68
|
+
const markerIds = getMarkerIds(markers, controlProps.id)
|
|
69
|
+
|
|
63
70
|
const componentProps = {
|
|
64
71
|
...props,
|
|
65
72
|
"aria-disabled": controlProps.disabled || undefined,
|
|
@@ -70,7 +77,7 @@ const Field = ({
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
return (
|
|
73
|
-
<FieldContext.Provider value={{ ...controlProps }}>
|
|
80
|
+
<FieldContext.Provider value={{ ...controlProps, ...markerIds, error }}>
|
|
74
81
|
<div {...componentProps} ref={ref}>
|
|
75
82
|
{children}
|
|
76
83
|
</div>
|
|
@@ -78,9 +85,21 @@ const Field = ({
|
|
|
78
85
|
)
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
const FieldLabel = (props: LabelProps): JSX.Element =>
|
|
82
|
-
const
|
|
83
|
-
|
|
88
|
+
const FieldLabel = (props: LabelProps): JSX.Element => {
|
|
89
|
+
const field = useField()
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<BaseLabel
|
|
93
|
+
htmlFor={field ? field.id + (field.suffix ?? "") : undefined}
|
|
94
|
+
id={field ? `${field.id}_label` : undefined}
|
|
95
|
+
{...props}
|
|
96
|
+
/>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
const FieldHint = (props: HintProps): JSX.Element => <BaseHint id={useField()?.hintId} {...props} />
|
|
100
|
+
const FieldError = (props: ErrorProps): JSX.Element => (
|
|
101
|
+
<BaseError id={useField()?.errorId} {...props} />
|
|
102
|
+
)
|
|
84
103
|
|
|
85
104
|
Field.slot = { name: "Field" }
|
|
86
105
|
FieldLabel.slot = { name: "FieldLabel" }
|
|
@@ -18,7 +18,8 @@ export const Error = ({ children, className, ref, ...props }: ErrorProps): JSX.E
|
|
|
18
18
|
}
|
|
19
19
|
return (
|
|
20
20
|
<span {...componentProps} ref={ref}>
|
|
21
|
-
{
|
|
21
|
+
{/* Tell screen reader users this is an error, not a hint, when it describes a control */}
|
|
22
|
+
<span className="sr-only">Error:</span> {children}
|
|
22
23
|
</span>
|
|
23
24
|
)
|
|
24
25
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import clsx from "clsx"
|
|
2
2
|
import { JSX, LabelHTMLAttributes, Ref } from "react"
|
|
3
3
|
|
|
4
|
-
import { useField } from "@/components/Field/Field.context"
|
|
5
|
-
|
|
6
4
|
export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
|
7
5
|
/** Content inside the component. */
|
|
8
6
|
children?: React.ReactNode
|
|
@@ -17,17 +15,12 @@ export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
|
|
17
15
|
export const Label = ({
|
|
18
16
|
children,
|
|
19
17
|
className,
|
|
20
|
-
htmlFor,
|
|
21
|
-
id,
|
|
22
18
|
isVisible = true,
|
|
23
19
|
ref,
|
|
24
20
|
...props
|
|
25
21
|
}: LabelProps): JSX.Element => {
|
|
26
|
-
const field = useField()
|
|
27
22
|
const componentProps = {
|
|
28
23
|
className: clsx("coop-field-label", !isVisible && "sr-only", className),
|
|
29
|
-
htmlFor: htmlFor ?? (field?.id ? field.id + (field?.suffix ?? "") : undefined),
|
|
30
|
-
id: id ?? (field?.id ? field.id + "_label" : undefined),
|
|
31
24
|
...props,
|
|
32
25
|
}
|
|
33
26
|
return (
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface Markers {
|
|
2
|
+
error?: { id?: string }
|
|
3
|
+
hint?: { id?: string }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface MarkerIds {
|
|
7
|
+
describedBy?: string
|
|
8
|
+
errorId: string
|
|
9
|
+
hintId: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Join ids for aria-describedby, skipping empties
|
|
13
|
+
export const joinIds = (...ids: (string | false | undefined)[]): string | undefined =>
|
|
14
|
+
ids.filter(Boolean).join(" ") || undefined
|
|
15
|
+
|
|
16
|
+
// Get the ids for a Field or Fieldset hint and error
|
|
17
|
+
export const getMarkerIds = (markers: Markers, baseId: string): MarkerIds => {
|
|
18
|
+
const hintId = markers.hint?.id ?? `${baseId}_hint`
|
|
19
|
+
const errorId = markers.error?.id ?? `${baseId}_error`
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
describedBy: joinIds(markers.hint && hintId, markers.error && errorId),
|
|
23
|
+
errorId,
|
|
24
|
+
hintId,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { createContext, useContext } from "react"
|
|
3
|
+
|
|
4
|
+
export interface FieldsetContextValue {
|
|
5
|
+
/** The error state passed down to Fields when the Fieldset is a group. */
|
|
6
|
+
error?: boolean
|
|
7
|
+
errorId?: string
|
|
8
|
+
hintId?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FieldsetContext = createContext<FieldsetContextValue | null>(null)
|
|
12
|
+
|
|
13
|
+
export const useFieldset = () => useContext(FieldsetContext)
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
"use client"
|
|
1
2
|
import clsx from "clsx"
|
|
2
3
|
import { type FieldsetHTMLAttributes, HTMLAttributes, type JSX, Ref } from "react"
|
|
3
4
|
|
|
4
5
|
import { Error as BaseError, type ErrorProps } from "@/components/FieldMarkers/Error"
|
|
6
|
+
import { getMarkerIds, joinIds, type Markers } from "@/components/FieldMarkers/fieldmarkers-utils"
|
|
5
7
|
import { Hint as BaseHint, type HintProps } from "@/components/FieldMarkers/Hint"
|
|
6
8
|
import { Legend as BaseLegend, type LegendProps } from "@/components/FieldMarkers/Legend"
|
|
9
|
+
import { useId } from "@/hooks/useId"
|
|
10
|
+
import { forEachSlot, getSlotProp } from "@/utils"
|
|
11
|
+
|
|
12
|
+
import { FieldsetContext, useFieldset } from "./Fieldset.context"
|
|
7
13
|
|
|
8
14
|
export interface FieldsetProps extends FieldsetHTMLAttributes<HTMLFieldSetElement> {
|
|
9
15
|
/** Content inside the component.
|
|
@@ -16,33 +22,57 @@ export interface FieldsetProps extends FieldsetHTMLAttributes<HTMLFieldSetElemen
|
|
|
16
22
|
disabled?: boolean
|
|
17
23
|
/** Specify the component error state. */
|
|
18
24
|
error?: boolean
|
|
25
|
+
/** Specify whether the Fieldset is a single question, such as a group of Radio inputs. Fields inside inherit its error state. */
|
|
26
|
+
group?: boolean
|
|
19
27
|
/** Specify whether the Fieldset should show the left-hand error bar if it is in an error state. */
|
|
20
28
|
hideErrorBar?: boolean
|
|
21
29
|
/** Specify a custom React ref for this component. */
|
|
22
30
|
ref?: Ref<HTMLFieldSetElement>
|
|
23
31
|
}
|
|
24
32
|
const Fieldset = ({
|
|
33
|
+
"aria-describedby": ariaDescribedBy,
|
|
25
34
|
children,
|
|
26
35
|
className,
|
|
27
36
|
disabled,
|
|
28
37
|
error = false,
|
|
38
|
+
group = false,
|
|
29
39
|
hideErrorBar = false,
|
|
40
|
+
id,
|
|
30
41
|
ref,
|
|
31
42
|
...props
|
|
32
43
|
}: FieldsetProps): JSX.Element => {
|
|
44
|
+
const uid = useId(id)
|
|
45
|
+
|
|
46
|
+
// The Hint and Error rendered in the Fieldset, for aria-describedby
|
|
47
|
+
const markers: Markers = {}
|
|
48
|
+
|
|
49
|
+
forEachSlot(children, (slot, child) => {
|
|
50
|
+
if (slot.name === "FieldsetHint") {
|
|
51
|
+
markers.hint = { id: getSlotProp<string>(child, "id") }
|
|
52
|
+
} else if (slot.name === "FieldsetError") {
|
|
53
|
+
markers.error = { id: getSlotProp<string>(child, "id") }
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const { describedBy, errorId, hintId } = getMarkerIds(markers, uid)
|
|
58
|
+
|
|
33
59
|
const componentProps = {
|
|
60
|
+
"aria-describedby": joinIds(describedBy, ariaDescribedBy),
|
|
34
61
|
"aria-disabled": disabled,
|
|
35
62
|
className: clsx("coop-fieldset", className),
|
|
36
63
|
"data-error": error || undefined,
|
|
37
64
|
"data-hide-error": hideErrorBar || undefined,
|
|
38
65
|
disabled,
|
|
66
|
+
id,
|
|
39
67
|
...props,
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
return (
|
|
43
|
-
<
|
|
44
|
-
{
|
|
45
|
-
|
|
71
|
+
<FieldsetContext.Provider value={{ error: group && error, errorId, hintId }}>
|
|
72
|
+
<fieldset {...componentProps} ref={ref}>
|
|
73
|
+
{children}
|
|
74
|
+
</fieldset>
|
|
75
|
+
</FieldsetContext.Provider>
|
|
46
76
|
)
|
|
47
77
|
}
|
|
48
78
|
|
|
@@ -71,8 +101,12 @@ const FieldsetFields = ({
|
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
const FieldsetLegend = (props: LegendProps): JSX.Element => <BaseLegend {...props} />
|
|
74
|
-
const FieldsetHint = (props: HintProps): JSX.Element =>
|
|
75
|
-
|
|
104
|
+
const FieldsetHint = (props: HintProps): JSX.Element => (
|
|
105
|
+
<BaseHint id={useFieldset()?.hintId} {...props} />
|
|
106
|
+
)
|
|
107
|
+
const FieldsetError = (props: ErrorProps): JSX.Element => (
|
|
108
|
+
<BaseError id={useFieldset()?.errorId} {...props} />
|
|
109
|
+
)
|
|
76
110
|
|
|
77
111
|
Fieldset.slot = { name: "Fieldset" }
|
|
78
112
|
FieldsetFields.slot = { name: "FieldsetField" }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import clsx from "clsx"
|
|
3
3
|
import { type InputHTMLAttributes, type JSX, Ref } from "react"
|
|
4
4
|
|
|
5
|
-
import { useField } from "@/components/Field/Field.context"
|
|
5
|
+
import { getDescribedBy, getFieldError, useField } from "@/components/Field/Field.context"
|
|
6
6
|
import { StandardSizes } from "@/types"
|
|
7
7
|
|
|
8
8
|
export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "type"> {
|
|
@@ -23,9 +23,10 @@ export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const Radio = ({
|
|
26
|
+
"aria-describedby": ariaDescribedBy,
|
|
26
27
|
className,
|
|
27
28
|
disabled,
|
|
28
|
-
error
|
|
29
|
+
error,
|
|
29
30
|
id,
|
|
30
31
|
name,
|
|
31
32
|
ref,
|
|
@@ -33,10 +34,12 @@ const Radio = ({
|
|
|
33
34
|
...props
|
|
34
35
|
}: RadioProps): JSX.Element => {
|
|
35
36
|
const field = useField()
|
|
37
|
+
const hasError = getFieldError(error, field)
|
|
36
38
|
|
|
37
39
|
const componentProps = {
|
|
40
|
+
"aria-describedby": getDescribedBy(ariaDescribedBy, field),
|
|
41
|
+
"aria-invalid": hasError || undefined,
|
|
38
42
|
className: clsx("coop-radio", className),
|
|
39
|
-
"data-error": error || undefined,
|
|
40
43
|
"data-size": size ?? undefined,
|
|
41
44
|
disabled,
|
|
42
45
|
id: id ?? field?.id,
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
import clsx from "clsx"
|
|
12
12
|
|
|
13
|
-
import { useField } from "@/components/Field/Field.context"
|
|
13
|
+
import { getDescribedBy, getFieldError, useField } from "@/components/Field/Field.context"
|
|
14
14
|
import { StandardSizes } from "@/types"
|
|
15
15
|
|
|
16
16
|
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
|
@@ -31,8 +31,8 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
|
|
|
31
31
|
fit?: boolean
|
|
32
32
|
/** Specify the component id. Will be auto-generated if not set. */
|
|
33
33
|
id?: string
|
|
34
|
-
/** Specify the Select name. */
|
|
35
|
-
name
|
|
34
|
+
/** Specify the Select name. Without a name, the value is not submitted with a form. */
|
|
35
|
+
name?: string
|
|
36
36
|
/** Specify a custom React ref for this component. */
|
|
37
37
|
ref?: Ref<HTMLSelectElement>
|
|
38
38
|
/** Specify the component size. */
|
|
@@ -54,6 +54,7 @@ interface SelectOptionProps extends OptionHTMLAttributes<HTMLOptionElement> {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const Select = ({
|
|
57
|
+
"aria-describedby": ariaDescribedBy,
|
|
57
58
|
children,
|
|
58
59
|
className,
|
|
59
60
|
disabled,
|
|
@@ -66,10 +67,12 @@ const Select = ({
|
|
|
66
67
|
...props
|
|
67
68
|
}: SelectProps): JSX.Element => {
|
|
68
69
|
const field = useField()
|
|
70
|
+
const hasError = getFieldError(error, field)
|
|
69
71
|
|
|
70
72
|
const componentProps = {
|
|
73
|
+
"aria-describedby": getDescribedBy(ariaDescribedBy, field),
|
|
74
|
+
"aria-invalid": hasError || undefined,
|
|
71
75
|
className: clsx("coop-select", className),
|
|
72
|
-
"data-error": error ?? undefined,
|
|
73
76
|
"data-fit": fit || undefined,
|
|
74
77
|
"data-size": size ?? undefined,
|
|
75
78
|
disabled,
|
|
@@ -3,7 +3,7 @@ import type { InputHTMLAttributes, JSX, Ref } from "react"
|
|
|
3
3
|
|
|
4
4
|
import clsx from "clsx"
|
|
5
5
|
|
|
6
|
-
import { useField } from "@/components/Field/Field.context"
|
|
6
|
+
import { getDescribedBy, getFieldError, useField } from "@/components/Field/Field.context"
|
|
7
7
|
import { StandardSizes } from "@/types"
|
|
8
8
|
|
|
9
9
|
export interface TextInputProps extends Omit<
|
|
@@ -23,8 +23,8 @@ export interface TextInputProps extends Omit<
|
|
|
23
23
|
fit?: boolean
|
|
24
24
|
/** Specify the component id. Will be auto-generated if not set. */
|
|
25
25
|
id?: string
|
|
26
|
-
/** Specify the component name. */
|
|
27
|
-
name
|
|
26
|
+
/** Specify the component name. Without a name, the value is not submitted with a form. */
|
|
27
|
+
name?: string
|
|
28
28
|
/** Specify placeholder text. Do not use in place of a form label. */
|
|
29
29
|
placeholder?: string
|
|
30
30
|
/** Specify the prefix. */
|
|
@@ -56,10 +56,10 @@ export interface TextInputProps extends Omit<
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const TextInput = ({
|
|
59
|
-
|
|
59
|
+
"aria-describedby": ariaDescribedBy,
|
|
60
60
|
className,
|
|
61
61
|
disabled,
|
|
62
|
-
error
|
|
62
|
+
error,
|
|
63
63
|
fit = false,
|
|
64
64
|
id,
|
|
65
65
|
name,
|
|
@@ -74,11 +74,12 @@ const TextInput = ({
|
|
|
74
74
|
...props
|
|
75
75
|
}: TextInputProps): JSX.Element => {
|
|
76
76
|
const field = useField()
|
|
77
|
+
const hasError = getFieldError(error, field)
|
|
77
78
|
|
|
78
79
|
const componentProps = {
|
|
79
|
-
|
|
80
|
+
"aria-describedby": getDescribedBy(ariaDescribedBy, field),
|
|
81
|
+
"aria-invalid": hasError || undefined,
|
|
80
82
|
className: clsx("coop-text-input", className),
|
|
81
|
-
"data-error": error || undefined,
|
|
82
83
|
"data-fit": fit || undefined,
|
|
83
84
|
"data-size": size ?? undefined,
|
|
84
85
|
disabled,
|
|
@@ -98,7 +99,7 @@ const TextInput = ({
|
|
|
98
99
|
: {}
|
|
99
100
|
|
|
100
101
|
return prefix || suffix ? (
|
|
101
|
-
<div className="coop-text-input-wrapper">
|
|
102
|
+
<div className="coop-text-input-wrapper" data-field="" data-size={size}>
|
|
102
103
|
{prefix && <span {...prefixProps}>{prefix}</span>}
|
|
103
104
|
<input {...componentProps} ref={ref} />
|
|
104
105
|
{suffix && <span {...suffixProps}>{suffix}</span>}
|
|
@@ -5,7 +5,7 @@ import type { ChangeEvent, JSX, Ref, TextareaHTMLAttributes } from "react"
|
|
|
5
5
|
import clsx from "clsx"
|
|
6
6
|
import { useState } from "react"
|
|
7
7
|
|
|
8
|
-
import { useField } from "@/components/Field/Field.context"
|
|
8
|
+
import { getDescribedBy, getFieldError, useField } from "@/components/Field/Field.context"
|
|
9
9
|
import { useDebounce } from "@/hooks/useDebounce"
|
|
10
10
|
import { StandardSizes } from "@/types"
|
|
11
11
|
|
|
@@ -41,8 +41,8 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|
|
41
41
|
fit?: boolean
|
|
42
42
|
/** Specify the component id. Will be auto-generated if not set. */
|
|
43
43
|
id?: string
|
|
44
|
-
/** Specify the component name. */
|
|
45
|
-
name
|
|
44
|
+
/** Specify the component name. Without a name, the value is not submitted with a form. */
|
|
45
|
+
name?: string
|
|
46
46
|
/** Specify placeholder text. Do not use in place of a form label. */
|
|
47
47
|
placeholder?: string
|
|
48
48
|
/** Specify a custom React ref for this component. */
|
|
@@ -57,13 +57,13 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const Textarea = ({
|
|
60
|
-
|
|
60
|
+
"aria-describedby": ariaDescribedBy,
|
|
61
61
|
className,
|
|
62
62
|
cols = 30,
|
|
63
63
|
counter = false,
|
|
64
64
|
cutoff = false,
|
|
65
65
|
disabled = false,
|
|
66
|
-
error
|
|
66
|
+
error,
|
|
67
67
|
fit = false,
|
|
68
68
|
id,
|
|
69
69
|
maxLength,
|
|
@@ -76,12 +76,13 @@ const Textarea = ({
|
|
|
76
76
|
...props
|
|
77
77
|
}: TextareaProps): JSX.Element => {
|
|
78
78
|
const field = useField()
|
|
79
|
+
const hasError = getFieldError(error, field)
|
|
79
80
|
|
|
80
81
|
const componentProps = {
|
|
81
|
-
|
|
82
|
+
"aria-describedby": getDescribedBy(ariaDescribedBy, field),
|
|
83
|
+
"aria-invalid": hasError || undefined,
|
|
82
84
|
className: clsx("coop-textarea", className),
|
|
83
85
|
cols,
|
|
84
|
-
"data-error": error ?? undefined,
|
|
85
86
|
"data-fit": fit || undefined,
|
|
86
87
|
"data-size": size ?? undefined,
|
|
87
88
|
disabled,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { createContext, useContext } from "react"
|
|
3
|
+
|
|
4
|
+
import type { FieldPosition } from "./validationsummary-utils"
|
|
5
|
+
|
|
6
|
+
interface ValidationSummaryContextValue {
|
|
7
|
+
/** The order of the fields in the form, read when the form is submitted. Empty until the first submit. */
|
|
8
|
+
fieldOrder: FieldPosition[]
|
|
9
|
+
/** Gets the form the summary is associated with. Call it in event handlers, not during render. */
|
|
10
|
+
getForm: () => HTMLFormElement | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ValidationSummaryContext = createContext<ValidationSummaryContextValue>({
|
|
14
|
+
fieldOrder: [],
|
|
15
|
+
getForm: () => null,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const useValidationSummary = () => useContext(ValidationSummaryContext)
|
|
@@ -5,12 +5,21 @@ import type { HTMLAttributes, JSX, ReactNode, Ref, RefObject } from "react"
|
|
|
5
5
|
|
|
6
6
|
import clsx from "clsx"
|
|
7
7
|
import mergeRefs from "merge-refs"
|
|
8
|
-
import React, { useCallback, useEffect, useRef, useState } from "react"
|
|
8
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
type FieldError,
|
|
12
|
+
FieldPosition,
|
|
11
13
|
getFieldElement,
|
|
14
|
+
getFieldOrder,
|
|
15
|
+
getForm,
|
|
12
16
|
sanitizeFieldId,
|
|
17
|
+
sortErrorsByFieldOrder,
|
|
13
18
|
} from "@/components/ValidationSummary/validationsummary-utils"
|
|
19
|
+
import {
|
|
20
|
+
useValidationSummary,
|
|
21
|
+
ValidationSummaryContext,
|
|
22
|
+
} from "@/components/ValidationSummary/ValidationSummary.context"
|
|
14
23
|
import { useSlots } from "@/hooks/useSlots"
|
|
15
24
|
import { getSlotProp } from "@/utils"
|
|
16
25
|
|
|
@@ -63,15 +72,6 @@ interface ValidationSummaryPromptProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
63
72
|
children?: React.ReactNode
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
interface FieldError {
|
|
67
|
-
message?: string | number
|
|
68
|
-
ref?: {
|
|
69
|
-
focus?: () => void
|
|
70
|
-
id?: string
|
|
71
|
-
}
|
|
72
|
-
type?: string | number
|
|
73
|
-
}
|
|
74
|
-
|
|
75
75
|
interface ValidationSummaryErrorsProps extends HTMLAttributes<HTMLDivElement> {
|
|
76
76
|
/** Specify the validation error array. Refer to the FieldError type for the shape.. */
|
|
77
77
|
errors?: Record<string, FieldError>
|
|
@@ -97,16 +97,25 @@ const ValidationSummary = ({
|
|
|
97
97
|
const slots = useSlots(componentSlots, children)
|
|
98
98
|
|
|
99
99
|
const errors = Object.keys(getSlotProp<FieldError>(slots.ValidationSummaryErrors, "errors") ?? {})
|
|
100
|
-
|
|
101
100
|
const hasErrors = errors && errors.length > 0
|
|
102
101
|
const internalRef = useRef<HTMLDivElement>(null)
|
|
103
102
|
|
|
103
|
+
const [fieldOrder, setFieldOrder] = useState<FieldPosition[]>([])
|
|
104
104
|
const onSubmit = useCallback(() => {
|
|
105
|
+
const form = getForm(formRef, internalRef)
|
|
106
|
+
if (form) setFieldOrder(getFieldOrder(form))
|
|
107
|
+
|
|
105
108
|
setStamp(Date.now())
|
|
106
|
-
}, [])
|
|
109
|
+
}, [formRef])
|
|
110
|
+
|
|
111
|
+
// The form is looked up when it is needed, so it is found even before the first submit
|
|
112
|
+
const context = useMemo(
|
|
113
|
+
() => ({ fieldOrder, getForm: () => getForm(formRef, internalRef) }),
|
|
114
|
+
[fieldOrder, formRef]
|
|
115
|
+
)
|
|
107
116
|
|
|
108
117
|
useEffect(() => {
|
|
109
|
-
const form = formRef
|
|
118
|
+
const form = getForm(formRef, internalRef)
|
|
110
119
|
form?.addEventListener("submit", onSubmit)
|
|
111
120
|
if (form && hasErrors && autoScroll) {
|
|
112
121
|
if (typeof form?.scrollIntoView === "function") {
|
|
@@ -134,10 +143,12 @@ const ValidationSummary = ({
|
|
|
134
143
|
role="alert"
|
|
135
144
|
tabIndex={-1}
|
|
136
145
|
>
|
|
137
|
-
{
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
<ValidationSummaryContext.Provider value={context}>
|
|
147
|
+
{slots.ValidationSummaryHeading}
|
|
148
|
+
{slots.ValidationSummaryPrompt}
|
|
149
|
+
{slots.ValidationSummaryErrors}
|
|
150
|
+
{slots.Children}
|
|
151
|
+
</ValidationSummaryContext.Provider>
|
|
141
152
|
</div>
|
|
142
153
|
)
|
|
143
154
|
}
|
|
@@ -159,14 +170,16 @@ const ValidationSummaryPrompt = ({ children }: ValidationSummaryPromptProps): JS
|
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
const focusable = ["INPUT", "TEXTAREA", "SELECT"]
|
|
162
|
-
|
|
173
|
+
const focusOptions = { focusVisible: true, preventScroll: true }
|
|
163
174
|
const ValidationSummaryErrors = ({ errors }: ValidationSummaryErrorsProps): JSX.Element | null => {
|
|
164
175
|
const errorsArray = (errors && Object.entries(errors)) ?? []
|
|
176
|
+
const { fieldOrder, getForm } = useValidationSummary()
|
|
177
|
+
const sortedErrors = sortErrorsByFieldOrder(errorsArray, fieldOrder)
|
|
165
178
|
|
|
166
179
|
return errorsArray.length ? (
|
|
167
180
|
<div className="coop-validation-summary--errors">
|
|
168
181
|
<ul>
|
|
169
|
-
{
|
|
182
|
+
{sortedErrors.map(([field, error]) => {
|
|
170
183
|
const id = error.ref?.id ? sanitizeFieldId(error.ref.id) : undefined
|
|
171
184
|
|
|
172
185
|
return (
|
|
@@ -175,14 +188,15 @@ const ValidationSummaryErrors = ({ errors }: ValidationSummaryErrorsProps): JSX.
|
|
|
175
188
|
href="#"
|
|
176
189
|
onClick={(e) => {
|
|
177
190
|
e.preventDefault()
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
191
|
+
const form = getForm()
|
|
192
|
+
const el = form && getFieldElement(form, field, id)
|
|
193
|
+
if (el instanceof HTMLElement) {
|
|
194
|
+
el.scrollIntoView({ block: "center" })
|
|
181
195
|
|
|
182
196
|
if (focusable.includes(el.nodeName)) {
|
|
183
|
-
el.focus()
|
|
197
|
+
el.focus(focusOptions)
|
|
184
198
|
} else {
|
|
185
|
-
el.querySelector("input")?.focus()
|
|
199
|
+
el.querySelector("input")?.focus(focusOptions)
|
|
186
200
|
}
|
|
187
201
|
}
|
|
188
202
|
}}
|