@coopdigital/react 1.1.3 → 1.2.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.
@@ -41,7 +41,7 @@ const Calendar = ({ captionLayout = "label", className, disabled, endMonth, form
41
41
  }, navLayout: "around", showOutsideDays: showOutsideDays, startMonth: startMonth, weekStartsOn: 1, ...props }));
42
42
  };
43
43
  const DatePicker = ({ className, closeOnSelect = true, dateFormat = "dd/MM/yyyy", disabled, disabledDates, endDate, error = false, id, initialDate = now, initialSelected = false, mode = "single", name, onChange, onSelect, ref, required = false, size = "md", startDate, ...props }) => {
44
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
44
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0;
45
45
  const uid = useId(id);
46
46
  initialDate = getInitialDate(mode, initialDate);
47
47
  const startDateProp = startDate;
@@ -57,8 +57,12 @@ const DatePicker = ({ className, closeOnSelect = true, dateFormat = "dd/MM/yyyy"
57
57
  initialValues = (_a = getDateValues(mode, initialDate, dateFormat, now)) !== null && _a !== void 0 ? _a : {};
58
58
  }
59
59
  const [state, setState] = useReducer((stale, fresh) => ({ ...stale, ...fresh }), { ...initialState, [mode]: initialValues.state, open: false, view: initialValues.view });
60
+ const dispatchChanges = useCallback((value) => {
61
+ typeof onChange === "function" && onChange(value);
62
+ typeof onSelect === "function" && onSelect(value);
63
+ }, [onChange, onSelect]);
60
64
  const updateValues = useCallback((selected) => {
61
- var _a;
65
+ var _a, _b, _c;
62
66
  const values = getDateValues(mode, selected, dateFormat, now);
63
67
  if (values) {
64
68
  setState({
@@ -71,11 +75,13 @@ const DatePicker = ({ className, closeOnSelect = true, dateFormat = "dd/MM/yyyy"
71
75
  }),
72
76
  });
73
77
  if (values.validated && values.state) {
74
- typeof onChange === "function" && onChange(values.state.field);
75
- typeof onSelect === "function" && onSelect(values.state.field);
78
+ dispatchChanges(values.state.field);
79
+ }
80
+ else {
81
+ dispatchChanges((_c = (_b = values.state) === null || _b === void 0 ? void 0 : _b.input) !== null && _c !== void 0 ? _c : undefined);
76
82
  }
77
83
  }
78
- }, [dateFormat, mode, onChange, onSelect, closeOnSelect]);
84
+ }, [dateFormat, mode, dispatchChanges, closeOnSelect]);
79
85
  const updateFromInput = useCallback((e) => {
80
86
  var _a;
81
87
  if (shouldSkipParsing(e.currentTarget.value)) {
@@ -84,11 +90,12 @@ const DatePicker = ({ className, closeOnSelect = true, dateFormat = "dd/MM/yyyy"
84
90
  [mode]: { input: e.currentTarget.value },
85
91
  validated: false,
86
92
  });
93
+ dispatchChanges(e.currentTarget.value);
87
94
  }
88
95
  else {
89
96
  updateValues((_a = e.currentTarget) === null || _a === void 0 ? void 0 : _a.value);
90
97
  }
91
- }, [updateValues, mode]);
98
+ }, [updateValues, mode, dispatchChanges]);
92
99
  const setOpen = useCallback((open) => setState({ open }), []);
93
100
  const setView = useCallback((view) => setState({ view }), []);
94
101
  const resetState = useCallback(() => setState({ ...initialState, [mode]: undefined }), [mode]);
