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

[Checkbox] Allow Checkbox BubbleInput to pass validation state when used within Form primitive #3221

Open
wants to merge 4 commits into
base: main
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
34 changes: 34 additions & 0 deletions packages/react/checkbox/src/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { css, keyframes } from '../../../../stitches.config';
import { Label as LabelPrimitive } from '@radix-ui/react-label';
import { RECOMMENDED_CSS__LABEL__ROOT } from '../../label/src/Label.stories';
import * as Checkbox from '@radix-ui/react-checkbox';
import * as Form from '@radix-ui/react-form';

export default { title: 'Components/Checkbox' };

Expand Down Expand Up @@ -172,6 +173,34 @@ export const WithinForm = () => {
);
};

export const WithinFormPrimitive = () => {
return (
<Form.Root
onSubmit={(event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));

window.alert(JSON.stringify(data, null, 2));
}}
className={formClass()}
>
<Form.Field name="consent">
<Form.Control asChild>
<Checkbox.Root defaultChecked={false} className={rootClass()} required value={'yes'}>
<Checkbox.Indicator className={indicatorClass()} />
</Checkbox.Root>
</Form.Control>
<Form.Label>Agree to T & C</Form.Label>
<div>
<Form.Message match="valueMissing">Agree to T & C is required</Form.Message>
</div>
</Form.Field>
<button type="reset">Reset</button>
<button>Submit</button>
</Form.Root>
);
};

export const Animated = () => {
const [checked, setChecked] = React.useState<boolean | 'indeterminate'>('indeterminate');

Expand Down Expand Up @@ -335,3 +364,8 @@ const styles = {
};
const rootAttrClass = css(styles);
const indicatorAttrClass = css(styles);

const formClass = css({
'& *[data-invalid]': { color: 'red', outlineColor: 'CurrentColor' },
'& *[data-valid]': { color: 'green', outlineColor: 'CurrentColor' },
});
31 changes: 30 additions & 1 deletion packages/react/checkbox/src/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@ describe('given an uncontrolled Checkbox in form', () => {
);
fireEvent.click(checkbox);
});

it('should trigger a change event on BubbleInput which changes `checked` to the opposite value of `defaultChecked` of Checkbox', () => {
const rendered = render(
<form>
<CheckboxTest defaultChecked />
</form>
);

const checkbox = rendered.getByRole(CHECKBOX_ROLE);
const input = rendered.getByTestId('bubble-input') as HTMLInputElement;
fireEvent.click(checkbox);
expect(input.checked).toBe(false);
});
});
});

Expand Down Expand Up @@ -179,6 +192,19 @@ describe('given a controlled Checkbox in a form', () => {
);
fireEvent.click(checkbox);
});

it('should trigger a change event on BubbleInput which changes `checked` to same as initial value of `checked` of Checkbox', () => {
const rendered = render(
<form>
<CheckboxTest checked />
</form>
);

const checkbox = rendered.getByRole(CHECKBOX_ROLE);
const input = rendered.getByTestId('bubble-input') as HTMLInputElement;
fireEvent.click(checkbox);
expect(input.checked).toBe(true);
});
});
});

