@coopdigital/react 1.30.1 → 1.31.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 (176) hide show
  1. package/dist/components/Author/Author.d.ts +3 -1
  2. package/dist/components/Author/Author.js +1 -1
  3. package/dist/components/Author/index.d.ts +2 -2
  4. package/dist/components/Author/index.js +2 -2
  5. package/dist/components/Combobox/Combobox.d.ts +1 -1
  6. package/dist/components/Combobox/Combobox.js +29 -8
  7. package/dist/components/Combobox/{combobox-utils.js → combobox-internal.js} +4 -2
  8. package/dist/components/DateInput/DateInput.context.js +7 -0
  9. package/dist/components/DateInput/DateInput.d.ts +104 -0
  10. package/dist/components/DateInput/DateInput.js +128 -0
  11. package/dist/components/DateInput/dateinput-internal.d.ts +6 -0
  12. package/dist/components/DateInput/dateinput-internal.js +82 -0
  13. package/dist/components/DateInput/dateinput-utils.d.ts +66 -0
  14. package/dist/components/DateInput/dateinput-utils.js +91 -0
  15. package/dist/components/DateInput/index.d.ts +3 -0
  16. package/dist/components/DateInput/index.js +6 -0
  17. package/dist/components/Field/Field.context.js +1 -1
  18. package/dist/components/Field/Field.js +1 -1
  19. package/dist/components/FieldMarkers/{fieldmarkers-utils.js → fieldmarkers-internal.js} +1 -1
  20. package/dist/components/Fieldset/Fieldset.js +1 -1
  21. package/dist/components/Form/Form.d.ts +7 -1
  22. package/dist/components/Form/Form.js +2 -1
  23. package/dist/components/Squircle/Squircle.d.ts +3 -1
  24. package/dist/components/Squircle/Squircle.js +1 -1
  25. package/dist/components/Squircle/index.d.ts +2 -2
  26. package/dist/components/Squircle/index.js +2 -2
  27. package/dist/components/Textarea/Textarea.js +14 -4
  28. package/dist/components/ValidationSummary/ValidationSummary.d.ts +1 -1
  29. package/dist/components/ValidationSummary/ValidationSummary.js +1 -1
  30. package/dist/components/ValidationSummary/{validationsummary-utils.d.ts → validationsummary-internal.d.ts} +1 -1
  31. package/dist/components/ValidationSummary/{validationsummary-utils.js → validationsummary-internal.js} +3 -2
  32. package/dist/components/icons/AccountCircleFilledIcon.d.ts +2 -1
  33. package/dist/components/icons/AccountCircleIcon.d.ts +2 -1
  34. package/dist/components/icons/AccountFilledIcon.d.ts +2 -1
  35. package/dist/components/icons/AccountIcon.d.ts +2 -1
  36. package/dist/components/icons/AddIcon.d.ts +2 -1
  37. package/dist/components/icons/AppsFilledIcon.d.ts +2 -1
  38. package/dist/components/icons/AppsIcon.d.ts +2 -1
  39. package/dist/components/icons/ArrowDownIcon.d.ts +2 -1
  40. package/dist/components/icons/ArrowLeftIcon.d.ts +2 -1
  41. package/dist/components/icons/ArrowRightIcon.d.ts +2 -1
  42. package/dist/components/icons/ArrowUpIcon.d.ts +2 -1
  43. package/dist/components/icons/BackspaceFilledIcon.d.ts +2 -1
  44. package/dist/components/icons/BackspaceIcon.d.ts +2 -1
  45. package/dist/components/icons/BasketFilledIcon.d.ts +2 -1
  46. package/dist/components/icons/BasketIcon.d.ts +2 -1
  47. package/dist/components/icons/BellFilledIcon.d.ts +2 -1
  48. package/dist/components/icons/BellIcon.d.ts +2 -1
  49. package/dist/components/icons/BroomIcon.d.ts +2 -1
  50. package/dist/components/icons/CalendarFilledIcon.d.ts +2 -1
  51. package/dist/components/icons/CalendarIcon.d.ts +2 -1
  52. package/dist/components/icons/CashFilledIcon.d.ts +2 -1
  53. package/dist/components/icons/CashIcon.d.ts +2 -1
  54. package/dist/components/icons/ChevronDownIcon.d.ts +2 -1
  55. package/dist/components/icons/ChevronLeftIcon.d.ts +2 -1
  56. package/dist/components/icons/ChevronRightIcon.d.ts +2 -1
  57. package/dist/components/icons/ChevronUpIcon.d.ts +2 -1
  58. package/dist/components/icons/CircleBurgerFilledIcon.d.ts +2 -1
  59. package/dist/components/icons/CircleBurgerIcon.d.ts +2 -1
  60. package/dist/components/icons/ClipboardFilledIcon.d.ts +2 -1
  61. package/dist/components/icons/ClipboardIcon.d.ts +2 -1
  62. package/dist/components/icons/ClockFilledIcon.d.ts +2 -1
  63. package/dist/components/icons/ClockIcon.d.ts +2 -1
  64. package/dist/components/icons/CloseAltFilledIcon.d.ts +2 -1
  65. package/dist/components/icons/CloseAltIcon.d.ts +2 -1
  66. package/dist/components/icons/CogIcon.d.ts +2 -1
  67. package/dist/components/icons/CommunityFilledIcon.d.ts +2 -1
  68. package/dist/components/icons/CommunityIcon.d.ts +2 -1
  69. package/dist/components/icons/ComputerIcon.d.ts +2 -1
  70. package/dist/components/icons/ContactFilledIcon.d.ts +2 -1
  71. package/dist/components/icons/ContactIcon.d.ts +2 -1
  72. package/dist/components/icons/ContactlessFilledIcon.d.ts +2 -1
  73. package/dist/components/icons/ContactlessIcon.d.ts +2 -1
  74. package/dist/components/icons/CoopLiveIcon.d.ts +2 -1
  75. package/dist/components/icons/CoopLocationFilledIcon.d.ts +2 -1
  76. package/dist/components/icons/CoopLogoIcon.d.ts +2 -1
  77. package/dist/components/icons/CoopMembershipCardFilledIcon.d.ts +2 -1
  78. package/dist/components/icons/CreditCardFilledIcon.d.ts +2 -1
  79. package/dist/components/icons/CreditCardIcon.d.ts +2 -1
  80. package/dist/components/icons/CrossIcon.d.ts +2 -1
  81. package/dist/components/icons/DocumentIcon.d.ts +2 -1
  82. package/dist/components/icons/DownloadIcon.d.ts +2 -1
  83. package/dist/components/icons/EnterStoreIcon.d.ts +2 -1
  84. package/dist/components/icons/ExitStoreIcon.d.ts +2 -1
  85. package/dist/components/icons/EyeFilledIcon.d.ts +2 -1
  86. package/dist/components/icons/EyeIcon.d.ts +2 -1
  87. package/dist/components/icons/EyeStrikeFilledIcon.d.ts +2 -1
  88. package/dist/components/icons/EyeStrikeIcon.d.ts +2 -1
  89. package/dist/components/icons/FeedbackFilledIcon.d.ts +2 -1
  90. package/dist/components/icons/FeedbackIcon.d.ts +2 -1
  91. package/dist/components/icons/FilterIcon.d.ts +2 -1
  92. package/dist/components/icons/GiftCardFilledIcon.d.ts +2 -1
  93. package/dist/components/icons/GiftCardIcon.d.ts +2 -1
  94. package/dist/components/icons/GiftFilledIcon.d.ts +2 -1
  95. package/dist/components/icons/GiftIcon.d.ts +2 -1
  96. package/dist/components/icons/HelpFilledIcon.d.ts +2 -1
  97. package/dist/components/icons/HelpIcon.d.ts +2 -1
  98. package/dist/components/icons/HomeFilledIcon.d.ts +2 -1
  99. package/dist/components/icons/HomeIcon.d.ts +2 -1
  100. package/dist/components/icons/HowDoIAssistantIcon.d.ts +2 -1
  101. package/dist/components/icons/InfoIcon.d.ts +2 -1
  102. package/dist/components/icons/KebabIcon.d.ts +2 -1
  103. package/dist/components/icons/LoadingIcon.d.ts +2 -1
  104. package/dist/components/icons/LocationFilledIcon.d.ts +2 -1
  105. package/dist/components/icons/LocationIcon.d.ts +2 -1
  106. package/dist/components/icons/LogoutIcon.d.ts +2 -1
  107. package/dist/components/icons/MailIcon.d.ts +2 -1
  108. package/dist/components/icons/MenuIcon.d.ts +2 -1
  109. package/dist/components/icons/MessageIcon.d.ts +2 -1
  110. package/dist/components/icons/MinusIcon.d.ts +2 -1
  111. package/dist/components/icons/NotesFilledIcon.d.ts +2 -1
  112. package/dist/components/icons/NotesIcon.d.ts +2 -1
  113. package/dist/components/icons/OpenNewIcon.d.ts +2 -1
  114. package/dist/components/icons/OwnBagIcon.d.ts +2 -1
  115. package/dist/components/icons/PadlockIcon.d.ts +2 -1
  116. package/dist/components/icons/PencilIcon.d.ts +2 -1
  117. package/dist/components/icons/PhoneIcon.d.ts +2 -1
  118. package/dist/components/icons/PowerIcon.d.ts +2 -1
  119. package/dist/components/icons/SaveFilledIcon.d.ts +2 -1
  120. package/dist/components/icons/SaveIcon.d.ts +2 -1
  121. package/dist/components/icons/ScooterIcon.d.ts +2 -1
  122. package/dist/components/icons/SearchFilledIcon.d.ts +2 -1
  123. package/dist/components/icons/SearchIcon.d.ts +2 -1
  124. package/dist/components/icons/SendIcon.d.ts +2 -1
  125. package/dist/components/icons/SpannerIcon.d.ts +2 -1
  126. package/dist/components/icons/StarFilledIcon.d.ts +2 -1
  127. package/dist/components/icons/StarIcon.d.ts +2 -1
  128. package/dist/components/icons/TickAltFilledIcon.d.ts +2 -1
  129. package/dist/components/icons/TickAltIcon.d.ts +2 -1
  130. package/dist/components/icons/TickIcon.d.ts +2 -1
  131. package/dist/components/icons/TruckIcon.d.ts +2 -1
  132. package/dist/components/icons/UserWipeIcon.d.ts +2 -1
  133. package/dist/components/icons/VoucherFilledIcon.d.ts +2 -1
  134. package/dist/components/icons/VoucherIcon.d.ts +2 -1
  135. package/dist/components/icons/WarningCircleFilledIcon.d.ts +2 -1
  136. package/dist/components/icons/WarningCircleIcon.d.ts +2 -1
  137. package/dist/components/icons/WarningFilledIcon.d.ts +2 -1
  138. package/dist/components/icons/WarningIcon.d.ts +2 -1
  139. package/dist/components/icons/WifiIcon.d.ts +2 -1
  140. package/dist/components/icons/WriteIcon.d.ts +2 -1
  141. package/dist/hooks/index.d.ts +2 -1
  142. package/dist/hooks/index.js +2 -1
  143. package/dist/hooks/useFormReset.d.ts +6 -0
  144. package/dist/hooks/useFormReset.js +19 -0
  145. package/dist/hooks/useSlots.d.ts +2 -1
  146. package/dist/index.d.ts +6 -3
  147. package/dist/index.js +6 -4
  148. package/dist/types/index.js +4 -0
  149. package/dist/utils/slots.d.ts +1 -1
  150. package/dist/utils/slots.js +20 -12
  151. package/package.json +22 -18
  152. package/src/components/Author/Author.tsx +1 -1
  153. package/src/components/Combobox/Combobox.context.ts +3 -1
  154. package/src/components/Combobox/Combobox.tsx +57 -24
  155. package/src/components/Combobox/combobox-internal.ts +60 -0
  156. package/src/components/Combobox/combobox-utils.ts +1 -41
  157. package/src/components/Combobox/index.ts +1 -0
  158. package/src/components/DateInput/DateInput.context.ts +25 -0
  159. package/src/components/DateInput/DateInput.tsx +252 -0
  160. package/src/components/DateInput/dateinput-internal.ts +176 -0
  161. package/src/components/DateInput/dateinput-utils.ts +194 -0
  162. package/src/components/DateInput/index.ts +6 -0
  163. package/src/components/Field/Field.context.ts +1 -1
  164. package/src/components/Field/Field.tsx +1 -1
  165. package/src/components/Fieldset/Fieldset.tsx +5 -1
  166. package/src/components/Form/Form.tsx +9 -1
  167. package/src/components/Squircle/Squircle.tsx +1 -1
  168. package/src/components/Textarea/Textarea.tsx +19 -4
  169. package/src/components/ValidationSummary/ValidationSummary.context.ts +1 -1
  170. package/src/components/ValidationSummary/ValidationSummary.tsx +1 -1
  171. package/src/components/ValidationSummary/{validationsummary-utils.ts → validationsummary-internal.ts} +9 -8
  172. package/src/hooks/index.ts +1 -0
  173. package/src/hooks/useFormReset.ts +28 -0
  174. package/src/index.ts +1 -0
  175. package/src/utils/slots.ts +37 -23
  176. /package/src/components/FieldMarkers/{fieldmarkers-utils.ts → fieldmarkers-internal.ts} +0 -0
