diff --git a/packages/react/checkbox/src/Checkbox.stories.tsx b/packages/react/checkbox/src/Checkbox.stories.tsx
index fd961b15f..7797b9f3b 100644
--- a/packages/react/checkbox/src/Checkbox.stories.tsx
+++ b/packages/react/checkbox/src/Checkbox.stories.tsx
@@ -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' };
@@ -172,6 +173,34 @@ export const WithinForm = () => {
);
};
+export const WithinFormPrimitive = () => {
+ return (
+
{
+ event.preventDefault();
+ const data = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
+
+ window.alert(JSON.stringify(data, null, 2));
+ }}
+ className={formClass()}
+ >
+
+
+
+
+
+
+ Agree to T & C
+
+
Agree to T & C is required
+
+
+
+
+
+ );
+};
+
export const Animated = () => {
const [checked, setChecked] = React.useState('indeterminate');
@@ -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' },
+});
diff --git a/packages/react/checkbox/src/Checkbox.test.tsx b/packages/react/checkbox/src/Checkbox.test.tsx
index c10c37541..01ea341d3 100644
--- a/packages/react/checkbox/src/Checkbox.test.tsx
+++ b/packages/react/checkbox/src/Checkbox.test.tsx
@@ -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(
+
+ );
+
+ const checkbox = rendered.getByRole(CHECKBOX_ROLE);
+ const input = rendered.getByTestId('bubble-input') as HTMLInputElement;
+ fireEvent.click(checkbox);
+ expect(input.checked).toBe(false);
+ });
});
});
@@ -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(
+
+ );
+
+ const checkbox = rendered.getByRole(CHECKBOX_ROLE);
+ const input = rendered.getByTestId('bubble-input') as HTMLInputElement;
+ fireEvent.click(checkbox);
+ expect(input.checked).toBe(true);
+ });
});
});
@@ -191,7 +217,10 @@ function CheckboxTest(props: React.ComponentProps) {
// 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 (
diff --git a/packages/react/checkbox/src/Checkbox.tsx b/packages/react/checkbox/src/Checkbox.tsx
index 95fef9995..f91912cd5 100644
--- a/packages/react/checkbox/src/Checkbox.tsx
+++ b/packages/react/checkbox/src/Checkbox.tsx
@@ -31,11 +31,13 @@ const [CheckboxProvider, useCheckboxContext] =
type CheckboxElement = React.ElementRef
;
type PrimitiveButtonProps = React.ComponentPropsWithoutRef;
-interface CheckboxProps extends Omit {
+interface CheckboxProps
+ extends Omit {
checked?: CheckedState;
defaultChecked?: CheckedState;
required?: boolean;
onCheckedChange?(checked: CheckedState): void;
+ onInvalid?(event: React.InvalidEvent): void;
}
const Checkbox = React.forwardRef(
@@ -49,11 +51,14 @@ const Checkbox = React.forwardRef(
disabled,
value = 'on',
onCheckedChange,
+ onInvalid,
form,
...checkboxProps
} = props;
const [button, setButton] = React.useState(null);
const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node));
+ const inputRef = React.useRef(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;
@@ -72,6 +77,17 @@ const Checkbox = React.forwardRef(
}
}, [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 (
(
// of the button.
style={{ transform: 'translateX(-100%)' }}
defaultChecked={isIndeterminate(defaultChecked) ? false : defaultChecked}
+ ref={inputRef}
+ onInvalid={onInvalid}
/>
)}
@@ -169,15 +187,16 @@ interface BubbleInputProps extends Omit {
bubbles: boolean;
}
-const BubbleInput = (props: BubbleInputProps) => {
+const BubbleInput = React.forwardRef((props, ref) => {
const { control, checked, bubbles = true, defaultChecked, ...inputProps } = props;
- const ref = React.useRef(null);
+ const inputRef = React.useRef(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;
@@ -192,24 +211,26 @@ const BubbleInput = (props: BubbleInputProps) => {
const defaultCheckedRef = React.useRef(isIndeterminate(checked) ? false : checked);
return (
-
+
+
+
);
-};
+});
function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
return checked === 'indeterminate';