@@ -134,7 +141,7 @@ const DatePicker = ({ className, closeOnSelect = true, dateFormat = "dd/MM/yyyy"
134
141
  // eslint-disable-next-line jsx-a11y/no-autofocus
135
142
  autoFocus: true, captionLayout: "dropdown",
136
143
  //defaultMonth={defaultMonth}
137
- disabled: disabledDates, endMonth: endDate, month: state.view, onMonthChange: setView, onSelect: updateValues, startMonth: startDate, ...calendarProps[mode] }), jsxs("div", { className: "coop-datepicker-actions", children: [jsxs(Button, { "aria-label": "Cancel", className: "bg-tint-grey", onClick: resetState, size: "sm", children: ["Clear ", jsx(CrossIcon, { stroke: "black", strokeWidth: 1 })] }), jsx(Popover.Close, { asChild: true, children: jsxs(Button, { "aria-label": "Accept", size: "sm", children: ["OK ", jsx(TickIcon, { stroke: "white", strokeWidth: 1 })] }) })] })] })] }), jsx("div", { "aria-live": "assertive", className: "sr-only coop-datepicker-status", role: "status", children: (_g = state.message) !== null && _g !== void 0 ? _g : footerMessage[mode] }), mode === "single" && jsx("input", { ...valueProps, value: (_j = (_h = state.single) === null || _h === void 0 ? void 0 : _h.field) !== null && _j !== void 0 ? _j : "" }), mode === "multiple" && jsx("input", { ...valueProps, value: (_l = (_k = state.multiple) === null || _k === void 0 ? void 0 : _k.field) !== null && _l !== void 0 ? _l : "" }), mode === "range" && (jsxs(Fragment, { children: [jsx("input", { ...baseValueProps, id: `${uid}_start`, name: `${name}_start`, value: (_p = (_o = (_m = state.range) === null || _m === void 0 ? void 0 : _m.field) === null || _o === void 0 ? void 0 : _o.from) !== null && _p !== void 0 ? _p : "" }), jsx("input", { ...baseValueProps, id: `${uid}_end`, name: `${name}_end`, value: (_s = (_r = (_q = state.range) === null || _q === void 0 ? void 0 : _q.field) === null || _r === void 0 ? void 0 : _r.to) !== null && _s !== void 0 ? _s : "" })] }))] }));
144
+ disabled: disabledDates, endMonth: endDate, month: state.view, onMonthChange: setView, onSelect: updateValues, startMonth: startDate, ...calendarProps[mode] }), jsxs("div", { className: "coop-datepicker-actions", children: [jsxs(Button, { "aria-label": "Cancel", className: "bg-tint-grey", onClick: resetState, size: "sm", children: ["Clear ", jsx(CrossIcon, { stroke: "black", strokeWidth: 1 })] }), jsx(Popover.Close, { asChild: true, children: jsxs(Button, { "aria-label": "Accept", size: "sm", children: ["OK ", jsx(TickIcon, { stroke: "white", strokeWidth: 1 })] }) })] })] })] }), jsx("div", { "aria-live": "assertive", className: "sr-only coop-datepicker-status", role: "status", children: (_g = state.message) !== null && _g !== void 0 ? _g : footerMessage[mode] }), mode === "single" && (jsx("input", { ...valueProps, value: (_l = (_j = (_h = state.single) === null || _h === void 0 ? void 0 : _h.field) !== null && _j !== void 0 ? _j : (_k = state.single) === null || _k === void 0 ? void 0 : _k.input) !== null && _l !== void 0 ? _l : "" })), mode === "multiple" && (jsx("input", { ...valueProps, value: (_q = (_o = (_m = state.multiple) === null || _m === void 0 ? void 0 : _m.field) !== null && _o !== void 0 ? _o : (_p = state.multiple) === null || _p === void 0 ? void 0 : _p.input) !== null && _q !== void 0 ? _q : "" })), mode === "range" && (jsxs(Fragment, { children: [jsx("input", { ...baseValueProps, id: `${uid}_start`, name: `${name}_start`, value: (_v = (_t = (_s = (_r = state.range) === null || _r === void 0 ? void 0 : _r.field) === null || _s === void 0 ? void 0 : _s.from) !== null && _t !== void 0 ? _t : (_u = state.range) === null || _u === void 0 ? void 0 : _u.input) !== null && _v !== void 0 ? _v : "" }), jsx("input", { ...baseValueProps, id: `${uid}_end`, name: `${name}_end`, value: (_0 = (_y = (_x = (_w = state.range) === null || _w === void 0 ? void 0 : _w.field) === null || _x === void 0 ? void 0 : _x.to) !== null && _y !== void 0 ? _y : (_z = state.range) === null || _z === void 0 ? void 0 : _z.input) !== null && _0 !== void 0 ? _0 : "" })] }))] }));
138
145
  };