@@ -0,0 +1,252 @@
1
+ "use client"
2
+ import type { ChangeEvent, FocusEvent, HTMLAttributes, JSX, ReactNode, Ref } from "react"
3
+
4
+ import clsx from "clsx"
5
+ import mergeRefs from "merge-refs"
6
+ import { useRef, useState } from "react"
7
+
8
+ import { Field } from "@/components/Field"
9
+ import { TextInput, type TextInputProps } from "@/components/TextInput"
10
+ import { useFormReset } from "@/hooks/useFormReset"
11
+ import { useId } from "@/hooks/useId"
12
+ import { StandardSizes } from "@/types"
13
+ import { forEachSlot } from "@/utils"
14
+
15
+ import { formatDateValue, toDateParts, toDateValue } from "./dateinput-internal"
16
+ import {
17
+ type DateInputMode,
18
+ type DatePart,
19
+ type DateParts,
20
+ getAutoComplete,
21
+ } from "./dateinput-utils"
22
+ import { DateInputContext, useDateInput } from "./DateInput.context"
23
+
24
+ export interface DateInputChangeEvent {
25
+ /** The parts as typed. */
26
+ parts: DateParts
27
+ /** The parts as typed, joined from year to day, such as `2026-2-30`.
28
+ *
29
+ * Not checked: use `validateDate` before relying on it. */
30
+ value: string
31
+ }
32
+
33
+ export interface DateInputProps extends Omit<
34
+ HTMLAttributes<HTMLDivElement>,
35
+ "defaultValue" | "onBlur" | "onChange"
36
+ > {
37
+ /** Content inside the component.
38
+ *
39
+ * This should be a set of `<DateInput.Day>`, `<DateInput.Month>` and `<DateInput.Year>`. */
40
+ children?: ReactNode
41
+ /** Specify additional CSS classes. */
42
+ className?: string
43
+ /** Specify the initial value, when not controlled. One of:
44
+ *
45
+ * - an ISO date, such as `1990-08-21`
46
+ * - the parts, such as `{ year: "2026" }` */
47
+ defaultValue?: DateParts | string
48
+ /** Specify the base for the part ids. Will be auto-generated if not set. */
49
+ id?: string
50
+ /** Specify the DateInput mode. */
51
+ mode?: DateInputMode
52
+ /** Specify the name.
53
+ *
54
+ * Each part is `{name}-day`, `{name}-month` and`{name}-year`.
55
+ *
56
+ * A hidden input `{name}` holds the value as typed. */
57
+ name?: string
58
+ /** Called when focus leaves the whole DateInput, not each part. */
59
+ onBlur?: (event: DateInputChangeEvent) => void
60
+ /** Called when any part changes. */
61
+ onChange?: (event: DateInputChangeEvent) => void
62
+ /** Specify a custom React ref for this component. */
63
+ ref?: Ref<HTMLDivElement>
64
+ /** Specify the component size. */
65
+ size?: StandardSizes
66
+ /** Specify the value, when controlled. One of:
67
+ *
68
+ * - an ISO date, such as `1990-08-21`
69
+ * - the parts, such as `{ year: "2026" }`
70
+ *
71
+ * Update it in `onChange` with the `value` or `parts` of the event. */
72
+ value?: DateParts | string
73
+ }
74
+
75
+ const PART_SLOTS: Record<string, DatePart> = {
76
+ DateInputDay: "day",
77
+ DateInputMonth: "month",
78
+ DateInputYear: "year",
79
+ }
80
+
81
+ const DateInput = ({
82
+ children,
83
+ className,
84
+ defaultValue,
85
+ id,
86
+ mode,
87
+ name,
88
+ onBlur,
89
+ onChange,
90
+ ref,
91
+ size,
92
+ value,
93
+ ...props
94
+ }: DateInputProps): JSX.Element => {
95
+ const uid = useId(id)
96
+
97
+ // The parts rendered, in order
98
+ const rendered: DatePart[] = []
99
+
100
+ forEachSlot(children, (slot) => {
101
+ const part = PART_SLOTS[slot.name]
102
+ if (part && !rendered.includes(part)) rendered.push(part)
103
+ })
104
+
105
+ const defaultParts = toDateParts(defaultValue, rendered)
106
+ const [typed, setTyped] = useState<DateParts>(() =>
107
+ value === undefined ? defaultParts : toDateParts(value, rendered)
108
+ )
109
+
110
+ // When controlled, show the parts as typed while they match the value,
111
+ // which keeps a dash typed in a part. Otherwise show the parts of the value.
112
+ const controlled = value !== undefined
113
+ const parts =
114
+ controlled && toDateValue(value, rendered) !== toDateValue(typed, rendered)
115
+ ? toDateParts(value, rendered)
116
+ : typed
117
+
118
+ // The browser resets each part input to its default value, so reset the
119
+ // parts too
120
+ const controlRef = useRef<HTMLInputElement>(null)
121
+ useFormReset(controlRef, () => {
122
+ if (!controlled) setTyped(defaultParts)
123
+ })
124
+
125
+ const getEvent = (next: DateParts): DateInputChangeEvent => ({
126
+ parts: next,
127
+ value: formatDateValue(next, rendered),
128
+ })
129
+
130
+ // Build on the parts shown, not typed, which still holds a change a
131
+ // controlling parent did not pass back
132
+ const changePart = (part: DatePart, partValue: string) => {
133
+ const next = { ...parts, [part]: partValue }
134
+ setTyped(next)
135
+ onChange?.(getEvent(next))
136
+ }
137
+
138
+ // Only call onBlur when focus moves outside the DateInput
139
+ const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
140
+ if (!event.currentTarget.contains(event.relatedTarget)) {
141
+ onBlur?.(getEvent(parts))
142
+ }
143
+ }
144
+
145
+ const componentProps = {
146
+ ...props,
147
+ className: clsx("coop-date-input", className),
148
+ onBlur: handleBlur,
149
+ }
150
+
151
+ return (
152
+ <DateInputContext.Provider
153
+ value={{
154
+ changePart,
155
+ controlled,
156
+ controlPart: rendered[0],
157
+ controlRef,
158
+ defaultValue: defaultParts,
159
+ id: uid,
160
+ mode,
161
+ name,
162
+ parts,
163
+ size,
164
+ }}
165
+ >
166
+ <div {...componentProps} ref={ref}>
167
+ {name && <input name={name} type="hidden" value={formatDateValue(parts, rendered)} />}
168
+ {children}
169
+ </div>
170
+ </DateInputContext.Provider>
171
+ )
172
+ }
173
+
174
+ export interface DateInputPartProps extends Omit<
175
+ TextInputProps,
176
+ "defaultValue" | "fit" | "prefix" | "prefixInline" | "suffix" | "suffixInline" | "type" | "value"
177
+ > {
178
+ /** Specify the part error state.
179
+ *
180
+ * Inherits error state of a `group` Fieldset when not set. */
181
+ error?: boolean
182
+ /** Specify the label. */
183
+ label?: ReactNode
184
+ }
185
+
186
+ const DateInputPart = ({
187
+ autoComplete,
188
+ className,
189
+ datePart: part,
190
+ error,
191
+ id,
192
+ label,
193
+ name,
194
+ onChange,
195
+ size,
196
+ ...props
197
+ }: DateInputPartProps & { datePart: DatePart; label: ReactNode }): JSX.Element => {
198
+ const dateInput = useDateInput()
199
+
200
+ const valueProps = dateInput?.controlled
201
+ ? { value: dateInput.parts[part] ?? "" }
202
+ : { defaultValue: dateInput?.defaultValue?.[part] }
203
+
204
+ const inputProps = {
205
+ ...valueProps,
206
+ autoComplete: autoComplete ?? getAutoComplete(dateInput?.mode, part),
207
+ className: clsx("coop-date-input--input", className),
208
+ "data-part": part,
209
+ id: id ?? (dateInput ? `${dateInput.id}-${part}` : undefined),
210
+ inputMode: "numeric" as const,
211
+ name: name ?? (dateInput?.name ? `${dateInput.name}-${part}` : undefined),
212
+ onChange: (event: ChangeEvent<HTMLInputElement>) => {
213
+ dateInput?.changePart(part, event.target.value)
214
+ onChange?.(event)
215
+ },
216
+ pattern: "[0-9]*",
217
+ size: size ?? dateInput?.size,
218
+ spellCheck: false,
219
+ ...props,
220
+ // The first part is the control the form reset is looked up from
221
+ ref: dateInput?.controlPart === part ? mergeRefs(dateInput.controlRef, props.ref) : props.ref,
222
+ }
223
+
224
+ return (
225
+ <Field error={error} hideErrorBar>
226
+ <Field.Label>{label}</Field.Label>
227
+ <TextInput {...inputProps} />
228
+ </Field>
229
+ )
230
+ }
231
+
232
+ const DateInputDay = ({ label = "Day", ...props }: DateInputPartProps): JSX.Element => (
233
+ <DateInputPart {...props} datePart="day" label={label} />
234
+ )
235
+ const DateInputMonth = ({ label = "Month", ...props }: DateInputPartProps): JSX.Element => (
236
+ <DateInputPart {...props} datePart="month" label={label} />
237
+ )
238
+ const DateInputYear = ({ label = "Year", ...props }: DateInputPartProps): JSX.Element => (
239
+ <DateInputPart {...props} datePart="year" label={label} />
240
+ )
241
+
242
+ DateInput.slot = { name: "DateInput" }
243
+ DateInputDay.slot = { name: "DateInputDay" }
244
+ DateInputMonth.slot = { name: "DateInputMonth" }
245
+ DateInputYear.slot = { name: "DateInputYear" }
246
+
247
+ DateInput.Day = DateInputDay
248
+ DateInput.Month = DateInputMonth
249
+ DateInput.Year = DateInputYear
250
+
251
+ export default DateInput
252
+ export { DateInput, DateInputDay, DateInputMonth, DateInputYear }
@@ -0,0 +1,176 @@
1
+ // Not exported from the DateInput entry. No React or "use client", as
2
+ // dateinput-utils imports it.
3
+
4
+ import { format, formatISO, isExists, isValid, parseISO } from "date-fns"
5
+
6
+ import type {
7
+ DATE_ERROR_MESSAGES,
8
+ DateErrorCode,
9
+ DatePart,
10
+ DateParts,
11
+ DateValidationOptions,
12
+ } from "./dateinput-utils"
13
+
14
+ type DefaultMessages = typeof DATE_ERROR_MESSAGES
15
+
16
+ export interface Limits {
17
+ max?: string
18
+ min?: string
19
+ }
20
+
21
+ // Year first, whatever order the parts render in
22
+ const VALUE_ORDER: DatePart[] = ["year", "month", "day"]
23
+
24
+ // Codes that mark every part as invalid
25
+ export const WHOLE_DATE_ERRORS = new Set<DateErrorCode>([
26
+ "after-max",
27
+ "before-min",
28
+ "impossible-date",
29
+ "missing-date",
30
+ "unsupported-parts",
31
+ ])
32
+
33
+ const valueParts = (parts: DatePart[]) => VALUE_ORDER.filter((part) => parts.includes(part))
34
+
35
+ // Parts as typed to a value, such as 2026-2-30. Empty if every part is empty
36
+ export const formatDateValue = (date: DateParts, parts: DatePart[] = VALUE_ORDER): string => {
37
+ const values = valueParts(parts).map((part) => date[part] ?? "")
38
+
39
+ return values.every((value) => value === "") ? "" : values.join("-")
40
+ }
41
+
42
+ // A piece per part. A leading -- is the ISO form of a date without a year
43
+ const splitValue = (value: string, used: DatePart[]) => {
44
+ const pieces = value.split("-")
45
+ const iso = !used.includes("year") && value.startsWith("--") && pieces.length === used.length + 2
46
+
47
+ return iso ? pieces.slice(2) : pieces
48
+ }
49
+
50
+ // A value or ISO date to parts. Undefined when a dash typed in a part leaves
51
+ // it unclear which part is which
52
+ export const parseDateValue = (
53
+ value: string,
54
+ parts: DatePart[] = VALUE_ORDER
55
+ ): DateParts | undefined => {
56
+ if (value === "") return {}
57
+
58
+ const used = valueParts(parts)
59
+ const pieces = splitValue(value, used)
60
+ if (pieces.length !== used.length) return undefined
61
+
62
+ return Object.fromEntries(used.map((part, index) => [part, pieces[index]]))
63
+ }
64
+
65
+ // A value, ISO date or parts to a value
66
+ export const toDateValue = (input: DateParts | string = "", parts?: DatePart[]): string =>
67
+ typeof input === "string" ? input : formatDateValue(input, parts)
68
+
69
+ // A value, ISO date or parts to parts. Empty when a value cannot be split
70
+ export const toDateParts = (input: DateParts | string = "", parts?: DatePart[]): DateParts =>
71
+ typeof input === "string" ? (parseDateValue(input, parts) ?? {}) : input
72
+
73
+ export const toNumber = (value: string): number | null =>
74
+ /^\d+$/.test(value) ? Number(value) : null
75
+
76
+ export const isValidPart = (part: DatePart, value: string): boolean => {
77
+ if (part === "year") return /^\d{4}$/.test(value)
78
+
79
+ const number = toNumber(value)
80
+ const max = part === "day" ? 31 : 12
81
+
82
+ return number !== null && number >= 1 && number <= max
83
+ }
84
+
85
+ export const trimParts = (date: DateParts): Required<DateParts> => ({
86
+ day: date.day?.trim() ?? "",
87
+ month: date.month?.trim() ?? "",
88
+ year: date.year?.trim() ?? "",
89
+ })
90
+
91
+ // An error code for each problem, in the order the parts are entered. A
92
+ // date with every part empty only returns missing-date. impossible-date
93
+ // is only checked once every part is valid.
94
+ export const getDateErrors = (values: Required<DateParts>, parts: DatePart[]): DateErrorCode[] => {
95
+ if (parts.every((part) => values[part] === "")) return ["missing-date"]
96
+
97
+ const errors: DateErrorCode[] = []
98
+
99
+ for (const part of parts) {
100
+ if (values[part] === "") errors.push(`missing-${part}`)
101
+ else if (!isValidPart(part, values[part])) errors.push(`invalid-${part}`)
102
+ }
103
+
104
+ if (errors.length > 0 || !parts.includes("day") || !parts.includes("month")) {
105
+ return errors
106
+ }
107
+
108
+ // Without a year, check against a leap year, so 29 February is allowed
109
+ const year = parts.includes("year") ? Number(values.year) : 2000
110
+
111
+ return isExists(year, Number(values.month) - 1, Number(values.day)) ? [] : ["impossible-date"]
112
+ }
113
+
114
+ export const getInvalidParts = (errors: DateErrorCode[], parts: DatePart[]): DatePart[] =>
115
+ parts.filter((part) => errors.some((code) => WHOLE_DATE_ERRORS.has(code) || code.endsWith(part)))
116
+
117
+ // Remove extra leading zeros, such as in "008", then pad to 2 digits
118
+ export const pad = (value: string): string => String(Number(value)).padStart(2, "0")
119
+
120
+ // The ISO 8601 string for a valid date, using the parts given. Empty
121
+ // when the parts have no ISO format, such as day and year.
122
+ export const toISODate = ({ day, month, year }: Required<DateParts>, parts: DatePart[]): string => {
123
+ const has = (part: DatePart) => parts.includes(part)
124
+
125
+ if (has("day") && has("month") && has("year")) return `${year}-${pad(month)}-${pad(day)}`
126
+ if (has("month") && has("year") && !has("day")) return `${year}-${pad(month)}`
127
+ if (has("day") && has("month")) return `--${pad(month)}-${pad(day)}`
128
+ if (parts.length === 1 && has("year")) return year
129
+
130
+ return ""
131
+ }
132
+
133
+ // Read a min or max limit. Throws for an invalid limit, as that is a
134
+ // mistake in the code. An ISO date string is read in local time.
135
+ export const toLimit = (limit: Date | string, name: "max" | "min"): Date => {
136
+ const date = typeof limit === "string" ? parseISO(limit) : limit
137
+ if (!isValid(date)) {
138
+ throw new Error(`${name} must be a valid Date or an ISO date, such as 2020-01-31`)
139
+ }
140
+
141
+ return date
142
+ }
143
+
144
+ // Write a limit out for a message, to the same detail as the value, such as
145
+ // "1 January 2020", "January 2020" or "2020"
146
+ export const formatLimit = (limit: Date, length: number): string =>
147
+ format(limit, length === 4 ? "yyyy" : length === 7 ? "MMMM yyyy" : "d MMMM yyyy")
148
+
149
+ // Compare the value with min and max, to the same detail as the value. A
150
+ // day and month value has no year, so it is not compared.
151
+ export const getRangeError = (value: string, min?: Date, max?: Date): DateErrorCode | undefined => {
152
+ if (value.startsWith("--")) return undefined
153
+
154
+ const cut = (limit: Date) => formatISO(limit, { representation: "date" }).slice(0, value.length)
155
+
156
+ if (min && value < cut(min)) return "before-min"
157
+ if (max && value > cut(max)) return "after-max"
158
+
159
+ return undefined
160
+ }
161
+
162
+ // A custom message, or the default. Only the defaults use the limits. The
163
+ // defaults are passed in, as they are public and live in dateinput-utils
164
+ export const getMessage = (
165
+ code: DateErrorCode,
166
+ label: string | undefined,
167
+ messages: DateValidationOptions["messages"] = {},
168
+ limits: Limits,
169
+ defaults: DefaultMessages
170
+ ): string => {
171
+ const custom = messages[code]
172
+ if (custom !== undefined) return typeof custom === "function" ? custom(label) : custom
173
+
174
+ const fallback = defaults[code]
175
+ return typeof fallback === "function" ? fallback(label, limits) : fallback
176
+ }
@@ -0,0 +1,194 @@
1
+ // Validation for dates entered as separate day, month and year parts.
2
+ // The DateInput entry exports this to server code too, so it must not import
3
+ // React or use "use client".
4
+
5
+ import {
6
+ formatLimit,
7
+ getDateErrors,
8
+ getInvalidParts,
9
+ getMessage,
10
+ getRangeError,
11
+ type Limits,
12
+ parseDateValue,
13
+ toISODate,
14
+ toLimit,
15
+ trimParts,
16
+ } from "./dateinput-internal"
17
+
18
+ export type DatePart = "day" | "month" | "year"
19
+
20
+ export interface DateParts {
21
+ day?: string
22
+ month?: string
23
+ year?: string
24
+ }
25
+
26
+ export type DateInputMode = "dob" | undefined
27
+
28
+ export type DateErrorCode =
29
+ | "missing-date"
30
+ | "missing-day"
31
+ | "missing-month"
32
+ | "missing-year"
33
+ | "invalid-day"
34
+ | "invalid-month"
35
+ | "invalid-year"
36
+ | "impossible-date"
37
+ | "unsupported-parts"
38
+ | "before-min"
39
+ | "after-max"
40
+
41
+ /** A message, or a function that builds one from the label. */
42
+ export type DateErrorMessage = string | ((label?: string) => string)
43
+
44
+ export interface DateValidationOptions {
45
+ /** The name of the date, as it would start a sentence, such as "Date
46
+ * of birth".
47
+ *
48
+ * Default: `Date`. */
49
+ label?: string
50
+ /** The latest date allowed, as a `Date` or an ISO date such as `2026-12-31`.
51
+ *
52
+ * Only the parts used are compared, such as the month and year. */
53
+ max?: Date | string
54
+ /** Messages to use instead of the defaults. */
55
+ messages?: Partial<Record<DateErrorCode, DateErrorMessage>>
56
+ /** The earliest date allowed, as a `Date` or an ISO date such as `2020-01-01`.
57
+ *
58
+ * Only the parts used are compared, such as the month and year. */
59
+ min?: Date | string
60
+ /** The parts used, in the order they are entered.
61
+ *
62
+ * Default: `day`, `month`, `year`. */
63
+ parts?: DatePart[]
64
+ }
65
+
66
+ export interface DateValidationSuccess {
67
+ /** Whether the date is valid. */
68
+ valid: true
69
+ /** The date as an ISO 8601 string, such as 1990-08-21. */
70
+ value: string
71
+ }
72
+
73
+ export interface DateValidationError {
74
+ /** The code for the first problem, in the order the parts are entered. */
75
+ error: DateErrorCode
76
+ /** The parts with an error. Every part is invalid when the date is
77
+ * empty, impossible, has no ISO format or is outside `min` and `max`. */
78
+ invalid: DatePart[]
79
+ /** The message for the error. */
80
+ message: string
81
+ /** Whether the date is valid. */
82
+ valid: false
83
+ }
84
+
85
+ export type DateValidation = DateValidationSuccess | DateValidationError
86
+
87
+ export const DATE_PARTS: DatePart[] = ["day", "month", "year"]
88
+
89
+ const realDate = (label = "Date") => `${label} must be a real date`
90
+
91
+ export const DATE_ERROR_MESSAGES: Record<
92
+ DateErrorCode,
93
+ string | ((label?: string, limits?: Limits) => string)
94
+ > = {
95
+ "after-max": (label = "Date", limits) => `${label} must be on or before ${limits?.max}`,
96
+ "before-min": (label = "Date", limits) => `${label} must be on or after ${limits?.min}`,
97
+ "impossible-date": realDate,
98
+ "invalid-day": realDate,
99
+ "invalid-month": realDate,
100
+ "invalid-year": "Year must include 4 numbers",
101
+ "missing-date": "Enter a date",
102
+ "missing-day": (label = "Date") => `${label} must include a day`,
103
+ "missing-month": (label = "Date") => `${label} must include a month`,
104
+ "missing-year": (label = "Date") => `${label} must include a year`,
105
+ "unsupported-parts": realDate,
106
+ }
107
+
108
+ // Check parts and return if they make a complete, possible date with an
109
+ // ISO format, between min and max. A valid date has its ISO value. An
110
+ // invalid date has the code and message for the first problem, and the
111
+ // parts with an error.
112
+ export const validateDateParts = (
113
+ date: DateParts,
114
+ { label, max, messages = {}, min, parts = DATE_PARTS }: DateValidationOptions = {}
115
+ ): DateValidation => {
116
+ const minDate = min === undefined ? undefined : toLimit(min, "min")
117
+ const maxDate = max === undefined ? undefined : toLimit(max, "max")
118
+ const values = trimParts(date)
119
+ const errors = getDateErrors(values, parts)
120
+ let value = ""
121
+
122
+ if (errors.length === 0) {
123
+ value = toISODate(values, parts)
124
+ const rangeError = value ? getRangeError(value, minDate, maxDate) : "unsupported-parts"
125
+
126
+ if (!rangeError) return { valid: true, value }
127
+ errors.push(rangeError)
128
+ }
129
+
130
+ const [error] = errors
131
+ const limits = {
132
+ max: maxDate && value ? formatLimit(maxDate, value.length) : undefined,
133
+ min: minDate && value ? formatLimit(minDate, value.length) : undefined,
134
+ }
135
+
136
+ return {
137
+ error,
138
+ invalid: getInvalidParts(errors, parts),
139
+ message: getMessage(error, label, messages, limits, DATE_ERROR_MESSAGES),
140
+ valid: false,
141
+ }
142
+ }
143
+
144
+ /** Check a value, such as the DateInput value or the value a form
145
+ * submits, the same way as `validateDateParts`. Use the same `parts` as
146
+ * the DateInput. */
147
+ export const validateDate = (
148
+ value: string,
149
+ options: DateValidationOptions = {}
150
+ ): DateValidation => {
151
+ const date = parseDateValue(value, options.parts)
152
+ if (date) return validateDateParts(date, options)
153
+
154
+ // A dash typed in a part leaves it unclear which part is wrong, so the
155
+ // whole date is
156
+ const { label, messages, parts = DATE_PARTS } = options
157
+
158
+ return {
159
+ error: "impossible-date",
160
+ invalid: parts,
161
+ message: getMessage("impossible-date", label, messages, {}, DATE_ERROR_MESSAGES),
162
+ valid: false,
163
+ }
164
+ }
165
+
166
+ /** As this is a more specific function for a birth of date validation, we can make some assumptions.
167
+ * Such as, date needs to be today or in the past and error message should reflect date of birth.
168
+ * Check a value, such as the DateInput value or the value a form
169
+ * submits, the same way as `validateDateParts`. Use the same `parts` as
170
+ * the DateInput. */
171
+
172
+ export const validateDob = (value: string, options: DateValidationOptions = {}): DateValidation => {
173
+ const { messages, ...rest } = options
174
+ return validateDate(value, {
175
+ label: "Date of birth",
176
+ max: new Date(),
177
+ messages: {
178
+ "after-max": "Date of birth must be today or in the past",
179
+ "missing-date": "Enter your date of birth",
180
+ ...messages,
181
+ },
182
+ parts: DATE_PARTS,
183
+ ...rest,
184
+ })
185
+ }
186
+
187
+ export const getAutoComplete = (mode: DateInputMode, part: DatePart): string | undefined => {
188
+ if (!mode) return undefined
189
+ if (mode === "dob") {
190
+ if (part === "day") return "bday-day"
191
+ if (part === "month") return "bday-month"
192
+ if (part === "year") return "bday-year"
193
+ }
194
+ }
@@ -0,0 +1,6 @@
1
+ import DateInput from "./DateInput"
2
+
3
+ export default DateInput
4
+ export { DateInput }
5
+ export * from "./DateInput"
6
+ export * from "./dateinput-utils"
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
  import { createContext, useContext } from "react"
