@dataimago/interview 0.2.0-alpha.5 → 0.2.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -94,6 +94,30 @@ function canAdvanceWith(question, value) {
94
94
  if (typeof value === "object") return Object.keys(value).length > 0;
95
95
  return true;
96
96
  }
97
+ function validationError(question, value) {
98
+ const v = question.validation;
99
+ if (!v) return null;
100
+ if (typeof value !== "string" || value.trim().length === 0) return null;
101
+ try {
102
+ return new RegExp(v.pattern).test(value) ? null : v.message;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ function hasValidationError(question, value) {
108
+ if (validationError(question, value) !== null) return true;
109
+ if (question.inputType === "composite" && question.subQuestions) {
110
+ const rec = value ?? {};
111
+ return question.subQuestions.some((sub) => validationError(sub, rec[sub.id]) !== null);
112
+ }
113
+ if ((question.inputType === "repeater" || question.inputType === "file-upload") && question.itemSchema) {
114
+ const rows = value ?? [];
115
+ return rows.some(
116
+ (row) => question.itemSchema.some((sub) => validationError(sub, row[sub.id]) !== null)
117
+ );
118
+ }
119
+ return false;
120
+ }
97
121
  function QuestionCard({
98
122
  question,
99
123
  existingAnswer,
@@ -114,9 +138,12 @@ function QuestionCard({
114
138
  onAnswerSubmit(question.id, localValue);
115
139
  onNext();
116
140
  };
117
- const canAdvance = canAdvanceWith(question, localValue);
141
+ const canAdvance = canAdvanceWith(question, localValue) && !hasValidationError(question, localValue);
118
142
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("form", { onSubmit: handleSubmit, className: "animate-slide-up", children: [
119
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { className: "font-display text-3xl font-medium text-stone-900 sm:text-4xl", children: question.prompt }),
143
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("h2", { className: "font-display text-3xl font-medium text-stone-900 sm:text-4xl", children: [
144
+ question.prompt,
145
+ !question.required && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "ml-3 inline-block translate-y-[-0.2em] rounded-full border border-stone-300 bg-stone-100 px-3 py-1 align-middle font-sans text-xs font-medium uppercase tracking-wider text-stone-700", children: "Optional" })
146
+ ] }),
120
147
  question.subprompt && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mt-4 text-lg text-stone-700", children: question.subprompt }),
121
148
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-8", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
122
149
  InputForType,
@@ -160,7 +187,15 @@ function QuestionCard({
160
187
  function InputForType({ question, value, onChange }) {
161
188
  switch (question.inputType) {
162
189
  case "short-text":
163
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ShortTextInput, { value, onChange });
190
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
191
+ ShortTextInput,
192
+ {
193
+ question,
194
+ value,
195
+ onChange
196
+ },
197
+ question.id
198
+ );
164
199
  case "long-text":
165
200
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LongTextInput, { value, onChange });
166
201
  case "single-select":
@@ -185,18 +220,33 @@ function InputForType({ question, value, onChange }) {
185
220
  }
186
221
  }
187
222
  }
188
- function ShortTextInput({ value, onChange }) {
189
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
190
- "input",
191
- {
192
- type: "text",
193
- value: value ?? "",
194
- onChange: (e) => onChange(e.target.value),
195
- autoFocus: true,
196
- className: "w-full rounded-lg border-2 border-stone-200 bg-stone-50 px-4 py-3 text-lg text-stone-900 focus:border-forest-700 focus:outline-none",
197
- placeholder: "Type your answer\u2026"
198
- }
199
- );
223
+ function ShortTextInput({
224
+ question,
225
+ value,
226
+ onChange
227
+ }) {
228
+ const [blurred, setBlurred] = (0, import_react.useState)(false);
229
+ const errorId = (0, import_react.useId)();
230
+ const error = question ? validationError(question, value) : null;
231
+ const showError = blurred && error !== null;
232
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
233
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
234
+ "input",
235
+ {
236
+ type: "text",
237
+ value: value ?? "",
238
+ onChange: (e) => onChange(e.target.value),
239
+ onBlur: () => setBlurred(true),
240
+ autoFocus: true,
241
+ "aria-label": question?.prompt,
242
+ "aria-invalid": showError || void 0,
243
+ "aria-describedby": showError ? errorId : void 0,
244
+ className: `w-full rounded-lg border-2 bg-stone-50 px-4 py-3 text-lg text-stone-900 focus:outline-none ${showError ? "border-red-400 focus:border-red-700" : "border-stone-200 focus:border-forest-700"}`,
245
+ placeholder: "Type your answer\u2026"
246
+ }
247
+ ),
248
+ showError && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: errorId, className: "mt-2 text-sm font-medium text-red-800", children: error })
249
+ ] });
200
250
  }