139
146
  DatePicker.config = componentConfig;
140
147
 
@@ -1,5 +1,5 @@
1
1
  import { Mode } from "react-day-picker";
2
- interface DateRangeStr {
2
+ export interface DateRangeStr {
3
3
  from?: string;
4
4
  to?: string;
5
5
  }
@@ -57,6 +57,7 @@ const Searchbox = ({ action, children, className, id, label, labelVisible = fals
57
57
  autoCapitalize: "off",
58
58
  id: uid,
59
59
  size,
60
+ type: "search",
60
61
  });
61
62
  const labelProps = {
62
63
  htmlFor: uid,
@@ -0,0 +1,64 @@
1
+ import type { HTMLAttributes, JSX, ReactNode, Ref } from "react";
2
+ import React from "react";
3
+ export interface ValidationSummaryProps extends HTMLAttributes<HTMLDivElement> {
4
+ /** **(Optional)** Specify if the summary should be focused and the parent form scrolled into view if there are errors. */
5
+ autoScroll?: boolean;
6
+ /** **(Optional)** This should be a set of `ValidationSummary.Heading`, `ValidationSummary.Prompt` and `ValidationSummary.Errors` components. */
7
+ children?: ReactNode;
8
+ /** **(Optional)** Specify additional CSS classes to be applied to the component. */
9
+ className?: string;
10
+ /** **(Optional)** Specify a custom React ref for this component. */
11
+ ref?: Ref<HTMLDivElement>;
12
+ }
13
+ interface ValidationSummaryHeadingProps extends HTMLAttributes<HTMLDivElement> {
14
+ /** **(Optional)** Specify a custom element to use instead of `h2`. */
15
+ as?: "h2" | "h3" | "h4" | "h5" | "h6";
16
+ /** **(Optional)** Content inside the Heading.
17
+ *
18
+ * Defaults to `There's a problem`.
19
+ */
20
+ children?: React.ReactNode;
21
+ /** **(Optional)** Specify additional CSS classes to be applied to the Heading. */
22
+ className?: string;
23
+ }
24
+ interface ValidationSummaryPromptProps extends HTMLAttributes<HTMLDivElement> {
25
+ /** **(Optional)** Content inside the Prompt.
26
+ *
27
+ * Defaults to `Check the form. You must:`.
28
+ */
29
+ children?: React.ReactNode;
30
+ }
31
+ interface FieldError {
32
+ message?: string | number;
33
+ ref?: {
34
+ focus?: () => void;
35
+ id?: string;
36
+ };
37
+ type?: string | number;
38
+ }
39
+ interface ValidationSummaryErrorsProps extends HTMLAttributes<HTMLDivElement> {
40
+ /** **(Optional)** Content inside the Errors. */
41
+ errors?: Record<string, FieldError>;
42
+ }
43
+ export declare const ValidationSummary: {
44
+ ({ autoScroll, children, className, ref, ...props }: ValidationSummaryProps): JSX.Element | null;
45
+ Heading: {
46
+ ({ as, children, className, }: ValidationSummaryHeadingProps): JSX.Element;
47
+ config: {
48
+ name: string;
49
+ };
50
+ };
51
+ Prompt: {
52
+ ({ children, }: ValidationSummaryPromptProps): JSX.Element;
53
+ config: {
54
+ name: string;
55
+ };
56
+ };
57
+ Errors: {
58
+ ({ errors }: ValidationSummaryErrorsProps): JSX.Element | null;
59
+ config: {
60
+ name: string;
61
+ };
62
+ };
63
+ };
64
+ export default ValidationSummary;
@@ -0,0 +1,77 @@
1
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
+ import clsx from 'clsx';
3
+ import mergeRefs from '../../node_modules/merge-refs/dist/index.js';
4
+ import React, { useRef, useEffect } from 'react';
5
+ import { useSlots } from '../../hooks/useSlots.js';
6
+ import { sanitizeFieldId, getFieldElement } from './validationsummary-utils.js';
7
+
8
+ /* eslint-disable jsx-a11y/anchor-is-valid */
9
+ const componentSlots = {
10
+ Children: null,
11
+ ValidationSummaryErrors: null,
12
+ ValidationSummaryHeading: null,
13
+ ValidationSummaryPrompt: null,
14
+ };
15
+ const ValidationSummary = ({ autoScroll = true, children, className, ref, ...props }) => {
16
+ var _a;
17
+ const slots = { ...useSlots(componentSlots, children) };
18
+ const errors = React.isValidElement(slots.ValidationSummaryErrors) &&
19
+ Object.keys((_a = slots.ValidationSummaryErrors.props.errors) !== null && _a !== void 0 ? _a : {});
20
+ const hasErrors = !!(Object.keys(errors).length > 0);
21
+ const internalRef = useRef(null);
22
+ useEffect(() => {
23
+ var _a, _b;
24
+ if (hasErrors && autoScroll) {
25
+ const form = (_a = internalRef.current) === null || _a === void 0 ? void 0 : _a.closest("form");
26
+ if (typeof (form === null || form === void 0 ? void 0 : form.scrollIntoView) === "function")
27
+ form.scrollIntoView({ behavior: "smooth" });
28
+ (_b = internalRef.current) === null || _b === void 0 ? void 0 : _b.focus();
29
+ }
30
+ }, [autoScroll, errors, hasErrors]);
31
+ if (!hasErrors) {
32
+ return null;
33
+ }
34
+ const componentProps = {
35
+ className: clsx("coop-validation-summary ", className),
36
+ ...props,
37
+ };
38
+ return (jsxs("div", { ...componentProps, "aria-live": "assertive", ref: mergeRefs(internalRef, ref), role: "alert", tabIndex: -1, children: [slots.ValidationSummaryHeading, slots.ValidationSummaryPrompt, slots.ValidationSummaryErrors, slots.Children] }));
39
+ };
40
+ const ValidationSummaryHeading = ({ as = "h2", children = "There's a problem", className, }) => {
41
+ return React.createElement(as, { className: clsx("coop-validation-summary--heading", className) }, children);
42
+ };
43
+ const ValidationSummaryPrompt = ({ children = "Check the form. You must:", }) => {
44
+ return jsx(Fragment, { children: children });
45
+ };
46
+ const focusable = ["INPUT", "TEXTAREA", "SELECT"];
47
+ const ValidationSummaryErrors = ({ errors }) => {
48
+ var _a;
49
+ const errorsArray = (_a = (errors && Object.entries(errors))) !== null && _a !== void 0 ? _a : [];
50
+ return errorsArray.length ? (jsx("div", { className: "coop-validation-summary--errors", children: jsx("ul", { children: errorsArray.map(([field, error]) => {
51
+ var _a;
52
+ const id = ((_a = error.ref) === null || _a === void 0 ? void 0 : _a.id) ? sanitizeFieldId(error.ref.id) : undefined;
53
+ return (jsx("li", { children: jsx("a", { href: "#", onClick: (e) => {
54
+ var _a;
55
+ e.preventDefault();
56
+ const el = getFieldElement(field, id);
57
+ if (el) {
58
+ if (typeof el.scrollIntoView === "function")
59
+ el.scrollIntoView();
60
+ if (focusable.includes(el.nodeName)) {
61
+ el.focus();
62
+ }
63
+ else {
64
+ (_a = el.querySelector("input")) === null || _a === void 0 ? void 0 : _a.focus();
65
+ }
66
+ }
67
+ }, children: error.message }) }, field));
68
+ }) }) })) : null;
69
+ };
70
+ ValidationSummaryHeading.config = { name: "ValidationSummaryHeading" };
71
+ ValidationSummaryPrompt.config = { name: "ValidationSummaryPrompt" };
72
+ ValidationSummaryErrors.config = { name: "ValidationSummaryErrors" };
73
+ ValidationSummary.Heading = ValidationSummaryHeading;
74
+ ValidationSummary.Prompt = ValidationSummaryPrompt;
75
+ ValidationSummary.Errors = ValidationSummaryErrors;
76
+
77
+ export { ValidationSummary, ValidationSummary as default };
@@ -0,0 +1,4 @@
1
+ import ValidationSummary from "./ValidationSummary";
2
+ export default ValidationSummary;
3
+ export { ValidationSummary };
4
+ export * from "./ValidationSummary";
@@ -0,0 +1,2 @@
1
+ export declare const sanitizeFieldId: (id: string) => string;
2
+ export declare const getFieldElement: (field: string, id?: string) => HTMLInputElement | null;
@@ -0,0 +1,13 @@
1
+ const sanitizeFieldId = (id) => ((id === null || id === void 0 ? void 0 : id.startsWith("#")) ? id.slice(1) : id);
2
+ const getFieldElement = (field, id) => {
3
+ var _a, _b, _c;
4
+ if (id) {
5
+ const safeId = sanitizeFieldId(id);
6
+ return ((_a = document.querySelector(`#${safeId}`)) !== null && _a !== void 0 ? _a : document.querySelector(`[name=${safeId}]`));
7
+ }
8
+ else {
9
+ return ((_c = (_b = document.querySelector(`[name=${field}_display]`)) !== null && _b !== void 0 ? _b : document.querySelector(`#${field}`)) !== null && _c !== void 0 ? _c : document.querySelector(`[name=${field}]`));
10
+ }
11
+ };
12
+
13
+ export { getFieldElement, sanitizeFieldId };
package/dist/index.d.ts CHANGED
@@ -22,3 +22,4 @@ export * from "./components/Squircle";
22
22
  export * from "./components/Tag";
23
23
  export * from "./components/Textarea";
24
24
  export * from "./components/TextInput";
25
+ export * from "./components/ValidationSummary";
package/dist/index.js CHANGED
@@ -22,3 +22,4 @@ export { Squircle } from './components/Squircle/Squircle.js';
22
22
  export { Tag } from './components/Tag/Tag.js';
23
23
  export { Textarea } from './components/Textarea/Textarea.js';
24
24
  export { TextInput } from './components/TextInput/TextInput.js';
25
+ export { ValidationSummary } from './components/ValidationSummary/ValidationSummary.js';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A function that merges React refs into one.
3
+ * Supports both functions and ref objects created using createRef() and useRef().
4
+ *
5
+ * Usage:
6
+ * ```tsx
7
+ * <div ref={mergeRefs(ref1, ref2, ref3)} />
8
+ * ```
9
+ *
10
+ * @param {(React.Ref<T> | undefined)[]} inputRefs Array of refs
11
+ * @returns {React.Ref<T> | React.RefCallback<T>} Merged refs
12
+ */
13
+ function mergeRefs() {
14
+ var inputRefs = [];
15
+ for (var _i = 0; _i < arguments.length; _i++) {
16
+ inputRefs[_i] = arguments[_i];
17
+ }
18
+ var filteredInputRefs = inputRefs.filter(Boolean);
19
+ if (filteredInputRefs.length <= 1) {
20
+ var firstRef = filteredInputRefs[0];
21
+ return firstRef || null;
22
+ }
23
+ return function mergedRefs(ref) {
24
+ for (var _i = 0, filteredInputRefs_1 = filteredInputRefs; _i < filteredInputRefs_1.length; _i++) {
25
+ var inputRef = filteredInputRefs_1[_i];
26
+ if (typeof inputRef === 'function') {
27
+ inputRef(ref);
28
+ }
29
+ else if (inputRef) {
30
+ inputRef.current = ref;
31
+ }
32
+ }
33
+ };
34
+ }
35
+
36
+ export { mergeRefs as default };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coopdigital/react",
3
3
  "type": "module",
4
- "version": "1.1.3",
4
+ "version": "1.2.0",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -84,10 +84,11 @@
84
84
  "storybook": "$storybook"
85
85
  },
86
86
  "dependencies": {
87
- "@coopdigital/styles": "^1.1.2",
87
+ "@coopdigital/styles": "^1.2.0",
88
88
  "@radix-ui/react-popover": "^1.1.15",
89
89
  "clsx": "^2.1.1",
90
+ "merge-refs": "^2.0.0",
90
91
  "react-day-picker": "^9.12.0"
91
92
  },
92
- "gitHead": "fd986750a8a2cbc8d0540f7d6d4345984eb8ce85"
93
+ "gitHead": "ac480f6925495d9a458a410398dfdd6fcbf289d7"
93
94
  }
