-
Notifications
You must be signed in to change notification settings - Fork 0
fix: make browser autofill work #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 = { | ||
|
|
@@ -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) { | ||
|
|
@@ -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 | ||
| 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} | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"], { | ||
|
|
||
| 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 { | ||
|
|
@@ -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], | ||
| ); | ||
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you add tests for all the handleNativeSelectChange functions?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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(); | ||
| } | ||
|
|
@@ -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} | ||
|
|
||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?