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

ECHOES-531 FormField Component #247

Open
wants to merge 3 commits into
base: form-components
Choose a base branch
from
Open
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
112 changes: 66 additions & 46 deletions src/components/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,107 +20,103 @@

import styled from '@emotion/styled';
import * as RadixCheckbox from '@radix-ui/react-checkbox';
import { forwardRef, useCallback, useId } from 'react';
import { forwardRef, useCallback } from 'react';
import { PropsWithLabels } from '~types/utils';
import { FormField } from '../form/FormField';
import { FormFieldControl } from '../form/FormFieldControl';
import { FormFieldDescription } from '../form/FormFieldDescription';
import { FormFieldLabelMedium } from '../form/FormFieldLabel';
import { FormFieldValidation, FormFieldValidationProps } from '../form/FormTypes';
import { getValidationMessage } from '../form/FormUtils';
import { Spinner } from '../spinner';
import { CheckboxIcon } from './CheckboxIcon';

interface Props extends FormFieldValidationProps {
interface Props {
checked: boolean | 'indeterminate';
className?: string;
hasError?: boolean;
innerClassName?: string;
isDisabled?: boolean;
isLoading?: boolean;
onCheck: (checked: boolean | 'indeterminate', id?: string) => void;
onFocus?: VoidFunction;
title?: string;
}

export const Checkbox = forwardRef<HTMLDivElement, PropsWithLabels<Props>>((props, ref) => {
export const Checkbox = forwardRef<HTMLButtonElement, PropsWithLabels<Props>>((props, ref) => {
const {
ariaLabel,
ariaLabelledBy,
className,
checked,
helpText,
hasError = false,
id,
innerClassName,
isDisabled,
isLoading = false,
label,
messageInvalid,
messageValid,
onCheck,
onFocus,
title,
validation = FormFieldValidation.None,
...radixProps
} = props;

const defaultId = `${useId()}checkbox`;
const controlId = id ?? defaultId;
const descriptionId = `${controlId}-description`;
const description = getValidationMessage({
validation,
helpText,
messageInvalid,
messageValid,
});

const handleChange = useCallback(
(checked: boolean | 'indeterminate') => {
if (!isDisabled && !isLoading) {
onCheck(checked, controlId);
onCheck(checked, id);
}
},
[controlId, isDisabled, isLoading, onCheck],
[isDisabled, isLoading, onCheck, id],
);

return (
<FormField className={className} controlPlacement="before" isInline ref={ref}>
{label && (
<FormFieldLabelMedium htmlFor={controlId} isDisabled={isDisabled}>
{label}
</FormFieldLabelMedium>
)}
<FormFieldControl>
<CheckboxContainer
{...radixProps}
aria-disabled={isDisabled}
as={label ? 'label' : 'span'}
className={className}
ref={ref}>
<CheckboxInnerContainer className={innerClassName}>
<Spinner isLoading={isLoading}>
<CheckboxRoot
aria-describedby={description ? descriptionId : undefined}
aria-disabled={isDisabled}
aria-label={ariaLabel ?? title}
aria-labelledby={ariaLabelledBy}
checked={checked}
id={controlId}
id={id}
onCheckedChange={handleChange}
onFocus={onFocus}
title={title}
// We only support the error state for unchecked checkboxes for now
{...(validation === FormFieldValidation.Invalid && checked === false
? { 'data-error': true }
: {})}
{...radixProps}>
{...(hasError && checked === false ? { 'data-error': true } : {})}>
<CheckboxIndicator>
<CheckboxIcon checked={checked} />
</CheckboxIndicator>
</CheckboxRoot>
</Spinner>
</FormFieldControl>
{description && (
<FormFieldDescription id={descriptionId} validation={validation}>
{description}
</FormFieldDescription>
)}
</FormField>
{(label || helpText) && (
<LabelWrapper aria-disabled={isDisabled}>
{label && <Label>{label}</Label>}
{helpText && <HelpText>{helpText}</HelpText>}
</LabelWrapper>
)}
</CheckboxInnerContainer>
</CheckboxContainer>
);
});

Checkbox.displayName = 'Checkbox';

const CheckboxContainer = styled.span`
display: inline-flex;
vertical-align: top;

&[aria-disabled='true'] {
pointer-events: none;
}
`;

const CheckboxInnerContainer = styled.span`
display: flex;
font-family: Arial, Helvetica, sans-serif;
font-size: 0.833rem;
`;

const CheckboxRoot = styled(RadixCheckbox.Root)`
color: var(--echoes-color-text-on-color);
background-color: var(--echoes-color-background-default);
Expand All @@ -146,7 +142,6 @@ const CheckboxRoot = styled(RadixCheckbox.Root)`
color: var(--echoes-color-icon-disabled);
background-color: var(--echoes-color-background-disabled);
border-color: var(--echoes-color-border-disabled);
pointer-events: none;

&[data-state='checked'],
&[data-state='indeterminate'] {
Expand Down Expand Up @@ -181,3 +176,28 @@ const CheckboxIndicator = styled(RadixCheckbox.Indicator)`
height: var(--echoes-dimension-height-400);
width: var(--echoes-dimension-width-200);
`;

const LabelWrapper = styled.span`
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
margin-left: var(--echoes-dimension-space-100);
`;

const Label = styled.span`
font: var(--echoes-typography-others-label-medium);

[aria-disabled='true'] > & {
color: var(--echoes-color-text-disabled);
}
`;

const HelpText = styled.span`
font: var(--echoes-typography-others-helper-text);
color: var(--echoes-color-text-subdued);

[aria-disabled='true'] > & {
color: var(--echoes-color-text-disabled);
}
`;
81 changes: 12 additions & 69 deletions src/components/form/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,7 @@
import styled from '@emotion/styled';
import { type ComponentProps, forwardRef } from 'react';

/**
* Used to control the placement of a fom control within a form field.
*/
export enum FormControlPlacement {
/**
* Place the form control before the label and description.
*/
Before = 'before',
/**
* Place the form control below the label and description.
*/
Below = 'below',
/**
* Place the form control between the label and description. (default)
*/
Between = 'between',
}

export type FormFieldProps = ComponentProps<'div'> & {
/**
* The placement of the form control within the form field. There are three
* possible placements:
*
* 1. `Before` - Indicates that the form control should be placed before the
* label and description. This is used for checkbox controls.
*
* 2. `Below` - Indicates that the form control should be placed below the
* label and description. This is used for radio group controls.
*
* 3. `Between` - Indicates that the form control should be placed between the
* label and description. This is the default placement.
*/
controlPlacement?: `${FormControlPlacement}`;
/**
* A form field is a block element by default. If `isInline` is set to `true`,
* it will render as an inline element.
Expand All @@ -61,58 +29,33 @@ export type FormFieldProps = ComponentProps<'div'> & {
};

/**
* @internal
*
* Acts as a container for a form control.
*
* Permitted Content: `FormFieldLabel`, `FormFieldControl`, `FormFieldDescription`
* **Permitted Content:**
*
* `FormFieldLabel | FormFieldDescription | FormFieldControl | FormFieldMessage`
*/
export const FormField = forwardRef<HTMLDivElement, FormFieldProps>(
({ controlPlacement = FormControlPlacement.Between, isInline = false, ...props }, ref) => {
return (
<StyledFormField
data-inline={isInline ? '' : undefined}
data-placement={controlPlacement}
ref={ref}
{...props}
/>
);
({ isInline = false, ...props }, ref) => {
return <StyledFormField data-inline={isInline ? '' : undefined} ref={ref} {...props} />;
},
);

FormField.displayName = 'FormField';

const StyledFormField = styled.div`
column-gap: var(--echoes-dimension-space-100);
display: grid;
grid-template-areas:
'label'
'description'
'control'
'message';

&[data-inline] {
display: inline-grid;
}

&[data-placement='before'] {
grid-template-areas:
'control label'
'. description';
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}

&[data-placement='below'] {
grid-template-areas:
'label'
'description'
'control';
grid-template-columns: auto;
grid-template-rows: auto auto;
}

&[data-placement='between'] {
grid-template-areas:
'label'
'control'
'description';
grid-template-columns: auto;
grid-template-rows: auto auto;
}
`;

StyledFormField.displayName = 'StyledFormField';
14 changes: 14 additions & 0 deletions src/components/form/FormFieldControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ interface Props {
children: ReactNode;
}

/**
* @internal
*
* A slot for a form control element.
*
* **Permitted Parents:**
*
* `FormField`
*
* **Permitted Content:**
*
* `Checkbox | RadioGroup | Select | Textarea | TextInput`
*/
export const FormFieldControl = forwardRef<HTMLDivElement, Props>((props, ref) => {
return <StyledFormFieldControl ref={ref} {...props} />;
});
Expand All @@ -32,4 +45,5 @@ FormFieldControl.displayName = 'FormFieldControl';

const StyledFormFieldControl = styled.div`
grid-area: control;
margin-bottom: var(--echoes-dimension-space-100);
`;
Loading