@@ -16,6 +16,7 @@ import { DayPicker } from "react-day-picker"
16
16
  import type { ComponentConfig, StandardSizes } from "../../types"
17
17
  import type {
18
18
  DateRange,
19
+ DateRangeStr,
19
20
  DateType,
20
21
  TDateMultiple,
21
22
  TDateRange,
@@ -211,6 +212,14 @@ export const DatePicker = ({
211
212
  { ...initialState, [mode]: initialValues.state, open: false, view: initialValues.view }
212
213
  )
213
214
 
215
+ const dispatchChanges = useCallback(
216
+ (value: string | DateRangeStr | undefined) => {
217
+ typeof onChange === "function" && onChange(value)
218
+ typeof onSelect === "function" && onSelect(value)
219
+ },
220
+ [onChange, onSelect]
221
+ )
222
+
214
223
  const updateValues = useCallback(
215
224
  (selected: DateType) => {
216
225
  const values = getDateValues(mode, selected, dateFormat, now)
@@ -227,12 +236,13 @@ export const DatePicker = ({
227
236
  })
228
237
 
229
238
  if (values.validated && values.state) {
230
- typeof onChange === "function" && onChange(values.state.field)
231
- typeof onSelect === "function" && onSelect(values.state.field)
239
+ dispatchChanges(values.state.field)
240
+ } else {
241
+ dispatchChanges(values.state?.input ?? undefined)
232
242
  }
233
243
  }
234
244
  },
235
- [dateFormat, mode, onChange, onSelect, closeOnSelect]
245
+ [dateFormat, mode, dispatchChanges, closeOnSelect]
236
246
  )
237
247
 
238
248
  const updateFromInput = useCallback(
@@ -243,11 +253,12 @@ export const DatePicker = ({
243
253
  [mode]: { input: e.currentTarget.value },
244
254
  validated: false,
245
255
  })
256
+ dispatchChanges(e.currentTarget.value)
246
257
  } else {
247
258
  updateValues(e.currentTarget?.value)
248
259
  }
249
260
  },