201
251
  function LongTextInput({ value, onChange }) {
202
252
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
@@ -411,6 +461,23 @@ function CompositeInput({
411
461
  ) })
412
462
  ] }, sub.id)) });
413
463
  }
464
+ function isRowComplete(itemSchema, row) {
465
+ return itemSchema.every((sub) => {
466
+ if (!sub.required) return true;
467
+ const v = row[sub.id];
468
+ return typeof v === "string" ? v.trim().length > 0 : v !== void 0 && v !== null;
469
+ });
470
+ }
471
+ function summarizeRow(question, itemSchema, row) {
472
+ const stringValue = (id) => {
473
+ const v = row[id];
474
+ return typeof v === "string" ? v.trim() : "";
475
+ };
476
+ let primaryId = question.summaryField && stringValue(question.summaryField) ? question.summaryField : itemSchema.find((sub) => stringValue(sub.id))?.id;
477
+ const primary = primaryId ? stringValue(primaryId) : "";
478
+ const secondary = itemSchema.filter((sub) => sub.id !== primaryId).map((sub) => stringValue(sub.id)).filter(Boolean).join(" \xB7 ");
479
+ return { primary, secondary: secondary.length > 80 ? `${secondary.slice(0, 80)}\u2026` : secondary };
480
+ }
414
481
  function RepeaterInput({
415
482
  question,
416
483
  value,
@@ -420,60 +487,137 @@ function RepeaterInput({
420
487
  const itemSchema = question.itemSchema ?? [];
421
488
  const min = question.listRange?.min ?? 1;
422
489
  const max = question.listRange?.max ?? Infinity;
490
+ const [activeRow, setActiveRow] = (0, import_react.useState)(() => {
491
+ const firstIncomplete = rows.findIndex((row) => !isRowComplete(itemSchema, row));
492
+ return firstIncomplete === -1 ? null : firstIncomplete;
493
+ });
423
494
  const updateRow = (i, patch) => {
424
495
  const next = [...rows];
425
496
  next[i] = { ...next[i], ...patch };
426
497
  onChange(next);
427
498
  };
428
499
  const addRow = () => {
429
- if (rows.length < max) onChange([...rows, {}]);
500
+ if (rows.length >= max) return;
501
+ onChange([...rows, {}]);
502
+ setActiveRow(rows.length);
430
503
  };
431
504
  const removeRow = (i) => {
432
505
  if (rows.length <= 1) return;
433
506
  onChange(rows.filter((_, idx) => idx !== i));
507
+ setActiveRow((a) => a === null ? null : a === i ? null : a > i ? a - 1 : a);
434
508
  };
435
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "space-y-4", children: [
436
- rows.map((row, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "rounded-lg border-2 border-stone-200 bg-stone-50 p-4", children: [
437
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mb-3 flex items-center justify-between", children: [
438
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "font-mono text-xs uppercase tracking-wider text-stone-700", children: [
439
- "Item ",
440
- i + 1
441
- ] }),
442
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
443
- "button",
444
- {
445
- type: "button",
446
- onClick: () => removeRow(i),
447
- disabled: rows.length <= min,
448
- className: "btn-ghost text-xs text-stone-700 disabled:opacity-30",
449
- "aria-label": `Remove item ${i + 1}`,
450
- children: "Remove"
451
- }
452
- )
453
- ] }),
454
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "space-y-4", children: itemSchema.map((sub) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
455
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "text-sm font-medium text-stone-900", children: sub.prompt }),
456
- sub.subprompt && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-1 text-xs text-stone-700", children: sub.subprompt }),
457
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-2", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
458
- InputForType,
509
+ const completeCount = rows.filter((row) => isRowComplete(itemSchema, row)).length;
510
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "space-y-3", children: [
511
+ rows.map((row, i) => {
512
+ const complete = isRowComplete(itemSchema, row);
513
+ if (i !== activeRow) {
514
+ const { primary, secondary } = summarizeRow(question, itemSchema, row);
515
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
516
+ "div",
459
517
  {
460
- question: sub,
461
- value: row[sub.id],
462
- onChange: (next) => updateRow(i, { [sub.id]: next })
463
- }
464
- ) })
465
- ] }, sub.id)) })
466
- ] }, i)),
467
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
468
- "button",
469
- {
470
- type: "button",
471
- onClick: addRow,
472
- disabled: rows.length >= max,
473
- className: "inline-flex min-h-12 items-center px-2 text-sm font-medium text-forest-700 hover:text-forest-800 disabled:opacity-40",
474
- children: "+ Add another"
518
+ className: "flex items-center justify-between gap-3 rounded-lg border-2 border-stone-200 bg-stone-50 px-4 py-2",
519
+ children: [
520
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "min-w-0", children: [
521
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "truncate text-sm font-medium text-stone-900", children: primary || /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "italic text-stone-600", children: "Empty entry" }) }),
522
+ (secondary || !complete) && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "truncate text-xs text-stone-700", children: [
523
+ secondary,
524
+ !complete && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "font-medium text-amber-800", children: [
525
+ secondary ? " \xB7 " : "",
526
+ "incomplete"
527
+ ] })
528
+ ] })
529
+ ] }),
530
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex shrink-0 items-center", children: [
531
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
532
+ "button",
533
+ {
534
+ type: "button",
535
+ onClick: () => setActiveRow(i),
536
+ className: "btn-ghost text-xs text-stone-700",
537
+ "aria-label": `Edit entry ${i + 1}${primary ? `: ${primary}` : ""}`,
538
+ children: "Edit"
539
+ }
540
+ ),
541
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
542
+ "button",
543
+ {
544
+ type: "button",
545
+ onClick: () => removeRow(i),
546
+ disabled: rows.length <= min,
547
+ className: "btn-ghost text-xs text-stone-700 disabled:opacity-30",
548
+ "aria-label": `Remove entry ${i + 1}${primary ? `: ${primary}` : ""}`,
549
+ children: "Remove"
550
+ }
551
+ )
552
+ ] })
553
+ ]
554
+ },
555
+ i
556
+ );
475
557
  }
