Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

components for Text field #90

Merged
merged 1 commit into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frontend/app/components/input-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ComponentProps, ReactNode } from 'react';

import { cn } from '~/utils/tailwind-utils';

export interface InputErrorProps extends ComponentProps<'span'> {
children: ReactNode;
id: string;
}

export function InputError(props: InputErrorProps) {
const { children, className, ...restProps } = props;
return (
<span
className={cn('inline-block max-w-prose border-l-2 border-red-600 bg-red-50 px-3 py-1', className)}
data-testid="input-error-test-id"
role="alert"
{...restProps}
>
{children}
</span>
);
}
116 changes: 116 additions & 0 deletions frontend/app/components/input-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { forwardRef } from 'react';

import { InputError } from './input-error';
import { InputHelp } from './input-help';

import { InputLabel } from '~/components/input-label';
import { cn } from '~/utils/tailwind-utils';

const inputBaseClassName =
'block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500';
const inputDisabledClassName =
'disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70';
const inputReadOnlyClassName =
'read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70';
const inputErrorClassName = 'border-red-500 focus:border-red-500 focus:ring-red-500';

export interface InputFieldProps {
ariaDescribedby?: string;
className?: string;
errorMessage?: string;
helpMessagePrimary?: React.ReactNode;
helpMessagePrimaryClassName?: string;
helpMessageSecondary?: React.ReactNode;
helpMessageSecondaryClassName?: string;
id: string;
label: string;
name: string;
required?: boolean;
type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url';
}

const InputField = forwardRef<HTMLInputElement, InputFieldProps>((props, ref) => {
const {
ariaDescribedby,
errorMessage,
className,
helpMessagePrimary,
helpMessagePrimaryClassName,
helpMessageSecondary,
helpMessageSecondaryClassName,
id,
label,
required,
type = 'text',
...restInputProps
} = props;

const inputErrorId = `input-${id}-error`;
const inputHelpMessagePrimaryId = `input-${id}-help-primary`;
const inputHelpMessageSecondaryId = `input-${id}-help-secondary`;
const inputLabelId = `input-${id}-label`;
const inputWrapperId = `input-${id}`;

function getAriaDescribedby() {
const describedby = [];
if (ariaDescribedby) describedby.push(ariaDescribedby);
if (helpMessagePrimary) describedby.push(inputHelpMessagePrimaryId);
if (helpMessageSecondary) describedby.push(inputHelpMessageSecondaryId);
return describedby.length > 0 ? describedby.join(' ') : undefined;
}

return (
<div id={inputWrapperId} data-testid={inputWrapperId}>
<InputLabel id={inputLabelId} htmlFor={id} className="mb-2">
{label}
</InputLabel>
{errorMessage && (
<p className="mb-2">
<InputError id={inputErrorId}>{errorMessage}</InputError>
</p>
)}
{helpMessagePrimary && (
<InputHelp
id={inputHelpMessagePrimaryId}
className={cn('mb-2', helpMessagePrimaryClassName)}
data-testid="input-field-help-primary"
>
{helpMessagePrimary}
</InputHelp>
)}
<input
ref={ref}
aria-describedby={getAriaDescribedby()}
aria-errormessage={errorMessage ? inputErrorId : undefined}
aria-invalid={!!errorMessage}
aria-labelledby={inputLabelId}
aria-required={required}
className={cn(
inputBaseClassName,
inputDisabledClassName,
inputReadOnlyClassName,
errorMessage && inputErrorClassName,
className,
)}
data-testid="input-field"
id={id}
required={required}
type={type}
{...restInputProps}
/>
{helpMessageSecondary && (
<InputHelp
id={inputHelpMessageSecondaryId}
className={cn('mt-2', helpMessageSecondaryClassName)}
data-testid="input-field-help-secondary"
>
{helpMessageSecondary}
</InputHelp>
)}
</div>
);
});

InputField.displayName = 'InputField';

export { InputField };
17 changes: 17 additions & 0 deletions frontend/app/components/input-help.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ComponentProps, ReactNode } from 'react';

import { cn } from '~/utils/tailwind-utils';

export interface InputHelpProps extends ComponentProps<'span'> {
children: ReactNode;
id: string;
}

export function InputHelp(props: InputHelpProps) {
const { children, className, ...restProps } = props;
return (
<span className={cn('block max-w-prose text-gray-500', className)} data-testid="input-help" {...restProps}>
{children}
</span>
);
}
18 changes: 18 additions & 0 deletions frontend/app/components/input-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ComponentProps, ReactNode } from 'react';

import { cn } from '~/utils/tailwind-utils';

export interface InputLabelProps extends ComponentProps<'label'> {
children: ReactNode;
id: string;
}

export function InputLabel(props: InputLabelProps) {
const { children, className, ...restProps } = props;

return (
<label className={cn('inline-block font-semibold', className)} data-testid="input-label" {...restProps}>
<span>{children}</span>
</label>
);
}
Loading