250
- [updateValues, mode]
261
+ [updateValues, mode, dispatchChanges]
251
262
  )
252
263
 
253
264
  const setOpen = useCallback((open: boolean) => setState({ open }), [])
@@ -360,21 +371,25 @@ export const DatePicker = ({
360
371
  {state.message ?? footerMessage[mode]}
361
372
  </div>
362
373
 
363
- {mode === "single" && <input {...valueProps} value={state.single?.field ?? ""} />}
364
- {mode === "multiple" && <input {...valueProps} value={state.multiple?.field ?? ""} />}
374
+ {mode === "single" && (
375
+ <input {...valueProps} value={state.single?.field ?? state.single?.input ?? ""} />
376
+ )}
377
+ {mode === "multiple" && (
378
+ <input {...valueProps} value={state.multiple?.field ?? state.multiple?.input ?? ""} />
379
+ )}
365
380
  {mode === "range" && (
366
381
  <>
367
382
  <input
368
383
  {...baseValueProps}
369
384
  id={`${uid}_start`}
370
385
  name={`${name}_start`}
371
- value={state.range?.field?.from ?? ""}
386
+ value={state.range?.field?.from ?? state.range?.input ?? ""}
372
387
  />
373
388
  <input
374
389
  {...baseValueProps}
375
390
  id={`${uid}_end`}
376
391
  name={`${name}_end`}
377
- value={state.range?.field?.to ?? ""}
392
+ value={state.range?.field?.to ?? state.range?.input ?? ""}
378
393
  />
379
394
  </>
380
395
  )}
@@ -1,7 +1,7 @@
1
1
  import { addYears, format, isBefore, isValid, parse } from "date-fns"
2
2
  import { Mode } from "react-day-picker"
3
3
 
4
- interface DateRangeStr {
4
+ export interface DateRangeStr {
5
5
  from?: string
6
6
  to?: string
7
7
  }
@@ -116,6 +116,7 @@ export const Searchbox = ({
116
116
  autoCapitalize: "off",
117
117
  id: uid,
118
118
  size,
119
+ type: "search",
119
120
  }
120
121
  )
121
122
 
@@ -0,0 +1,180 @@
1
+ /* eslint-disable jsx-a11y/anchor-is-valid */
2
+ "use client"
3
+
4
+ import type { HTMLAttributes, JSX, ReactNode, Ref } from "react"
5
+
6
+ import clsx from "clsx"
7
+ import mergeRefs from "merge-refs"
8
+ import React, { useEffect, useRef } from "react"
9
+
10
+ import { useSlots } from "../../hooks/useSlots"
11
+ import { getFieldElement, sanitizeFieldId } from "./validationsummary-utils"
12
+
13
+ export interface ValidationSummaryProps extends HTMLAttributes<HTMLDivElement> {
14
+ /** **(Optional)** Specify if the summary should be focused and the parent form scrolled into view if there are errors. */
15
+ autoScroll?: boolean
16
+ /** **(Optional)** This should be a set of `ValidationSummary.Heading`, `ValidationSummary.Prompt` and `ValidationSummary.Errors` components. */
17
+ children?: ReactNode
18
+ /** **(Optional)** Specify additional CSS classes to be applied to the component. */
19
+ className?: string
20
+ /** **(Optional)** Specify a custom React ref for this component. */
21
+ ref?: Ref<HTMLDivElement>
22
+ }
23
+
24
+ interface ValidationSummaryHeadingProps extends HTMLAttributes<HTMLDivElement> {
25
+ /** **(Optional)** Specify a custom element to use instead of `h2`. */
26
+ as?: "h2" | "h3" | "h4" | "h5" | "h6"
27
+ /** **(Optional)** Content inside the Heading.
28
+ *
29
+ * Defaults to `There's a problem`.
30
+ */
31
+ children?: React.ReactNode
32
+ /** **(Optional)** Specify additional CSS classes to be applied to the Heading. */
33
+ className?: string
34
+ }
35
+
36
+ interface ValidationSummaryPromptProps extends HTMLAttributes<HTMLDivElement> {
37
+ /** **(Optional)** Content inside the Prompt.
38
+ *
39
+ * Defaults to `Check the form. You must:`.
40
+ */
41
+ children?: React.ReactNode
42
+ }
43
+
44
+ interface FieldError {
45
+ message?: string | number
46
+ ref?: {
47
+ focus?: () => void
48
+ id?: string
49
+ }
50
+ type?: string | number
51
+ }
52
+
53
+ interface ValidationSummaryErrorsProps extends HTMLAttributes<HTMLDivElement> {
54
+ /** **(Optional)** Content inside the Errors. */
55
+ errors?: Record<string, FieldError>
56
+ }
57
+
58
+ const componentSlots = {
59
+ Children: null,
60
+ ValidationSummaryErrors: null,
61
+ ValidationSummaryHeading: null,
62
+ ValidationSummaryPrompt: null,
63
+ }
64
+
65
+ export const ValidationSummary = ({
66
+ autoScroll = true,
67
+ children,
68
+ className,
69
+ ref,
70
+ ...props
71
+ }: ValidationSummaryProps): JSX.Element | null => {
72
+ const slots = { ...useSlots(componentSlots, children) }
73
+
74
+ const errors =
75
+ React.isValidElement(slots.ValidationSummaryErrors) &&
76
+ Object.keys((slots.ValidationSummaryErrors.props as ValidationSummaryErrorsProps).errors ?? {})
77
+
78
+ const hasErrors = !!(Object.keys(errors).length > 0)
79
+
80
+ const internalRef = useRef<HTMLDivElement>(null)
81
+
82
+ useEffect(() => {
83
+ if (hasErrors && autoScroll) {
84
+ const form = internalRef.current?.closest("form")
85
+ if (typeof form?.scrollIntoView === "function") form.scrollIntoView({ behavior: "smooth" })
86
+ internalRef.current?.focus()
87
+ }
88
+ }, [autoScroll, errors, hasErrors])
89
+
90
+ if (!hasErrors) {
91
+ return null
92
+ }
93
+
94
+ const componentProps = {
95
+ className: clsx("coop-validation-summary ", className),
96
+ ...props,
97
+ }
98
+
99
+ return (
100
+ <div
101
+ {...componentProps}
102
+ aria-live="assertive"
103
+ ref={mergeRefs(internalRef, ref)}
104
+ role="alert"
105
+ tabIndex={-1}
106
+ >
107
+ {slots.ValidationSummaryHeading}
108
+ {slots.ValidationSummaryPrompt}
109
+ {slots.ValidationSummaryErrors}
110
+ {slots.Children}
111
+ </div>
112
+ )
113
+ }
114
+
115
+ const ValidationSummaryHeading = ({
116
+ as = "h2",
117
+ children = "There's a problem",
118
+ className,
119
+ }: ValidationSummaryHeadingProps): JSX.Element => {
120
+ return React.createElement(
121
+ as,
122
+ { className: clsx("coop-validation-summary--heading", className) },
123
+ children
124
+ )
125
+ }
126
+
127
+ const ValidationSummaryPrompt = ({
128
+ children = "Check the form. You must:",
129
+ }: ValidationSummaryPromptProps): JSX.Element => {
130
+ return <>{children}</>
131
+ }
132
+
133
+ const focusable = ["INPUT", "TEXTAREA", "SELECT"]
134
+
135
+ const ValidationSummaryErrors = ({ errors }: ValidationSummaryErrorsProps): JSX.Element | null => {
136
+ const errorsArray = (errors && Object.entries(errors)) ?? []
137
+
138
+ return errorsArray.length ? (
139
+ <div className="coop-validation-summary--errors">
140
+ <ul>
141
+ {errorsArray.map(([field, error]) => {
142
+ const id: string | undefined = error.ref?.id ? sanitizeFieldId(error.ref.id) : undefined
143
+
144
+ return (
145
+ <li key={field}>
146
+ <a
147
+ href="#"
148
+ onClick={(e) => {
149
+ e.preventDefault()
150
+ const el = getFieldElement(field, id)
151
+ if (el) {
152
+ if (typeof el.scrollIntoView === "function") el.scrollIntoView()
153
+
154
+ if (focusable.includes(el.nodeName)) {
155
+ el.focus()
156
+ } else {
157
+ el.querySelector("input")?.focus()
158
+ }
159
+ }
160
+ }}
161
+ >
162
+ {error.message}
163
+ </a>
164
+ </li>
165
+ )
166
+ })}
167
+ </ul>
168
+ </div>
169
+ ) : null
170
+ }
171
+
172
+ ValidationSummaryHeading.config = { name: "ValidationSummaryHeading" }
173
+ ValidationSummaryPrompt.config = { name: "ValidationSummaryPrompt" }
174
+ ValidationSummaryErrors.config = { name: "ValidationSummaryErrors" }
175
+
176
+ ValidationSummary.Heading = ValidationSummaryHeading
177
+ ValidationSummary.Prompt = ValidationSummaryPrompt
178
+ ValidationSummary.Errors = ValidationSummaryErrors
179
+
180
+ export default ValidationSummary
@@ -0,0 +1,5 @@
1
+ import ValidationSummary from "./ValidationSummary"
2
+
3
+ export default ValidationSummary
4
+ export { ValidationSummary }
5
+ export * from "./ValidationSummary"
@@ -0,0 +1,17 @@
1
+ export const sanitizeFieldId = (id: string): string => (id?.startsWith("#") ? id.slice(1) : id)
2
+
3
+ export const getFieldElement = (field: string, id?: string) => {
4
+ if (id) {
5
+ const safeId = sanitizeFieldId(id)
6
+ return (
7
+ document.querySelector<HTMLInputElement>(`#${safeId}`) ??
8
+ document.querySelector<HTMLInputElement>(`[name=${safeId}]`)
9
+ )
10
+ } else {
11
+ return (
12
+ document.querySelector<HTMLInputElement>(`[name=${field}_display]`) ??
13
+ document.querySelector<HTMLInputElement>(`#${field}`) ??
14
+ document.querySelector<HTMLInputElement>(`[name=${field}]`)
15
+ )
16
+ }
17
+ }
package/src/index.ts CHANGED
@@ -22,3 +22,4 @@ export * from "./components/Squircle"
22
22
  export * from "./components/Tag"
23
23
  export * from "./components/Textarea"
24
24
  export * from "./components/TextInput"
25
+ export * from "./components/ValidationSummary"