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';