476
- )
558
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "rounded-lg border-2 border-forest-700 bg-stone-50 p-4", children: [
559
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mb-3 flex items-center justify-between", children: [
560
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "font-mono text-xs uppercase tracking-wider text-stone-700", children: [
561
+ "Entry ",
562
+ i + 1
563
+ ] }),
564
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center", children: [
565
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
566
+ "button",
567
+ {
568
+ type: "button",
569
+ onClick: () => setActiveRow(null),
570
+ className: "btn-ghost text-xs text-stone-700",
571
+ children: "Done"
572
+ }
573
+ ),
574
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
575
+ "button",
576
+ {
577
+ type: "button",
578
+ onClick: () => removeRow(i),
579
+ disabled: rows.length <= min,
580
+ className: "btn-ghost text-xs text-stone-700 disabled:opacity-30",
581
+ "aria-label": `Remove entry ${i + 1}`,
582
+ children: "Remove"
583
+ }
584
+ )
585
+ ] })
586
+ ] }),
587
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "space-y-4", children: itemSchema.map((sub) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
588
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "text-sm font-medium text-stone-900", children: sub.prompt }),
589
+ sub.subprompt && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-1 text-xs text-stone-700", children: sub.subprompt }),
590
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-2", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
591
+ InputForType,
592
+ {
593
+ question: sub,
594
+ value: row[sub.id],
595
+ onChange: (next) => updateRow(i, { [sub.id]: next })
596
+ }
597
+ ) })
598
+ ] }, sub.id)) })
599
+ ] }, i);
600
+ }),
601
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center justify-between", children: [
602
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
603
+ "button",
604
+ {
605
+ type: "button",
606
+ onClick: addRow,
607
+ disabled: rows.length >= max,
608
+ className: "inline-flex min-h-12 items-center px-2 text-sm font-medium text-forest-700 hover:text-forest-800 disabled:opacity-40",
609
+ children: "+ Add another"
610
+ }
611
+ ),
612
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { "aria-live": "polite", className: "font-mono text-xs text-stone-700", children: [
613
+ completeCount,
614
+ " of ",
615
+ rows.length,
616
+ " ",
617
+ rows.length === 1 ? "entry" : "entries",
618
+ " complete"
619
+ ] })
620
+ ] })
477
621
  ] });
478
622
  }
479
623
  function FileUploadInput({
@@ -726,7 +870,12 @@ function AnswerSidebar({ questions, currentIndex, answers, onSelectQuestion }) {
726
870
  children: q.prompt.length > 50 ? q.prompt.slice(0, 50) + "\u2026" : q.prompt
727
871
  }
728
872
  ),
729
- answered && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "mt-1 text-xs italic text-stone-700 line-clamp-1", children: preview })
873
+ answered && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "mt-1 text-xs italic text-stone-700 line-clamp-1", children: preview }),
874
+ !answered && q.required && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "mt-1 text-xs font-medium text-amber-800", children: [
875
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { "aria-hidden": "true", children: "\u25CF" }),
876
+ " Required \u2014 not yet answered"
877
+ ] }),
878
+ !answered && !q.required && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "mt-1 text-xs text-stone-600", children: "Optional" })
730
879
  ] })
731
880
  ] })
732
881
  }