@usefillo/react 0.4.0 → 0.5.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.
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,7 +1,7 @@
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,
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  // src/context.ts
14
14
  import { allFields } from "@usefillo/core";
15
- import { createContext, useContext, useId } from "react";
15
+ import { createContext, useContext, useId, useMemo } from "react";
16
16
  var FilloContext = createContext(null);
17
17
  var FilloInstanceIdContext = createContext(null);
18
18
  function useFilloInstanceId() {
@@ -41,7 +41,12 @@ function useFilloFieldIds(fieldId, ids) {
41
41
  }
42
42
  function useField(fieldId) {
43
43
  const api = useFillo();
44
- const field = allFields(api.form).find((f) => f.id === fieldId);
44
+ const fieldsById = useMemo(() => {
45
+ const map = /* @__PURE__ */ new Map();
46
+ for (const f of allFields(api.form)) map.set(f.id, f);
47
+ return map;
48
+ }, [api.form]);
49
+ const field = fieldsById.get(fieldId);
45
50
  return {
46
51
  field,
47
52
  value: api.data[fieldId],
@@ -51,7 +56,7 @@ function useField(fieldId) {
51
56
  }
52
57
 
53
58
  // src/controller.ts
54
- import { useEffect, useRef, useSyncExternalStore } from "react";
59
+ import { useEffect, useMemo as useMemo2, useRef, useSyncExternalStore } from "react";
55
60
  import {
56
61
  createFormController
57
62
  } from "@usefillo/core";
@@ -78,17 +83,20 @@ function useFilloController(options) {
78
83
  }, [store, options.form, options.formId, options.client]);
79
84
  useEffect(() => () => store.destroy(), [store]);
80
85
  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
- };
86
+ return useMemo2(
87
+ () => ({
88
+ form: options.form,
89
+ formId: options.formId,
90
+ client: options.client,
91
+ ...state,
92
+ setValue: store.setValue,
93
+ setUploading: store.setUploading,
94
+ next: store.next,
95
+ back: store.back,
96
+ submit: store.submit
97
+ }),
98
+ [store, state, options.form, options.formId, options.client]
99
+ );
92
100
  }
93
101
 
94
102
  // src/define.ts
