@patreon/studio
Version:
Patreon Studio Design System
204 lines (203 loc) • 11.4 kB
JSX
'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