@usefillo/react 0.5.3 → 0.5.4

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
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentType, ReactNode } from 'react';
3
- import { FormSchema, FilloClient, ResponseData, FieldValue, FormPage, Block, FormStatus, FieldKind, Field, FormTheme, FilloError, CodeForm } from '@usefillo/core';
3
+ import { FormSchema, FilloClient, ResponseData, FieldValue, FormPage, Block, FormStatus, FieldKind, Field, CustomField, FormTheme, FilloError, CodeForm } from '@usefillo/core';
4
4
  export { CodeForm, Field, FieldValue, FileValue, FilloClient, FilloError, FormSchema, FormStatus, FormTheme, ProvisionWorkspaceResult, PublishedForm, ResponseData, createClient, defineForm, provisionWorkspace } from '@usefillo/core';
5
5
 
6
6
  /**
@@ -47,14 +47,25 @@ interface FieldComponentProps<F extends Field = Field> {
47
47
  api: FilloApi;
48
48
  ids?: FilloFieldIds;
49
49
  }
50
- /** Per-kind component overrides — swap any built-in field for your own. */
51
- type FieldComponents = Partial<Record<FieldKind, ComponentType<FieldComponentProps>>>;
50
+ /**
51
+ * The Field variant carrying a given kind. Some variants share a union `kind`
52
+ * (ChoiceField is "select" | "multi_select" | "dropdown"), so `Extract` would
53
+ * collapse to `never` — distribute over the union and keep the matching member.
54
+ */
55
+ type FieldOfKind<K extends FieldKind> = Field extends infer V ? V extends Field ? K extends V["kind"] ? V : never : never : never;
56
+ /**
57
+ * Per-kind component overrides — swap any built-in field for your own. Each
58
+ * kind's `field` is narrowed to its variant, so an override reads it cast-free.
59
+ */
60
+ type FieldComponents = {
61
+ [K in FieldKind]?: ComponentType<FieldComponentProps<FieldOfKind<K>>>;
62
+ };
52
63
  /**
53
64
  * Renderers for your own field kinds, keyed by a custom field's `component`.
54
65
  * A block `{ kind: "custom", component: "color", config: {...} }` renders
55
66
  * `customComponents.color`. The component reads `field.config` for options.
56
67
  */
57
- type CustomComponents = Record<string, ComponentType<FieldComponentProps>>;
68
+ type CustomComponents = Record<string, ComponentType<FieldComponentProps<CustomField>>>;
58
69
 
