crystal-ball/componentry

View on GitHub
src/components/Input/Input.tsx

Summary

Maintainability
A
35 mins
Test Coverage
A
100%
import { nanoid } from 'nanoid'
import React, { createContext, useContext, useRef } from 'react'
import { createElement } from '../../utils/create-element'
import { createStaticComponent } from '../../utils/create-static-component'
import { UtilityProps } from '../../utils/utility-props'
import { useThemeProps } from '../Provider/Provider'

export interface InputProps
  extends UtilityProps,
    Omit<React.ComponentPropsWithoutRef<'input'>, 'height' | 'width'> {}

export interface InputDescriptionProps
  extends UtilityProps,
    React.ComponentPropsWithoutRef<'div'> {}

export interface InputErrorProps
  extends UtilityProps,
    React.ComponentPropsWithoutRef<'div'> {}

export interface InputFieldProps
  extends UtilityProps,
    Omit<React.ComponentPropsWithoutRef<'input'>, 'height' | 'width'> {
  invalid?: boolean
}

export interface InputLabelProps
  extends UtilityProps,
    React.ComponentPropsWithoutRef<'label'> {}

export interface Input {
  (props: InputProps): React.ReactElement
  displayName: 'Input'
  /**
   * [Input description component 📝](https://componentry.design/components/input)
   */
  Description: React.FC<InputDescriptionProps>
  /**
   * [Input error component 📝](https://componentry.design/components/input)
   */
  Error: React.FC<InputErrorProps>
  /**
   * [Input field component 📝](https://componentry.design/components/input)
   */
  Field: React.FC<InputFieldProps>
  /**
   * [Input label component 📝](https://componentry.design/components/input)
   */
  Label: React.FC<InputLabelProps>
}

type InputCtx = { guid: string }
const InputCtx = createContext<null | InputCtx>(null)

/**
 * [Input component 📝](https://componentry.design/components/input)
 * @experimental
 */
export const Input: Input = ({ children }: InputProps) => {
  /**
   * Guid instance property will be uniquely assigned once for each input
   * instance, this unique id is then passed to all children through context
   * where it can be used to wire together aria attributes
   */
  const { current: guid } = useRef(nanoid())

  return <InputCtx.Provider value={{ guid }}>{children}</InputCtx.Provider>
}
Input.displayName = 'Input'

// --- Description sub-component ---

Input.Description = createStaticComponent('InputDescription')

// --- Error sub-component ---

Input.Error = createStaticComponent('InputError')

// --- Field sub-component ---

Input.Field = function InputField(props) {
  const ctx = useContext(InputCtx)
  assertContext(ctx)

  const { invalid = false, ...rest } = {
    ...useThemeProps('InputField'),
    ...props,
  }

  return createElement({
    as: 'input',
    type: 'text',
    id: ctx.guid, // aria -> htmlFor
    invalid: invalid ? true : undefined,
    componentClassName: [
      'C9Y-InputField',
      {
        'C9Y-invalid': invalid,
      },
    ],
    ...rest,
  })
}
Input.Field.displayName = 'InputField'

// --- Label sub-component ---

Input.Label = function InputLabel(props) {
  const ctx = useContext(InputCtx)
  assertContext(ctx)

  return createElement({
    as: 'label',
    htmlFor: ctx.guid, // aria -> id
    componentClassName: 'C9Y-InputLabel',
    ...useThemeProps('InputLabel'),
    ...props,
  })
}
Input.Label.displayName = 'InputLabel'

// --------------------------------------------------------
// Utils

/**
 * Utility asserts ctx presence for safe access
 */
function assertContext(ctx: null | InputCtx): asserts ctx is InputCtx {
  if (!ctx) throw new Error('Input context cannot be used outside of Input component')
}