@windstream/react-shared-components 0.2.54 → 0.2.56

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 (27) hide show
  1. package/dist/contentful/index.d.ts +200 -2
  2. package/dist/contentful/index.esm.js +5 -5
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +4 -4
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/styles.css +1 -1
  8. package/package.json +1 -1
  9. package/src/contentful/blocks/dynamic-form/DynamicForm.stories.tsx +154 -0
  10. package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +108 -0
  11. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +103 -0
  12. package/src/contentful/blocks/dynamic-form/constants.ts +28 -0
  13. package/src/contentful/blocks/dynamic-form/context.ts +35 -0
  14. package/src/contentful/blocks/dynamic-form/field-map.test.tsx +421 -0
  15. package/src/contentful/blocks/dynamic-form/field-map.tsx +486 -0
  16. package/src/contentful/blocks/dynamic-form/form-field.test.tsx +319 -0
  17. package/src/contentful/blocks/dynamic-form/form-field.tsx +224 -0
  18. package/src/contentful/blocks/dynamic-form/index.test.tsx +355 -0
  19. package/src/contentful/blocks/dynamic-form/index.tsx +552 -0
  20. package/src/contentful/blocks/dynamic-form/render.test.tsx +116 -0
  21. package/src/contentful/blocks/dynamic-form/render.tsx +172 -0
  22. package/src/contentful/blocks/dynamic-form/types.ts +211 -0
  23. package/src/contentful/blocks/dynamic-form/utils.test.ts +238 -0
  24. package/src/contentful/blocks/dynamic-form/utils.ts +255 -0
  25. package/src/contentful/blocks/primary-hero/index.test.tsx +28 -3
  26. package/src/contentful/blocks/primary-hero/index.tsx +5 -1
  27. package/src/contentful/index.ts +20 -0
