Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion sdk/src/components/channel-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ const ChannelForm = forwardRef<ChannelFormHandle, Props>(
const form = formRef.current;
if (!form) return;
Array.from(form.elements)
.filter((el) => el instanceof HTMLInputElement)
.filter(
(el) =>
el instanceof HTMLInputElement || el instanceof HTMLSelectElement,
)
.forEach((input) => {
if (!input.name) {
// only mark named fields as touched
Expand Down
74 changes: 70 additions & 4 deletions sdk/src/components/field-country.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { useCallback, useLayoutEffect, useRef, useState } from "preact/hooks";
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "preact/hooks";
import { FieldProps } from "./field";
import { CountryCode, getCountries } from "libphonenumber-js";
import { Dropdown, DropdownOption } from "./core/dropdown";
import { formFieldId, formFieldName, usePrevious } from "../utils";
import { FunctionComponent } from "preact";
import { FunctionComponent, TargetedEvent } from "preact";
import { useChannelComponentData } from "./channel-root";

type FlagIconProps = {
Expand Down Expand Up @@ -42,7 +48,7 @@ export const CountryField: FunctionComponent<FieldProps> = (props) => {
(option) => option.value === selectedCountry,
);

const hiddenFieldRef = useRef<HTMLInputElement>(null);
const hiddenFieldRef = useRef<HTMLSelectElement>(null);

useOnCardCountryChange((newCountry: CountryCode) => {
if (hiddenFieldRef.current) {
Expand Down Expand Up @@ -75,9 +81,55 @@ export const CountryField: FunctionComponent<FieldProps> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleNativeSelectChange = useCallback(
(event: TargetedEvent<HTMLSelectElement>) => {
const filledValue = event.currentTarget.value;

if (!filledValue) {
// browser cleared the field, so clear our copy too
setSelectedCountry(undefined);
onChange();
return;
}

const option = COUNTRIES_AS_DROPDOWN_OPTIONS.find(
(o) => o.value === filledValue,
);
if (option) {
onChangeWrapper(option);
} else if (hiddenFieldRef.current) {
// not a country we offer
hiddenFieldRef.current.value = selectedCountry ?? "";
}
},
[onChange, onChangeWrapper, selectedCountry],
);

// the country list never changes
const selectOptions = useMemo(
() =>
COUNTRIES_AS_DROPDOWN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.title}
</option>
)),
[],
);

return (
<div>
<input type="hidden" name={name} defaultValue="" ref={hiddenFieldRef} />
{/* a `<select>`, not `type="hidden"` browsers only autofill what they render */}
<select

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does <input autoComplete="country"/> work?

Rendering 200+ option elements isn't ideal

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i tried it but doesn't work. i searching why and i think it's because the input already gets a value from the card's country before input showed up, and browser won't override an input that already has a value. that's why i use select + option, and select + option doesn't have that problem though. Also yeah, rendering 200+ options felt wrong to me too at first, so I wrapped it in a memo. Should be fine to leave as is?

name={name}
ref={hiddenFieldRef}
autoComplete="country"
onChange={handleNativeSelectChange}
style={VISUALLY_HIDDEN}
tabIndex={-1}
>
<option value="" />
{selectOptions}
</select>
<Dropdown
id={id}
options={COUNTRIES_AS_DROPDOWN_OPTIONS}
Expand All @@ -91,6 +143,20 @@ export const CountryField: FunctionComponent<FieldProps> = (props) => {
);
};

/** Hidden from sight but still rendered, so browser autofill can reach it. */
export const VISUALLY_HIDDEN = {
position: "absolute",
width: "1px",
height: "1px",
margin: "0",
padding: "0",
border: "0",
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
pointerEvents: "none",
Comment on lines +148 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clipPath is hiding everything here, but should probably also remove the margin, padding, and border

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noted thankyou

};

export const COUNTRIES_AS_DROPDOWN_OPTIONS = getCountries()
.map((countryCode) => {
const country = new Intl.DisplayNames(["en"], {
Expand Down
11 changes: 11 additions & 0 deletions sdk/src/components/field-phone-number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ export const PhoneNumberField: FunctionComponent<FieldProps> = (props) => {
function formatForUser(_country = country, _localNumber = localNumber) {
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,
);
if (matchedCountry) {
setCountryCode(matchedCountry.value as string);
_country = matchedCountry;
}
}
const international = phoneNumber.formatInternational();
// remove country dial code from displayed local number
setLocalNumber(
Expand Down
118 changes: 93 additions & 25 deletions sdk/src/components/field-province.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { useRef, useCallback, useLayoutEffect, useState } from "preact/hooks";
import {
useRef,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from "preact/hooks";
import { FieldProps } from "./field";
import { CountryCode } from "libphonenumber-js";
import { Dropdown, DropdownOption } from "./core/dropdown";
import { VISUALLY_HIDDEN } from "./field-country";
import { useSession } from "./session-provider";
import { PROVINCES_CA, PROVINCES_GB, PROVINCES_US } from "../data/provinces";
import {
Expand Down Expand Up @@ -29,36 +36,45 @@ export const ProvinceField: FunctionComponent<FieldProps> = (props) => {

const [value, setValue] = useState(field.initial_value as string);

const hiddenFieldRef = useRef<HTMLInputElement>(null);
// carries a `<select>` or `<input>` depending on mode, so a callback ref is used since one ref object can't be typed to both.
const valueFieldRef = useRef<HTMLInputElement | HTMLSelectElement | null>(
null,
);
const setFieldRef = useCallback(
(element: HTMLInputElement | HTMLSelectElement | null) => {
valueFieldRef.current = element;
},
[],
);

const clearValue = useCallback(() => {
setValue("");
if (hiddenFieldRef.current) {
hiddenFieldRef.current.value = "";
if (valueFieldRef.current) {
valueFieldRef.current.value = "";
}
onChange();
}, [onChange]);

const onChangeDropdown = useCallback(
(option: DropdownOption) => {
setValue(option.value);
if (hiddenFieldRef.current) {
hiddenFieldRef.current.value = option.value;
if (valueFieldRef.current) {
valueFieldRef.current.value = option.value;
}
onChange();
hiddenFieldRef.current?.dispatchEvent(new InternalSetFieldTouchedEvent());
valueFieldRef.current?.dispatchEvent(new InternalSetFieldTouchedEvent());
},
[onChange],
);

const onChangeInput = useCallback(
(e: TargetedEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
if (hiddenFieldRef.current) {
hiddenFieldRef.current.value = (e.target as HTMLInputElement).value;
if (valueFieldRef.current) {
valueFieldRef.current.value = (e.target as HTMLInputElement).value;
}
onChange();
hiddenFieldRef.current?.dispatchEvent(new InternalSetFieldTouchedEvent());
valueFieldRef.current?.dispatchEvent(new InternalSetFieldTouchedEvent());
},
[onChange],
);
Expand All @@ -76,6 +92,38 @@ export const ProvinceField: FunctionComponent<FieldProps> = (props) => {
? options.findIndex((option) => option.value === value)
: -1;

// rebuild only when the province list changes
const selectOptions = useMemo(
() =>
options?.map((option) => (
<option key={option.value} value={option.value}>
{option.title}
</option>
)),
[options],
);

const handleNativeSelectChange = useCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add tests for all the handleNativeSelectChange functions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay simon 🫡

(event: TargetedEvent<HTMLSelectElement>) => {
const filledValue = event.currentTarget.value;

if (!filledValue) {
// browser cleared the field, so clear our copy too
clearValue();
return;
}

const option = options?.find((o) => o.value === filledValue);
if (option) {
onChangeDropdown(option);
} else if (valueFieldRef.current) {
// not a province we offer, keep our value so the UI and the form agree
valueFieldRef.current.value = value ?? "";
}
},
[options, onChangeDropdown, clearValue, value],
);

// if the options list changes, clear the value,
// but not on first render,
// or if the current value happens to be a valid option in the new list
Expand All @@ -89,16 +137,21 @@ export const ProvinceField: FunctionComponent<FieldProps> = (props) => {

// if options list changes, clear the selected value
if (options !== previousOptions) {
if (selectedOptionIndex !== -1) return; // ok, this is still valid
if (selectedOptionIndex !== -1) {
if (valueFieldRef.current) {
valueFieldRef.current.value = value;
}
return;
}
clearValue();
}
}, [clearValue, options, previousOptions, selectedOptionIndex]);
}, [clearValue, options, previousOptions, selectedOptionIndex, value]);

// on first render, populate hidden field and notify parent of initial value
useLayoutEffect(() => {
if (field.initial_value) {
if (hiddenFieldRef.current) {
hiddenFieldRef.current.value = value;
if (valueFieldRef.current) {
valueFieldRef.current.value = value;
}
onChange();
}
Expand All @@ -107,22 +160,37 @@ export const ProvinceField: FunctionComponent<FieldProps> = (props) => {

return (
<>
<input type="hidden" name={name} defaultValue="" ref={hiddenFieldRef} />
{options ? (
<Dropdown
key={objectId(options)}
id={id}
options={options}
selectedIndex={selectedOptionIndex}
onChange={onChangeDropdown}
placeholder={field.placeholder}
enableSearch
className="xendit-form-field-inner"
/>
<>
{/* a `<select>`, not `type="hidden"` browsers only autofill what they render */}
<select
name={name}
ref={setFieldRef}
autoComplete="address-level1"
onChange={handleNativeSelectChange}
style={VISUALLY_HIDDEN}
tabIndex={-1}
>
<option value="" />
{selectOptions}
</select>
<Dropdown
key={objectId(options)}
id={id}
options={options}
selectedIndex={selectedOptionIndex}
onChange={onChangeDropdown}
placeholder={field.placeholder}
enableSearch
className="xendit-form-field-inner"
/>
</>
) : (
<input
type="text"
id={id}
name={name}
ref={setFieldRef}
value={value}
onChange={onChangeInput}
placeholder={field.placeholder}
Expand Down
34 changes: 26 additions & 8 deletions sdk/src/components/field-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,39 @@ export const TextField: FunctionComponent<FieldProps> = (props) => {
id={id}
name={name}
ref={inputRef}
type="text"
placeholder={field.placeholder}
className={`xendit-form-field-inner xendit-text-14`}
value={value}
onBlur={handleBlur}
onChange={handleChange}
minLength={isTextField(field) ? field.type.min_length : undefined}
maxLength={isTextField(field) ? field.type.max_length : undefined}
autoComplete={isTextField(field) ? field.type.autocomplete : undefined}
{...inputAttributesFor(field)}
/>
);
};

function isTextField(field: ChannelFormField): field is ChannelFormField & {
type: { name: "text" };
} {
return field.type.name === "text";
type TypeDerivedInputAttributes = {
type: "text" | "email";
minLength?: number;
maxLength?: number;
autoComplete?: string;
};

function inputAttributesFor(
field: ChannelFormField,
): TypeDerivedInputAttributes {
switch (field.type.name) {
case "email":
return { type: "email", autoComplete: "email" };
case "postal_code":
return { type: "text", autoComplete: "postal-code" };
case "text":
return {
type: "text",
minLength: field.type.min_length,
maxLength: field.type.max_length,
autoComplete: field.type.autocomplete,
};
default:
return { type: "text" };
}
}
Loading
Loading