59
70
  interface FilloFormBaseProps {
60
71
  theme?: FormTheme;
package/dist/index.js CHANGED
@@ -138,6 +138,7 @@ import { memo, useEffect as useEffect6, useState as useState5 } from "react";
138
138
  import {
139
139
  PHONE_COUNTRIES,
140
140
  countryByIso,
141
+ countryByTimeZone,
141
142
  digitsOnly,
142
143
  flagEmoji,
143
144
  formatNational,
@@ -152,17 +153,58 @@ function localeCountry() {
152
153
  const region = tag.split("-")[1];
153
154
  return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
154
155
  }
156
+ function browserTimeZone() {
157
+ if (typeof Intl === "undefined") return void 0;
158
+ try {
159
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
160
+ return typeof timeZone === "string" && timeZone ? timeZone : void 0;
161
+ } catch {
162
+ return void 0;
163
+ }
164
+ }
155
165
  function nationalFor(stored, country) {
156
166
  const digits = digitsOnly(stored);
157
167
  return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
158
168
  }
169
+ var PHONE_POPOVER_VIEWPORT_GAP = 8;
170
+ function positionPhonePopover(anchor, popover) {
171
+ if (!anchor || !popover || typeof window === "undefined") return "below";
172
+ const gap = PHONE_POPOVER_VIEWPORT_GAP;
173
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
174
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
175
+ const anchorRect = anchor.getBoundingClientRect();
176
+ const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
177
+ const spaceAbove = Math.max(0, anchorRect.top - gap);
178
+ const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
179
+ const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
180
+ const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
181
+ const maxWidth = Math.max(0, viewportWidth - gap * 2);
182
+ popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
183
+ popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
184
+ popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
185
+ const width = Math.min(popover.offsetWidth, maxWidth);
186
+ let offsetX = 0;
187
+ const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
188
+ if (rightOverflow > 0) offsetX -= rightOverflow;
189
+ const leftOverflow = gap - (anchorRect.left + offsetX);
190
+ if (leftOverflow > 0) offsetX += leftOverflow;
191
+ popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
192
+ return placement;
193
+ }
159
194
  function PhoneField({ field, value, error, setValue, ids: providedIds }) {
160
195
  const ids = useFilloFieldIds(field.id, providedIds);
161
196
  const schema = field;
162
197
  const stored = typeof value === "string" ? value : "";
163
198
  const [country, setCountry] = useState2(
164
- () => parsePhone(stored).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(localeCountry()) ?? PHONE_COUNTRIES[0]
199
+ () => parsePhone(stored).country ?? countryByIso(schema.defaultCountry) ?? PHONE_COUNTRIES[0]
165
200
  );
201
+ const touched = useRef2(false);
202
+ useEffect3(() => {
203
+ if (touched.current) return;
204
+ if (parsePhone(stored).country || countryByIso(schema.defaultCountry)) return;
205
+ const guess = countryByTimeZone(browserTimeZone()) ?? countryByIso(localeCountry());
206
+ if (guess) setCountry(guess);
207
+ }, []);
166
208
  const national = nationalFor(stored, country);
167
209
  const display = formatNational(country, national);
168
210
  const inputRef = useRef2(null);
@@ -185,6 +227,7 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
185
227
  });
186
228
  const commit = (c, nat) => setValue(nat ? toE164(c, nat) : "");
187
229
  function onInput(e) {
230
+ touched.current = true;
188
231
  const el = e.target;
189
232
  const raw = el.value;
190
233
  const caret = el.selectionStart ?? raw.length;
@@ -198,6 +241,7 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
198
241
  commit(country, digitsOnly(raw));
199
242
  }
200
243
  function pickCountry(c) {
244
+ touched.current = true;
201
245
  setCountry(c);
202
246
  commit(c, national);
203
247
  inputRef.current?.focus();
@@ -240,7 +284,10 @@ function CountrySelect({
240
284
  const [open, setOpen] = useState2(false);
241
285
  const [query, setQuery] = useState2("");
242
286
  const [active, setActive] = useState2(0);
287
+ const [placement, setPlacement] = useState2("below");
243
288
  const rootRef = useRef2(null);
289
+ const triggerRef = useRef2(null);
290
+ const popoverRef = useRef2(null);
244
291
  const searchRef = useRef2(null);
245
292
  const listId = `${labelledBy}-countries`;
246
293
  const matches = useMemo3(() => {
@@ -266,6 +313,17 @@ function CountrySelect({
266
313
  document.addEventListener("mousedown", onDown);
267
314
  return () => document.removeEventListener("mousedown", onDown);
268
315
  }, [open]);
316
+ useLayoutEffect(() => {
317
+ if (!open) return;
318
+ const update = () => setPlacement(positionPhonePopover(rootRef.current, popoverRef.current));
319
+ update();
320
+ window.addEventListener("resize", update);
321
+ window.addEventListener("scroll", update, true);
322
+ return () => {
323
+ window.removeEventListener("resize", update);
324
+ window.removeEventListener("scroll", update, true);
325
+ };
326
+ }, [open, matches.length]);
269
327
  useEffect3(() => {
270
328
  if (!open) return;
271
329
  document.getElementById(`${listId}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
@@ -288,12 +346,14 @@ function CountrySelect({
288
346
  } else if (e.key === "Escape") {
289
347
  e.preventDefault();
290
348
  setOpen(false);
349
+ triggerRef.current?.focus();
291
350
  }
292
351
  }
293
352
  return /* @__PURE__ */ jsxs("div", { className: "fillo-phone-country", ref: rootRef, children: [
294
353
  /* @__PURE__ */ jsxs(
295
354
  "button",
296
355
  {
356
+ ref: triggerRef,
297
357
  type: "button",
298
358
  className: "fillo-phone-flag",
299
359
  "aria-haspopup": "listbox",
@@ -310,7 +370,7 @@ function CountrySelect({
310
370
  ]
311
371
  }
312
372
  ),
313
- open && /* @__PURE__ */ jsxs("div", { className: "fillo-phone-popover", children: [
373
+ open && /* @__PURE__ */ jsxs("div", { ref: popoverRef, className: `fillo-phone-popover fillo-phone-popover--${placement}`, children: [
314
374
  /* @__PURE__ */ jsx(
315
375
  "input",
316
376
  {
@@ -603,7 +663,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
603
663
  }
604
664
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
605
665
  return /* @__PURE__ */ jsxs3("div", { className: `fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`, children: [
606
- /* @__PURE__ */ jsxs3("label", { className: "fillo-label", htmlFor: ids.inputId, children: [
666
+ /* @__PURE__ */ jsxs3("label", { className: "fillo-label", id: ids.labelId, htmlFor: ids.inputId, children: [
607
667
  field.label,
608
668
  !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
609
669
  ] }),
@@ -625,6 +685,10 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
625
685
  onClick: () => canUpload && inputRef.current?.click(),
626
686
  role: "button",
627
687
  tabIndex: 0,
688
+ "aria-labelledby": ids.labelId,
689
+ "aria-required": field.required ? true : void 0,
690
+ "aria-invalid": error ? true : void 0,
691
+ "aria-describedby": describedBy,
628
692
  onKeyDown: (e) => {
629
693
  if ((e.key === "Enter" || e.key === " ") && canUpload) inputRef.current?.click();
630
694
  },
@@ -636,9 +700,6 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
636
700
  id: ids.inputId,
637
701
  type: "file",
638
702
  hidden: true,
639
- "aria-invalid": error ? true : void 0,
640
- "aria-required": field.required ? true : void 0,
641
- "aria-describedby": describedBy,
642
703
  multiple: maxFiles > 1,
643
704
  accept: schema.accept?.join(","),
644
705
  onChange: (e) => handleFiles(e.target.files)
@@ -740,6 +801,9 @@ function fieldAria(field, error, ids) {
740
801
  "aria-describedby": describedBy.length ? describedBy.join(" ") : void 0
741
802
  };
742
803
  }
804
+ function isNativeControlTarget(target) {
805
+ return target instanceof HTMLElement && Boolean(target.closest("input, textarea, select, button"));
806
+ }
743
807
  function FieldShell({
744
808
  field,
745
809
  error,
@@ -852,6 +916,12 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
852
916
  {
853
917
  "data-option": opt.id,
854
918
  className: `fillo-option${opt.icon ? " fillo-option--has-icon" : ""}${value === opt.id ? " fillo-option--selected" : ""}`,
919
+ onClick: (event) => {
920
+ if (isNativeControlTarget(event.target)) return;
921
+ event.preventDefault();
922
+ setOtherOn(false);
923
+ setValue(opt.id);
924
+ },
855
925
  children: [
856
926
  /* @__PURE__ */ jsx4(
857
927
  "input",
@@ -937,6 +1007,11 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
937
1007
  {
938
1008
  "data-option": opt.id,
939
1009
  className: `fillo-option${opt.icon ? " fillo-option--has-icon" : ""}${selected.includes(opt.id) ? " fillo-option--selected" : ""}`,
1010
+ onClick: (event) => {
1011
+ if (isNativeControlTarget(event.target)) return;
1012
+ event.preventDefault();
1013
+ toggle(opt.id);
1014
+ },
940
1015
  children: [
941
1016
  /* @__PURE__ */ jsx4(
942
1017
  "input",
@@ -1382,8 +1457,12 @@ function blockPropsEqual(prev, next) {
1382
1457
  const id = next.block.id;
1383
1458
  if (prev.api.data[id] !== next.api.data[id]) return false;
1384
1459
  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;
1460
+ if (next.block.kind === "custom") {
1461
+ return prev.api.data === next.api.data && prev.api.status === next.api.status && prev.api.uploading === next.api.uploading && prev.api.isFirstPage === next.api.isFirstPage && prev.api.isLastPage === next.api.isLastPage && prev.api.pageIndex === next.api.pageIndex;
1462
+ }
1463
+ if ((blockHasPiping(prev.block) || blockHasPiping(next.block)) && prev.api.data !== next.api.data) {
1464
+ return false;
1465
+ }
1387
1466
  if (next.api.form.settings.submitMode === "auto") {
1388
1467
  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
1468
  return false;
@@ -1511,6 +1590,10 @@ function FilloForm(props) {
1511
1590
  const inlineSchema = codeForm ? codeForm.schema : form;
1512
1591
  const onError = props.onError;
1513
1592
  const syncedFormId = useCodeFormSync(form, client, onError);
1593
+ const onErrorRef = useRef5(onError);
1594
+ useEffect7(() => {
1595
+ onErrorRef.current = onError;
1596
+ });
1514
1597
  const needsFetch = !inlineSchema && Boolean(client && formId);
1515
1598
  useEffect7(() => {
1516
1599
  if (!needsFetch || !client || !formId) return;
@@ -1527,12 +1610,12 @@ function FilloForm(props) {
1527
1610
  if (cancelled) return;
1528
1611
  const error = err instanceof FilloError2 ? err : new FilloError2("This form could not be loaded.", 0);
1529
1612
  setLoadError(error);
1530
- onError?.(error);
1613
+ onErrorRef.current?.(error);
1531
1614
  });
1532
1615
  return () => {
1533
1616
  cancelled = true;
1534
1617
  };
1535
- }, [needsFetch, client, formId, onError]);
1618
+ }, [needsFetch, client, formId]);
1536
1619
  const rawSchema = inlineSchema ?? fetched;
1537
1620
  const normalized = useMemo4(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1538
1621
  const schema = normalized?.ok ? normalized.schema : null;
@@ -1618,15 +1701,37 @@ function ResolvedForm(props) {
1618
1701
  const url = safeHttpUrl(settings.redirectUrl);
1619
1702
  if (url) window.location.assign(url);
1620
1703
  }, [submitted, settings.redirectUrl]);
1704
+ const successRef = useRef5(null);
1705
+ useEffect7(() => {
1706
+ if (submitted) successRef.current?.focus();
1707
+ }, [submitted]);
1708
+ const blocksRef = useRef5(null);
1709
+ const prevPageIndex = useRef5(api.pageIndex);
1710
+ useEffect7(() => {
1711
+ if (prevPageIndex.current === api.pageIndex) return;
1712
+ prevPageIndex.current = api.pageIndex;
1713
+ blocksRef.current?.focus();
1714
+ }, [api.pageIndex]);
1621
1715
  if (api.status === "submitted") {
1622
- return /* @__PURE__ */ jsxs5("div", { className: `fillo-form fillo-form--success ${props.className ?? ""}`, ...themed, children: [
1623
- props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs5("div", { className: "fillo-success", children: [
1624
- /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1625
- /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? "Thanks!" }),
1626
- /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: settings.successMessage ?? "Your response has been recorded." })
1627
- ] }),
1628
- props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1629
- ] });
1716
+ return /* @__PURE__ */ jsxs5(
1717
+ "div",
1718
+ {
1719
+ ref: successRef,
1720
+ tabIndex: -1,
1721
+ role: "status",
1722
+ "aria-live": "polite",
1723
+ className: `fillo-form fillo-form--success ${props.className ?? ""}`,
1724
+ ...themed,
1725
+ children: [
1726
+ props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs5("div", { className: "fillo-success", children: [
1727
+ /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1728
+ /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? "Thanks!" }),
1729
+ /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: settings.successMessage ?? "Your response has been recorded." })
1730
+ ] }),
1731
+ props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1732
+ ]
1733
+ }
1734
+ );
1630
1735
  }
1631
1736
  const multiPage = api.pageCount > 1;
1632
1737
  const autoSubmit = settings.submitMode === "auto";
@@ -1671,7 +1776,7 @@ function ResolvedForm(props) {
1671
1776
  schema.description && /* @__PURE__ */ jsx5("p", { className: "fillo-form-description", children: schema.description })
1672
1777
  ] }),
1673
1778
  multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx5("h2", { className: "fillo-page-title", children: api.page.title }),
1674
- /* @__PURE__ */ jsx5("div", { className: "fillo-blocks", children: api.blocks.map((block) => /* @__PURE__ */ jsx5(
1779
+ /* @__PURE__ */ jsx5("div", { className: "fillo-blocks", ref: blocksRef, tabIndex: -1, children: api.blocks.map((block) => /* @__PURE__ */ jsx5(
1675
1780
  BlockRenderer,
1676
1781
  {
1677
1782
  block,
package/dist/styles.css CHANGED
@@ -126,12 +126,18 @@
126
126
  .fillo-field--error .fillo-phone-flag { border-color: var(--fillo-error); }
127
127
  .fillo-phone-popover {
128
128
  position: absolute; z-index: 30; top: calc(100% + 0.3rem); left: 0;
129
- width: 18rem; max-width: 84vw; padding: 0.4rem;
129
+ width: min(18rem, calc(100vw - 1rem));
130
+ max-width: var(--fillo-phone-popover-max-width, 84vw);
131
+ max-height: min(var(--fillo-phone-popover-max-height, 20rem), calc(100vh - 1rem));
132
+ padding: 0.4rem;
133
+ box-sizing: border-box; overflow: hidden;
134
+ transform: translateX(var(--fillo-phone-popover-offset-x, 0px));
130
135
  display: flex; flex-direction: column; gap: 0.35rem;
131
136
  background: var(--fillo-control-bg);
132
137
  border: 1px solid var(--fillo-border); border-radius: var(--fillo-radius);
133
138
  box-shadow: 0 12px 32px -14px rgba(0, 0, 0, 0.35);
134
139
  }
140
+ .fillo-phone-popover--above { top: auto; bottom: calc(100% + 0.3rem); }
135
141
  .fillo-phone-search {
136
142
  appearance: none; font: inherit; width: 100%; box-sizing: border-box;
137
143
  color: var(--fillo-text); background: var(--fillo-bg);
@@ -139,7 +145,10 @@
139
145
  border-radius: calc(var(--fillo-radius) - 3px); padding: 0.45rem 0.6rem;
140
146
  }
141
147
  .fillo-phone-search:focus-visible { outline: none; border-color: var(--fillo-primary); }
142
- .fillo-phone-list { list-style: none; margin: 0; padding: 0; max-height: 14rem; overflow-y: auto; }
148
+ .fillo-phone-list {
149
+ flex: 1 1 auto; min-height: 0;
150
+ list-style: none; margin: 0; padding: 0; max-height: 14rem; overflow-y: auto;
151
+ }
143
152
  .fillo-phone-option {
144
153
  display: flex; align-items: center; gap: 0.5rem;
145
154
  padding: 0.45rem 0.5rem; border-radius: calc(var(--fillo-radius) - 3px);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/react",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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.5.0"
35
+ "@usefillo/core": "^0.5.1"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": "^18.0.0 || ^19.0.0",