From 9d349c36c797f528313e00dd82ce70f7a0103539 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Tue, 23 Jul 2024 16:43:26 +1000 Subject: [PATCH] feat: add phone number and text area input components --- packages/react/package.json | 1 + .../components/combo-box/ComboBox.stories.tsx | 12 +- .../src/components/input/Input.stories.tsx | 32 +-- .../react/src/components/input/InputGroup.tsx | 6 +- .../react/src/components/input/InputPhone.tsx | 188 ++++++++++++++++++ .../input/{Input.tsx => InputText.tsx} | 8 +- .../src/components/input/InputTextArea.tsx | 66 ++++++ .../src/components/input/component.parts.ts | 6 +- packages/react/src/components/input/index.ts | 4 +- 9 files changed, 292 insertions(+), 31 deletions(-) create mode 100644 packages/react/src/components/input/InputPhone.tsx rename packages/react/src/components/input/{Input.tsx => InputText.tsx} (93%) create mode 100644 packages/react/src/components/input/InputTextArea.tsx diff --git a/packages/react/package.json b/packages/react/package.json index 2a14c0c..4e3216a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -31,6 +31,7 @@ "dependencies": { "@giantnodes/theme": "workspace:*", "class-variance-authority": "^0.7.0", + "libphonenumber-js": "^1.11.4", "next-themes": "^0.3.0", "react-aria": "^3.33.1", "react-aria-components": "^1.2.1", diff --git a/packages/react/src/components/combo-box/ComboBox.stories.tsx b/packages/react/src/components/combo-box/ComboBox.stories.tsx index e19484d..ca15c0b 100644 --- a/packages/react/src/components/combo-box/ComboBox.stories.tsx +++ b/packages/react/src/components/combo-box/ComboBox.stories.tsx @@ -35,9 +35,9 @@ const people = [ export const Default: StoryFn> = (args) => ( - - - + + + {(item) => {item.name}} @@ -51,9 +51,9 @@ Default.args = { export const Custom: StoryFn> = (args) => ( - - - + + + diff --git a/packages/react/src/components/input/Input.stories.tsx b/packages/react/src/components/input/Input.stories.tsx index cb483b8..076185f 100644 --- a/packages/react/src/components/input/Input.stories.tsx +++ b/packages/react/src/components/input/Input.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryFn } from '@storybook/react' import { input } from '@giantnodes/theme' -import type { InputGroupProps, InputProps } from '~/components/input' +import type { InputProps, InputTextProps } from '~/components/input' import { Input } from '~/components' type InputComponentProps = React.ComponentProps @@ -25,43 +25,43 @@ const defaultProps = { ...input.defaultVariants, } -export const Default: StoryFn = (args) => +export const Default: StoryFn = (args) => Default.args = { ...defaultProps, } -export const UsingGroup: StoryFn = (args) => ( - +export const UsingGroup: StoryFn = (args) => ( + $ - + USD - + ) UsingGroup.args = { ...defaultProps, } -export const UsingSize: StoryFn = (args) => ( +export const UsingSize: StoryFn = (args) => (
- + $ - + USD - + - + $ - + USD - + - + $ - + USD - +
) diff --git a/packages/react/src/components/input/InputGroup.tsx b/packages/react/src/components/input/InputGroup.tsx index 89cb8b3..24aa42a 100644 --- a/packages/react/src/components/input/InputGroup.tsx +++ b/packages/react/src/components/input/InputGroup.tsx @@ -6,6 +6,7 @@ import React from 'react' import { Group } from 'react-aria-components' import type * as Polymophic from '~/utilities/polymorphic' +import { useFormGroupContext } from '~/components/form/use-form-group.hook' import { InputContext, useInput } from '~/components/input/use-input.hook' const __ELEMENT_TYPE__ = 'div' @@ -30,7 +31,8 @@ const Component: ComponentType = React.forwardRef( const Element = as ?? Group - const context = useInput({ color, size, shape, variant }) + const group = useFormGroupContext() + const context = useInput({ color: color ?? group?.status, size, shape, variant }) const component = React.useMemo( () => ({ @@ -50,5 +52,5 @@ const Component: ComponentType = React.forwardRef( } ) -export type { ComponentProps as InputGroupProps, ComponentOwnProps as InputGroupOwnProps } +export type { ComponentProps as InputProps, ComponentOwnProps as InputOwnProps } export default Component diff --git a/packages/react/src/components/input/InputPhone.tsx b/packages/react/src/components/input/InputPhone.tsx new file mode 100644 index 0000000..5abd380 --- /dev/null +++ b/packages/react/src/components/input/InputPhone.tsx @@ -0,0 +1,188 @@ +'use client' + +import type { InputVariantProps } from '@giantnodes/theme' +import type { CountryCode } from 'libphonenumber-js' +import type { InputProps } from 'react-aria-components' +import React from 'react' +import { getExampleNumber, parsePhoneNumber } from 'libphonenumber-js/min' +import examples from 'libphonenumber-js/mobile/examples' +import { Input } from 'react-aria-components' + +import type * as Polymophic from '~/utilities/polymorphic' +import { useFormGroupContext } from '~/components/form/use-form-group.hook' +import { Addon } from '~/components/input/component.parts' +import { useInput, useInputContext } from '~/components/input/use-input.hook' +import { cn } from '~/utilities' + +const __ELEMENT_TYPE__ = 'input' + +type ComponentOwnProps = InputVariantProps & { + country: CountryCode + onTemplateChange?: (template: string) => void +} + +type ComponentProps = Polymophic.ComponentPropsWithRef< + TElement, + ComponentOwnProps +> + +type ComponentType = ( + props: ComponentProps +) => React.ReactNode + +const Component: ComponentType = React.forwardRef( + ( + props: ComponentProps, + ref: Polymophic.Ref + ) => { + const { as, children, className, country, color, size, shape, variant, onTemplateChange, ...rest } = props + + const Element = as ?? Input + const group = useFormGroupContext() + const context = useInputContext() + + const { slots } = useInput({ + color: color ?? group?.status ?? context?.color, + size: size ?? context?.size, + shape: shape ?? context?.shape, + variant: variant ?? context?.variant, + }) + + /** + * Generates a national phone number template for a given country. + * + * @returns {string | null} The phone number template with '#' for digits, or null if no example is available. + * + * @example + * // For Australia (AU), return: + * "#### ### ###" + * + * @example + * // For United States (US), return: + * "(###) ###-####" + */ + const template = React.useMemo(() => { + const example = getExampleNumber(country, examples) + + if (!example) return null + + const parsed = parsePhoneNumber(example.number, country) + const formatted = parsed.format('NATIONAL') + const template = formatted.replace(/\d/g, '#') + + return template + }, [country]) + + /** + * Formats a phone number input string according to the national format of the specified country. + * + * @param {string} input - The raw input element text that will be formatted the phone number. + * @returns {string} The formatted phone number string. + */ + const format = React.useCallback( + (input: string) => { + if (template == null) return input + + input = input.replace(/\D/g, '') + + let result = '' + let index = 0 + + for (const element of template) { + if (index >= input.length) break + + if (element === '#') { + result += input[index] + index++ + } else { + result += element + } + } + + return result + }, + [template] + ) + + const onChange: React.ChangeEventHandler = React.useCallback( + (event) => { + event.target.value = format(event.target.value) + + return group?.onChange?.({ ...event }) + }, + [format, group] + ) + + const component = React.useMemo( + () => ({ + name: group?.name, + onChange: onChange, + onBlur: group?.onBlur, + className: slots.input({ className: cn(className) }), + ...group?.fieldProps, + ...rest, + }), + [className, group?.fieldProps, group?.name, group?.onBlur, onChange, rest, slots] + ) + + React.useEffect(() => { + if (template) onTemplateChange?.(template) + }, [template, onTemplateChange]) + + return ( + <> + + + + + | undefined) ?? ref}> + {children} + + + ) + } +) + +type PhoneFlagProps = { + country: CountryCode +} + +const CountryFlag: React.FC = ({ country }) => { + const ALPHABET = 'abcdefghijklmnopqrstuvwxyz' + const A_LETTER_CODEPOINT = '1f1e6' + + const increment = (codepoint: string, increment: number): string => { + const decimal = parseInt(codepoint, 16) + return Number(decimal + increment).toString(16) + } + + const codepoints: Record = ALPHABET.split('').reduce( + (obj, letter, index) => ({ + ...obj, + [letter]: increment(A_LETTER_CODEPOINT, index), + }), + {} + ) + + const source = React.useMemo(() => { + if (country.length < 2) { + throw new Error('country code must be at least 2 characters long') + } + + const first = country[0]?.toLowerCase() + const second = country[1]?.toLowerCase() + + if (first == undefined || second == undefined) { + throw new Error(`country code ${country} is invalid`) + } + + const codepoint = [codepoints[first], codepoints[second]].join('-') + + return `https://raw.githubusercontent.com/jdecked/twemoji/main/assets/svg/${codepoint}.svg` + }, [codepoints, country]) + + return {`${country.toLowerCase()}-flag-icon`} +} + +export type { ComponentOwnProps as InputPhoneOwnProps, ComponentProps as InputPhoneProps } +export default Component diff --git a/packages/react/src/components/input/Input.tsx b/packages/react/src/components/input/InputText.tsx similarity index 93% rename from packages/react/src/components/input/Input.tsx rename to packages/react/src/components/input/InputText.tsx index 2f3176a..8b4c7d8 100644 --- a/packages/react/src/components/input/Input.tsx +++ b/packages/react/src/components/input/InputText.tsx @@ -32,16 +32,16 @@ const Component: ComponentType = React.forwardRef( const Element = as ?? Input + const group = useFormGroupContext() const context = useInputContext() + const { slots } = useInput({ - color: color ?? context?.color, + color: color ?? group?.status ?? context?.color, size: size ?? context?.size, shape: shape ?? context?.shape, variant: variant ?? context?.variant, }) - const group = useFormGroupContext() - const component = React.useMemo( () => ({ name: group?.name, @@ -62,5 +62,5 @@ const Component: ComponentType = React.forwardRef( } ) -export type { ComponentOwnProps as InputOwnProps, ComponentProps as InputProps } +export type { ComponentOwnProps as InputTextOwnProps, ComponentProps as InputTextProps } export default Component diff --git a/packages/react/src/components/input/InputTextArea.tsx b/packages/react/src/components/input/InputTextArea.tsx new file mode 100644 index 0000000..acae6e3 --- /dev/null +++ b/packages/react/src/components/input/InputTextArea.tsx @@ -0,0 +1,66 @@ +'use client' + +import type { InputVariantProps } from '@giantnodes/theme' +import type { TextAreaProps } from 'react-aria-components' +import React from 'react' +import { TextArea } from 'react-aria-components' + +import type * as Polymophic from '~/utilities/polymorphic' +import { useFormGroupContext } from '~/components/form/use-form-group.hook' +import { useInput, useInputContext } from '~/components/input/use-input.hook' +import { cn } from '~/utilities' + +const __ELEMENT_TYPE__ = 'textarea' + +type ComponentOwnProps = InputVariantProps & TextAreaProps + +type ComponentProps = Polymophic.ComponentPropsWithRef< + TElement, + ComponentOwnProps +> + +type ComponentType = ( + props: ComponentProps +) => React.ReactNode + +const Component: ComponentType = React.forwardRef( + ( + props: ComponentProps, + ref: Polymophic.Ref + ) => { + const { as, children, className, color, size, shape, variant, ...rest } = props + + const Element = as ?? TextArea + + const context = useInputContext() + const { slots } = useInput({ + color: color ?? context?.color, + size: size ?? context?.size, + shape: shape ?? context?.shape, + variant: variant ?? context?.variant, + }) + + const group = useFormGroupContext() + + const component = React.useMemo( + () => ({ + name: group?.name, + onChange: group?.onChange, + onBlur: group?.onBlur, + className: slots.input({ className: cn(className) }), + ...group?.fieldProps, + ...rest, + }), + [className, group?.fieldProps, group?.name, group?.onBlur, group?.onChange, rest, slots] + ) + + return ( + | undefined) ?? ref}> + {children} + + ) + } +) + +export type { ComponentOwnProps as InputTextAreaOwnProps, ComponentProps as InputTextAreaProps } +export default Component diff --git a/packages/react/src/components/input/component.parts.ts b/packages/react/src/components/input/component.parts.ts index 67d50e8..d48216f 100644 --- a/packages/react/src/components/input/component.parts.ts +++ b/packages/react/src/components/input/component.parts.ts @@ -1,3 +1,5 @@ -export { default as Root } from '~/components/input/Input' +export { default as Root } from '~/components/input/InputGroup' export { default as Addon } from '~/components/input/InputAddon' -export { default as Group } from '~/components/input/InputGroup' +export { default as Phone } from '~/components/input/InputPhone' +export { default as Text } from '~/components/input/InputText' +export { default as TextArea } from '~/components/input/InputTextArea' diff --git a/packages/react/src/components/input/index.ts b/packages/react/src/components/input/index.ts index e050fe9..1911e12 100644 --- a/packages/react/src/components/input/index.ts +++ b/packages/react/src/components/input/index.ts @@ -1,5 +1,7 @@ -export type * from '~/components/input/Input' export type * from '~/components/input/InputAddon' export type * from '~/components/input/InputGroup' +export type * from '~/components/input/InputPhone' +export type * from '~/components/input/InputText' +export type * from '~/components/input/InputTextArea' export * as Input from '~/components/input/component.parts'