Expand All @@ -191,7 +217,10 @@ function CheckboxTest(props: React.ComponentProps<typeof Checkbox>) {
// input doesn't have a label. This adds an additional `aria-hidden` attribute to the input to
// get around that.
// https://developer.paciellogroup.com/blog/2012/05/html5-accessibility-chops-hidden-and-aria-hidden/
containerRef.current?.querySelector('input')?.setAttribute('aria-hidden', 'true');
const input = containerRef.current?.querySelector('input');
input?.setAttribute('aria-hidden', 'true');
// Add testid so that we can find the input element in tests
input?.setAttribute('data-testid', 'bubble-input');
}, []);
return (
<div ref={containerRef}>
Expand Down
63 changes: 42 additions & 21 deletions packages/react/checkbox/src/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ const [CheckboxProvider, useCheckboxContext] =

type CheckboxElement = React.ElementRef<typeof Primitive.button>;
type PrimitiveButtonProps = React.ComponentPropsWithoutRef<typeof Primitive.button>;
interface CheckboxProps extends Omit<PrimitiveButtonProps, 'checked' | 'defaultChecked'> {
interface CheckboxProps
extends Omit<PrimitiveButtonProps, 'checked' | 'defaultChecked' | 'onInvalid'> {
checked?: CheckedState;
defaultChecked?: CheckedState;
required?: boolean;
onCheckedChange?(checked: CheckedState): void;
onInvalid?(event: React.InvalidEvent<HTMLInputElement>): void;
}

const Checkbox = React.forwardRef<CheckboxElement, CheckboxProps>(
Expand All @@ -49,11 +51,14 @@ const Checkbox = React.forwardRef<CheckboxElement, CheckboxProps>(
disabled,
value = 'on',
onCheckedChange,
onInvalid,
form,
...checkboxProps
} = props;
const [button, setButton] = React.useState<HTMLButtonElement | null>(null);
const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node));
const inputRef = React.useRef<HTMLInputElement & HTMLButtonElement>(null); // Create an internal ref
React.useImperativeHandle(forwardedRef, () => inputRef.current!);
const hasConsumerStoppedPropagationRef = React.useRef(false);
// We set this to true by default so that events bubble to forms without JS (SSR)
const isFormControl = button ? form || !!button.closest('form') : true;
Expand All @@ -72,6 +77,17 @@ const Checkbox = React.forwardRef<CheckboxElement, CheckboxProps>(
}
}, [button, setChecked]);

React.useEffect(() => {
if (isFormControl) {
const input = inputRef.current!;
isIndeterminate(checked) ? (input.indeterminate = true) : (input.checked = checked);

// Create and dispatch a change event
const changeEvent = new Event('change', { bubbles: true });
input.dispatchEvent(changeEvent);
}
}, [isFormControl, checked]);

return (
<CheckboxProvider scope={__scopeCheckbox} state={checked} disabled={disabled}>
<Primitive.button
Expand Down Expand Up @@ -115,6 +131,8 @@ const Checkbox = React.forwardRef<CheckboxElement, CheckboxProps>(
// of the button.
style={{ transform: 'translateX(-100%)' }}
defaultChecked={isIndeterminate(defaultChecked) ? false : defaultChecked}
ref={inputRef}
onInvalid={onInvalid}
/>
)}
</CheckboxProvider>
Expand Down Expand Up @@ -169,15 +187,16 @@ interface BubbleInputProps extends Omit<InputProps, 'checked'> {
bubbles: boolean;
}

const BubbleInput = (props: BubbleInputProps) => {
const BubbleInput = React.forwardRef<HTMLInputElement, BubbleInputProps>((props, ref) => {
const { control, checked, bubbles = true, defaultChecked, ...inputProps } = props;
const ref = React.useRef<HTMLInputElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null); // Create an internal ref
const prevChecked = usePrevious(checked);
const controlSize = useSize(control);
React.useImperativeHandle(ref, () => inputRef.current!);

// Bubble checked change to parents (e.g form change event)
React.useEffect(() => {
const input = ref.current!;
const input = inputRef.current!;
const inputProto = window.HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'checked') as PropertyDescriptor;
const setChecked = descriptor.set;
Expand All @@ -192,24 +211,26 @@ const BubbleInput = (props: BubbleInputProps) => {

const defaultCheckedRef = React.useRef(isIndeterminate(checked) ? false : checked);
return (
<input
type="checkbox"
aria-hidden
defaultChecked={defaultChecked ?? defaultCheckedRef.current}
{...inputProps}
tabIndex={-1}
ref={ref}
style={{
...props.style,
...controlSize,
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
margin: 0,
}}
/>
<span aria-hidden>
<input
type="checkbox"
defaultChecked={defaultChecked ?? defaultCheckedRef.current}
{...inputProps}
tabIndex={-1}
ref={inputRef}
onInvalid={props.onInvalid}
style={{
...props.style,
...controlSize,
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
margin: 0,
}}
/>
</span>
);
};
});

function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
return checked === 'indeterminate';
Expand Down