@usefillo/react 0.4.0 → 0.5.1

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.d.ts CHANGED
@@ -37,8 +37,10 @@ interface FilloFieldIds {
37
37
  errorId: string;
38
38
  name: string;
39
39
  }
40
- interface FieldComponentProps {
41
- field: Field;
40
+ interface FieldComponentProps<F extends Field = Field> {
41
+ /** Narrow with the type param to skip a cast, e.g.
42
+ * `FieldComponentProps<CustomField>` to read `field.config` directly. */
43
+ field: F;
42
44
  value: FieldValue;
43
45
  error: string | undefined;
44
46
  setValue: (value: FieldValue) => void;
@@ -174,12 +176,18 @@ interface FieldHandle {
174
176
  */
175
177
  declare function useField(fieldId: string): FieldHandle;
176
178
 
177
- declare function BlockRenderer({ block, api, components, customComponents, }: {
179
+ interface BlockRendererProps {
178
180
  block: Block;
179
181
  api: FilloApi;
180
182
  components?: FieldComponents;
181
183
  customComponents?: CustomComponents;
182
- }): react.JSX.Element | null;
184
+ }
185
+ declare function BlockRendererInner({ block, api, components, customComponents }: BlockRendererProps): react.JSX.Element | null;
186
+ /**
187
+ * Memoized so a keystroke in one field doesn't re-render every other field —
188
+ * see blockPropsEqual for exactly what a block is allowed to depend on.
189
+ */
190
+ declare const BlockRenderer: react.MemoExoticComponent<typeof BlockRendererInner>;
183
191
  /**
184
192
  * Render a single field by id using the default (or overridden) component —
185
193
  * drop it anywhere inside a <FilloProvider> to place fields in your own
package/dist/index.js CHANGED
@@ -1,18 +1,19 @@
1
1
  "use client";
2
2
 
3
3
  // src/FilloForm.tsx
4
- import { useEffect as useEffect7, useId as useId2, useMemo as useMemo2, useRef as useRef5, useState as useState6 } from "react";
4
+ import { useEffect as useEffect7, useId as useId2, useMemo as useMemo4, useRef as useRef5, useState as useState6 } from "react";
5
5
  import {
6
6
  FilloError as FilloError2,
7
7
  createClient,
8
8
  isField as isField2,
9
9
  normalizeFormSchema,
10
- normalizeFormTheme
10
+ normalizeFormTheme,
11
+ visibleFields as visibleFields2
11
12
  } from "@usefillo/core";
12
13
 
13
14
  // src/context.ts
14
15
  import { allFields } from "@usefillo/core";
15
- import { createContext, useContext, useId } from "react";
16
+ import { createContext, useContext, useId, useMemo } from "react";
16
17
  var FilloContext = createContext(null);
17
18
  var FilloInstanceIdContext = createContext(null);
18
19
  function useFilloInstanceId() {
@@ -41,7 +42,12 @@ function useFilloFieldIds(fieldId, ids) {
41
42
  }
42
43
  function useField(fieldId) {
43
44
  const api = useFillo();
44
- const field = allFields(api.form).find((f) => f.id === fieldId);
45
+ const fieldsById = useMemo(() => {
46
+ const map = /* @__PURE__ */ new Map();
47
+ for (const f of allFields(api.form)) map.set(f.id, f);
48
+ return map;
49
+ }, [api.form]);
50
+ const field = fieldsById.get(fieldId);
45
51
  return {
46
52
  field,
47
53
  value: api.data[fieldId],
@@ -51,7 +57,7 @@ function useField(fieldId) {
51
57
  }
52
58
 
53
59
  // src/controller.ts
54
- import { useEffect, useRef, useSyncExternalStore } from "react";
60
+ import { useEffect, useMemo as useMemo2, useRef, useSyncExternalStore } from "react";
55
61
  import {
56
62
  createFormController
57
63
  } from "@usefillo/core";
@@ -78,17 +84,20 @@ function useFilloController(options) {
78
84
  }, [store, options.form, options.formId, options.client]);
79
85
  useEffect(() => () => store.destroy(), [store]);
80
86
  const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
81
- return {
82
- form: options.form,
83
- formId: options.formId,
84
- client: options.client,
85
- ...state,
86
- setValue: store.setValue,
87
- setUploading: store.setUploading,
88
- next: store.next,
89
- back: store.back,
90
- submit: store.submit
91
- };
87
+ return useMemo2(
88
+ () => ({
89
+ form: options.form,
90
+ formId: options.formId,
91
+ client: options.client,
92
+ ...state,
93
+ setValue: store.setValue,
94
+ setUploading: store.setUploading,
95
+ next: store.next,
96
+ back: store.back,
97
+ submit: store.submit
98
+ }),
99
+ [store, state, options.form, options.formId, options.client]
100
+ );
92
101
  }
93
102
 
94
103
  // src/define.ts
@@ -122,8 +131,8 @@ function useCodeFormSync(form, client, onError) {
122
131
  }
123
132
 
124
133
  // src/fields.tsx
