From 685fab8c434032524395c9d4401b65c1ec0a4018 Mon Sep 17 00:00:00 2001 From: Ngakan Nyoman Ari Surya Khrisna Date: Sun, 23 Aug 2026 17:03:55 +0700 Subject: [PATCH 1/2] chore: split libphonenumber-js into a separate chunk * add manualChunks to esm-bundled output config in sdk/build.ts --- sdk/build.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdk/build.ts b/sdk/build.ts index aae813a..409df0d 100755 --- a/sdk/build.ts +++ b/sdk/build.ts @@ -108,6 +108,11 @@ function rollupConfig( banner: bannerComment, entryFileNames: "[name].mjs", chunkFileNames: "[name].mjs", + manualChunks(id) { + if (id.includes("libphonenumber-js")) { + return "libphonenumber"; + } + }, }, ] : [ From f47b440ac419ebb85794890169551859175d63a5 Mon Sep 17 00:00:00 2001 From: Ngakan Nyoman Ari Surya Khrisna Date: Tue, 25 Aug 2026 16:09:01 +0700 Subject: [PATCH 2/2] fix: load libphonenumber asynchronously instead of blocking SDK startup * replace the static chunk split with a dynamic import() via a new libphonenumber-loader.ts, preloaded in parallel with the session fetch instead of before it, so the SDK no longer waits on this chunk to become interactive * rename the chunk output to libphonenumber.mjs using a chunkFileNames function * disable the phone/country dropdown and input while the library is loading, and show a shimmer (matching the existing iframe field pattern) instead of a flat disabled state with overlapping placeholder text * re-sync the phone field's displayed value once the library finishes loading, fixing prefilled phone numbers not appearing when the initial value depends on parsing * gate the initial-value useLayoutEffect on the library being loaded * add libphonenumber-loader.test.ts covering preload/wait/synchronous-check behavior --- sdk/build.ts | 10 +- sdk/src/components/field-country.tsx | 95 +++++++++--- sdk/src/components/field-phone-number.tsx | 176 ++++++++++++---------- sdk/src/libphonenumber-loader.test.ts | 54 +++++++ sdk/src/libphonenumber-loader.ts | 30 ++++ sdk/src/public-sdk.ts | 6 + sdk/src/styles.css | 73 ++------- sdk/src/validation.test.ts | 8 +- sdk/src/validation.ts | 8 +- 9 files changed, 287 insertions(+), 173 deletions(-) create mode 100644 sdk/src/libphonenumber-loader.test.ts create mode 100644 sdk/src/libphonenumber-loader.ts diff --git a/sdk/build.ts b/sdk/build.ts index 409df0d..ede358c 100755 --- a/sdk/build.ts +++ b/sdk/build.ts @@ -107,12 +107,10 @@ function rollupConfig( inlineDynamicImports: false, banner: bannerComment, entryFileNames: "[name].mjs", - chunkFileNames: "[name].mjs", - manualChunks(id) { - if (id.includes("libphonenumber-js")) { - return "libphonenumber"; - } - }, + chunkFileNames: (chunk) => + chunk.facadeModuleId?.includes("libphonenumber-js") + ? "libphonenumber.mjs" + : "[name].mjs", }, ] : [ diff --git a/sdk/src/components/field-country.tsx b/sdk/src/components/field-country.tsx index 56324f1..8cdeec0 100644 --- a/sdk/src/components/field-country.tsx +++ b/sdk/src/components/field-country.tsx @@ -1,13 +1,18 @@ import { useCallback, + useEffect, useLayoutEffect, useMemo, useRef, useState, } from "preact/hooks"; import { FieldProps } from "./field"; -import { CountryCode, getCountries } from "libphonenumber-js"; +import type { CountryCode } from "libphonenumber-js"; import { Dropdown, DropdownOption } from "./core/dropdown"; +import { + getLibphonenumber, + getLoadedLibphonenumber, +} from "../libphonenumber-loader"; import { formFieldId, formFieldName, usePrevious } from "../utils"; import { FunctionComponent, TargetedEvent } from "preact"; import { useChannelComponentData } from "./channel-root"; @@ -35,16 +40,58 @@ const FlagIcon: FunctionComponent = ({ ); }; +function buildCountryOptions( + getCountries: () => CountryCode[], +): DropdownOption[] { + return getCountries() + .map((countryCode) => { + const country = new Intl.DisplayNames(["en"], { + type: "region", + }).of(countryCode); + return { + title: country, + value: countryCode, + leadingAsset: , + } as DropdownOption; + }) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +export function useCountriesAsDropdownOptions(): DropdownOption[] { + const [options, setOptions] = useState(() => { + const lib = getLoadedLibphonenumber(); + return lib ? buildCountryOptions(lib.getCountries) : []; + }); + + useEffect(() => { + if (options.length > 0) return; + + let cancelled = false; + getLibphonenumber().then((lib) => { + if (cancelled) return; + setOptions(buildCountryOptions(lib.getCountries)); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return options; +} + export const CountryField: FunctionComponent = (props) => { const { field, onChange } = props; const id = formFieldId(field); const name = formFieldName(field); + const countriesAsDropdownOptions = useCountriesAsDropdownOptions(); + const [selectedCountry, setSelectedCountry] = useState< CountryCode | undefined >(field.initial_value as CountryCode | undefined); - const selectedCountryIndex = COUNTRIES_AS_DROPDOWN_OPTIONS.findIndex( + const selectedCountryIndex = countriesAsDropdownOptions.findIndex( (option) => option.value === selectedCountry, ); @@ -52,7 +99,7 @@ export const CountryField: FunctionComponent = (props) => { useOnCardCountryChange((newCountry: CountryCode) => { if (hiddenFieldRef.current) { - const newOption = COUNTRIES_AS_DROPDOWN_OPTIONS.find((option) => { + const newOption = countriesAsDropdownOptions.find((option) => { return option.value === newCountry; }); if (newOption) onChangeWrapper(newOption); @@ -70,7 +117,6 @@ export const CountryField: FunctionComponent = (props) => { [onChange], ); - // on first render populate hidden field with initial value and notify parent of change useLayoutEffect(() => { if (field.initial_value) { if (hiddenFieldRef.current) { @@ -81,6 +127,18 @@ export const CountryField: FunctionComponent = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if ( + countriesAsDropdownOptions.length > 0 && + selectedCountry && + hiddenFieldRef.current && + hiddenFieldRef.current.value !== selectedCountry + ) { + hiddenFieldRef.current.value = selectedCountry; + onChange(true); + } + }, [countriesAsDropdownOptions]); + const handleNativeSelectChange = useCallback( (event: TargetedEvent) => { const filledValue = event.currentTarget.value; @@ -92,7 +150,7 @@ export const CountryField: FunctionComponent = (props) => { return; } - const option = COUNTRIES_AS_DROPDOWN_OPTIONS.find( + const option = countriesAsDropdownOptions.find( (o) => o.value === filledValue, ); if (option) { @@ -102,22 +160,22 @@ export const CountryField: FunctionComponent = (props) => { hiddenFieldRef.current.value = selectedCountry ?? ""; } }, - [onChange, onChangeWrapper, selectedCountry], + [onChange, onChangeWrapper, selectedCountry, countriesAsDropdownOptions], ); - // the country list never changes + // the country list never changes after it loads const selectOptions = useMemo( () => - COUNTRIES_AS_DROPDOWN_OPTIONS.map((option) => ( + countriesAsDropdownOptions.map((option) => ( )), - [], + [countriesAsDropdownOptions], ); return ( -
+
{/* a ` = (props) => {
); @@ -157,20 +216,6 @@ export const VISUALLY_HIDDEN = { pointerEvents: "none", }; -export const COUNTRIES_AS_DROPDOWN_OPTIONS = getCountries() - .map((countryCode) => { - const country = new Intl.DisplayNames(["en"], { - type: "region", - }).of(countryCode); - - return { - title: country, - value: countryCode, - leadingAsset: , - } as DropdownOption; - }) - .sort((a, b) => a.title.localeCompare(b.title)); - export function useOnCardCountryChange(fn: (newCountry: CountryCode) => void) { const cardDetails = useChannelComponentData()?.cardDetails; const cardDetailsCountry = cardDetails?.details?.country_codes[0]; diff --git a/sdk/src/components/field-phone-number.tsx b/sdk/src/components/field-phone-number.tsx index a62fa36..d2c91e6 100644 --- a/sdk/src/components/field-phone-number.tsx +++ b/sdk/src/components/field-phone-number.tsx @@ -1,19 +1,18 @@ import { FieldProps } from "./field"; import { Dropdown, DropdownOption } from "./core/dropdown"; -import { CountryCode, getCountryCallingCode } from "libphonenumber-js/min"; +import type { CountryCode } from "libphonenumber-js"; +import type { PhoneNumber } from "libphonenumber-js"; import { - COUNTRIES_AS_DROPDOWN_OPTIONS, + useCountriesAsDropdownOptions, useOnCardCountryChange, } from "./field-country"; -import parsePhoneNumberFromString, { - getExampleNumber, - PhoneNumber, -} from "libphonenumber-js"; +import { getLoadedLibphonenumber } from "../libphonenumber-loader"; import examples from "libphonenumber-js/mobile/examples"; import { useSession } from "./session-provider"; import { formFieldId, formFieldName } from "../utils"; import { useCallback, + useEffect, useLayoutEffect, useMemo, useRef, @@ -22,6 +21,22 @@ import { import { FunctionComponent, TargetedEvent, TargetedFocusEvent } from "preact"; import { InternalSetFieldTouchedEvent } from "../private-event-types"; +type DropdownOptionWithDial = DropdownOption & { dial: string }; + +const sanitizePhoneNumber = ( + country: DropdownOptionWithDial, + phoneNumber: string, +): PhoneNumber | null => { + const lib = getLoadedLibphonenumber(); + if (!lib) return null; + const parsed = lib.parsePhoneNumberFromString( + phoneNumber, + country.value as CountryCode, + ); + if (parsed && parsed.isPossible()) return parsed; + return null; +}; + export const PhoneNumberField: FunctionComponent = (props) => { const { field, onChange } = props; const id = formFieldId(field); @@ -31,6 +46,53 @@ export const PhoneNumberField: FunctionComponent = (props) => { const hiddenFieldRef = useRef(null); + const countriesAsDropdownOptions = useCountriesAsDropdownOptions(); + + const isLibraryLoaded = countriesAsDropdownOptions.length > 0; + + const countriesWithDialCodesAsDropdownOptions = useMemo(() => { + const lib = getLoadedLibphonenumber(); + if (!lib) return []; + return countriesAsDropdownOptions + .map((country) => { + const dial = lib.getCountryCallingCode(country.value as CountryCode); + if (!dial) return null; + return { + ...country, + shortTitle: `+${dial}`, + title: `${country.title} (+${dial})`, + dial, + }; + }) + .filter((c): c is DropdownOptionWithDial => Boolean(c)); + }, [countriesAsDropdownOptions]); + + function initialValues(initial: string | undefined, sessionCountry: string) { + const defaultInitial = { + country: sessionCountry, + localNumber: "", + }; + if (!initial) return defaultInitial; + const lib = getLoadedLibphonenumber(); + if (!lib) return defaultInitial; + const parsed = lib.parsePhoneNumberFromString(initial); + if (!parsed) return defaultInitial; + const countryOption = countriesWithDialCodesAsDropdownOptions.find( + (option) => option.value === parsed.country, + ); + if (!countryOption) return defaultInitial; + const sanitized = sanitizePhoneNumber(countryOption, parsed.nationalNumber); + if (!sanitized) return defaultInitial; + const international = parsed.formatInternational(); + const countryCode = lib.getCountryCallingCode( + countryOption.value as CountryCode, + ); + return { + country: countryOption.value as string, + localNumber: international.replace(`+${countryCode} `, ""), + }; + } + const initial = useMemo( () => initialValues(field.initial_value, session.country), [field.initial_value, session.country], @@ -38,26 +100,31 @@ export const PhoneNumberField: FunctionComponent = (props) => { const [countryCode, setCountryCode] = useState(initial.country); const countryCodeIndex = useMemo(() => { - const index = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.findIndex( + const index = countriesWithDialCodesAsDropdownOptions.findIndex( (r) => r.value === countryCode, ); if (index === -1) return 0; return index; - }, [countryCode]); - const country = - COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS[countryCodeIndex]; + }, [countryCode, countriesWithDialCodesAsDropdownOptions]); + const country = countriesWithDialCodesAsDropdownOptions[countryCodeIndex]; const [localNumber, setLocalNumber] = useState(initial.localNumber); const inputRef = useRef(null); + // re-sync display once the library finishes loading + useEffect(() => { + if (!isLibraryLoaded || !field.initial_value) return; + const recomputed = initialValues(field.initial_value, session.country); + setCountryCode(recomputed.country); + setLocalNumber(recomputed.localNumber); + }, [isLibraryLoaded]); + const formatPhoneNumber = useCallback( (country: DropdownOptionWithDial, localNumber: string) => { const phoneNumber = sanitizePhoneNumber(country, localNumber); if (phoneNumber) { - // use parsed format if parsing was successful return phoneNumber.number; } else { - // else just concat the dial code and local number return `+${country.dial}${localNumber}`; } }, @@ -76,7 +143,7 @@ export const PhoneNumberField: FunctionComponent = (props) => { function handleLocalChange(event: TargetedEvent): void { const nextLocal = (event.target as HTMLInputElement).value; setLocalNumber(nextLocal); - updateHiddenField(country, nextLocal); + if (country) updateHiddenField(country, nextLocal); onChange(); } @@ -96,7 +163,7 @@ export const PhoneNumberField: FunctionComponent = (props) => { // when the user inputs a card number, update the phone number field to match useOnCardCountryChange((newCountry: CountryCode) => { - const newOption = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.find( + const newOption = countriesWithDialCodesAsDropdownOptions.find( (option) => option.value === newCountry, ); if (newOption && newOption.value !== countryCode && !localNumber) { @@ -105,25 +172,29 @@ export const PhoneNumberField: FunctionComponent = (props) => { }); function getExampleLocalNumber() { + const lib = getLoadedLibphonenumber(); + if (!lib || !country) return ""; return ( - getExampleNumber(country.value as CountryCode, examples) + lib + .getExampleNumber(country.value as CountryCode, examples) ?.formatInternational() ?.replace( - `+${getCountryCallingCode(country.value as CountryCode)} `, + `+${lib.getCountryCallingCode(country.value as CountryCode)} `, "", ) || "" ); } function formatForUser(_country = country, _localNumber = localNumber) { + const lib = getLoadedLibphonenumber(); + if (!lib || !_country) return; const phoneNumber = sanitizePhoneNumber(_country, _localNumber); if (phoneNumber) { // sync the dropdown if the number is from a different country if (phoneNumber.country && phoneNumber.country !== _country.value) { - const matchedCountry = - COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.find( - (option) => option.value === phoneNumber.country, - ); + const matchedCountry = countriesWithDialCodesAsDropdownOptions.find( + (option) => option.value === phoneNumber.country, + ); if (matchedCountry) { setCountryCode(matchedCountry.value as string); _country = matchedCountry; @@ -133,34 +204,36 @@ export const PhoneNumberField: FunctionComponent = (props) => { // remove country dial code from displayed local number setLocalNumber( international.replace( - `+${getCountryCallingCode(_country.value as CountryCode)} `, + `+${lib.getCountryCallingCode(_country.value as CountryCode)} `, "", ), ); } } - // on first render, populate hidden input and notify parent component of initial value + // wait for the library so validation never runs against an unparsed value; + // useLayoutEffect keeps this synchronous once isLibraryLoaded is already true useLayoutEffect(() => { + if (!isLibraryLoaded) return; if (field.initial_value) { if (hiddenFieldRef.current) { hiddenFieldRef.current.value = field.initial_value; } onChange(true); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isLibraryLoaded]); return (
= (props) => { onChange={handleLocalChange} value={localNumber} autoComplete="tel" + disabled={!isLibraryLoaded} />
); }; - -type DropdownOptionWithDial = DropdownOption & { dial: string }; -const COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS = - COUNTRIES_AS_DROPDOWN_OPTIONS.map( - (country) => { - const dial = getCountryCallingCode(country.value as CountryCode); - if (!dial) return null; - return { - ...country, - shortTitle: `+${dial}`, - title: `${country.title} (+${dial})`, - dial, - }; - }, - ).filter((country): country is DropdownOptionWithDial => { - return Boolean(country); - }); - -const sanitizePhoneNumber = ( - country: DropdownOptionWithDial, - phoneNumber: string, -): PhoneNumber | null => { - const parsed = parsePhoneNumberFromString( - phoneNumber, - country.value as CountryCode, - ); - if (parsed && parsed.isPossible()) return parsed; - - return null; -}; - -function initialValues(initial: string | undefined, sessionCountry: string) { - const defaultInitial = { - country: sessionCountry, - localNumber: "", - }; - if (!initial) return defaultInitial; - const parsed = parsePhoneNumberFromString(initial); - if (!parsed) return defaultInitial; - const countryOption = COUNTRIES_WITH_DIAL_CODES_AS_DROPDOWN_OPTIONS.find( - (option) => option.value === parsed.country, - ); - if (!countryOption) return defaultInitial; - const sanitized = sanitizePhoneNumber(countryOption, parsed.nationalNumber); - if (!sanitized) return defaultInitial; - const international = parsed.formatInternational(); - const countryCode = getCountryCallingCode(countryOption.value as CountryCode); - return { - country: countryOption.value as string, - localNumber: international.replace(`+${countryCode} `, ""), - }; -} diff --git a/sdk/src/libphonenumber-loader.test.ts b/sdk/src/libphonenumber-loader.test.ts new file mode 100644 index 0000000..6cfbd91 --- /dev/null +++ b/sdk/src/libphonenumber-loader.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("libphonenumber-loader", () => { + // module-level state must be reset between tests to avoid leaking state. + beforeEach(() => { + vi.resetModules(); + }); + + it("should return null before anything has loaded", async () => { + const { getLoadedLibphonenumber } = await import("./libphonenumber-loader"); + expect(getLoadedLibphonenumber()).toBeNull(); + }); + + it("should not block when preloading", async () => { + const { preloadLibphonenumber, getLoadedLibphonenumber } = + await import("./libphonenumber-loader"); + preloadLibphonenumber(); + expect(getLoadedLibphonenumber()).toBeNull(); + }); + + it("should resolve to the real module and expose it synchronously afterwards", async () => { + const { getLibphonenumber, getLoadedLibphonenumber } = + await import("./libphonenumber-loader"); + const mod = await getLibphonenumber(); + expect(typeof mod.parsePhoneNumberFromString).toBe("function"); + expect(typeof mod.getCountries).toBe("function"); + expect(getLoadedLibphonenumber()).toBe(mod); + }); + + it("should auto-trigger loading even if preload was never called", async () => { + const { getLibphonenumber } = await import("./libphonenumber-loader"); + const mod = await getLibphonenumber(); + expect(typeof mod.parsePhoneNumberFromString).toBe("function"); + }); + + it("should be safe to call preload multiple times", async () => { + const { preloadLibphonenumber, getLibphonenumber } = + await import("./libphonenumber-loader"); + preloadLibphonenumber(); + preloadLibphonenumber(); + preloadLibphonenumber(); + const mod = await getLibphonenumber(); + expect(typeof mod.parsePhoneNumberFromString).toBe("function"); + }); + + it("should resolve concurrent calls to the same module instance", async () => { + const { getLibphonenumber } = await import("./libphonenumber-loader"); + const [mod1, mod2] = await Promise.all([ + getLibphonenumber(), + getLibphonenumber(), + ]); + expect(mod1).toBe(mod2); + }); +}); diff --git a/sdk/src/libphonenumber-loader.ts b/sdk/src/libphonenumber-loader.ts new file mode 100644 index 0000000..f1b5442 --- /dev/null +++ b/sdk/src/libphonenumber-loader.ts @@ -0,0 +1,30 @@ +// The in-flight/resolved Promise, null means loading hasn't started yet. +let libphonenumberPromise: Promise | null = + null; + +// The resolved module, null until loading finishes. +let libphonenumberModule: typeof import("libphonenumber-js") | null = null; + +// Starts loading without waiting. +export function preloadLibphonenumber(): void { + if (libphonenumberPromise !== null) return; + libphonenumberPromise = import("libphonenumber-js").then((mod) => { + libphonenumberModule = mod; + return mod; + }); +} + +// Waits for the module, triggering the load first if it hasn't started. +export async function getLibphonenumber(): Promise< + typeof import("libphonenumber-js") +> { + preloadLibphonenumber(); + return libphonenumberPromise!; +} + +// Synchronous check: returns the module once loaded, otherwise null. +export function getLoadedLibphonenumber(): + | typeof import("libphonenumber-js") + | null { + return libphonenumberModule; +} diff --git a/sdk/src/public-sdk.ts b/sdk/src/public-sdk.ts index 25d3f9c..7dfe05f 100644 --- a/sdk/src/public-sdk.ts +++ b/sdk/src/public-sdk.ts @@ -115,6 +115,7 @@ import { ChannelValidBehavior } from "./lifecycle/behaviors/channel-valid"; import { CustomerDetailsFormHandle } from "./components/customer-form"; import { getTelemetry, SessionTelemetry } from "./telemetry"; import { TelemetryEvents } from "./telemetry-events"; +import { preloadLibphonenumber } from "./libphonenumber-loader"; /** * @internal @@ -388,6 +389,8 @@ export class XenditComponents extends EventTarget { * Initialize session data asynchronously */ protected async initializeAsync(): Promise { + // Start loading libphonenumber-js in the background + preloadLibphonenumber(); let bff: BffResponse; try { // Fetch session data from the server @@ -1942,6 +1945,9 @@ export class XenditComponentsTest extends XenditComponents { * Override to use test data instead of making API calls */ protected async initializeAsync(): Promise { + // Start loading libphonenumber-js in the background (same as the real initializeAsync) + preloadLibphonenumber(); + // Simulate network delay and prevent firing the init event before the constructor returns await sleep(MOCK_NETWORK_DELAY_MS); diff --git a/sdk/src/styles.css b/sdk/src/styles.css index 1bc5458..9ababf3 100644 --- a/sdk/src/styles.css +++ b/sdk/src/styles.css @@ -883,7 +883,10 @@ xendit-channel-picker[inert] { } } -.xendit-shimmer { +.xendit-shimmer, +.xendit-input-phone .xendit-phone-number-input:disabled, +.xendit-input-phone .xendit-dropdown:disabled, +.xendit-input-country .xendit-dropdown:disabled { background: linear-gradient( 90deg, var(--xendit-color-disabled) 25%, @@ -894,70 +897,16 @@ xendit-channel-picker[inert] { animation: xendit-shimmer-sweep 1.5s ease-in-out infinite; } -@media (prefers-reduced-motion: reduce) { - .xendit-shimmer { - animation: none; - } -} - -.xendit-channel-form-field .xendit-iframe-shimmer { - position: absolute; - margin: 6px 12px; - inset: 0; - z-index: 1; - border-radius: 4px; -} - -.xendit-channel-form-field .xendit-iframe-hidden { - opacity: 0; -} - -/* Loading shimmer ends */ - -/* Iframe field load failure */ -.xendit-channel-form-field .xendit-iframe-field-error { - position: absolute; - margin: 6px 12px; - inset: 0; - z-index: 1; - display: flex; - align-items: center; - font-family: monospace; - font-size: 10px; - box-sizing: border-box; -} - -.xendit-channel-form-field .xendit-iframe-field-error > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Iframe field load failure ends */ - -/* Loading shimmer */ -@keyframes xendit-shimmer-sweep { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} - -.xendit-shimmer { - background: linear-gradient( - 90deg, - var(--xendit-color-disabled) 25%, - color-mix(in srgb, var(--xendit-color-disabled), white 40%) 50%, - var(--xendit-color-disabled) 75% - ); - background-size: 200% 100%; - animation: xendit-shimmer-sweep 1.5s ease-in-out infinite; +.xendit-input-phone .xendit-dropdown:disabled > *, +.xendit-input-country .xendit-dropdown:disabled > * { + visibility: hidden; } @media (prefers-reduced-motion: reduce) { - .xendit-shimmer { + .xendit-shimmer, + .xendit-input-phone .xendit-phone-number-input:disabled, + .xendit-input-phone .xendit-dropdown:disabled, + .xendit-input-country .xendit-dropdown:disabled { animation: none; } } diff --git a/sdk/src/validation.test.ts b/sdk/src/validation.test.ts index 3741a7a..71f13d1 100644 --- a/sdk/src/validation.test.ts +++ b/sdk/src/validation.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as libphonenumber from "libphonenumber-js"; import { BffChannel, ChannelFormField } from "./backend-types/channel"; import { channelPropertiesAreValid, @@ -6,6 +7,11 @@ import { } from "./validation"; import { ChannelComponentData } from "./public-sdk"; +// Make the loader return the real libphonenumber-js module synchronously. +vi.mock("./libphonenumber-loader", () => ({ + getLoadedLibphonenumber: () => libphonenumber, +})); + function makeField( channelProperty: string, fieldType: ChannelFormField["type"], diff --git a/sdk/src/validation.ts b/sdk/src/validation.ts index 7c28362..07a7c92 100644 --- a/sdk/src/validation.ts +++ b/sdk/src/validation.ts @@ -4,7 +4,7 @@ import { ChannelProperties, ChannelPropertyPrimative, } from "./backend-types/channel"; -import parsePhoneNumberFromString from "libphonenumber-js/min"; +import { getLoadedLibphonenumber } from "./libphonenumber-loader"; import { filterFormFields } from "./components/channel-form"; import { BffSessionType } from "./backend-types/session"; import { LocaleKey, LocalizedString } from "./localization"; @@ -47,7 +47,11 @@ export const validateEmail = (value: string): LocaleKey | undefined => { }; export const validatePhoneNumber = (value: string): LocaleKey | undefined => { - const phone = parsePhoneNumberFromString(value); + const lib = getLoadedLibphonenumber(); + if (!lib) { + return undefined; + } + const phone = lib.parsePhoneNumberFromString(value); if (!phone || !phone.isValid()) { return { localeKey: "validation.generic_invalid",