UNPKG

@patreon/studio

Version:

Patreon Studio Design System

204 lines (203 loc) 11.4 kB
'use client'; import React, { useRef } from 'react'; import { BodyText } from '../../components/BodyText'; import { useSequentialId } from '../../hooks/useSequentialId'; import { useTestIdGenerator } from '../../hooks/useTestIdGenerator'; import devWarn from '../../utilities/dev-warn'; import { IS_DEV } from '../../utilities/env'; import { isRequired } from '../../utilities/isRequired'; import { InlineError } from '../InlineError'; import { InlineHelpText } from '../InlineHelpText'; import { InlineSuccess } from '../InlineSuccess'; import { Label } from '../Label'; import { Affix, InteractiveAffix, CharacterCounter, InputBox, InputsOnly, InputsWithAffixes, InputWrapper, StyledInput, ExpandableTextArea, } from './components'; export const TextInput = React.forwardRef(function TextInput({ 'aria-activedescendant': ariaActiveDescendant, 'aria-autocomplete': ariaAutocomplete, 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-owns': ariaOwns, 'aria-label': ariaLabel, autoComplete, autoFocus, corners = 'rounded', 'data-tag': dataTag, disabled, error, helpText, hideLabel, id, inline, label, loading, maxLength, multiline, name, onBlur, /* istanbul ignore next */ onChange = () => { // Do nothing }, onFocus, onInput, onKeyDown, placeholder, prefix, prefixOnClick, prefixLabel, readOnly, required, role, showCharacterCount, spellCheck, success, suffix, maxHeight, suffixOnClick, suffixLabel, textAlign = 'left', type = 'text', value, variant = 'outlined', disabledVariant = 'default', width = '100%', inputMode, minValue, maxValue, step, containerRef, }, givenRef) { /* * There's some complicated typing in this component to be aware of. * We use a <textarea> element if we're multiline, and an <input * type="text"> element if we're not. We also have to pass refs * around. Since refs are tied to a particular element type, we have * to make sure that TypeScript is able to correlate these properly. * * Specifically, we need to tell TypeScript that: * - `isMultine === true` means that `RefType` is `HTMLTextAreaElement` * - `isMultine === false` means that `RefType` is `HTMLInputElement` * Implied in the above is that `ref` can _never_ be typed as * `HTMLTextAreaElement | HTMLInputElement` except in the component * props themselves. * * This gets tricky though, because the `isMultiline` check is a * _runtime_ check, but `RefType` is a _compile time_ check! So how * do we correlate these? With a somewhat complicated TypeScript * feature called "Conditional Types." In short, anytime you see * `typeof isMultiline extends true`, this means we're correlating * these types. If you ever get errors about `HTMLTextAreaElement` * and `HTMLInputElement` being incompatible with each other, it * means you need to add this check. * * See https://www.typescriptlang.org/docs/handbook/2/conditional-types.html */ const isMultiline = typeof multiline === 'number' ? multiline > 0 : !!multiline; const localRef = useRef(null); const ref = givenRef ?? localRef; const localId = useSequentialId('TextInput'); const thisId = id ?? localId ?? ''; const labelId = `${thisId}-label`; const prefixId = `${thisId}-prefix`; const suffixId = `${thisId}-suffix`; const charCountId = `${thisId}-char-count`; const errorId = InlineError.getErrorId(thisId); const successId = InlineSuccess.getSuccessId(thisId); const helpTextId = `${thisId}-help`; const getTestId = useTestIdGenerator(dataTag); const describedBy = []; if (error) { describedBy.push(errorId); } if (success) { describedBy.push(successId); } if (helpText) { describedBy.push(helpTextId); } if (showCharacterCount) { describedBy.push(charCountId); } const labelledBy = []; if (!hideLabel) { labelledBy.push(labelId); } if (prefix) { labelledBy.push(prefixId); } if (suffix) { labelledBy.push(suffixId); } // This is to deal with unicode code points, which can take up to 2 code units. // If we directly slice on the string truncation happens at code unit level, but // using iterable will ensure we only trucate at code point level. // Details in https://stackoverflow.com/a/70303029 let charCount = 0; if (value) { charCount = Array.from(String(value)).length; } const rows = multiline && typeof multiline === 'number' && multiline > 1 ? multiline : 1; const additionalProps = isMultiline ? { rows } : { type, step, }; let intervalValues = {}; if (type === 'date' || type === 'number' || type === 'time' || type === 'datetime-local') { if (minValue) { intervalValues = { min: minValue }; } if (maxValue) { intervalValues = { ...intervalValues, max: maxValue, }; } if (step) { intervalValues = { ...intervalValues, step, }; } } if (!ariaLabel && hideLabel) { if (typeof label === 'string') { ariaLabel = label; } else { devWarn('You must supply the `aria-label` prop when the label is a `ReactNode`'); } } let ariaLabelledBy; if (!ariaLabel) { const labelledByIds = []; if (!hideLabel) { labelledByIds.push(labelId); } if (prefix) { labelledByIds.push(prefixId); } if (suffix) { labelledByIds.push(suffixId); } ariaLabelledBy = labelledByIds.join(' '); } // TODO (legacied @typescript-eslint/no-shadow) // This failure is legacied in and should be updated. DO NOT COPY. // eslint-disable-next-line @typescript-eslint/no-shadow const isElement = (prefix) => typeof prefix === 'object' && 'props' in prefix; // TODO (legacied @typescript-eslint/no-shadow) // This failure is legacied in and should be updated. DO NOT COPY. // eslint-disable-next-line @typescript-eslint/no-shadow const isStringOrElement = (prefix) => typeof prefix === 'string' || isElement(prefix); // TODO (legacied @typescript-eslint/no-shadow) // This failure is legacied in and should be updated. DO NOT COPY. // eslint-disable-next-line @typescript-eslint/no-shadow const filterIcon = (prefix) => isStringOrElement(prefix) ? undefined : prefix; const Prefix = filterIcon(prefix); const Suffix = filterIcon(suffix); const Input = (isMultiline ? ExpandableTextArea : StyledInput); // eslint-disable-line @typescript-eslint/no-explicit-any // We need to perform these manual casts as Typescript's type narrowing is not powerful enough // to automatically realize these guarantees based on the type of TextInputComponentProps const onChangeHandler = onChange; const onKeyDownHandler = onKeyDown; if (prefixOnClick && !prefixLabel && IS_DEV) { throw new Error('Please provide the `prefixLabel` prop when using `prefixOnClick` to ensure `TextInput` is accessible to screenreader users.'); } if (suffixOnClick && !suffixLabel && IS_DEV) { throw new Error('Please provide the `suffixLabel` prop when using `suffixOnClick` to ensure `TextInput` is accessible to screenreader users.'); } return (<InputWrapper ref={containerRef} inline={inline} widthValue={width} disabled={!!disabled} disabledVariant={disabledVariant}> {label && !hideLabel && (<Label data-tag={getTestId('label')} id={labelId} error={!!error} htmlFor={thisId}> {label} {required && required !== 'no-indicator' && ' *'} </Label>)} <InputBox data-tag={getTestId('box')} isDisabled={disabled} isMultiline={isMultiline} onClick={() => { /** * This seems like a workaround, but we don't have any history on why this is here. * We tried to remove this and it resulted in a number of tests failing in PRF. * TODO: We should investigate this further and remove this if possible. */ /* istanbul ignore next */ ref.current?.focus(); }}> <InputsWithAffixes data-tag={getTestId('with-affixes')} corners={corners} variant={variant} isDisabled={disabled} hasError={!!error} isMultiline={isMultiline} showCharacterCount={showCharacterCount} disabledVariant={disabledVariant}> {prefix && (<Affix data-tag={getTestId('prefix')} id={prefixId} left multiline={multiline}> <InteractiveAffix type={prefixOnClick ? 'button' : undefined} onClick={prefixOnClick} as={prefixOnClick ? 'button' : undefined} aria-label={prefixLabel}> {isStringOrElement(prefix) ? prefix : Prefix && <Prefix size="20px"/>} </InteractiveAffix> </Affix>)} <InputsOnly> <Input hasError={!!error} isDisabled={disabled} variant={variant} aria-activedescendant={ariaActiveDescendant} aria-autocomplete={ariaAutocomplete} aria-controls={ariaControls} aria-describedby={describedBy.length ? describedBy.join(' ') : undefined} aria-expanded={ariaExpanded} aria-invalid={Boolean(error)} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} aria-multiline={isMultiline ? true : undefined} aria-owns={ariaOwns} aria-readonly={readOnly} aria-required={isRequired(required)} autoComplete={autoComplete} // TODO (legacied jsx-a11y/no-autofocus) // This failure is legacied in and should be updated. DO NOT COPY. // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={autoFocus} data-tag={dataTag} disabled={disabled} id={id} maxLength={maxLength} name={name} onBlur={onBlur} onChange={onChangeHandler} onInput={onInput} onFocus={onFocus} onKeyDown={onKeyDownHandler} placeholder={placeholder} readOnly={readOnly} ref={ref} required={required} role={role} spellCheck={spellCheck} textAlign={textAlign} value={value} showCharacterCount={showCharacterCount} maxHeight={maxHeight} inputMode={inputMode} {...additionalProps} {...intervalValues}/> </InputsOnly> {suffix && (<Affix data-tag={getTestId('suffix')} id={suffixId} right multiline={multiline}> <InteractiveAffix type={suffixOnClick ? 'button' : undefined} as={suffixOnClick ? 'button' : undefined} onClick={suffixOnClick} aria-label={suffixLabel}> {isStringOrElement(suffix) ? suffix : Suffix && <Suffix size="20px"/>} </InteractiveAffix> </Affix>)} {showCharacterCount && (<CharacterCounter aria-atomic="true" aria-live="polite" id={charCountId} isMultiline={isMultiline}> <BodyText aria-label={`${charCount} ${maxLength ? `of ${maxLength}` : ''}`} data-tag={getTestId('charCount')} size="sm"> {charCount} {maxLength && ` / ${maxLength}`} </BodyText> </CharacterCounter>)} </InputsWithAffixes> </InputBox> <InlineHelpText id={helpTextId} inputId={thisId} helpText={helpText} error={error} success={success} loading={loading} data-tag={dataTag}/> </InputWrapper>); }); //# sourceMappingURL=index.jsx.map