Skip to content

Commit

Permalink
Merge branch 'main' into feat/form-legend-and-fieldset
Browse files Browse the repository at this point in the history
  • Loading branch information
PHILLIPS71 authored Jul 23, 2024
2 parents 352ada8 + da8340c commit 83ec5f3
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 31 deletions.
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions packages/react/src/components/combo-box/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ const people = [

export const Default: StoryFn<ComboBoxProps<object>> = (args) => (
<ComboBox.Root {...args}>
<Input.Group>
<Input.Root placeholder="People" type="text" />
</Input.Group>
<Input.Root>
<Input.Text placeholder="People" type="text" />
</Input.Root>

<ComboBox.Popover>
<ComboBox.List items={people}>{(item) => <ComboBox.Item key={item.id}>{item.name}</ComboBox.Item>}</ComboBox.List>
Expand All @@ -51,9 +51,9 @@ Default.args = {

export const Custom: StoryFn<ComboBoxProps<object>> = (args) => (
<ComboBox.Root {...args}>
<Input.Group>
<Input.Root placeholder="People" type="text" />
</Input.Group>
<Input.Root>
<Input.Text placeholder="People" type="text" />
</Input.Root>

<ComboBox.Popover>
<ComboBox.List items={people}>
Expand Down
32 changes: 16 additions & 16 deletions packages/react/src/components/input/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Input.Root>
Expand All @@ -25,43 +25,43 @@ const defaultProps = {
...input.defaultVariants,
}

export const Default: StoryFn<InputProps> = (args) => <Input.Root placeholder="Username" type="text" {...args} />
export const Default: StoryFn<InputTextProps> = (args) => <Input.Text placeholder="Username" type="text" {...args} />

Default.args = {
...defaultProps,
}

export const UsingGroup: StoryFn<InputGroupProps> = (args) => (
<Input.Group {...args}>
export const UsingGroup: StoryFn<InputProps> = (args) => (
<Input.Root {...args}>
<Input.Addon>$</Input.Addon>
<Input.Root placeholder="Username" type="text" />
<Input.Text placeholder="Username" type="text" />
<Input.Addon>USD</Input.Addon>
</Input.Group>
</Input.Root>
)

UsingGroup.args = {
...defaultProps,
}

export const UsingSize: StoryFn<InputGroupProps> = (args) => (
export const UsingSize: StoryFn<InputProps> = (args) => (
<div className="flex flex-col gap-2">
<Input.Group {...args} size="sm">
<Input.Root {...args} size="sm">
<Input.Addon>$</Input.Addon>
<Input.Root placeholder="sm" type="text" />
<Input.Text placeholder="sm" type="text" />
<Input.Addon>USD</Input.Addon>
</Input.Group>
</Input.Root>

<Input.Group {...args} size="md">
<Input.Root {...args} size="md">
<Input.Addon>$</Input.Addon>
<Input.Root placeholder="md" type="text" />
<Input.Text placeholder="md" type="text" />
<Input.Addon>USD</Input.Addon>
</Input.Group>
</Input.Root>

<Input.Group {...args} size="lg">
<Input.Root {...args} size="lg">
<Input.Addon>$</Input.Addon>
<Input.Root placeholder="lg" type="text" />
<Input.Text placeholder="lg" type="text" />
<Input.Addon>USD</Input.Addon>
</Input.Group>
</Input.Root>
</div>
)

Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/components/input/InputGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<GroupProps>(
() => ({
Expand All @@ -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
188 changes: 188 additions & 0 deletions packages/react/src/components/input/InputPhone.tsx
Original file line number Diff line number Diff line change
@@ -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<TElement extends React.ElementType = typeof __ELEMENT_TYPE__> = Polymophic.ComponentPropsWithRef<
TElement,
ComponentOwnProps
>

type ComponentType = <TElement extends React.ElementType = typeof __ELEMENT_TYPE__>(
props: ComponentProps<TElement>
) => React.ReactNode

const Component: ComponentType = React.forwardRef(
<TElement extends React.ElementType = typeof __ELEMENT_TYPE__>(
props: ComponentProps<TElement>,
ref: Polymophic.Ref<TElement>
) => {
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<HTMLInputElement> = React.useCallback(
(event) => {
event.target.value = format(event.target.value)

return group?.onChange?.({ ...event })
},
[format, group]
)

const component = React.useMemo<InputProps>(
() => ({
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 (
<>
<Addon>
<CountryFlag country={country} />
</Addon>

<Element {...component} ref={(group?.ref as React.RefObject<HTMLInputElement> | undefined) ?? ref}>
{children}
</Element>
</>
)
}
)

type PhoneFlagProps = {
country: CountryCode
}

const CountryFlag: React.FC<PhoneFlagProps> = ({ 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<string, string> = ALPHABET.split('').reduce(
(obj, letter, index) => ({
...obj,
[letter]: increment(A_LETTER_CODEPOINT, index),
}),
{}
)

const source = React.useMemo<string>(() => {
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 <img alt={`${country.toLowerCase()}-flag-icon`} height={24} src={source} width={24} />
}

export type { ComponentOwnProps as InputPhoneOwnProps, ComponentProps as InputPhoneProps }
export default Component
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputProps>(
() => ({
name: group?.name,
Expand All @@ -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
Loading

0 comments on commit 83ec5f3

Please sign in to comment.