@coopdigital/react 1.27.2 → 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.
Files changed (65) hide show
  1. package/dist/components/Button/Button.js +1 -1
  2. package/dist/components/Card/Card.js +1 -1
  3. package/dist/components/Checkbox/Checkbox.d.ts +3 -3
  4. package/dist/components/Checkbox/Checkbox.js +5 -3
  5. package/dist/components/Combobox/Combobox.js +6 -3
  6. package/dist/components/DatePicker/DatePicker.js +5 -3
  7. package/dist/components/Dialog/Dialog.js +1 -1
  8. package/dist/components/Expandable/Expandable.js +1 -1
  9. package/dist/components/Field/Field.context.js +5 -6
  10. package/dist/components/Field/Field.d.ts +2 -2
  11. package/dist/components/Field/Field.js +40 -20
  12. package/dist/components/FieldMarkers/Error.js +10 -3
  13. package/dist/components/FieldMarkers/Label.js +1 -5
  14. package/dist/components/FieldMarkers/fieldmarkers-utils.js +13 -0
  15. package/dist/components/Fieldset/Fieldset.context.js +7 -0
  16. package/dist/components/Fieldset/Fieldset.d.ts +3 -1
  17. package/dist/components/Fieldset/Fieldset.js +34 -7
  18. package/dist/components/Pill/Pill.js +1 -1
  19. package/dist/components/Popover/Popover.js +1 -1
  20. package/dist/components/Radio/Radio.d.ts +1 -1
  21. package/dist/components/Radio/Radio.js +5 -3
  22. package/dist/components/Searchbox/Searchbox.js +2 -1
  23. package/dist/components/Select/Select.d.ts +3 -3
  24. package/dist/components/Select/Select.js +5 -3
  25. package/dist/components/Tag/Tag.js +1 -1
  26. package/dist/components/TextInput/TextInput.d.ts +3 -3
  27. package/dist/components/TextInput/TextInput.js +7 -3
  28. package/dist/components/Textarea/Textarea.d.ts +3 -3
  29. package/dist/components/Textarea/Textarea.js +5 -3
  30. package/dist/components/ValidationSummary/ValidationSummary.context.js +10 -0
  31. package/dist/components/ValidationSummary/ValidationSummary.d.ts +1 -8
  32. package/dist/components/ValidationSummary/ValidationSummary.js +36 -17
  33. package/dist/components/ValidationSummary/validationsummary-utils.d.ts +11 -0
  34. package/dist/components/ValidationSummary/validationsummary-utils.js +29 -6
  35. package/dist/hooks/useSlots.js +2 -26
  36. package/dist/types/index.d.ts +15 -0
  37. package/dist/utils/classes.d.ts +6 -0
  38. package/dist/utils/classes.js +13 -0
  39. package/dist/utils/index.d.ts +3 -7
  40. package/dist/utils/index.js +3 -17
  41. package/dist/utils/slots.d.ts +9 -0
  42. package/dist/utils/slots.js +37 -0
  43. package/package.json +3 -3
  44. package/src/components/Checkbox/Checkbox.tsx +8 -5
  45. package/src/components/Combobox/Combobox.context.ts +1 -0
  46. package/src/components/Combobox/Combobox.tsx +7 -3
  47. package/src/components/DatePicker/DatePicker.tsx +5 -3
  48. package/src/components/Field/Field.context.ts +14 -8
  49. package/src/components/Field/Field.tsx +39 -20
  50. package/src/components/FieldMarkers/Error.tsx +2 -1
  51. package/src/components/FieldMarkers/Label.tsx +0 -7
  52. package/src/components/FieldMarkers/fieldmarkers-utils.ts +26 -0
  53. package/src/components/Fieldset/Fieldset.context.ts +13 -0
  54. package/src/components/Fieldset/Fieldset.tsx +39 -5
  55. package/src/components/Radio/Radio.tsx +6 -3
  56. package/src/components/Select/Select.tsx +7 -4
  57. package/src/components/TextInput/TextInput.tsx +9 -8
  58. package/src/components/Textarea/Textarea.tsx +8 -7
  59. package/src/components/ValidationSummary/ValidationSummary.context.ts +18 -0
  60. package/src/components/ValidationSummary/ValidationSummary.tsx +38 -24
  61. package/src/components/ValidationSummary/validationsummary-utils.ts +81 -13
  62. package/src/hooks/useSlots.ts +1 -41
  63. package/src/utils/classes.ts +14 -0
  64. package/src/utils/index.ts +2 -24
  65. 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 { SlotConfig } from "@/types"
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 = false,
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
- Array.from([children].flat()).forEach((child: React.ReactNode) => {
50
- if (child && typeof child === "object" && "type" in child) {
51
- if (typeof child?.type === "function" && "slot" in child.type) {
52
- const { slot } = child.type as { slot: SlotConfig }
53
- if (slot.field && child.props) {
54
- controlProps.inline = slot.inline ?? false
55
- controlProps.disabled = getSlotProp<boolean>(child, "disabled") ?? false
56
- controlProps.id = getSlotProp<string>(child, "id") ?? controlProps.id
57
- controlProps.suffix = slot.suffix ?? undefined
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 => <BaseLabel {...props} />
82
- const FieldHint = (props: HintProps): JSX.Element => <BaseHint {...props} />
83
- const FieldError = (props: ErrorProps): JSX.Element => <BaseError {...props} />
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
- {children}
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
- <fieldset {...componentProps} ref={ref}>
44
- {children}
45
- </fieldset>
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 => <BaseHint {...props} />
75
- const FieldsetError = (props: ErrorProps): JSX.Element => <BaseError {...props} />
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 = false,
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: string
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: string
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
- // "aria-placeholder": ariaPlaceholder,
59
+ "aria-describedby": ariaDescribedBy,
60
60
  className,
61
61
  disabled,
62
- error = false,
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
- // "aria-placeholder": placeholder ?? ariaPlaceholder ?? undefined,
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: string
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
- // "aria-placeholder": ariaPlaceholder,
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 = false,
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
- // "aria-placeholder": placeholder ?? ariaPlaceholder ?? undefined,
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?.current ?? internalRef.current?.closest("form")
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
- {slots.ValidationSummaryHeading}
138
- {slots.ValidationSummaryPrompt}
139
- {slots.ValidationSummaryErrors}
140
- {slots.Children}
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
- {errorsArray.map(([field, error]) => {
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 el = getFieldElement(field, id)
179
- if (el) {
180
- // if (typeof el.scrollIntoView === "function") el.scrollIntoView()
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
  }}