<div class="space-y-8"> <div> <p class="text-sm font-medium text-gray-700 mb-2">Default (no state)</p> <div class="w-80"> <div class="lui-phone-number lui-phone-number--inline relative" data-controller="phone-number" data-phone-number-initial-country-value="pt" data-phone-number-separate-dial-code-value="true" data-phone-number-country-search-value="true" data-phone-number-show-flags-value="true" data-phone-number-format-as-you-type-value="true" data-phone-number-strict-mode-value="true" data-phone-number-allowed-number-types-value="["MOBILE","FIXED_LINE"]" data-phone-number-placeholder-number-type-value="MOBILE" data-phone-number-country-name-locale-value="en" data-phone-number-has-country-input-value="false"> <div data-controller="input" data-input-open-actions-value="false" class="lui-inner-input relative flex gap-2" data-input-original-input-value="" data-input-mode-value="inline" data-input-form-value=""> <div class="w-full flex flex-col"> <div class="lui-input-chrome relative w-full"> <span class="lui-input "> <input name="_looposui-inputs-phonenumber_phone_default_5793709857" type="tel" class="lui-input__input" mode="inline" contentEditable="true" data-input-target="input" data-action="input->input#onChange change->input#onChange" data-phone-number-target="visibleInput" autocomplete="off"> <span class="lui-input__spinner"> <i class="fa-regular fa-spinner"></i> </span> </span> </div> </div> <span class="lui-inner-input__actions opacity-0 flex items-center gap-1 h-fit" data-input-target="actions"> <span class="lui-button__tooltip-wrapper inline-flex w-fit"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-input-target="cancel" data-action="click->input#handleClose" type="button" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-xmark" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-rounded" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button></span> <span class="lui-button__tooltip-wrapper inline-flex w-fit"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-input-target="submit" data-action="click->input#setLoading lui--button#startLoading" type="submit" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-check" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-rounded" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button></span> </span> </div> <input type="hidden" name="phone_default" id="phone_default" data-phone-number-target="hiddenInput" /> </div> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">With help text</p> <div class="w-80"> <div class="lui-phone-number lui-phone-number--inline relative" data-controller="phone-number" data-phone-number-initial-country-value="pt" data-phone-number-separate-dial-code-value="false" data-phone-number-country-search-value="true" data-phone-number-show-flags-value="true" data-phone-number-format-as-you-type-value="true" data-phone-number-strict-mode-value="true" data-phone-number-allowed-number-types-value="["MOBILE","FIXED_LINE"]" data-phone-number-placeholder-number-type-value="MOBILE" data-phone-number-country-name-locale-value="en" data-phone-number-has-country-input-value="false"> <div data-controller="input" data-input-open-actions-value="false" class="lui-inner-input relative flex gap-2" data-input-original-input-value="" data-input-mode-value="inline" data-input-form-value=""> <div class="w-full flex flex-col"> <div class="lui-input-chrome relative w-full"> <span class="lui-input "> <input name="_looposui-inputs-phonenumber_phone_help_9912568611" type="tel" class="lui-input__input" mode="inline" contentEditable="true" data-input-target="input" data-action="input->input#onChange change->input#onChange" data-phone-number-target="visibleInput" autocomplete="tel"> <span class="lui-input__spinner"> <i class="fa-regular fa-spinner"></i> </span> </span> </div> <span class="lui-input__help">Include country code. E.g. +351 912 345 678</span> </div> <span class="lui-inner-input__actions opacity-0 flex items-center gap-1 h-fit" data-input-target="actions"> <span class="lui-button__tooltip-wrapper inline-flex w-fit"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-input-target="cancel" data-action="click->input#handleClose" type="button" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-xmark" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-rounded" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button></span> <span class="lui-button__tooltip-wrapper inline-flex w-fit"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-input-target="submit" data-action="click->input#setLoading lui--button#startLoading" type="submit" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-check" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-rounded" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button></span> </span> </div> <input type="hidden" name="phone_help" id="phone_help" data-phone-number-target="hiddenInput" /> </div> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">With error</p> <div class="w-80"> <div class="lui-phone-number lui-phone-number--inline relative" data-controller="phone-number" data-phone-number-initial-country-value="pt" data-phone-number-separate-dial-code-value="true" data-phone-number-country-search-value="true" data-phone-number-show-flags-value="true" data-phone-number-format-as-you-type-value="true" data-phone-number-strict-mode-value="true" data-phone-number-allowed-number-types-value="["MOBILE","FIXED_LINE"]" data-phone-number-placeholder-number-type-value="MOBILE" data-phone-number-country-name-locale-value="en" data-phone-number-has-country-input-value="false"> <div data-controller="input" data-input-open-actions-value="false" class="lui-inner-input relative flex gap-2" data-input-original-input-value="123" data-input-mode-value="inline" data-input-form-value=""> <div class="w-full flex flex-col"> <div class="lui-input-chrome relative w-full"> <span class="lui-input lui-input--with-error"> <input name="_looposui-inputs-phonenumber_phone_error_2322156580" type="tel" value="123" class="lui-input__input" mode="inline" contentEditable="true" data-input-target="input" data-action="input->input#onChange change->input#onChange" data-phone-number-target="visibleInput" autocomplete="off"> <span class="lui-input__spinner"> <i class="fa-regular fa-spinner"></i> </span> </span> </div> <span class="lui-input__error">is not a valid phone number</span> </div> <span class="lui-inner-input__actions opacity-0 flex items-center gap-1 h-fit" data-input-target="actions"> <span class="lui-button__tooltip-wrapper inline-flex w-fit"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-input-target="cancel" data-action="click->input#handleClose" type="button" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-xmark" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-rounded" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button></span> <span class="lui-button__tooltip-wrapper inline-flex w-fit"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-input-target="submit" data-action="click->input#setLoading lui--button#startLoading" type="submit" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-check" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-rounded" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button></span> </span> </div> <input type="hidden" name="phone_error" id="phone_error" value="123" data-phone-number-target="hiddenInput" /> </div> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">Readonly — value displayed as text</p> <div class="w-80"> <div class="lui-phone-number lui-phone-number--inline relative" data-controller="phone-number" data-phone-number-initial-country-value="pt" data-phone-number-separate-dial-code-value="true" data-phone-number-country-search-value="true" data-phone-number-show-flags-value="true" data-phone-number-format-as-you-type-value="true" data-phone-number-strict-mode-value="true" data-phone-number-allowed-number-types-value="["MOBILE","FIXED_LINE"]" data-phone-number-placeholder-number-type-value="MOBILE" data-phone-number-country-name-locale-value="en" data-phone-number-has-country-input-value="false"> <span class="lui-input__value">+351912345678</span> <input type="hidden" name="phone_readonly" id="phone_readonly" value="+351912345678" data-phone-number-target="hiddenInput" /> </div> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">Readonly — empty value</p> <div class="w-80"> <div class="lui-phone-number lui-phone-number--inline relative" data-controller="phone-number" data-phone-number-initial-country-value="pt" data-phone-number-separate-dial-code-value="true" data-phone-number-country-search-value="true" data-phone-number-show-flags-value="true" data-phone-number-format-as-you-type-value="true" data-phone-number-strict-mode-value="true" data-phone-number-allowed-number-types-value="["MOBILE","FIXED_LINE"]" data-phone-number-placeholder-number-type-value="MOBILE" data-phone-number-country-name-locale-value="en" data-phone-number-has-country-input-value="false"> <span class="lui-input__value">-</span> <input type="hidden" name="phone_readonly_empty" id="phone_readonly_empty" data-phone-number-target="hiddenInput" /> </div> </div> </div></div>No Usage documentation to display.
<div class="space-y-8"> <div> <p class="text-sm font-medium text-gray-700 mb-2">Default (no state)</p> <div class="w-80"> <%= render LooposUi::Inputs::PhoneNumber.new( name: "phone_default", value: nil, initial_country: "pt", autocomplete_type: "off", ) %> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">With help text</p> <div class="w-80"> <%= render LooposUi::Inputs::PhoneNumber.new( name: "phone_help", value: nil, initial_country: "pt", separate_dial_code: false, autocomplete_type: "tel", help: "Include country code. E.g. +351 912 345 678" ) %> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">With error</p> <div class="w-80"> <%= render LooposUi::Inputs::PhoneNumber.new( name: "phone_error", value: "123", initial_country: "pt", error: "is not a valid phone number" ) %> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">Readonly — value displayed as text</p> <div class="w-80"> <%= render LooposUi::Inputs::PhoneNumber.new( name: "phone_readonly", value: "+351912345678", initial_country: "pt", readonly: true ) %> </div> </div> <div> <p class="text-sm font-medium text-gray-700 mb-2">Readonly — empty value</p> <div class="w-80"> <%= render LooposUi::Inputs::PhoneNumber.new( name: "phone_readonly_empty", value: nil, initial_country: "pt", readonly: true ) %> </div> </div></div>No notes provided.
No params configured.
Description
LooposUi::Inputs::PhoneNumber is an international telephone number input component powered by intl-tel-input. It provides a country-flag selector, dial code display, real-time formatting, and client-side validation. The component submits phone numbers in E.164 format via a hidden field, keeping the visible input human-readable.
Dependencies
- JavaScript:
intl-tel-input(v29+), loaded lazily via dynamicimport("intl-tel-input/utils") - Stimulus controller:
phone-number(app/javascript/controllers/phone_number_controller.js) - CSS:
intl-tel-input/dist/css/intlTelInput.css(imported inapplication.postcss.css)
Arguments
Base Arguments
| Property | Default | Description |
|---|---|---|
model |
nil |
ActiveRecord instance. |
attribute |
nil |
Attribute name of the model. |
name |
nil |
Name of the hidden (submitted) field. Required when model is not provided. |
value |
nil |
Initial phone value. Prefer E.164 format (e.g. +351912345678) so the controller can restore the correct country. |
placeholder |
nil |
Placeholder shown in the visible input. |
error |
nil |
Error message. Automatically extracted from model errors when model is present. |
help |
nil |
Help text shown below the input. |
mode |
:inline |
:inline, :form, or :autosubmit. |
appearance |
nil |
Visual modifier. Defaults to mode when omitted. |
form |
nil |
HTML form attribute to associate the input with a specific form element. |
readonly |
false |
When true, renders the value as plain text without an editable input. |
custom_readonly |
false |
In readonly mode, renders provided content instead of the plain value. |
open_actions |
false |
Open field in edit mode by default (inline mode). |
extra_input_attributes |
{} |
Extra HTML attributes merged onto the visible <input type="tel"> element. |
Phone-specific Arguments
| Property | Default | Description |
|---|---|---|
autocomplete_type |
"off" |
HTML autocomplete token for the visible input (e.g. "tel", "off"). Disabled by default. |
initial_country |
"pt" |
ISO2 country code preselected on load (e.g. "pt", "gb", "us"). |
separate_dial_code |
true |
Show the dial code next to the flag in a separate box. |
country_search |
true |
Enable a search field in the country dropdown. |
show_flags |
true |
Show country flag icons. |
format_as_you_type |
true |
Format the phone number as the user types. |
strict_mode |
true |
Restrict input to valid phone number characters only. |
allowed_number_types |
["MOBILE", "FIXED_LINE"] |
Restrict validation to these number types. |
placeholder_number_type |
"MOBILE" |
Number type used to generate the placeholder example. Accepted: "MOBILE", "FIXED_LINE", "FIXED_LINE_OR_MOBILE". |
locale |
I18n.locale.to_s |
Locale for country name display (countryNameLocale). |
submit_country_name |
nil |
When provided, a second hidden input is rendered with this name to store the selected ISO2 country. |
Submit Value Strategy
The component renders two inputs:
- Visible
<input type="tel">— enhanced byintl-tel-inputfor formatting and country selection. Uses a safe internal name (never submitted directly). - Hidden
<input type="hidden">— carries the real field name and the E.164 formatted value. Updated by the Stimulus controller oninput,change,blur,countrychange, and formsubmitevents.
When intl-tel-input considers the number valid (iti.isValidNumber()), the hidden field receives the E.164 output of iti.getNumber() (e.g. +351912345678). When the number is invalid or the utils are not yet ready, the raw visible value is stored so the consuming application can validate and report errors.
If submit_country_name is set, a third hidden field is updated with the selected country ISO2 code (e.g. "pt").
Validation Note
- Client side:
intl-tel-inputformats and syncs the E.164 value in real time. Invalid numbers fall back to the raw visible value in the hidden field. - Server side: Final validation is the responsibility of the consuming application. Treat client-side formatting as a convenience, not as the source of truth.
Usage
Example using name + value
<%= render LooposUi::Inputs::PhoneNumber.new( name: "contact_phone", value: "+351912345678", initial_country: "pt", placeholder: "Enter phone number", help: "Include country code") %>Example using model + attribute
<%= render LooposUi::Inputs::PhoneNumber.new( model: @contact, attribute: :phone_number, initial_country: "gb") %>The hidden input will use the Rails field name contact[phone_number] and pick up the model's current value and errors automatically.
Example with submit_country_name
<%= render LooposUi::Inputs::PhoneNumber.new( name: "phone", value: nil, initial_country: "us", submit_country_name: "phone_country_code") %>This renders an extra <input type="hidden" name="phone_country_code"> that is updated with the selected ISO2 country code (e.g. "us") on every change.
⚠️ Important
- Pass existing phone numbers as E.164 strings (e.g.
"+351912345678") so the Stimulus controller can restore the correct country flag and national format on page load. initial_countryaccepts lowercase ISO2 codes only (e.g."pt","gb","us").- JS behavior (country picker, real-time sync) requires the
intl-tel-inputpackage and thephone-numberStimulus controller to be loaded. Verify withyarn build/yarn build:css. - Readonly mode renders the raw value as text; no JS enhancement is applied.