diff --git a/frui/frui.css b/frui/frui.css index e765e9c..57c0fb1 100644 --- a/frui/frui.css +++ b/frui/frui.css @@ -31,6 +31,7 @@ @import url('./styles/fields/multiselect.css'); @import url('./styles/fields/option.css'); @import url('./styles/fields/password.css'); +@import url('./styles/fields/phoneinput.css'); @import url('./styles/fields/rating.css'); @import url('./styles/fields/select.css'); @import url('./styles/fields/switch.css'); diff --git a/frui/package.json b/frui/package.json index f798d40..281e1be 100644 --- a/frui/package.json +++ b/frui/package.json @@ -93,6 +93,7 @@ "dependencies": { "codemirror": "6.0.1", "inputmask": "5.0.9", + "libphonenumber-js": "^1.12.23", "markdown-to-jsx": "7.7.4", "moment": "2.30.1", "react-syntax-highlighter": "15.6.1" diff --git a/frui/src/element/index.tsx b/frui/src/element/index.tsx index 5c37655..f97b2f9 100644 --- a/frui/src/element/index.tsx +++ b/frui/src/element/index.tsx @@ -1,11 +1,11 @@ -export type { AlertProps } from './Alert'; -export type { BadgeProps } from './Badge'; -export type { LoaderProps } from './Loader'; +export type { AlertProps } from './Alert.js'; +export type { BadgeProps } from './Badge.js'; +export type { LoaderProps } from './Loader.js'; export type { ModalContextProps, ModalProviderProps, ModalProps -} from './Modal'; +} from './Modal.js'; export type { TableColProps, TableFootProps, @@ -13,24 +13,24 @@ export type { TableProps, TableRowProps, TableRuleProps -} from './Table'; -export type { TooltipProps, TooltipDirection } from './Tooltip'; +} from './Table.js'; +export type { TooltipProps, TooltipDirection } from './Tooltip.js'; -export { ModalContext, ModalProvider, useModal } from './Modal'; +export { ModalContext, ModalProvider, useModal } from './Modal.js'; export { Thead, Tfoot, Tcol, Trow, Tgroup -} from './Table'; +} from './Table.js'; -import Alert from './Alert'; -import Badge from './Badge'; -import Loader from './Loader'; -import Modal from './Modal'; -import Table from './Table'; -import Tooltip from './Tooltip'; +import Alert from './Alert.js'; +import Badge from './Badge.js'; +import Loader from './Loader.js'; +import Modal from './Modal.js'; +import Table from './Table.js'; +import Tooltip from './Tooltip.js'; export { Alert, diff --git a/frui/src/field/ColorPicker.tsx b/frui/src/field/ColorPicker.tsx index 3366335..a1cf430 100644 --- a/frui/src/field/ColorPicker.tsx +++ b/frui/src/field/ColorPicker.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react'; -import ColorDisplay, { ColorProps } from '../format/Color'; +import ColorDisplay, { ColorProps } from '../format/Color.js'; export type RGBA = { r: number; g: number; b: number; a: number; }; export type HSVA = { h: number; s: number; v: number; a: number; }; diff --git a/frui/src/field/PhoneInput.tsx b/frui/src/field/PhoneInput.tsx new file mode 100644 index 0000000..609c0db --- /dev/null +++ b/frui/src/field/PhoneInput.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + parsePhoneNumberFromString, + AsYouType, + CountryCode +} from 'libphonenumber-js'; +import type { CountryData as Country } from '../field/Country'; +import countriesData from '../data/countries'; + +export type DialCode = `+${string}`; + +export type PhoneInputProps = { + error?: boolean; + initialValue?: string; + name?: string; + placeholder?: string; + searchable?: boolean; + className?: string; + style?: React.CSSProperties; +}; + +const typedCountries = countriesData as Country[]; + +export function usePhoneInput(initialValue?: string, defaultCountry: Country = typedCountries[0]) { + const [ phoneNumber, setPhoneNumber ] = useState(''); + const [ rawNumber, setRawNumber ] = useState(''); + const [ selectedCountry, setSelectedCountry ] = useState(defaultCountry); + const [ searchTerm, setSearchTerm ] = useState(''); + const [ countries, setCountries ] = useState(typedCountries); + const [ isDropdownOpen, setIsDropdownOpen ] = useState(false); + + const dropdownRef = useRef(null); + + //format & validate input + const handlePhoneInputChange = (value: string) => { + const numericValue = value.replace(/\D/g, ''); + setRawNumber(numericValue); + + const formatter = new AsYouType(selectedCountry.iso2.toUpperCase() as CountryCode); + const formatted = formatter.input(numericValue); + + setPhoneNumber(formatted); + }; + + //select country + const handleCountryChange = (country: Country) => { + setSelectedCountry(country); + setSearchTerm(''); + setCountries(typedCountries); + setIsDropdownOpen(false); + }; + + //search countries + const handleSearch = (term: string) => { + setSearchTerm(term); + const filtered = typedCountries.filter(c => + c.name.toLowerCase().includes(term.toLowerCase()) || + c.iso2.toLowerCase().includes(term.toLowerCase()) + ); + setCountries(filtered); + }; + + //format initial value + useEffect(() => { + if (!initialValue) return; + + const parsed = parsePhoneNumberFromString(initialValue); + if (parsed) { + const isoCode = parsed.country as CountryCode; + const match = typedCountries.find(c => c.iso2.toUpperCase() === isoCode); + + if (match) { + setSelectedCountry(match); + const formatter = new AsYouType(isoCode); + const formatted = formatter.input(parsed.nationalNumber); + setPhoneNumber(formatted); + setRawNumber(parsed.nationalNumber); + } else { + setPhoneNumber(parsed.nationalNumber || initialValue); + setRawNumber(parsed.nationalNumber || initialValue); + } + } else { + setPhoneNumber(initialValue); + setRawNumber(initialValue.replace(/\D/g, '')); + } + }, [ initialValue ]); + + //outside click closes dropdown + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + setSearchTerm(''); + setCountries(typedCountries); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + //escape key closes dropdown + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setIsDropdownOpen(false); + setSearchTerm(''); + setCountries(typedCountries); + } + } + if (isDropdownOpen) { + document.addEventListener('keydown', handleKeyDown); + } else { + document.removeEventListener('keydown', handleKeyDown); + } + }, [ isDropdownOpen ]); + + return { + phoneNumber, + rawNumber, + selectedCountry, + countries, + searchTerm, + isDropdownOpen, + dropdownRef, + setIsDropdownOpen, + handlers: { handlePhoneInputChange, handleCountryChange, handleSearch } + }; +} + +//dropdown component +export function PhoneDropdown({ + countries, + searchTerm, + searchable, + onSearch, + onSelect +}: { + countries: Country[]; + searchTerm: string; + searchable?: boolean; + onSearch: (term: string) => void; + onSelect: (country: Country) => void; +}) { + return ( +
+
    + {searchable && ( + onSearch(e.target.value)} + placeholder="Search for country..." + className="frui-phone-input-search" + type="search" + /> + )} + {countries.map(country => ( +
  • onSelect(country)} + > + {country.flag} + {country.name} + {country.tel} +
  • + ))} +