125
- import { isField, pipeBlock, validateResponse } from "@usefillo/core";
126
- import { useEffect as useEffect6, useState as useState5 } from "react";
134
+ import { isField, pipeBlock, validateResponse, visibleFields } from "@usefillo/core";
135
+ import { memo, useEffect as useEffect6, useState as useState5 } from "react";
127
136
 
128
137
  // src/phone.tsx
129
138
  import {
@@ -135,7 +144,7 @@ import {
135
144
  parsePhone,
136
145
  toE164
137
146
  } from "@usefillo/core";
138
- import { useEffect as useEffect3, useLayoutEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
147
+ import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
139
148
  import { jsx, jsxs } from "react/jsx-runtime";
140
149
  function localeCountry() {
141
150
  if (typeof navigator === "undefined") return void 0;
@@ -234,7 +243,7 @@ function CountrySelect({
234
243
  const rootRef = useRef2(null);
235
244
  const searchRef = useRef2(null);
236
245
  const listId = `${labelledBy}-countries`;
237
- const matches = useMemo(() => {
246
+ const matches = useMemo3(() => {
238
247
  const q = query.trim().toLowerCase();
239
248
  if (!q) return PHONE_COUNTRIES;
240
249
  const qDigits = q.replace(/[^\d]/g, "");
@@ -357,9 +366,11 @@ import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } fro
357
366
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
358
367
  function SignatureField({ field, value, error, setValue, ids: providedIds }) {
359
368
  const ids = useFilloFieldIds(field.id, providedIds);
369
+ const typedInputId = `${ids.inputId}-typed`;
360
370
  const canvasRef = useRef3(null);
361
371
  const drawing = useRef3(false);
362
372
  const [hasInk, setHasInk] = useState3(Boolean(value));
373
+ const [typedName, setTypedName] = useState3("");
363
374
  useEffect4(() => {
364
375
  const canvas = canvasRef.current;
365
376
  if (!canvas) return;
@@ -387,12 +398,31 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
387
398
  function start(e) {
388
399
  drawing.current = true;
389
400
  e.currentTarget.setPointerCapture(e.pointerId);
401
+ if (typedName) setTypedName("");
390
402
  const ctx = e.currentTarget.getContext("2d");
391
403
  if (!ctx) return;
392
404
  const { x, y } = point(e);
393
405
  ctx.beginPath();
394
406
  ctx.moveTo(x, y);
395
407
  }
408
+ function drawTypedName(text) {
409
+ const canvas = canvasRef.current;
410
+ const ctx = canvas?.getContext("2d");
411
+ if (!canvas || !ctx) return;
412
+ const rect = canvas.getBoundingClientRect();
413
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
414
+ if (text.trim()) {
415
+ ctx.fillStyle = getComputedStyle(canvas).color;
416
+ ctx.font = `italic ${Math.min(40, rect.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
417
+ ctx.textBaseline = "middle";
418
+ ctx.fillText(text, 12, rect.height / 2);
419
+ setHasInk(true);
420
+ setValue(canvas.toDataURL("image/png"));
421
+ } else {
422
+ setHasInk(false);
423
+ setValue(null);
424
+ }
425
+ }
396
426
  function move(e) {
397
427
  if (!drawing.current) return;
398
428
  const ctx = e.currentTarget.getContext("2d");
@@ -413,33 +443,63 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
413
443
  if (!canvas || !ctx) return;
414
444
  ctx.clearRect(0, 0, canvas.width, canvas.height);
415
445
  setHasInk(false);
446
+ setTypedName("");
416
447
  setValue(null);
417
448
  }
418
449
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
419
450
  return /* @__PURE__ */ jsxs2("div", { className: `fillo-field fillo-field--signature${error ? " fillo-field--error" : ""}`, children: [
420
- /* @__PURE__ */ jsxs2("label", { className: "fillo-label", htmlFor: ids.inputId, children: [
451
+ /* @__PURE__ */ jsxs2("span", { className: "fillo-label", id: ids.labelId, children: [
421
452
  field.label,
422
453
  !field.required && /* @__PURE__ */ jsx2("span", { className: "fillo-optional", children: " (optional)" })
423
454
  ] }),
424
455
  field.description && /* @__PURE__ */ jsx2("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
425
- /* @__PURE__ */ jsxs2("div", { className: "fillo-signature", children: [
426
- /* @__PURE__ */ jsx2(
427
- "canvas",
428
- {
429
- ref: canvasRef,
430
- id: ids.inputId,
431
- className: "fillo-signature-canvas",
432
- "aria-invalid": error ? true : void 0,
433
- "aria-describedby": describedBy,
434
- onPointerDown: start,
435
- onPointerMove: move,
436
- onPointerUp: end,
437
- onPointerLeave: end
438
- }
439
- ),
440
- hasInk && /* @__PURE__ */ jsx2("button", { type: "button", className: "fillo-signature-clear", onClick: clear, children: "Clear" }),
441
- !hasInk && /* @__PURE__ */ jsx2("span", { className: "fillo-signature-hint", children: "Sign here" })
442
- ] }),
456
+ /* @__PURE__ */ jsxs2(
457
+ "div",
458
+ {
459
+ role: "group",
460
+ "aria-labelledby": ids.labelId,
461
+ "aria-describedby": describedBy,
462
+ children: [
463
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature", children: [
464
+ /* @__PURE__ */ jsx2(
465
+ "canvas",
466
+ {
467
+ ref: canvasRef,
468
+ className: "fillo-signature-canvas",
469
+ "aria-hidden": "true",
470
+ onPointerDown: start,
471
+ onPointerMove: move,
472
+ onPointerUp: end,
473
+ onPointerLeave: end
474
+ }
475
+ ),
476
+ hasInk && /* @__PURE__ */ jsx2("button", { type: "button", className: "fillo-signature-clear", onClick: clear, children: "Clear" }),
477
+ !hasInk && /* @__PURE__ */ jsx2("span", { className: "fillo-signature-hint", "aria-hidden": "true", children: "Sign here" })
478
+ ] }),
479
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature-type", children: [
480
+ /* @__PURE__ */ jsx2("label", { className: "fillo-signature-type-label", htmlFor: typedInputId, children: "Or type your full name to sign" }),
481
+ /* @__PURE__ */ jsx2(
482
+ "input",
483
+ {
484
+ id: typedInputId,
485
+ type: "text",
486
+ className: "fillo-input fillo-signature-type-input",
487
+ autoComplete: "name",
488
+ value: typedName,
489
+ required: field.required || void 0,
490
+ "aria-required": field.required || void 0,
491
+ "aria-invalid": error ? true : void 0,
492
+ "aria-describedby": describedBy,
493
+ onChange: (e) => {
494
+ setTypedName(e.target.value);
495
+ drawTypedName(e.target.value);
496
+ }
497
+ }
498
+ )
499
+ ] })
500
+ ]
501
+ }
502
+ ),
443
503
  error && /* @__PURE__ */ jsx2("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
444
504
  ] });
445
505
  }
@@ -447,6 +507,21 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
447
507
  // src/upload.tsx
448
508
  import { useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
449
509
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
510
+ function uploadStatus(inFlight, doneCount) {
511
+ const failed = inFlight.filter((f) => f.error).length;
512
+ const active = inFlight.length - failed;
513
+ const parts = [];
514
+ if (active > 0) {
515
+ parts.push(`Uploading ${active} ${active === 1 ? "file" : "files"}, please wait\u2026`);
516
+ }
517
+ if (failed > 0) {
518
+ parts.push(`${failed} ${failed === 1 ? "upload" : "uploads"} failed.`);
519
+ }
520
+ if (active === 0 && doneCount > 0) {
521
+ parts.push(`${doneCount} ${doneCount === 1 ? "file" : "files"} uploaded.`);
522
+ }
523
+ return parts.join(" ");
524
+ }
450
525
  function formatBytes(bytes) {
451
526
  if (bytes < 1024) return `${bytes} B`;
452
527
  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -584,6 +659,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
584
659
  ]
585
660
  }
586
661
  ),
662
+ /* @__PURE__ */ jsx3("div", { role: "status", "aria-live": "polite", className: "fillo-sr-only", children: uploadStatus(inFlight, files.length) }),
587
663
  (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs3("ul", { className: "fillo-files", children: [
588
664
  files.map((f) => /* @__PURE__ */ jsxs3("li", { className: "fillo-file fillo-file--done", children: [
589
665
  /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
@@ -599,39 +675,53 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
599
675
  }
600
676
  )
601
677
  ] }, f.fileId)),
602
- inFlight.map((f) => /* @__PURE__ */ jsxs3("li", { className: `fillo-file${f.error ? " fillo-file--failed" : ""}`, children: [
603
- /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
604
- f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
605
- /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
606
- /* @__PURE__ */ jsx3(
607
- "button",
608
- {
609
- type: "button",
610
- className: "fillo-file-remove",
611
- "aria-label": "Dismiss",
612
- onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
613
- children: "\xD7"
614
- }
615
- )
616
- ] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
617
- /* @__PURE__ */ jsxs3("span", { className: "fillo-file-meta", children: [
618
- Math.round(f.fraction * 100),
619
- "% of ",
620
- formatBytes(f.size)
621
- ] }),
622
- /* @__PURE__ */ jsx3("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx3("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
623
- /* @__PURE__ */ jsx3(
624
- "button",
625
- {
626
- type: "button",
627
- className: "fillo-file-remove",
628
- "aria-label": `Cancel ${f.name}`,
629
- onClick: () => cancelUpload(f.key),
630
- children: "\xD7"
631
- }
632
- )
633
- ] })
634
- ] }, f.key))
678
+ inFlight.map((f) => /* @__PURE__ */ jsxs3(
679
+ "li",
680
+ {
681
+ className: `fillo-file${f.error ? " fillo-file--failed" : ""}`,
682
+ ...f.error ? {} : {
683
+ role: "progressbar",
684
+ "aria-label": `Uploading ${f.name}`,
685
+ "aria-valuemin": 0,
686
+ "aria-valuemax": 100,
687
+ "aria-valuenow": Math.round(f.fraction * 100)
688
+ },
689
+ children: [
690
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
691
+ f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
692
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
693
+ /* @__PURE__ */ jsx3(
694
+ "button",
695
+ {
696
+ type: "button",
697
+ className: "fillo-file-remove",
698
+ "aria-label": "Dismiss",
699
+ onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
700
+ children: "\xD7"
701
+ }
702
+ )
703
+ ] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
704
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-file-meta", children: [
705
+ Math.round(f.fraction * 100),
706
+ "% of ",
707
+ formatBytes(f.size)
708
+ ] }),
709
+ /* @__PURE__ */ jsx3("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx3("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
710
+ /* @__PURE__ */ jsx3(
711
+ "button",
712
+ {
713
+ type: "button",
714
+ className: "fillo-file-remove",
715
+ "aria-label": `Cancel ${f.name}`,
716
+ onClick: () => cancelUpload(f.key),
717
+ children: "\xD7"
718
+ }
719
+ )
720
+ ] })
721
+ ]
722
+ },
723
+ f.key
724
+ ))
635
725
  ] }),
636
726
  error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
637
727
  ] });
@@ -782,12 +872,14 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
782
872
  },
783
873
  opt.id
784
874
  )),
785
- choice.allowOther && /* @__PURE__ */ jsxs4(
786
- "label",
875
+ choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
876
+ // toggle the radio. The radio + "Other" text keep their own <label>.
877
+ /* @__PURE__ */ jsxs4(
878
+ "div",
787
879
  {
788
880
  className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
789
881
  children: [
790
- /* @__PURE__ */ jsxs4("span", { className: "fillo-option-main", children: [
882
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option-main", children: [
791
883
  /* @__PURE__ */ jsx4(
792
884
  "input",
793
885
  {
@@ -808,6 +900,7 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
808
900
  {
809
901
  type: "text",
810
902
  className: "fillo-input fillo-other-input",
903
+ "aria-label": "Other \u2014 please specify",
811
904
  placeholder: "Your answer",
812
905
  autoFocus: !isOtherValue,
813
906
  value: isOtherValue ? String(value) : "",
@@ -860,12 +953,14 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
860
953
  },
861
954
  opt.id
862
955
  )),
863
- choice.allowOther && /* @__PURE__ */ jsxs4(
864
- "label",
956
+ choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
957
+ // toggle the checkbox. The checkbox + "Other" text keep their own <label>.
958
+ /* @__PURE__ */ jsxs4(
959
+ "div",
865
960
  {
866
961
  className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
867
962
  children: [
868
- /* @__PURE__ */ jsxs4("span", { className: "fillo-option-main", children: [
963
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option-main", children: [
869
964
  /* @__PURE__ */ jsx4(
870
965
  "input",
871
966
  {
@@ -889,6 +984,7 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
889
984
  {
890
985
  type: "text",
891
986
  className: "fillo-input fillo-other-input",
987
+ "aria-label": "Other \u2014 please specify",
892
988
  placeholder: "Your answer",
893
989
  autoFocus: otherText === void 0,
894
990
  value: otherText ?? "",
@@ -945,6 +1041,7 @@ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
945
1041
  {
946
1042
  type: "text",
947
1043
  className: "fillo-input fillo-other-input fillo-other-input--block",
1044
+ "aria-label": "Other \u2014 please specify",
948
1045
  placeholder: "Your answer",
949
1046
  autoFocus: !isOtherValue,
950
1047
  value: isOtherValue ? String(value) : "",
@@ -1018,22 +1115,62 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1018
1115
  }
1019
1116
  );
1020
1117
  }
1118
+ function radiogroupKeyDown(e, steps, current, setValue) {
1119
+ const forward = e.key === "ArrowRight" || e.key === "ArrowDown";
1120
+ const backward = e.key === "ArrowLeft" || e.key === "ArrowUp";
1121
+ if (!forward && !backward) return;
1122
+ e.preventDefault();
1123
+ const index = current == null ? -1 : steps.indexOf(current);
1124
+ let next;
1125
+ if (index === -1) {
1126
+ next = forward ? 0 : steps.length - 1;
1127
+ } else {
1128
+ next = forward ? index + 1 : index - 1;
1129
+ next = (next + steps.length) % steps.length;
1130
+ }
1131
+ const value = steps[next];
1132
+ if (value === void 0) return;
1133
+ setValue(value);
1134
+ const group = e.currentTarget;
1135
+ const buttons = group.querySelectorAll('[role="radio"]');
1136
+ buttons[next]?.focus();
1137
+ }
1138
+ function rovingIndex(steps, current) {
1139
+ if (current == null) return 0;
1140
+ const i = steps.indexOf(current);
1141
+ return i === -1 ? 0 : i;
1142
+ }
1021
1143
  function Rating({ field, value, error, setValue, ids: providedIds }) {
1022
1144
  const ids = useFilloFieldIds(field.id, providedIds);
1023
1145
  const max = field.max ?? 5;
1024
1146
  const current = typeof value === "number" ? value : 0;
1025
- return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("div", { className: "fillo-rating", role: "group", "aria-label": field.label, ...fieldAria(field, error, ids), children: Array.from({ length: max }, (_, i) => i + 1).map((n) => /* @__PURE__ */ jsx4(
1026
- "button",
1147
+ const steps = Array.from({ length: max }, (_, i) => i + 1);
1148
+ const selected = current > 0 ? current : null;
1149
+ const tabbable = rovingIndex(steps, selected);
1150
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1151
+ "div",
1027
1152
  {
1028
- type: "button",
1029
- className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
1030
- "aria-label": `${n} of ${max}`,
1031
- "aria-pressed": n === current,
1032
- onClick: () => setValue(n === current ? null : n),
1033
- children: "\u2605"
1034
- },
1035
- n
1036
- )) }) });
1153
+ className: "fillo-rating",
1154
+ role: "radiogroup",
1155
+ "aria-labelledby": ids.labelId,
1156
+ ...fieldAria(field, error, ids),
1157
+ onKeyDown: (e) => radiogroupKeyDown(e, steps, selected, (n) => setValue(n)),
1158
+ children: steps.map((n, i) => /* @__PURE__ */ jsx4(
1159
+ "button",
1160
+ {
1161
+ type: "button",
1162
+ className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
1163
+ role: "radio",
1164
+ "aria-label": `${n} of ${max}`,
1165
+ "aria-checked": n === current,
1166
+ tabIndex: i === tabbable ? 0 : -1,
1167
+ onClick: () => setValue(n === current ? null : n),
1168
+ children: "\u2605"
1169
+ },
1170
+ n
1171
+ ))
1172
+ }
1173
+ ) });
1037
1174
  }
1038
1175
  function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1039
1176
  const ids = useFilloFieldIds(field.id, providedIds);
@@ -1041,21 +1178,47 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1041
1178
  const min = scale.min ?? 1;
1042
1179
  const max = scale.max ?? 10;
1043
1180
  const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i);
1181
+ const current = typeof value === "number" ? value : null;
1182
+ const tabbable = rovingIndex(steps, current);
1183
+ const hasLabels = Boolean(scale.minLabel || scale.maxLabel);
1184
+ const minLabelId = `${ids.inputId}-min`;
1185
+ const maxLabelId = `${ids.inputId}-max`;
1186
+ const describedBy = [
1187
+ scale.minLabel ? minLabelId : null,
1188
+ scale.maxLabel ? maxLabelId : null,
1189
+ field.description ? ids.descriptionId : null,
1190
+ error ? ids.errorId : null
1191
+ ].filter(Boolean).join(" ") || void 0;
1044
1192
  return /* @__PURE__ */ jsxs4(FieldShell, { field, error, ids, children: [
1045
- /* @__PURE__ */ jsx4("div", { className: "fillo-scale", role: "group", "aria-label": field.label, ...fieldAria(field, error, ids), children: steps.map((n) => /* @__PURE__ */ jsx4(
1046
- "button",
1193
+ /* @__PURE__ */ jsx4(
1194
+ "div",
1047
1195
  {
1048
- type: "button",
1049
- className: `fillo-scale-step${value === n ? " fillo-scale-step--active" : ""}`,
1050
- "aria-pressed": value === n,
1051
- onClick: () => setValue(value === n ? null : n),
1052
- children: n
1053
- },
1054
- n
1055
- )) }),
1056
- (scale.minLabel || scale.maxLabel) && /* @__PURE__ */ jsxs4("div", { className: "fillo-scale-labels", children: [
1057
- /* @__PURE__ */ jsx4("span", { children: scale.minLabel }),
1058
- /* @__PURE__ */ jsx4("span", { children: scale.maxLabel })
1196
+ className: "fillo-scale",
1197
+ role: "radiogroup",
1198
+ "aria-labelledby": ids.labelId,
1199
+ "aria-invalid": error ? true : void 0,
1200
+ "aria-required": field.required ? true : void 0,
1201
+ "aria-describedby": describedBy,
1202
+ onKeyDown: (e) => radiogroupKeyDown(e, steps, current, (n) => setValue(n)),
1203
+ children: steps.map((n, i) => /* @__PURE__ */ jsx4(
1204
+ "button",
1205
+ {
1206
+ type: "button",
1207
+ className: `fillo-scale-step${value === n ? " fillo-scale-step--active" : ""}`,
1208
+ role: "radio",
1209
+ "aria-label": String(n),
1210
+ "aria-checked": value === n,
1211
+ tabIndex: i === tabbable ? 0 : -1,
1212
+ onClick: () => setValue(value === n ? null : n),
1213
+ children: n
1214
+ },
1215
+ n
1216
+ ))
1217
+ }
1218
+ ),
1219
+ hasLabels && /* @__PURE__ */ jsxs4("div", { className: "fillo-scale-labels", children: [
1220
+ /* @__PURE__ */ jsx4("span", { id: minLabelId, children: scale.minLabel }),
1221
+ /* @__PURE__ */ jsx4("span", { id: maxLabelId, children: scale.maxLabel })
1059
1222
  ] })
1060
1223
  ] });
1061
1224
  }
@@ -1117,20 +1280,30 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1117
1280
  /* @__PURE__ */ jsx4("th", {}),
1118
1281
  matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
1119
1282
  ] }) }),
1120
- /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => /* @__PURE__ */ jsxs4("tr", { children: [
1121
- /* @__PURE__ */ jsx4("th", { scope: "row", children: row.label }),
1122
- matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1123
- "input",
1124
- {
1125
- type: "radio",
1126
- className: "fillo-option-input",
1127
- name: `${ids.name}-${row.id}`,
1128
- "aria-label": `${row.label}: ${col.label}`,
1129
- checked: answers[row.id] === col.id,
1130
- onChange: () => setValue({ ...answers, [row.id]: col.id })
1131
- }
1132
- ) }, col.id))
1133
- ] }, row.id)) })
1283
+ /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1284
+ const rowLabelId = `${ids.inputId}-${row.id}`;
1285
+ const answered = answers[row.id] !== void 0;
1286
+ const rowInvalid = Boolean(error) && field.required && !answered;
1287
+ return (
1288
+ // Each row is its own radiogroup, labelled by the row question.
1289
+ /* @__PURE__ */ jsxs4("tr", { role: "radiogroup", "aria-labelledby": rowLabelId, children: [
1290
+ /* @__PURE__ */ jsx4("th", { scope: "row", id: rowLabelId, children: row.label }),
1291
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1292
+ "input",
1293
+ {
1294
+ type: "radio",
1295
+ className: "fillo-option-input",
1296
+ name: `${ids.name}-${row.id}`,
1297
+ "aria-label": `${row.label}: ${col.label}`,
1298
+ "aria-required": field.required ? true : void 0,
1299
+ "aria-invalid": rowInvalid ? true : void 0,
1300
+ checked: answers[row.id] === col.id,
1301
+ onChange: () => setValue({ ...answers, [row.id]: col.id })
1302
+ }
1303
+ ) }, col.id))
1304
+ ] }, row.id)
1305
+ );
1306
+ }) })
1134
1307
  ] }) }) });
1135
1308
  }
1136
1309
  function Hidden() {
@@ -1183,14 +1356,42 @@ function shouldAutoSubmit(field, value, api) {
1183
1356
  return false;
1184
1357
  }
1185
1358
  })();
1186
- return hasDiscreteAnswer && validateResponse(api.form, { ...api.data, [field.id]: value }).ok;
1359
+ if (!hasDiscreteAnswer) return false;
1360
+ if (value === api.data[field.id]) return false;
1361
+ const nextData = { ...api.data, [field.id]: value };
1362
+ const visible = visibleFields(api.form, nextData);
1363
+ if (visible.length !== 1 || visible[0]?.id !== field.id) return false;
1364
+ return validateResponse(api.form, nextData).ok;
1187
1365
  }
1188
- function BlockRenderer({
1189
- block,
1190
- api,
1191
- components,
1192
- customComponents
1193
- }) {
1366
+ function blockHasPiping(block) {
1367
+ const has = (s) => typeof s === "string" && s.includes("{{");
1368
+ switch (block.kind) {
1369
+ case "heading":
1370
+ case "paragraph":
1371
+ return has(block.text);
1372
+ case "divider":
1373
+ return false;
1374
+ default:
1375
+ return has(block.label) || has(block.description);
1376
+ }
1377
+ }
1378
+ function blockPropsEqual(prev, next) {
1379
+ if (prev.block !== next.block || prev.components !== next.components || prev.customComponents !== next.customComponents) {
1380
+ return false;
1381
+ }
1382
+ const id = next.block.id;
1383
+ if (prev.api.data[id] !== next.api.data[id]) return false;
1384
+ if (prev.api.errors[id] !== next.api.errors[id]) return false;
1385
+ const needsWiderState = next.block.kind === "custom" || blockHasPiping(prev.block) || blockHasPiping(next.block);
1386
+ if (needsWiderState && prev.api.data !== next.api.data) return false;
1387
+ if (next.api.form.settings.submitMode === "auto") {
1388
+ if (prev.api.data !== next.api.data || prev.api.status !== next.api.status || prev.api.uploading !== next.api.uploading || prev.api.isLastPage !== next.api.isLastPage) {
1389
+ return false;
1390
+ }
1391
+ }
1392
+ return true;
1393
+ }
1394
+ function BlockRendererInner({ block, api, components, customComponents }) {
1194
1395
  block = pipeBlock(block, api.data, api.form);
1195
1396
  const ids = useFilloFieldIds(block.id);
1196
1397
  if (!isField(block)) return /* @__PURE__ */ jsx4(ContentRenderer, { block });
@@ -1224,6 +1425,7 @@ function BlockRenderer({
1224
1425
  }
1225
1426
  );
1226
1427
  }
1428
+ var BlockRenderer = memo(BlockRendererInner, blockPropsEqual);
1227
1429
  function FormField({
1228
1430
  id,
1229
1431
  components,
@@ -1276,15 +1478,26 @@ function badgeUrl(href) {
1276
1478
  url.searchParams.set("utm_source", "form_badge");
1277
1479
  return url.toString();
1278
1480
  }
1481
+ function focusFirstInvalid(formEl) {
1482
+ if (!formEl || typeof window === "undefined") return;
1483
+ const run = () => {
1484
+ const target = formEl.querySelector('[aria-invalid="true"]');
1485
+ target?.focus?.();
1486
+ };
1487
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
1488
+ else setTimeout(run, 0);
1489
+ }
1279
1490
  function isAutoSubmitBlock(block) {
1280
1491
  return isField2(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
1281
1492
  }
1282
- function needsExplicitSubmit(blocks) {
1283
- return blocks.some((block) => isField2(block) && block.kind !== "hidden" && !isAutoSubmitBlock(block));
1493
+ function needsExplicitSubmit(visible) {
1494
+ if (visible.length === 0) return false;
1495
+ if (visible.length > 1) return true;
1496
+ return !isAutoSubmitBlock(visible[0]);
1284
1497
  }
1285
1498
  function FilloForm(props) {
1286
1499
  const { form, formId } = props;
1287
- const client = useMemo2(
1500
+ const client = useMemo4(
1288
1501
  () => props.client ?? (formId ? createClient() : void 0),
1289
1502
  [props.client, formId]
1290
1503
  );
@@ -1320,7 +1533,7 @@ function FilloForm(props) {
1320
1533
  };
1321
1534
  }, [needsFetch, client, formId, onError]);
1322
1535
  const rawSchema = inlineSchema ?? fetched;
1323
- const normalized = useMemo2(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1536
+ const normalized = useMemo4(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1324
1537
  const schema = normalized?.ok ? normalized.schema : null;
1325
1538
  const theme = props.theme ?? codeForm?.theme ?? fetchedTheme ?? void 0;
1326
1539
  if (normalized && !normalized.ok) {
@@ -1396,7 +1609,7 @@ function ResolvedForm(props) {
1396
1609
  skipValidation: props.skipValidation,
1397
1610
  getHoneypot: () => hpRef.current
1398
1611
  });
1399
- const themed = useMemo2(() => themeProps(theme), [theme]);
1612
+ const themed = useMemo4(() => themeProps(theme), [theme]);
1400
1613
  const settings = schema.settings;
1401
1614
  const submitted = api.status === "submitted";
1402
1615
  useEffect7(() => {
@@ -1416,7 +1629,7 @@ function ResolvedForm(props) {
1416
1629
  }
1417
1630
  const multiPage = api.pageCount > 1;
1418
1631
  const autoSubmit = settings.submitMode === "auto";
1419
- const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(api.blocks);
1632
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields2(api.form, api.data));
1420
1633
  const showFooter = showPrimaryButton || multiPage && !api.isFirstPage;
1421
1634
  return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx5(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
1422
1635
  "form",
@@ -1426,8 +1639,12 @@ function ResolvedForm(props) {
1426
1639
  noValidate: true,
1427
1640
  onSubmit: (e) => {
1428
1641
  e.preventDefault();
1429
- if (api.isLastPage) void api.submit();
1430
- else api.next();
1642
+ const formEl = e.currentTarget;
1643
+ if (api.isLastPage) void api.submit().then(() => focusFirstInvalid(formEl));
1644
+ else {
1645
+ api.next();
1646
+ focusFirstInvalid(formEl);
1647
+ }
1431
1648
  },
1432
1649
  children: [
1433
1650
  !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
@@ -1498,15 +1715,15 @@ function ResolvedForm(props) {
1498
1715
  }
1499
1716
 
1500
1717
  // src/provider.tsx
1501
- import { useId as useId3, useMemo as useMemo3 } from "react";
1718
+ import { useId as useId3, useMemo as useMemo5 } from "react";
1502
1719
  import { FilloError as FilloError3, normalizeFormSchema as normalizeFormSchema2 } from "@usefillo/core";
1503
1720
  import { jsx as jsx6 } from "react/jsx-runtime";
1504
1721
  function FilloProvider({ children, form, formId, client, ...options }) {
1505
1722
  const instanceId = useId3().replace(/:/g, "");
1506
1723
  const codeForm = isCodeForm2(form) ? form : null;
1507
1724
  const rawSchema = codeForm ? codeForm.schema : form;
1508
- const schemaKey = JSON.stringify(rawSchema);
1509
- const normalized = useMemo3(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
1725
+ const schemaKey = useMemo5(() => JSON.stringify(rawSchema), [rawSchema]);
1726
+ const normalized = useMemo5(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
1510
1727
  if (!normalized.ok) {
1511
1728
  throw new FilloError3(`This form could not be rendered: ${normalized.error}`, 422);
1512
1729
  }
package/dist/styles.css CHANGED
@@ -328,6 +328,11 @@
328
328
  }
329
329
  .fillo-star:hover { transform: scale(1.15); }
330
330
  .fillo-star--active { color: var(--fillo-primary); }
331
+ .fillo-star:focus-visible {
332
+ outline: none;
333
+ border-radius: calc(var(--fillo-radius) * 0.5);
334
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--fillo-primary) 30%, transparent);
335
+ }
331
336
 
332
337
  /* ---------- Linear scale ---------- */
333
338
  /* Steps share one row and shrink to fit — an 11-step NPS scale must never
@@ -348,6 +353,11 @@
348
353
  transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
349
354
  }
350
355
  .fillo-scale-step:hover { border-color: var(--fillo-primary); }
356
+ .fillo-scale-step:focus-visible {
357
+ outline: none;
358
+ border-color: var(--fillo-primary);
359
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--fillo-primary) 15%, transparent);
360
+ }
351
361
  .fillo-scale-step--active {
352
362
  background: var(--fillo-primary);
353
363
  border-color: var(--fillo-primary);
@@ -450,6 +460,21 @@
450
460
  }
451
461
  .fillo-signature-clear:hover { color: var(--fillo-error); border-color: var(--fillo-error); }
452
462
 
463
+ /* Type-to-sign: the keyboard/screen-reader path under the canvas. */
464
+ .fillo-signature-type {
465
+ display: flex;
466
+ flex-direction: column;
467
+ gap: 0.35rem;
468
+ margin-top: 0.55rem;
469
+ }
470
+ .fillo-signature-type-label {
471
+ color: var(--fillo-muted);
472
+ font-size: 0.8rem;
473
+ }
474
+ .fillo-signature-type-input {
475
+ margin-top: 0;
476
+ }
477
+
453
478
  /* ---------- File upload ---------- */
454
479
  .fillo-dropzone {
455
480
  display: flex;
@@ -469,6 +494,11 @@
469
494
  border-color: var(--fillo-primary);
470
495
  background: color-mix(in srgb, var(--fillo-primary) 4%, var(--fillo-control-bg));
471
496
  }
497
+ .fillo-dropzone:focus-visible {
498
+ outline: none;
499
+ border-color: var(--fillo-primary);
500
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--fillo-primary) 15%, transparent);
501
+ }
472
502
  .fillo-dropzone--disabled { cursor: not-allowed; opacity: 0.6; }
473
503
  .fillo-dropzone-title { font-weight: 550; font-size: 0.95rem; }
474
504
  .fillo-dropzone-hint { color: var(--fillo-muted); font-size: 0.82rem; }
@@ -526,6 +556,10 @@
526
556
  }
527
557
  .fillo-button:active { transform: translateY(1px); }
528
558
  .fillo-button:disabled { opacity: 0.55; cursor: not-allowed; }
559
+ .fillo-button:focus-visible {
560
+ outline: none;
561
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--fillo-primary) 30%, transparent);
562
+ }
529
563
  .fillo-button--primary { background: var(--fillo-primary); color: var(--fillo-primary-contrast); }
530
564
  .fillo-button--ghost { background: transparent; color: var(--fillo-text); border-color: var(--fillo-border); }
531
565
 
@@ -574,6 +608,20 @@
574
608
  pointer-events: none;
575
609
  }
576
610
 
611
+ /* Visually hidden but exposed to assistive tech — for aria-live narration. */
612
+ .fillo-sr-only {
613
+ position: absolute;
614
+ width: 1px;
615
+ height: 1px;
616
+ margin: -1px;
617
+ padding: 0;
618
+ overflow: hidden;
619
+ clip: rect(0 0 0 0);
620
+ clip-path: inset(50%);
621
+ white-space: nowrap;
622
+ border: 0;
623
+ }
624
+
577
625
  /* ---------- Loading skeleton ---------- */
578
626
  .fillo-form--loading { gap: 0.9rem; }
579
627
  .fillo-skeleton {
@@ -588,6 +636,22 @@
588
636
  to { background-position: -200% 0; }
589
637
  }
590
638
 
639
+ /* Respect a reduced-motion preference: drop transitions, the press nudge,
640
+ the rating hover scale, and the skeleton shimmer. */
641
+ @media (prefers-reduced-motion: reduce) {
642
+ .fillo-form *,
643
+ .fillo-form *::before,
644
+ .fillo-form *::after {
645
+ animation-duration: 0.001ms !important;
646
+ animation-iteration-count: 1 !important;
647
+ transition-duration: 0.001ms !important;
648
+ scroll-behavior: auto !important;
649
+ }
650
+ .fillo-button:active { transform: none; }
651
+ .fillo-star:hover { transform: none; }
652
+ .fillo-skeleton { animation: none; }
653
+ }
654
+
591
655
  /* ---------- "Powered by Fillo" badge ---------- */
592
656
  .fillo-powered {
593
657
  display: block;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/react",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Headless React components for embedding Fillo forms natively in your product.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@usefillo/core": "^0.4.0"
35
+ "@usefillo/core": "^0.5.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": "^18.0.0 || ^19.0.0",