3
3
 
4
- import { joinIds, type MarkerIds } from "@/components/FieldMarkers/fieldmarkers-utils"
4
+ import { joinIds, type MarkerIds } from "@/components/FieldMarkers/fieldmarkers-internal"
5
5
 
6
6
  export interface FieldConfig {
7
7
  disabled: boolean
@@ -5,7 +5,7 @@ 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
+ import { getMarkerIds, type Markers } from "@/components/FieldMarkers/fieldmarkers-internal"
9
9
  import { Hint as BaseHint, type HintProps } from "@/components/FieldMarkers/Hint"
10
10
  import { Label as BaseLabel, type LabelProps } from "@/components/FieldMarkers/Label"
11
11
  import { useFieldset } from "@/components/Fieldset/Fieldset.context"
@@ -3,7 +3,11 @@ import clsx from "clsx"
3
3
  import { type FieldsetHTMLAttributes, HTMLAttributes, type JSX, Ref } from "react"
4
4
 
5
5
  import { Error as BaseError, type ErrorProps } from "@/components/FieldMarkers/Error"
6
- import { getMarkerIds, joinIds, type Markers } from "@/components/FieldMarkers/fieldmarkers-utils"
6
+ import {
7
+ getMarkerIds,
8
+ joinIds,
9
+ type Markers,
10
+ } from "@/components/FieldMarkers/fieldmarkers-internal"
7
11
  import { Hint as BaseHint, type HintProps } from "@/components/FieldMarkers/Hint"
8
12
  import { Legend as BaseLegend, type LegendProps } from "@/components/FieldMarkers/Legend"
9
13
  import { useId } from "@/hooks/useId"
@@ -2,6 +2,8 @@ import type { FormHTMLAttributes, JSX, ReactNode, Ref } from "react"
2
2
 
3
3
  import clsx from "clsx"
4
4
 
5
+ import { StandardSizes } from "@/types"
6
+
5
7
  export interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
6
8
  /** Content inside the component. */
7
9
  children?: ReactNode
@@ -9,11 +11,17 @@ export interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
9
11
  className?: string
10
12
  /** Specify a custom React ref for this component. */
11
13
  ref?: Ref<HTMLFormElement>
14
+ /** Specify the spacing.
15
+ *
16
+ * Default: `md`
17
+ */
18
+ spacing?: StandardSizes
12
19
  }
13
20
 
14
- const Form = ({ children, className, ref, ...props }: FormProps): JSX.Element => {
21
+ const Form = ({ children, className, ref, spacing, ...props }: FormProps): JSX.Element => {
15
22
  const componentProps = {
16
23
  className: clsx("coop-form", className),
24
+ "data-spacing": spacing ?? undefined,
17
25
  ...props,
18
26
  }
19
27