+
+ ); +} + +//main component +export default function PhoneInput({ + error = false, + initialValue, + name, + placeholder, + searchable = true, + className, + style +}: PhoneInputProps) { + const { + phoneNumber, + rawNumber, + selectedCountry, + countries, + searchTerm, + isDropdownOpen, + dropdownRef, + setIsDropdownOpen, + handlers + } = usePhoneInput(initialValue, typedCountries[0]); + + //base + user class + const classNames = [ 'frui-phone-input-wrapper' ]; + if (error) classNames.push('frui-phone-input-error'); + if (className) classNames.push(className); + + return ( + <> +
+
+ + handlers.handlePhoneInputChange(e.target.value)} + placeholder={placeholder} + /> +
+ {isDropdownOpen && ( +
+ +
+ )} +
+ {/* hidden input for form submission */} + + + ); +} \ No newline at end of file diff --git a/frui/src/field/index.ts b/frui/src/field/index.ts index 980d5f7..518fe51 100644 --- a/frui/src/field/index.ts +++ b/frui/src/field/index.ts @@ -50,6 +50,7 @@ export type { export type { MultiSelectProps } from './MultiSelect.js'; export type { NumberOptions, NumberProps } from './Number.js'; export type { PasswordProps } from './Password.js'; +export type { DialCode, PhoneInputProps } from './PhoneInput.js'; export type { RadioProps } from './Radio.js'; export type { RatingConfig, RatingProps } from './Rating.js'; //export type {} from './Radiolist.js'; @@ -108,6 +109,7 @@ export { useMarkdown } from './Markdown.js'; export { useMetadata, MetadataFields } from './Metadata.js'; export { useNumber } from './Number.js'; export { usePassword } from './Password.js'; +export { usePhoneInput, PhoneDropdown } from './PhoneInput.js'; export { useRadio } from './Radio.js'; export { useRating, Star } from './Rating.js'; //export {} from './Radiolist.js'; @@ -143,6 +145,7 @@ import Metadata from './Metadata.js'; import MultiSelect from './MultiSelect.js'; import Number from './Number.js'; import Password from './Password.js'; +import PhoneInput from './PhoneInput.js'; import Radio from './Radio.js'; //import Radiolist from './Radiolist.js'; import Rating from './Radio.js'; @@ -177,6 +180,7 @@ export { MultiSelect, Number, Password, + PhoneInput, Radio, Rating, Select, diff --git a/frui/src/form/index.ts b/frui/src/form/index.ts index b1449d8..daa3b49 100644 --- a/frui/src/form/index.ts +++ b/frui/src/form/index.ts @@ -1,16 +1,16 @@ -export type { ButtonProps } from './Button'; -export type { ControlProps } from './Control'; +export type { ButtonProps } from './Button.js'; +export type { ControlProps } from './Control.js'; export type { FieldsProps, FieldsetConfig, FieldsetProps -} from './Fieldset'; +} from './Fieldset.js'; -export { useFieldset } from './Fieldset'; +export { useFieldset } from './Fieldset.js'; -import Button from './Button'; -import Control from './Control'; -import Fieldset from './Fieldset'; +import Button from './Button.js'; +import Control from './Control.js'; +import Fieldset from './Fieldset.js'; export { Button, diff --git a/frui/src/index.ts b/frui/src/index.ts index 975c7fd..2a86839 100644 --- a/frui/src/index.ts +++ b/frui/src/index.ts @@ -3,14 +3,14 @@ import * as Field from './field/index.js'; import * as Form from './form/index.js'; import * as Format from './format/index.js'; -export type { AlertProps } from './element/Alert'; -export type { BadgeProps } from './element/Badge'; -export type { LoaderProps } from './element/Loader'; +export type { AlertProps } from './element/Alert.js'; +export type { BadgeProps } from './element/Badge.js'; +export type { LoaderProps } from './element/Loader.js'; export type { ModalContextProps, ModalProviderProps, ModalProps -} from './element/Modal'; +} from './element/Modal.js'; export type { TableColProps, TableFootProps, @@ -18,35 +18,35 @@ export type { TableProps, TableRowProps, TableRuleProps -} from './element/Table'; -export type { TooltipProps, TooltipDirection } from './element/Tooltip'; -export type { ButtonProps } from './form/Button'; -export type { ControlProps } from './form/Control'; +} from './element/Table.js'; +export type { TooltipProps, TooltipDirection } from './element/Tooltip.js'; +export type { ButtonProps } from './form/Button.js'; +export type { ControlProps } from './form/Control.js'; export type { FieldsProps, FieldsetConfig, FieldsetProps -} from './form/Fieldset'; +} from './form/Fieldset.js'; -export { ModalContext, ModalProvider, useModal } from './element/Modal'; +export { ModalContext, ModalProvider, useModal } from './element/Modal.js'; export { Thead, Tfoot, Tcol, Trow, Tgroup -} from './element/Table'; -export { useFieldset } from './form/Fieldset'; +} from './element/Table.js'; +export { useFieldset } from './form/Fieldset.js'; -import Alert from './element/Alert'; -import Badge from './element/Badge'; -import Loader from './element/Loader'; -import Modal from './element/Modal'; -import Table from './element/Table'; -import Tooltip from './element/Tooltip'; -import Button from './form/Button'; -import Control from './form/Control'; -import Fieldset from './form/Fieldset'; +import Alert from './element/Alert.js'; +import Badge from './element/Badge.js'; +import Loader from './element/Loader.js'; +import Modal from './element/Modal.js'; +import Table from './element/Table.js'; +import Tooltip from './element/Tooltip.js'; +import Button from './form/Button.js'; +import Control from './form/Control.js'; +import Fieldset from './form/Fieldset.js'; export { Element, diff --git a/frui/styles/fields/phoneinput.css b/frui/styles/fields/phoneinput.css new file mode 100644 index 0000000..959e51f --- /dev/null +++ b/frui/styles/fields/phoneinput.css @@ -0,0 +1,105 @@ +@import url("https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap"); + +.frui-phone-input-container { + align-items: center; + border: 1px solid var(--black); + display: flex; + overflow: hidden; + width: 100%; +} + +.frui-phone-input-container-input { + border: none; + border-left: 1px solid var(--black); + flex: 1; + font-size: 14px; + outline: none; + padding: 8px; +} + +.frui-phone-input-dialcode { + color: inherit; + font-size: small; + opacity: 0.7; +} + +.frui-phone-input-dropdown { + background-color: var(--white); + border: 1px solid var(--black); + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.2); + left: 0; + margin-top: 4px; + max-height: 200px; + overflow-y: auto; + position: absolute; + top: 100%; + width: 100%; + z-index: 1000; +} + +.frui-phone-input-dropdown-container { + position: relative; + width: 100%; +} + +.frui-phone-input-dropdown-item { + align-items: center; + color: var(--black); + display: flex; + font-size: small; + gap: 8px; + padding: 8px; + text-align: left; +} + +.frui-phone-input-dropdown-item:hover { + background-color: var(--info); + color: var(--white); + cursor: pointer; +} + +.frui-phone-input-error .frui-phone-input-container { + border: 1px solid var(--error); + box-shadow: 0 0 0 1px var(--error); +} + +.frui-phone-input-error .frui-phone-input-container-input { + color: var(--black); +} + +.frui-phone-input-error .frui-phone-input-select-btn { + color: var(--black); +} + +.frui-phone-input-flag { + font-family: "Noto Color Emoji", "sans-serif"; + font-size: 14px; +} + +.frui-phone-input-search { + font-size: 16px; + margin: 4px; + padding: 8px 8px; + width: calc(100% - 16px); +} + +.frui-phone-input-select-btn { + align-items: center; + background: var(--white); + border: none; + cursor: pointer; + display: flex; + font-size: 14px; + gap: 6px; + min-width: 90px; + padding: 8px 12px; +} + +.frui-phone-input-select-btn:focus { + outline: 2px solid var(--black); +} + +.frui-phone-input-wrapper { + position: relative; + width: 100%; +} \ No newline at end of file diff --git a/web/modules/theme/layouts/components/MainMenu.tsx b/web/modules/theme/layouts/components/MainMenu.tsx index c5eeb3a..900886c 100644 --- a/web/modules/theme/layouts/components/MainMenu.tsx +++ b/web/modules/theme/layouts/components/MainMenu.tsx @@ -133,6 +133,9 @@ const MainMenu: React.FC<{ {_('Password')} + + {_('Phone Input')} + {_('Radio')} diff --git a/web/package.json b/web/package.json index 4bf0ebf..17a7896 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,7 @@ "dependencies": { "@codemirror/language-data": "6.5.1", "cookies-next": "4.1.1", - "frui": "0.1.4", + "frui": "0.1.8", "next": "15.2.5", "r22n": "1.0.7", "react": "19.1.0", diff --git a/web/pages/field/phone-input.tsx b/web/pages/field/phone-input.tsx new file mode 100644 index 0000000..9c9ca1b --- /dev/null +++ b/web/pages/field/phone-input.tsx @@ -0,0 +1,221 @@ +//types +import type { Crumb } from 'modules/components/Crumbs'; +//hooks +import { useLanguage } from 'r22n'; +//components +import Link from 'next/link'; +import { Translate } from 'r22n'; +import PhoneInput from 'frui/field/PhoneInput'; +import { LayoutPanel } from 'modules/theme'; +import Crumbs from 'modules/components/Crumbs'; +import Props from 'modules/components/Props'; +import Code, { InlineCode as C } from 'modules/components/Code'; +import { Button } from 'frui/form'; +import React from 'react'; + +export default function Home() { + //hooks + const { _ } = useLanguage(); + + //breadcrumbs + const crumbs: Crumb[] = [ + { icon: 'rectangle-list', label: 'Fields', href: '/field' }, + { label: 'PhoneInput' } + ]; + + //component props list + const props = [ + [ _('name'), _('string'), _('No'), _('Used for form submission. Hidden input generates value like `+11234567890`.') ], + [ _('initialValue'), _('string'), _('No'), _('Initial value of the phone input. Can include dial code (e.g. "+14155552671").') ], + [ _('placeholder'), _('string'), _('No'), _('Placeholder text for the phone number input.') ], + [ _('searchable'), _('boolean'), _('No'), _('Whether the country dropdown should include a search filter (default: true).') ], + [ _('error'), _('boolean'), _('No'), _('If true, applies error styling via frui-phone-input-error class.') ], + ]; + + //functions + const handleFormSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const formData = new FormData(form); + const phoneValue = formData.get('phone'); + alert(`Form submitted! Phone: ${phoneValue}`); + } + + //render + return ( + +
+
+ +
+
+ {/* Sidebar Navigation */} + + + {/* Content */} +
+ {/* Top Header */} +

+ {_('PhoneInput')} +

+ + {`import PhoneInput from 'frui/field/PhoneInput';`} + + + {/* Props */} +

+ {_('Props')} +

+

+ + PhoneInput provides a country selector and phone number field, + with optional search functionality. + +

+ + + {/* Basics Example */} +

+ {_('Basics')} +

+

+ + The basic usage of PhoneInput includes a number field prefixed + with a country dial code. + +

+
+
+ +
+ + {``} + +
+ + {/* Search Example */} + +

+ + By default, users can search for a country when selecting + from the dropdown. This can be disabled with prop. + +

+
+
+ +
+ + {``} + +
+ + {/* Hidden Form Value Example */} +

+ {_('Hidden Form Value')} +

+

+ + The prop ensures that the full number + (dial code + phone number) is included as a hidden input in forms. + +

+
+
+
+ + + +
+ +{`
+ + +`} +
+
+ + {/* Error */} +

+ {_('Error')} +

+

+ + The prop applies the + class to the wrapper, + highlighting the PhoneInput with error styling: + +

+
+
+ +
+ + {``} + +
+ + {/* Custom Styles */} +

+ {_('Custom Styles')} +

+

+ + You can also add your own custom class to components + or use any combination of + , + , + , + , + , + , + , + , + , + , and + CSS classes. + +

+ + {/* Navigation Footer */} +
+ + + {_('Knob')} + +
+ + {_('Input')} + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index fe9cc17..0c8521a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2585,6 +2585,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libphonenumber-js@^1.12.23: + version "1.12.23" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.23.tgz#b838c1e93907ca200395bdb6b06e123c6558f0fd" + integrity sha512-RN3q3gImZ91BvRDYjWp7ICz3gRn81mW5L4SW+2afzNCC0I/nkXstBgZThQGTE3S/9q5J90FH4dP+TXx8NhdZKg== + lilconfig@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"