@@ -123,7 +131,7 @@ function useCodeFormSync(form, client, onError) {
123
131
 
124
132
  // src/fields.tsx
125
133
  import { isField, pipeBlock, validateResponse } from "@usefillo/core";
126
- import { useEffect as useEffect6, useState as useState5 } from "react";
134
+ import { memo, useEffect as useEffect6, useState as useState5 } from "react";
127
135
 
128
136
  // src/phone.tsx
129
137
  import {
@@ -135,7 +143,7 @@ import {
135
143
  parsePhone,
136
144
  toE164
137
145
  } from "@usefillo/core";
138
- import { useEffect as useEffect3, useLayoutEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
146
+ import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
139
147
  import { jsx, jsxs } from "react/jsx-runtime";
140
148
  function localeCountry() {
141
149
  if (typeof navigator === "undefined") return void 0;
@@ -234,7 +242,7 @@ function CountrySelect({
234
242
  const rootRef = useRef2(null);
235
243
  const searchRef = useRef2(null);
236
244
  const listId = `${labelledBy}-countries`;
237
- const matches = useMemo(() => {
245
+ const matches = useMemo3(() => {
238
246
  const q = query.trim().toLowerCase();
239
247
  if (!q) return PHONE_COUNTRIES;
240
248
  const qDigits = q.replace(/[^\d]/g, "");
@@ -357,9 +365,11 @@ import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } fro
357
365
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
358
366
  function SignatureField({ field, value, error, setValue, ids: providedIds }) {
359
367
  const ids = useFilloFieldIds(field.id, providedIds);
368
+ const typedInputId = `${ids.inputId}-typed`;
360
369
  const canvasRef = useRef3(null);
361
370
  const drawing = useRef3(false);
362
371
  const [hasInk, setHasInk] = useState3(Boolean(value));
372
+ const [typedName, setTypedName] = useState3("");
363
373
  useEffect4(() => {
364
374
  const canvas = canvasRef.current;
365
375
  if (!canvas) return;
@@ -387,12 +397,31 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
387
397
  function start(e) {
388
398
  drawing.current = true;
389
399
  e.currentTarget.setPointerCapture(e.pointerId);
400
+ if (typedName) setTypedName("");
390
401
  const ctx = e.currentTarget.getContext("2d");
391
402
  if (!ctx) return;
392
403
  const { x, y } = point(e);
393
404
  ctx.beginPath();
394
405
  ctx.moveTo(x, y);
395
406
  }
407
+ function drawTypedName(text) {
408
+ const canvas = canvasRef.current;
409
+ const ctx = canvas?.getContext("2d");
410
+ if (!canvas || !ctx) return;
411
+ const rect = canvas.getBoundingClientRect();
412
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
413
+ if (text.trim()) {
414
+ ctx.fillStyle = getComputedStyle(canvas).color;
415
+ ctx.font = `italic ${Math.min(40, rect.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
416
+ ctx.textBaseline = "middle";
417
+ ctx.fillText(text, 12, rect.height / 2);
418
+ setHasInk(true);
419
+ setValue(canvas.toDataURL("image/png"));
420
+ } else {
421
+ setHasInk(false);
422
+ setValue(null);
423
+ }
424
+ }
396
425
  function move(e) {
397
426
  if (!drawing.current) return;
398
427
  const ctx = e.currentTarget.getContext("2d");
@@ -413,33 +442,63 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
413
442
  if (!canvas || !ctx) return;
414
443
  ctx.clearRect(0, 0, canvas.width, canvas.height);
415
444
  setHasInk(false);
445
+ setTypedName("");
416
446
  setValue(null);
417
447
  }
418
448
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
419
449
  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: [
450
+ /* @__PURE__ */ jsxs2("span", { className: "fillo-label", id: ids.labelId, children: [
421
451
  field.label,
422
452
  !field.required && /* @__PURE__ */ jsx2("span", { className: "fillo-optional", children: " (optional)" })
423
453
  ] }),
424
454
  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
- ] }),
455
+ /* @__PURE__ */ jsxs2(
456
+ "div",
457
+ {
458
+ role: "group",
459
+ "aria-labelledby": ids.labelId,
460
+ "aria-describedby": describedBy,
461
+ children: [
462
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature", children: [
463
+ /* @__PURE__ */ jsx2(
464
+ "canvas",
465
+ {
466
+ ref: canvasRef,
467
+ className: "fillo-signature-canvas",
468
+ "aria-hidden": "true",
469
+ onPointerDown: start,
470
+ onPointerMove: move,
471
+ onPointerUp: end,
472
+ onPointerLeave: end
473
+ }
474
+ ),
475
+ hasInk && /* @__PURE__ */ jsx2("button", { type: "button", className: "fillo-signature-clear", onClick: clear, children: "Clear" }),
476
+ !hasInk && /* @__PURE__ */ jsx2("span", { className: "fillo-signature-hint", "aria-hidden": "true", children: "Sign here" })
477
+ ] }),
478
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature-type", children: [
479
+ /* @__PURE__ */ jsx2("label", { className: "fillo-signature-type-label", htmlFor: typedInputId, children: "Or type your full name to sign" }),
480
+ /* @__PURE__ */ jsx2(
481
+ "input",
482
+ {
483
+ id: typedInputId,
484
+ type: "text",
485
+ className: "fillo-input fillo-signature-type-input",
486
+ autoComplete: "name",
487
+ value: typedName,
488
+ required: field.required || void 0,
489
+ "aria-required": field.required || void 0,
490
+ "aria-invalid": error ? true : void 0,
491
+ "aria-describedby": describedBy,
492
+ onChange: (e) => {
493
+ setTypedName(e.target.value);
494
+ drawTypedName(e.target.value);
495
+ }
496
+ }
497
+ )
498
+ ] })
499
+ ]
500
+ }
501
+ ),
443
502
  error && /* @__PURE__ */ jsx2("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
444
503
  ] });
445
504
  }
@@ -447,6 +506,21 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
447
506
  // src/upload.tsx
448
507
  import { useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
449
508
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
509
+ function uploadStatus(inFlight, doneCount) {
510
+ const failed = inFlight.filter((f) => f.error).length;
511
+ const active = inFlight.length - failed;
512
+ const parts = [];
513
+ if (active > 0) {
514
+ parts.push(`Uploading ${active} ${active === 1 ? "file" : "files"}, please wait\u2026`);
515
+ }
516
+ if (failed > 0) {
517
+ parts.push(`${failed} ${failed === 1 ? "upload" : "uploads"} failed.`);
518
+ }
519
+ if (active === 0 && doneCount > 0) {
520
+ parts.push(`${doneCount} ${doneCount === 1 ? "file" : "files"} uploaded.`);
521
+ }
522
+ return parts.join(" ");
523
+ }
450
524
  function formatBytes(bytes) {
451
525
  if (bytes < 1024) return `${bytes} B`;
452
526
  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -584,6 +658,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
584
658
  ]
585
659
  }
586
660
  ),
661
+ /* @__PURE__ */ jsx3("div", { role: "status", "aria-live": "polite", className: "fillo-sr-only", children: uploadStatus(inFlight, files.length) }),
587
662
  (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs3("ul", { className: "fillo-files", children: [
588
663
  files.map((f) => /* @__PURE__ */ jsxs3("li", { className: "fillo-file fillo-file--done", children: [
589
664
  /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
@@ -599,39 +674,53 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
599
674
  }
600
675
  )
601
676
  ] }, 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))
677
+ inFlight.map((f) => /* @__PURE__ */ jsxs3(
678
+ "li",
679
+ {
680
+ className: `fillo-file${f.error ? " fillo-file--failed" : ""}`,
681
+ ...f.error ? {} : {
682
+ role: "progressbar",
683
+ "aria-label": `Uploading ${f.name}`,
684
+ "aria-valuemin": 0,
685
+ "aria-valuemax": 100,
686
+ "aria-valuenow": Math.round(f.fraction * 100)
687
+ },
688
+ children: [
689
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
690
+ f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
691
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
692
+ /* @__PURE__ */ jsx3(
693
+ "button",
694
+ {
695
+ type: "button",
696
+ className: "fillo-file-remove",
697
+ "aria-label": "Dismiss",
698
+ onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
699
+ children: "\xD7"
700
+ }
701
+ )
702
+ ] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
703
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-file-meta", children: [
704
+ Math.round(f.fraction * 100),
705
+ "% of ",
706
+ formatBytes(f.size)
707
+ ] }),
708
+ /* @__PURE__ */ jsx3("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx3("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
709
+ /* @__PURE__ */ jsx3(
710
+ "button",
711
+ {
712
+ type: "button",
713
+ className: "fillo-file-remove",
714
+ "aria-label": `Cancel ${f.name}`,
715
+ onClick: () => cancelUpload(f.key),
716
+ children: "\xD7"
717
+ }
718
+ )
719
+ ] })
720
+ ]
721
+ },
722
+ f.key
723
+ ))
635
724
  ] }),
636
725
  error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
637
726
  ] });
@@ -782,12 +871,14 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
782
871
  },
783
872
  opt.id
784
873
  )),
785
- choice.allowOther && /* @__PURE__ */ jsxs4(
786
- "label",
874
+ choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
875
+ // toggle the radio. The radio + "Other" text keep their own <label>.
876
+ /* @__PURE__ */ jsxs4(
877
+ "div",
787
878
  {
788
879
  className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
789
880
  children: [
790
- /* @__PURE__ */ jsxs4("span", { className: "fillo-option-main", children: [
881
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option-main", children: [
791
882
  /* @__PURE__ */ jsx4(
792
883
  "input",
793
884
  {
@@ -808,6 +899,7 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
808
899
  {
809
900
  type: "text",
810
901
  className: "fillo-input fillo-other-input",
902
+ "aria-label": "Other \u2014 please specify",
811
903
  placeholder: "Your answer",
812
904
  autoFocus: !isOtherValue,
813
905
  value: isOtherValue ? String(value) : "",
@@ -860,12 +952,14 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
860
952
  },
861
953
  opt.id
862
954
  )),
863
- choice.allowOther && /* @__PURE__ */ jsxs4(
864
- "label",
955
+ choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
956
+ // toggle the checkbox. The checkbox + "Other" text keep their own <label>.
957
+ /* @__PURE__ */ jsxs4(
958
+ "div",
865
959
  {
866
960
  className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
867
961
  children: [
868
- /* @__PURE__ */ jsxs4("span", { className: "fillo-option-main", children: [
962
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option-main", children: [
869
963
  /* @__PURE__ */ jsx4(
870
964
  "input",
871
965
  {
@@ -889,6 +983,7 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
889
983
  {
890
984
  type: "text",
891
985
  className: "fillo-input fillo-other-input",
986
+ "aria-label": "Other \u2014 please specify",
892
987
  placeholder: "Your answer",
893
988
  autoFocus: otherText === void 0,
894
989
  value: otherText ?? "",
@@ -945,6 +1040,7 @@ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
945
1040
  {
946
1041
  type: "text",
947
1042
  className: "fillo-input fillo-other-input fillo-other-input--block",
1043
+ "aria-label": "Other \u2014 please specify",
948
1044
  placeholder: "Your answer",
949
1045
  autoFocus: !isOtherValue,
950
1046
  value: isOtherValue ? String(value) : "",
@@ -1018,22 +1114,62 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1018
1114
  }
1019
1115
  );
1020
1116
  }
1117
+ function radiogroupKeyDown(e, steps, current, setValue) {
1118
+ const forward = e.key === "ArrowRight" || e.key === "ArrowDown";
1119
+ const backward = e.key === "ArrowLeft" || e.key === "ArrowUp";
1120
+ if (!forward && !backward) return;
1121
+ e.preventDefault();
1122
+ const index = current == null ? -1 : steps.indexOf(current);
1123
+ let next;
1124
+ if (index === -1) {
1125
+ next = forward ? 0 : steps.length - 1;
1126
+ } else {
1127
+ next = forward ? index + 1 : index - 1;
1128
+ next = (next + steps.length) % steps.length;
1129
+ }
1130
+ const value = steps[next];
1131
+ if (value === void 0) return;
1132
+ setValue(value);
1133
+ const group = e.currentTarget;
1134
+ const buttons = group.querySelectorAll('[role="radio"]');
1135
+ buttons[next]?.focus();
1136
+ }
1137
+ function rovingIndex(steps, current) {
1138
+ if (current == null) return 0;
1139
+ const i = steps.indexOf(current);
1140
+ return i === -1 ? 0 : i;
1141
+ }
1021
1142
  function Rating({ field, value, error, setValue, ids: providedIds }) {
1022
1143
  const ids = useFilloFieldIds(field.id, providedIds);
1023
1144
  const max = field.max ?? 5;
1024
1145
  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",
1146
+ const steps = Array.from({ length: max }, (_, i) => i + 1);
1147
+ const selected = current > 0 ? current : null;
1148
+ const tabbable = rovingIndex(steps, selected);
1149
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1150
+ "div",
1027
1151
  {
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
- )) }) });
1152
+ className: "fillo-rating",
1153
+ role: "radiogroup",
1154
+ "aria-labelledby": ids.labelId,
1155
+ ...fieldAria(field, error, ids),
1156
+ onKeyDown: (e) => radiogroupKeyDown(e, steps, selected, (n) => setValue(n)),
1157
+ children: steps.map((n, i) => /* @__PURE__ */ jsx4(
1158
+ "button",
1159
+ {
1160
+ type: "button",
1161
+ className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
1162
+ role: "radio",
1163
+ "aria-label": `${n} of ${max}`,
1164
+ "aria-checked": n === current,
1165
+ tabIndex: i === tabbable ? 0 : -1,
1166
+ onClick: () => setValue(n === current ? null : n),
1167
+ children: "\u2605"
1168
+ },
1169
+ n
1170
+ ))
1171
+ }
1172
+ ) });
1037
1173
  }
1038
1174
  function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1039
1175
  const ids = useFilloFieldIds(field.id, providedIds);
@@ -1041,21 +1177,47 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1041
1177
  const min = scale.min ?? 1;
1042
1178
  const max = scale.max ?? 10;
1043
1179
  const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i);
1180
+ const current = typeof value === "number" ? value : null;
1181
+ const tabbable = rovingIndex(steps, current);
1182
+ const hasLabels = Boolean(scale.minLabel || scale.maxLabel);
1183
+ const minLabelId = `${ids.inputId}-min`;
1184
+ const maxLabelId = `${ids.inputId}-max`;
1185
+ const describedBy = [
1186
+ scale.minLabel ? minLabelId : null,
1187
+ scale.maxLabel ? maxLabelId : null,
1188
+ field.description ? ids.descriptionId : null,
1189
+ error ? ids.errorId : null
1190
+ ].filter(Boolean).join(" ") || void 0;
1044
1191
  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",
1192
+ /* @__PURE__ */ jsx4(
1193
+ "div",
1047
1194
  {
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 })
1195
+ className: "fillo-scale",
1196
+ role: "radiogroup",
1197
+ "aria-labelledby": ids.labelId,
1198
+ "aria-invalid": error ? true : void 0,
1199
+ "aria-required": field.required ? true : void 0,
1200
+ "aria-describedby": describedBy,
1201
+ onKeyDown: (e) => radiogroupKeyDown(e, steps, current, (n) => setValue(n)),
1202
+ children: steps.map((n, i) => /* @__PURE__ */ jsx4(
1203
+ "button",
1204
+ {
1205
+ type: "button",
1206
+ className: `fillo-scale-step${value === n ? " fillo-scale-step--active" : ""}`,
1207
+ role: "radio",
1208
+ "aria-label": String(n),
1209
+ "aria-checked": value === n,
1210
+ tabIndex: i === tabbable ? 0 : -1,
1211
+ onClick: () => setValue(value === n ? null : n),
1212
+ children: n
1213
+ },
1214
+ n
1215
+ ))
1216
+ }
1217
+ ),
1218
+ hasLabels && /* @__PURE__ */ jsxs4("div", { className: "fillo-scale-labels", children: [
1219
+ /* @__PURE__ */ jsx4("span", { id: minLabelId, children: scale.minLabel }),
1220
+ /* @__PURE__ */ jsx4("span", { id: maxLabelId, children: scale.maxLabel })
1059
1221
  ] })
1060
1222
  ] });
1061
1223
  }
@@ -1117,20 +1279,30 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1117
1279
  /* @__PURE__ */ jsx4("th", {}),
1118
1280
  matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
1119
1281
  ] }) }),
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)) })
1282
+ /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1283
+ const rowLabelId = `${ids.inputId}-${row.id}`;
1284
+ const answered = answers[row.id] !== void 0;
1285
+ const rowInvalid = Boolean(error) && field.required && !answered;
1286
+ return (
1287
+ // Each row is its own radiogroup, labelled by the row question.
1288
+ /* @__PURE__ */ jsxs4("tr", { role: "radiogroup", "aria-labelledby": rowLabelId, children: [
1289
+ /* @__PURE__ */ jsx4("th", { scope: "row", id: rowLabelId, children: row.label }),
1290
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1291
+ "input",
1292
+ {
1293
+ type: "radio",
1294
+ className: "fillo-option-input",
1295
+ name: `${ids.name}-${row.id}`,
1296
+ "aria-label": `${row.label}: ${col.label}`,
1297
+ "aria-required": field.required ? true : void 0,
1298
+ "aria-invalid": rowInvalid ? true : void 0,
1299
+ checked: answers[row.id] === col.id,
1300
+ onChange: () => setValue({ ...answers, [row.id]: col.id })
1301
+ }
1302
+ ) }, col.id))
1303
+ ] }, row.id)
1304
+ );
1305
+ }) })
1134
1306
  ] }) }) });
1135
1307
  }
1136
1308
  function Hidden() {
@@ -1183,14 +1355,39 @@ function shouldAutoSubmit(field, value, api) {
1183
1355
  return false;
1184
1356
  }
1185
1357
  })();
1186
- return hasDiscreteAnswer && validateResponse(api.form, { ...api.data, [field.id]: value }).ok;
1358
+ if (!hasDiscreteAnswer) return false;
1359
+ if (value === api.data[field.id]) return false;
1360
+ return validateResponse(api.form, { ...api.data, [field.id]: value }).ok;
1187
1361
  }
1188
- function BlockRenderer({
1189
- block,
1190
- api,
1191
- components,
1192
- customComponents
1193
- }) {
1362
+ function blockHasPiping(block) {
1363
+ const has = (s) => typeof s === "string" && s.includes("{{");
1364
+ switch (block.kind) {
1365
+ case "heading":
1366
+ case "paragraph":
1367
+ return has(block.text);
1368
+ case "divider":
1369
+ return false;
1370
+ default:
1371
+ return has(block.label) || has(block.description);
1372
+ }
1373
+ }
1374
+ function blockPropsEqual(prev, next) {
1375
+ if (prev.block !== next.block || prev.components !== next.components || prev.customComponents !== next.customComponents) {
1376
+ return false;
1377
+ }
1378
+ const id = next.block.id;
1379
+ if (prev.api.data[id] !== next.api.data[id]) return false;
1380
+ if (prev.api.errors[id] !== next.api.errors[id]) return false;
1381
+ const needsWiderState = next.block.kind === "custom" || blockHasPiping(prev.block) || blockHasPiping(next.block);
1382
+ if (needsWiderState && prev.api.data !== next.api.data) return false;
1383
+ if (next.api.form.settings.submitMode === "auto") {
1384
+ 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) {
1385
+ return false;
1386
+ }
1387
+ }
1388
+ return true;
1389
+ }
1390
+ function BlockRendererInner({ block, api, components, customComponents }) {
1194
1391
  block = pipeBlock(block, api.data, api.form);
1195
1392
  const ids = useFilloFieldIds(block.id);
1196
1393
  if (!isField(block)) return /* @__PURE__ */ jsx4(ContentRenderer, { block });
@@ -1224,6 +1421,7 @@ function BlockRenderer({
1224
1421
  }
1225
1422
  );
1226
1423
  }
1424
+ var BlockRenderer = memo(BlockRendererInner, blockPropsEqual);
1227
1425
  function FormField({
1228
1426
  id,
1229
1427
  components,
@@ -1276,6 +1474,15 @@ function badgeUrl(href) {
1276
1474
  url.searchParams.set("utm_source", "form_badge");
1277
1475
  return url.toString();
1278
1476
  }
1477
+ function focusFirstInvalid(formEl) {
1478
+ if (!formEl || typeof window === "undefined") return;
1479
+ const run = () => {
1480
+ const target = formEl.querySelector('[aria-invalid="true"]');
1481
+ target?.focus?.();
1482
+ };
1483
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
1484
+ else setTimeout(run, 0);
1485
+ }
1279
1486
  function isAutoSubmitBlock(block) {
1280
1487
  return isField2(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
1281
1488
  }
@@ -1284,7 +1491,7 @@ function needsExplicitSubmit(blocks) {
1284
1491
  }
1285
1492
  function FilloForm(props) {
1286
1493
  const { form, formId } = props;
1287
- const client = useMemo2(
1494
+ const client = useMemo4(
1288
1495
  () => props.client ?? (formId ? createClient() : void 0),
1289
1496
  [props.client, formId]
1290
1497
  );
@@ -1320,7 +1527,7 @@ function FilloForm(props) {
1320
1527
  };
1321
1528
  }, [needsFetch, client, formId, onError]);
1322
1529
  const rawSchema = inlineSchema ?? fetched;
1323
- const normalized = useMemo2(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1530
+ const normalized = useMemo4(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1324
1531
  const schema = normalized?.ok ? normalized.schema : null;
1325
1532
  const theme = props.theme ?? codeForm?.theme ?? fetchedTheme ?? void 0;
1326
1533
  if (normalized && !normalized.ok) {
@@ -1396,7 +1603,7 @@ function ResolvedForm(props) {
1396
1603
  skipValidation: props.skipValidation,
1397
1604
  getHoneypot: () => hpRef.current
1398
1605
  });
1399
- const themed = useMemo2(() => themeProps(theme), [theme]);
1606
+ const themed = useMemo4(() => themeProps(theme), [theme]);
1400
1607
  const settings = schema.settings;
1401
1608
  const submitted = api.status === "submitted";
1402
1609
  useEffect7(() => {
@@ -1426,8 +1633,12 @@ function ResolvedForm(props) {
1426
1633
  noValidate: true,
1427
1634
  onSubmit: (e) => {
1428
1635
  e.preventDefault();
1429
- if (api.isLastPage) void api.submit();
1430
- else api.next();
1636
+ const formEl = e.currentTarget;
1637
+ if (api.isLastPage) void api.submit().then(() => focusFirstInvalid(formEl));
1638
+ else {
1639
+ api.next();
1640
+ focusFirstInvalid(formEl);
1641
+ }
1431
1642
  },
1432
1643
  children: [
1433
1644
  !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
@@ -1498,15 +1709,15 @@ function ResolvedForm(props) {
1498
1709
  }
1499
1710
 
1500
1711
  // src/provider.tsx
1501
- import { useId as useId3, useMemo as useMemo3 } from "react";
1712
+ import { useId as useId3, useMemo as useMemo5 } from "react";
1502
1713
  import { FilloError as FilloError3, normalizeFormSchema as normalizeFormSchema2 } from "@usefillo/core";
1503
1714
  import { jsx as jsx6 } from "react/jsx-runtime";
1504
1715
  function FilloProvider({ children, form, formId, client, ...options }) {
1505
1716
  const instanceId = useId3().replace(/:/g, "");
1506
1717
  const codeForm = isCodeForm2(form) ? form : null;
1507
1718
  const rawSchema = codeForm ? codeForm.schema : form;
1508
- const schemaKey = JSON.stringify(rawSchema);
1509
- const normalized = useMemo3(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
1719
+ const schemaKey = useMemo5(() => JSON.stringify(rawSchema), [rawSchema]);
1720
+ const normalized = useMemo5(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
1510
1721
  if (!normalized.ok) {
1511
1722
  throw new FilloError3(`This form could not be rendered: ${normalized.error}`, 422);
1512
1723
  }
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.0",
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",