@@ -0,0 +1,486 @@
1
+ "use client";
2
+
3
+ import React, { useContext, useEffect, useRef, useState } from "react";
4
+ import { FormContext } from "./context";
5
+ import { FieldRenderProps } from "./types";
6
+
7
+ import { Checkbox } from "@shared/components/checkbox";
8
+ import { Input } from "@shared/components/input";
9
+ import { MaterialIcon } from "@shared/components/material-icon";
10
+ import { RadioButton } from "@shared/components/radio-button";
11
+ import { Select } from "@shared/components/select";
12
+ import { Text } from "@shared/components/text";
13
+ import { cx } from "@shared/utils";
14
+
15
+ export const formatFormPhoneNumber = (value: string): string => {
16
+ const phoneNumber = (value || "").replace(/\D/g, "");
17
+ if (phoneNumber.length <= 3) {
18
+ return phoneNumber;
19
+ } else if (phoneNumber.length <= 6) {
20
+ return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3)}`;
21
+ } else if (phoneNumber.length <= 9) {
22
+ return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6)}`;
23
+ }
24
+ return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
25
+ };
26
+
27
+ export const labelCreator = (
28
+ label: string | undefined,
29
+ required?: boolean,
30
+ variant = "footnote"
31
+ ) => {
32
+ return (
33
+ <Text as="div" className={cx(variant, "validation-label mb-1")}>
34
+ {label}
35
+ {required ? <span className="text-text-critical">*</span> : null}
36
+ </Text>
37
+ );
38
+ };
39
+
40
+ const TextInputField: React.FC<FieldRenderProps> = args => {
41
+ const {
42
+ displayName,
43
+ formData,
44
+ textInputType,
45
+ handleInputChange,
46
+ fieldId,
47
+ errorClass,
48
+ } = args;
49
+
50
+ if (textInputType === "phone") {
51
+ return (
52
+ <div className="w-full">
53
+ <Input
54
+ aria-label={displayName}
55
+ name={fieldId}
56
+ type="tel"
57
+ value={
58
+ formData[fieldId!] ? formatFormPhoneNumber(formData[fieldId!]) : ""
59
+ }
60
+ onChange={handleInputChange}
61
+ className={cx("w-full", errorClass)}
62
+ maxLength={14}
63
+ />
64
+ </div>
65
+ );
66
+ }
67
+
68
+ if (textInputType === "multiline") {
69
+ return (
70
+ <div className="w-full">
71
+ <textarea
72
+ aria-label={displayName}
73
+ name={fieldId}
74
+ value={formData?.[fieldId!] ?? ""}
75
+ onChange={handleInputChange}
76
+ rows={4}
77
+ className={cx(
78
+ "w-full rounded-xl border border-input-border bg-white px-4 py-3 text-text outline-none focus:border-input-border-selected",
79
+ errorClass
80
+ )}
81
+ />
82
+ </div>
83
+ );
84
+ }
85
+
86
+ return (
87
+ <div className="w-full">
88
+ <Input
89
+ aria-label={displayName}
90
+ name={fieldId}
91
+ value={formData?.[fieldId!] ?? ""}
92
+ onChange={handleInputChange}
93
+ className={cx("w-full", errorClass)}
94
+ />
95
+ </div>
96
+ );
97
+ };
98
+
99
+ const ConsentCheckbox: React.FC<FieldRenderProps> = args => {
100
+ const { requiredField, handleInputChange, fieldId, displayName, hasError } =
101
+ args;
102
+ const [checked, setChecked] = useState(false);
103
+
104
+ const handleCheckboxChange = () => {
105
+ setChecked(prev => {
106
+ handleInputChange(!prev);
107
+ return !prev;
108
+ });
109
+ };
110
+
111
+ return (
112
+ <Checkbox
113
+ name={fieldId}
114
+ id={fieldId}
115
+ checked={checked}
116
+ error={hasError}
117
+ onChange={handleCheckboxChange}
118
+ labelClassName="footnote text-text-secondary"
119
+ label={
120
+ <>
121
+ {displayName}
122
+ {requiredField ? <span className="text-text-critical">*</span> : null}
123
+ </>
124
+ }
125
+ />
126
+ );
127
+ };
128
+
129
+ const CheckboxInputField: React.FC<FieldRenderProps> = args => {
130
+ const {
131
+ formData,
132
+ handleInputChange,
133
+ fieldId,
134
+ dataSource,
135
+ isConsentCheckbox,
136
+ hasError,
137
+ } = args;
138
+
139
+ if (isConsentCheckbox) {
140
+ return <ConsentCheckbox {...args} />;
141
+ }
142
+
143
+ const raw = formData?.[fieldId!];
144
+ const selectedValues: string[] =
145
+ typeof raw === "string" && raw.length > 0 ? raw.split(",") : [];
146
+
147
+ const handleCheckboxChange = (value: string) => {
148
+ let updated: string[];
149
+ if (selectedValues.includes(value)) {
150
+ updated = selectedValues.filter(item => item !== value);
151
+ } else {
152
+ updated = [...selectedValues, value];
153
+ }
154
+ handleInputChange(updated.join(","));
155
+ };
156
+
157
+ return (
158
+ <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
159
+ {(dataSource || []).map((data: any, idx: number) => (
160
+ <div key={`${fieldId}-${idx}`} className="w-full lg:w-[384px]">
161
+ <Checkbox
162
+ name={fieldId}
163
+ label={data.label}
164
+ value={data.value}
165
+ checked={selectedValues.includes(data.value)}
166
+ onChange={() => handleCheckboxChange(data.value)}
167
+ error={hasError}
168
+ labelClassName="footnote"
169
+ />
170
+ </div>
171
+ ))}
172
+ </div>
173
+ );
174
+ };
175
+
176
+ const DropdownInputField: React.FC<FieldRenderProps> = args => {
177
+ const {
178
+ formData,
179
+ handleInputChange,
180
+ fieldId,
181
+ dataSource,
182
+ existingDataSource,
183
+ errorClass,
184
+ } = args;
185
+ const [options, setOptions] = useState<any[]>(dataSource ?? []);
186
+ const context = useContext(FormContext);
187
+ const resolve = context?.resolveExistingDataSource;
188
+
189
+ useEffect(() => {
190
+ const fetchData = async () => {
191
+ if (
192
+ (dataSource == null || dataSource.length <= 0) &&
193
+ existingDataSource
194
+ ) {
195
+ if (existingDataSource === "City List") return;
196
+ if (!resolve) return;
197
+ try {
198
+ const resolved = await resolve(existingDataSource, {
199
+ selectedState: context?.selectedState,
200
+ });
201
+ setOptions(resolved || []);
202
+ } catch (error) {
203
+ context?.onError?.({
204
+ type: "resolve_data_source",
205
+ error,
206
+ metadata: { existingDataSource },
207
+ });
208
+ }
209
+ }
210
+ };
211
+ fetchData();
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [dataSource, existingDataSource]);
214
+
215
+ useEffect(() => {
216
+ if (existingDataSource === "City List") {
217
+ handleInputChange(null);
218
+ setOptions([]);
219
+ if (context?.selectedState && resolve) {
220
+ const fetchCities = async () => {
221
+ try {
222
+ const resolved = await resolve("City List", {
223
+ selectedState: context?.selectedState,
224
+ });
225
+ setOptions(resolved || []);
226
+ } catch (error) {
227
+ context?.onError?.({
228
+ type: "resolve_data_source",
229
+ error,
230
+ metadata: { existingDataSource: "City List" },
231
+ });
232
+ }
233
+ };
234
+ fetchCities();
235
+ }
236
+ }
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps
238
+ }, [context?.selectedState, existingDataSource]);
239
+
240
+ const selectedOption = formData?.[fieldId!]
241
+ ? options.find((option: any) => option.value === formData?.[fieldId!])
242
+ : null;
243
+
244
+ return (
245
+ <Select
246
+ className={cx("body3", errorClass)}
247
+ controlClassName="px-4 [&>div]:p-0"
248
+ options={options}
249
+ value={selectedOption ?? undefined}
250
+ onChange={handleInputChange as any}
251
+ aria-label={args.displayName ?? "Select"}
252
+ />
253
+ );
254
+ };
255
+
256
+ const DatePickerField: React.FC<FieldRenderProps> = args => {
257
+ const { displayName, handleInputChange, errorClass, formData, fieldId } =
258
+ args;
259
+ return (
260
+ <div className="w-full">
261
+ <input
262
+ type="datetime-local"
263
+ aria-label={displayName}
264
+ value={formData?.[fieldId!] ?? ""}
265
+ onChange={e => handleInputChange(e.target.value)}
266
+ className={cx(
267
+ "w-full rounded-xl border border-input-border bg-white px-4 py-3 text-text outline-none focus:border-input-border-selected",
268
+ errorClass
269
+ )}
270
+ />
271
+ </div>
272
+ );
273
+ };
274
+
275
+ const RadioButtonInputField: React.FC<FieldRenderProps> = args => {
276
+ const {
277
+ requiredField,
278
+ formData,
279
+ handleInputChange,
280
+ fieldId,
281
+ hasError,
282
+ dataSource,
283
+ } = args;
284
+ return (
285
+ <div className="mt-6 flex flex-col gap-8 sm:flex-row">
286
+ {(dataSource || []).map((data: any, idx: number) => (
287
+ <div key={idx} className="w-full lg:w-[384px]">
288
+ <RadioButton
289
+ name={fieldId!}
290
+ label={data.label}
291
+ value={data.value}
292
+ checked={formData?.[fieldId!] === data.value}
293
+ hasError={hasError}
294
+ onChange={handleInputChange}
295
+ required={requiredField}
296
+ labelClassName="footnote"
297
+ />
298
+ </div>
299
+ ))}
300
+ </div>
301
+ );
302
+ };
303
+
304
+ const ImageuploadField: React.FC<FieldRenderProps> = args => {
305
+ const { handleInputChange, loading } = args;
306
+ const [files, setFiles] = useState<File[]>([]);
307
+ const [previewUrls, setPreviewUrls] = useState<string[]>([]);
308
+ const [dragActive, setDragActive] = useState(false);
309
+ const [error, setError] = useState<string | null>(null);
310
+ const inputRef = useRef<HTMLInputElement | null>(null);
311
+ const context = useContext(FormContext);
312
+
313
+ useEffect(() => {
314
+ const urls = files.map(file =>
315
+ file.type.startsWith("image/") ? URL.createObjectURL(file) : ""
316
+ );
317
+ setPreviewUrls(urls);
318
+ return () => {
319
+ urls.forEach(url => {
320
+ if (url) URL.revokeObjectURL(url);
321
+ });
322
+ };
323
+ }, [files]);
324
+
325
+ const openFileDialog = () => inputRef.current?.click();
326
+ const isMedia = (file: File) => file.type.startsWith("image/");
327
+
328
+ const validateTotalSize = (list: File[], maxBytes: number) => {
329
+ const total = list.reduce((sum, f) => sum + f.size, 0);
330
+ return { total, ok: total <= maxBytes };
331
+ };
332
+
333
+ const addFiles = (incoming: FileList | File[]) => {
334
+ const next: File[] = [];
335
+ if (incoming && incoming.length) {
336
+ const fileList = Array.from(incoming);
337
+ fileList.forEach(f => {
338
+ if (isMedia(f)) next.push(f);
339
+ });
340
+ if (next.length === 0) {
341
+ setError("Only image files are allowed.");
342
+ return;
343
+ }
344
+ if (files.length + next.length > 5) {
345
+ setError("You can only upload a maximum of 5 files.");
346
+ return;
347
+ }
348
+ const combined = [...files, ...next];
349
+ if (!validateTotalSize(combined, 20 * 1024 * 1024).ok) {
350
+ setError("Total file size should not be more than 20 MB.");
351
+ return;
352
+ }
353
+ context?.setFiles(combined);
354
+ setFiles(combined);
355
+ handleInputChange(combined);
356
+ setError(null);
357
+ }
358
+ };
359
+
360
+ const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
361
+ if (e.target.files && e.target.files.length > 0) {
362
+ addFiles(e.target.files);
363
+ e.target.value = "";
364
+ }
365
+ };
366
+
367
+ const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
368
+ e.preventDefault();
369
+ e.stopPropagation();
370
+ setDragActive(false);
371
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
372
+ addFiles(e.dataTransfer.files);
373
+ e.dataTransfer.clearData();
374
+ }
375
+ };
376
+
377
+ const removeFile = (idx: number) => {
378
+ const copy = [...files];
379
+ copy.splice(idx, 1);
380
+ setFiles(copy);
381
+ context?.setFiles(copy);
382
+ handleInputChange(copy);
383
+ };
384
+
385
+ return (
386
+ <div>
387
+ <div
388
+ onDragOver={e => {
389
+ e.preventDefault();
390
+ e.stopPropagation();
391
+ setDragActive(true);
392
+ }}
393
+ onDragLeave={e => {
394
+ e.preventDefault();
395
+ e.stopPropagation();
396
+ setDragActive(false);
397
+ }}
398
+ onDrop={onDrop}
399
+ className={cx(
400
+ "relative flex flex-col items-center justify-center rounded-xl border border-input-border bg-white px-4 py-10 text-center transition",
401
+ dragActive
402
+ ? "border-input-border-selected bg-bg-surface-active"
403
+ : "hover:border-input-border-hover"
404
+ )}
405
+ >
406
+ <p className="mb-3 text-sm text-text-secondary">Drop files here or</p>
407
+ <button
408
+ type="button"
409
+ onClick={openFileDialog}
410
+ className="inline-flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-text shadow-sm ring-1 ring-inset ring-input-border hover:bg-bg-surface-hover"
411
+ >
412
+ <MaterialIcon name="upload" weight="700" size={24} />
413
+ Choose files
414
+ </button>
415
+ <input
416
+ ref={inputRef}
417
+ type="file"
418
+ accept="image/*"
419
+ multiple={true}
420
+ name="files"
421
+ onChange={onInputChange}
422
+ className="pointer-events-none absolute inset-0 h-0 w-0 opacity-0"
423
+ aria-hidden={true}
424
+ tabIndex={-1}
425
+ />
426
+ {error && <p className="mt-2 text-sm text-text-critical">{error}</p>}
427
+ {files.length > 0 && (
428
+ <div className="mt-8">
429
+ <ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
430
+ {files.map((file, idx) => (
431
+ <li
432
+ key={idx}
433
+ className="group relative rounded-xl border border-input-border p-2"
434
+ >
435
+ {file.type.startsWith("image/") && previewUrls[idx] ? (
436
+ <img
437
+ src={previewUrls[idx]}
438
+ alt={file.name}
439
+ className="h-28 w-full rounded object-cover"
440
+ />
441
+ ) : (
442
+ <div className="h-28 w-full rounded bg-bg-surface-active" />
443
+ )}
444
+ <div className="mt-2 flex items-center justify-between">
445
+ <span
446
+ className="max-w-[50%] truncate text-sm text-text"
447
+ title={file.name}
448
+ >
449
+ {file.name}
450
+ </span>
451
+ {loading ? (
452
+ <button
453
+ type="button"
454
+ disabled={true}
455
+ className="cursor-default text-sm text-text-secondary"
456
+ >
457
+ Remove
458
+ </button>
459
+ ) : (
460
+ <button
461
+ type="button"
462
+ onClick={() => removeFile(idx)}
463
+ className="text-sm text-text-critical hover:underline"
464
+ >
465
+ Remove
466
+ </button>
467
+ )}
468
+ </div>
469
+ </li>
470
+ ))}
471
+ </ul>
472
+ </div>
473
+ )}
474
+ </div>
475
+ </div>
476
+ );
477
+ };
478
+
479
+ export const FormFieldTypeMap: Record<string, React.FC<FieldRenderProps>> = {
480
+ Checkbox: CheckboxInputField,
481
+ Imageupload: ImageuploadField,
482
+ Datepicker: DatePickerField,
483
+ Dropdown: DropdownInputField,
484
+ Radio: RadioButtonInputField,
485
+ Text: TextInputField,
486
+ };