diff --git a/docs/reference/generated/checkbox-indicator.json b/docs/reference/generated/checkbox-indicator.json index bf7e961917..8cedf08ff9 100644 --- a/docs/reference/generated/checkbox-indicator.json +++ b/docs/reference/generated/checkbox-indicator.json @@ -44,6 +44,12 @@ "data-touched": { "description": "Present when the checkbox has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the checkbox is checked (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the checkbox is focused (when wrapped in Field.Root)." + }, "data-starting-style": { "description": "Present when the checkbox indicator is animating in." }, diff --git a/docs/reference/generated/checkbox-root.json b/docs/reference/generated/checkbox-root.json index 8fb1d47b6a..7747487552 100644 --- a/docs/reference/generated/checkbox-root.json +++ b/docs/reference/generated/checkbox-root.json @@ -90,6 +90,12 @@ }, "data-touched": { "description": "Present when the checkbox has been touched (when wrapped in Field.Root)." + }, + "data-filled": { + "description": "Present when the checkbox is checked (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the checkbox is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/field-control.json b/docs/reference/generated/field-control.json index bde9519997..e4f9b540c7 100644 --- a/docs/reference/generated/field-control.json +++ b/docs/reference/generated/field-control.json @@ -30,6 +30,12 @@ }, "data-touched": { "description": "Present when the field has been touched." + }, + "data-filled": { + "description": "Present when the field is filled." + }, + "data-focused": { + "description": "Present when the field control is focused." } }, "cssVariables": {} diff --git a/docs/reference/generated/field-description.json b/docs/reference/generated/field-description.json index abee043672..8039e9b852 100644 --- a/docs/reference/generated/field-description.json +++ b/docs/reference/generated/field-description.json @@ -26,6 +26,12 @@ }, "data-touched": { "description": "Present when the field has been touched." + }, + "data-filled": { + "description": "Present when the field is filled." + }, + "data-focused": { + "description": "Present when the field control is focused." } }, "cssVariables": {} diff --git a/docs/reference/generated/field-error.json b/docs/reference/generated/field-error.json index 33d77c209a..c4514b9017 100644 --- a/docs/reference/generated/field-error.json +++ b/docs/reference/generated/field-error.json @@ -20,6 +20,9 @@ } }, "dataAttributes": { + "data-disabled": { + "description": "Present when the field is disabled." + }, "data-valid": { "description": "Present when the field is in valid state." }, @@ -31,6 +34,12 @@ }, "data-touched": { "description": "Present when the field has been touched." + }, + "data-filled": { + "description": "Present when the field is filled." + }, + "data-focused": { + "description": "Present when the field control is focused." } }, "cssVariables": {} diff --git a/docs/reference/generated/field-label.json b/docs/reference/generated/field-label.json index d3768ffb17..bcfdd6383c 100644 --- a/docs/reference/generated/field-label.json +++ b/docs/reference/generated/field-label.json @@ -26,6 +26,12 @@ }, "data-touched": { "description": "Present when the field has been touched." + }, + "data-filled": { + "description": "Present when the field is filled." + }, + "data-focused": { + "description": "Present when the field control is focused." } }, "cssVariables": {} diff --git a/docs/reference/generated/field-root.json b/docs/reference/generated/field-root.json index 6f51f1933f..cb1e0f6168 100644 --- a/docs/reference/generated/field-root.json +++ b/docs/reference/generated/field-root.json @@ -42,11 +42,23 @@ "data-disabled": { "description": "Present when the field is disabled." }, + "data-valid": { + "description": "Present when the field is valid." + }, + "data-invalid": { + "description": "Present when the field is invalid." + }, "data-dirty": { "description": "Present when the field's value has changed." }, "data-touched": { "description": "Present when the field has been touched." + }, + "data-filled": { + "description": "Present when the field is filled." + }, + "data-focused": { + "description": "Present when the field control is focused." } }, "cssVariables": {} diff --git a/docs/reference/generated/input.json b/docs/reference/generated/input.json index 34354e2489..9c1f936feb 100644 --- a/docs/reference/generated/input.json +++ b/docs/reference/generated/input.json @@ -26,6 +26,12 @@ }, "data-touched": { "description": "Present when the input has been touched." + }, + "data-filled": { + "description": "Present when the input is filled." + }, + "data-focused": { + "description": "Present when the input is focused." } }, "cssVariables": {} diff --git a/docs/reference/generated/number-field-decrement.json b/docs/reference/generated/number-field-decrement.json index 4942d9bfc4..3b896540aa 100644 --- a/docs/reference/generated/number-field-decrement.json +++ b/docs/reference/generated/number-field-decrement.json @@ -33,6 +33,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/number-field-group.json b/docs/reference/generated/number-field-group.json index 00e4a70975..257c966a7b 100644 --- a/docs/reference/generated/number-field-group.json +++ b/docs/reference/generated/number-field-group.json @@ -33,6 +33,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/number-field-increment.json b/docs/reference/generated/number-field-increment.json index 1a26290904..0da9522d10 100644 --- a/docs/reference/generated/number-field-increment.json +++ b/docs/reference/generated/number-field-increment.json @@ -33,6 +33,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/number-field-input.json b/docs/reference/generated/number-field-input.json index 3e33678dfd..400f65b637 100644 --- a/docs/reference/generated/number-field-input.json +++ b/docs/reference/generated/number-field-input.json @@ -33,6 +33,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/number-field-root.json b/docs/reference/generated/number-field-root.json index 51717f6b06..f37ff8d6d3 100644 --- a/docs/reference/generated/number-field-root.json +++ b/docs/reference/generated/number-field-root.json @@ -110,6 +110,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/number-field-scrub-area-cursor.json b/docs/reference/generated/number-field-scrub-area-cursor.json index fc0d4b5ee5..92f8d8518e 100644 --- a/docs/reference/generated/number-field-scrub-area-cursor.json +++ b/docs/reference/generated/number-field-scrub-area-cursor.json @@ -33,6 +33,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/number-field-scrub-area.json b/docs/reference/generated/number-field-scrub-area.json index ac0c86d7e7..ebf995b651 100644 --- a/docs/reference/generated/number-field-scrub-area.json +++ b/docs/reference/generated/number-field-scrub-area.json @@ -47,6 +47,12 @@ "data-touched": { "description": "Present when the number field has been touched (when wrapped in Field.Root)." }, + "data-filled": { + "description": "Present when the number field is filled (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the number field is focused (when wrapped in Field.Root)." + }, "data-scrubbing": { "description": "Present while scrubbing." } diff --git a/docs/reference/generated/radio-indicator.json b/docs/reference/generated/radio-indicator.json index ebe8fd7160..95ca1634a6 100644 --- a/docs/reference/generated/radio-indicator.json +++ b/docs/reference/generated/radio-indicator.json @@ -43,6 +43,12 @@ }, "data-touched": { "description": "Present when the radio has been touched (when wrapped in Field.Root)." + }, + "data-filled": { + "description": "Present when the radio is checked (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the radio is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/radio-root.json b/docs/reference/generated/radio-root.json index e9e625756a..802113f470 100644 --- a/docs/reference/generated/radio-root.json +++ b/docs/reference/generated/radio-root.json @@ -58,6 +58,12 @@ }, "data-touched": { "description": "Present when the radio has been touched (when wrapped in Field.Root)." + }, + "data-filled": { + "description": "Present when the radio is checked (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the radio is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/select-trigger.json b/docs/reference/generated/select-trigger.json index a1719d2882..0df23465d1 100644 --- a/docs/reference/generated/select-trigger.json +++ b/docs/reference/generated/select-trigger.json @@ -43,6 +43,12 @@ }, "data-touched": { "description": "Present when the select has been touched (when wrapped in Field.Root)." + }, + "data-filled": { + "description": "Present when the select has a value (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the select trigger is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/slider-control.json b/docs/reference/generated/slider-control.json index 7e3f90ca1b..78e92a3bad 100644 --- a/docs/reference/generated/slider-control.json +++ b/docs/reference/generated/slider-control.json @@ -39,6 +39,9 @@ }, "data-touched": { "description": "Present when the slider has been touched (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the slider is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/slider-indicator.json b/docs/reference/generated/slider-indicator.json index 663a5de831..6416d24760 100644 --- a/docs/reference/generated/slider-indicator.json +++ b/docs/reference/generated/slider-indicator.json @@ -39,6 +39,9 @@ }, "data-touched": { "description": "Present when the slider has been touched (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the slider is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/slider-root.json b/docs/reference/generated/slider-root.json index d432e1c472..01ef679553 100644 --- a/docs/reference/generated/slider-root.json +++ b/docs/reference/generated/slider-root.json @@ -106,6 +106,9 @@ }, "data-touched": { "description": "Present when the slider has been touched (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the slider is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/slider-thumb.json b/docs/reference/generated/slider-thumb.json index 88568a6eda..32d3c62b09 100644 --- a/docs/reference/generated/slider-thumb.json +++ b/docs/reference/generated/slider-thumb.json @@ -60,6 +60,9 @@ }, "data-touched": { "description": "Present when the slider has been touched (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the slider is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/slider-track.json b/docs/reference/generated/slider-track.json index 98c7248c0e..5da9293c5d 100644 --- a/docs/reference/generated/slider-track.json +++ b/docs/reference/generated/slider-track.json @@ -39,6 +39,9 @@ }, "data-touched": { "description": "Present when the slider has been touched (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the slider is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/slider-value.json b/docs/reference/generated/slider-value.json index 2a98492617..277d2d6aff 100644 --- a/docs/reference/generated/slider-value.json +++ b/docs/reference/generated/slider-value.json @@ -43,6 +43,9 @@ }, "data-touched": { "description": "Present when the slider has been touched (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the slider is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/switch-root.json b/docs/reference/generated/switch-root.json index 3b1944ec69..41e4cc2bf5 100644 --- a/docs/reference/generated/switch-root.json +++ b/docs/reference/generated/switch-root.json @@ -74,6 +74,12 @@ }, "data-touched": { "description": "Present when the switch has been touched (when wrapped in Field.Root)." + }, + "data-filled": { + "description": "Present when the switch is active (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the switch is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/docs/reference/generated/switch-thumb.json b/docs/reference/generated/switch-thumb.json index b06ddf88ad..73399c292b 100644 --- a/docs/reference/generated/switch-thumb.json +++ b/docs/reference/generated/switch-thumb.json @@ -38,6 +38,12 @@ }, "data-touched": { "description": "Present when the switch has been touched (when wrapped in Field.Root)." + }, + "data-filled": { + "description": "Present when the switch is active (when wrapped in Field.Root)." + }, + "data-focused": { + "description": "Present when the switch is focused (when wrapped in Field.Root)." } }, "cssVariables": {} diff --git a/packages/react/src/checkbox/indicator/CheckboxIndicator.test.tsx b/packages/react/src/checkbox/indicator/CheckboxIndicator.test.tsx index 3bc3f913f9..7b8d63d629 100644 --- a/packages/react/src/checkbox/indicator/CheckboxIndicator.test.tsx +++ b/packages/react/src/checkbox/indicator/CheckboxIndicator.test.tsx @@ -14,6 +14,8 @@ const testContext = { dirty: false, touched: false, valid: null, + filled: false, + focused: false, }; describe('', () => { diff --git a/packages/react/src/checkbox/indicator/CheckboxIndicatorDataAttributes.ts b/packages/react/src/checkbox/indicator/CheckboxIndicatorDataAttributes.ts index d35a3b0401..110fca2b4b 100644 --- a/packages/react/src/checkbox/indicator/CheckboxIndicatorDataAttributes.ts +++ b/packages/react/src/checkbox/indicator/CheckboxIndicatorDataAttributes.ts @@ -43,4 +43,12 @@ export enum CheckboxIndicatorDataAttributes { * Present when the checkbox's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the checkbox is checked (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the checkbox is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/checkbox/root/CheckboxRootDataAttributes.ts b/packages/react/src/checkbox/root/CheckboxRootDataAttributes.ts index 83c0c81a37..0e1bac0122 100644 --- a/packages/react/src/checkbox/root/CheckboxRootDataAttributes.ts +++ b/packages/react/src/checkbox/root/CheckboxRootDataAttributes.ts @@ -35,4 +35,12 @@ export enum CheckboxRootDataAttributes { * Present when the checkbox's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the checkbox is checked (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the checkbox is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/checkbox/root/useCheckboxRoot.ts b/packages/react/src/checkbox/root/useCheckboxRoot.ts index 1d5f869db0..0af036c73e 100644 --- a/packages/react/src/checkbox/root/useCheckboxRoot.ts +++ b/packages/react/src/checkbox/root/useCheckboxRoot.ts @@ -40,7 +40,8 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox state: 'checked', }); - const { labelId, setControlId, setTouched, setDirty, validityData } = useFieldRootContext(); + const { labelId, setControlId, setTouched, setDirty, validityData, setFilled, setFocused } = + useFieldRootContext(); const buttonRef = React.useRef(null); @@ -71,11 +72,14 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox const inputRef = React.useRef(null); const mergedInputRef = useForkRef(externalInputRef, inputRef, inputValidationRef); - React.useEffect(() => { + useEnhancedEffect(() => { if (inputRef.current) { inputRef.current.indeterminate = indeterminate; + if (checked) { + setFilled(true); + } } - }, [indeterminate]); + }, [checked, indeterminate, setFilled]); const getButtonProps: UseCheckboxRoot.ReturnValue['getButtonProps'] = React.useCallback( (externalProps = {}) => @@ -88,12 +92,17 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox 'aria-checked': indeterminate ? 'mixed' : checked, 'aria-readonly': readOnly || undefined, 'aria-labelledby': labelId, + onFocus() { + setFocused(true); + }, onBlur() { const element = inputRef.current; if (!element) { return; } + setTouched(true); + setFocused(false); commitValidation(element.checked); }, onClick(event) { @@ -107,13 +116,14 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox }, }), [ - id, getValidationProps, + id, + disabled, indeterminate, checked, - disabled, readOnly, labelId, + setFocused, setTouched, commitValidation, ], @@ -143,6 +153,10 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox const nextChecked = event.target.checked; + if (!groupContext) { + setFilled(nextChecked); + } + setDirty(nextChecked !== validityData.initialValue); setCheckedState(nextChecked); onCheckedChange?.(nextChecked, event.nativeEvent); @@ -152,6 +166,7 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox ? [...groupValue, name] : groupValue.filter((item) => item !== name); setGroupValue(nextGroupValue, event.nativeEvent); + setFilled(nextGroupValue.length > 0); } }, }), @@ -164,12 +179,14 @@ export function useCheckboxRoot(params: UseCheckboxRoot.Parameters): UseCheckbox required, autoFocus, mergedInputRef, + groupContext, setDirty, validityData.initialValue, setCheckedState, onCheckedChange, groupValue, setGroupValue, + setFilled, ], ); diff --git a/packages/react/src/field/control/FieldControlDataAttributes.ts b/packages/react/src/field/control/FieldControlDataAttributes.ts index 0a07cb1f8b..907471ba0d 100644 --- a/packages/react/src/field/control/FieldControlDataAttributes.ts +++ b/packages/react/src/field/control/FieldControlDataAttributes.ts @@ -19,4 +19,12 @@ export enum FieldControlDataAttributes { * Present when the field's value has changed. */ dirty = 'data-dirty', + /** + * Present when the field is filled. + */ + filled = 'data-filled', + /** + * Present when the field control is focused. + */ + focused = 'data-focused', } diff --git a/packages/react/src/field/control/useFieldControl.ts b/packages/react/src/field/control/useFieldControl.ts index b775e46361..1216543640 100644 --- a/packages/react/src/field/control/useFieldControl.ts +++ b/packages/react/src/field/control/useFieldControl.ts @@ -13,7 +13,8 @@ import { useEventCallback } from '../../utils/useEventCallback'; export function useFieldControl(params: useFieldControl.Parameters) { const { id: idProp, name, value: valueProp, defaultValue, onValueChange, disabled } = params; - const { setControlId, labelId, setTouched, setDirty, validityData } = useFieldRootContext(); + const { setControlId, labelId, setTouched, setDirty, validityData, setFocused, setFilled } = + useFieldRootContext(); const { errors, onClearErrors } = useFormContext(); @@ -29,6 +30,12 @@ export function useFieldControl(params: useFieldControl.Parameters) { }; }, [id, setControlId]); + useEnhancedEffect(() => { + if (inputRef.current?.value) { + setFilled(true); + } + }, [inputRef, setFilled]); + const [value, setValueUnwrapped] = useControlled({ controlled: valueProp, default: defaultValue, @@ -64,15 +71,22 @@ export function useFieldControl(params: useFieldControl.Parameters) { if (value != null) { setValue(event.currentTarget.value, event.nativeEvent); } + setDirty(event.currentTarget.value !== validityData.initialValue); + setFilled(event.currentTarget.value !== ''); + if (name && {}.hasOwnProperty.call(errors, name)) { const nextErrors = { ...errors }; delete nextErrors[name]; onClearErrors(nextErrors); } }, + onFocus() { + setFocused(true); + }, onBlur(event) { setTouched(true); + setFocused(false); commitValidation(event.currentTarget.value); }, onKeyDown(event) { @@ -91,11 +105,13 @@ export function useFieldControl(params: useFieldControl.Parameters) { inputRef, labelId, value, - setValue, setDirty, validityData.initialValue, + setFilled, errors, + setValue, onClearErrors, + setFocused, setTouched, commitValidation, ], diff --git a/packages/react/src/field/description/FieldDescriptionDataAttributes.ts b/packages/react/src/field/description/FieldDescriptionDataAttributes.ts index 7d656c48d0..a3ff8d4b9b 100644 --- a/packages/react/src/field/description/FieldDescriptionDataAttributes.ts +++ b/packages/react/src/field/description/FieldDescriptionDataAttributes.ts @@ -19,4 +19,12 @@ export enum FieldDescriptionDataAttributes { * Present when the field's value has changed. */ dirty = 'data-dirty', + /** + * Present when the field is filled. + */ + filled = 'data-filled', + /** + * Present when the field control is focused. + */ + focused = 'data-focused', } diff --git a/packages/react/src/field/error/FieldErrorDataAttributes.ts b/packages/react/src/field/error/FieldErrorDataAttributes.ts index 570c81ea66..6bd638bc9a 100644 --- a/packages/react/src/field/error/FieldErrorDataAttributes.ts +++ b/packages/react/src/field/error/FieldErrorDataAttributes.ts @@ -1,4 +1,8 @@ export enum FieldErrorDataAttributes { + /** + * Present when the field is disabled. + */ + disabled = 'data-disabled', /** * Present when the field is in valid state. */ @@ -15,4 +19,12 @@ export enum FieldErrorDataAttributes { * Present when the field's value has changed. */ dirty = 'data-dirty', + /** + * Present when the field is filled. + */ + filled = 'data-filled', + /** + * Present when the field control is focused. + */ + focused = 'data-focused', } diff --git a/packages/react/src/field/label/FieldLabelDataAttributes.ts b/packages/react/src/field/label/FieldLabelDataAttributes.ts index 129e79ffc6..52bb02ff52 100644 --- a/packages/react/src/field/label/FieldLabelDataAttributes.ts +++ b/packages/react/src/field/label/FieldLabelDataAttributes.ts @@ -19,4 +19,12 @@ export enum FieldLabelDataAttributes { * Present when the field's value has changed. */ dirty = 'data-dirty', + /** + * Present when the field is filled. + */ + filled = 'data-filled', + /** + * Present when the field control is focused. + */ + focused = 'data-focused', } diff --git a/packages/react/src/field/root/FieldRoot.test.tsx b/packages/react/src/field/root/FieldRoot.test.tsx index 56c6205bdf..ba5af4c2f7 100644 --- a/packages/react/src/field/root/FieldRoot.test.tsx +++ b/packages/react/src/field/root/FieldRoot.test.tsx @@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event'; import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; import { expect } from 'chai'; import { createRenderer, describeConformance } from '#test-utils'; +import { CheckboxGroup } from '@base-ui-components/react/checkbox-group'; const user = userEvent.setup(); @@ -717,5 +718,474 @@ describe('', () => { expect(trigger).to.have.attribute('data-dirty', ''); }); }); + + describe('filled', async () => { + it('should apply [data-filled] style hook to all components when filled', async () => { + await render( + + + + + + , + ); + + const root = screen.getByTestId('root'); + const control = screen.getByTestId('control'); + const label = screen.getByTestId('label'); + const description = screen.getByTestId('description'); + + expect(root).not.to.have.attribute('data-filled'); + expect(control).not.to.have.attribute('data-filled'); + expect(label).not.to.have.attribute('data-filled'); + expect(description).not.to.have.attribute('data-filled'); + + fireEvent.change(control, { target: { value: 'value' } }); + + expect(root).to.have.attribute('data-filled', ''); + expect(control).to.have.attribute('data-filled', ''); + expect(label).to.have.attribute('data-filled', ''); + expect(description).to.have.attribute('data-filled', ''); + + fireEvent.change(control, { target: { value: '' } }); + + expect(root).not.to.have.attribute('data-filled'); + expect(control).not.to.have.attribute('data-filled'); + expect(label).not.to.have.attribute('data-filled'); + expect(description).not.to.have.attribute('data-filled'); + }); + + describe('Checkbox', () => { + it('adds [data-filled] attribute when checked after being initially unchecked', async () => { + await render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('data-filled'); + + fireEvent.click(button); + + expect(button).to.have.attribute('data-filled', ''); + + fireEvent.click(button); + + expect(button).not.to.have.attribute('data-filled'); + }); + + it('removes [data-filled] attribute when unchecked after being initially checked', async () => { + await render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).to.have.attribute('data-filled'); + + fireEvent.click(button); + + expect(button).not.to.have.attribute('data-filled', ''); + }); + + it('adds [data-filled] attribute when any checkbox is filled when inside a group', async () => { + await render( + + + + + + , + ); + + const button1 = screen.getByTestId('button-1'); + const button2 = screen.getByTestId('button-2'); + + expect(button1).to.have.attribute('data-filled'); + expect(button2).to.have.attribute('data-filled'); + + fireEvent.click(button1); + + expect(button1).to.have.attribute('data-filled'); + expect(button2).to.have.attribute('data-filled'); + + fireEvent.click(button2); + + expect(button1).not.to.have.attribute('data-filled'); + expect(button2).not.to.have.attribute('data-filled'); + }); + }); + + describe('Switch', () => { + it('adds [data-filled] attribute when checked after being initially unchecked', async () => { + await render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('data-filled'); + + fireEvent.click(button); + + expect(button).to.have.attribute('data-filled', ''); + + fireEvent.click(button); + + expect(button).not.to.have.attribute('data-filled'); + }); + + it('removes [data-filled] attribute when unchecked after being initially checked', async () => { + await render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).to.have.attribute('data-filled'); + + fireEvent.click(button); + + expect(button).not.to.have.attribute('data-filled', ''); + }); + }); + + describe('NumberField', () => { + it('adds [data-filled] attribute when filled', async () => { + await render( + + + + + , + ); + + const input = screen.getByTestId('input'); + + expect(input).not.to.have.attribute('data-filled'); + + fireEvent.change(input, { target: { value: '1' } }); + + expect(input).to.have.attribute('data-filled', ''); + + fireEvent.change(input, { target: { value: '' } }); + + expect(input).not.to.have.attribute('data-filled'); + }); + + it('has [data-filled] attribute when already filled', async () => { + await render( + + + + + , + ); + + const input = screen.getByTestId('input'); + + expect(input).to.have.attribute('data-filled'); + + fireEvent.change(input, { target: { value: '' } }); + + expect(input).not.to.have.attribute('data-filled'); + }); + }); + + describe('Select', () => { + it('adds [data-filled] attribute when filled', async () => { + await render( + + + + + + + Select + Option 1 + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger).not.to.have.attribute('data-filled'); + + await userEvent.click(trigger); + + await flushMicrotasks(); + + const option = screen.getByRole('option', { name: 'Option 1' }); + + // Arrow Down to focus the Option 1 + await user.keyboard('{ArrowDown}'); + + await userEvent.click(option); + + await flushMicrotasks(); + + expect(trigger).to.have.attribute('data-filled', ''); + + await userEvent.click(trigger); + + await flushMicrotasks(); + + const select = screen.getByRole('listbox'); + + expect(select).not.to.have.attribute('data-filled'); + }); + + it('adds [data-filled] attribute when already filled', async () => { + await render( + + + + + + + Option 1 + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger).to.have.attribute('data-filled'); + }); + }); + + describe('RadioGroup', () => { + it('adds [data-filled] attribute when filled', async () => { + await render( + + + One + Two + + , + ); + + const group = screen.getByTestId('group'); + + expect(group).not.to.have.attribute('data-filled'); + + fireEvent.click(screen.getByText('One')); + + expect(group).to.have.attribute('data-filled', ''); + + fireEvent.click(screen.getByText('Two')); + + expect(group).to.have.attribute('data-filled', ''); + }); + + it('adds [data-filled] attribute when already filled initially', async () => { + await render( + + + One + Two + + , + ); + + const group = screen.getByTestId('group'); + + expect(group).to.have.attribute('data-filled'); + }); + }); + }); + + describe('focused', () => { + it('should apply [data-focused] style hook to all components when focused', async () => { + await render( + + + + + + , + ); + + const root = screen.getByTestId('root'); + const control = screen.getByTestId('control'); + const label = screen.getByTestId('label'); + const description = screen.getByTestId('description'); + + expect(root).not.to.have.attribute('data-focused'); + expect(control).not.to.have.attribute('data-focused'); + expect(label).not.to.have.attribute('data-focused'); + expect(description).not.to.have.attribute('data-focused'); + + fireEvent.focus(control); + + expect(root).to.have.attribute('data-focused', ''); + expect(control).to.have.attribute('data-focused', ''); + expect(label).to.have.attribute('data-focused', ''); + expect(description).to.have.attribute('data-focused', ''); + + fireEvent.blur(control); + + expect(root).not.to.have.attribute('data-focused'); + expect(control).not.to.have.attribute('data-focused'); + expect(label).not.to.have.attribute('data-focused'); + expect(description).not.to.have.attribute('data-focused'); + }); + + it('supports Checkbox', async () => { + await render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('data-focused'); + + fireEvent.focus(button); + + expect(button).to.have.attribute('data-focused', ''); + + fireEvent.blur(button); + + expect(button).not.to.have.attribute('data-focused'); + }); + + it('supports Switch', async () => { + await render( + + + , + ); + + const button = screen.getByTestId('button'); + + expect(button).not.to.have.attribute('data-focused'); + + fireEvent.focus(button); + + expect(button).to.have.attribute('data-focused', ''); + + fireEvent.blur(button); + + expect(button).not.to.have.attribute('data-focused'); + }); + + it('supports NumberField', async () => { + await render( + + + + + , + ); + + const input = screen.getByTestId('input'); + + expect(input).not.to.have.attribute('data-focused'); + + fireEvent.focus(input); + + expect(input).to.have.attribute('data-focused', ''); + + fireEvent.blur(input); + + expect(input).not.to.have.attribute('data-focused'); + }); + + it('supports Slider', async () => { + const { container } = await render( + + + + + + + , + ); + + const root = screen.getByTestId('root'); + // eslint-disable-next-line testing-library/no-node-access + const input = container.querySelector('input')!; + + expect(root).not.to.have.attribute('data-focused'); + + fireEvent.focus(input); + + expect(root).to.have.attribute('data-focused', ''); + + fireEvent.blur(input); + + expect(root).not.to.have.attribute('data-focused'); + }); + + it('supports Select', async () => { + await render( + + + + + + + Select + Option 1 + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger).not.to.have.attribute('data-focused'); + + fireEvent.focus(trigger); + + expect(trigger).to.have.attribute('data-focused', ''); + + fireEvent.blur(trigger); + + expect(trigger).not.to.have.attribute('data-focused'); + }); + + it('supports RadioGroup', async () => { + await render( + + + One + Two + + , + ); + + const group = screen.getByTestId('group'); + const radio = screen.getByText('One'); + + expect(group).not.to.have.attribute('data-focused'); + + fireEvent.focus(radio); + + expect(group).to.have.attribute('data-focused', ''); + + fireEvent.blur(radio); + + expect(group).not.to.have.attribute('data-focused'); + }); + }); }); }); diff --git a/packages/react/src/field/root/FieldRoot.tsx b/packages/react/src/field/root/FieldRoot.tsx index 56f46add8e..5e4d942291 100644 --- a/packages/react/src/field/root/FieldRoot.tsx +++ b/packages/react/src/field/root/FieldRoot.tsx @@ -45,6 +45,8 @@ const FieldRoot = React.forwardRef(function FieldRoot( const [touched, setTouched] = React.useState(false); const [dirty, setDirtyUnwrapped] = React.useState(false); + const [filled, setFilled] = React.useState(false); + const [focused, setFocused] = React.useState(false); const markedDirtyRef = React.useRef(false); @@ -73,8 +75,10 @@ const FieldRoot = React.forwardRef(function FieldRoot( touched, dirty, valid, + filled, + focused, }), - [disabled, touched, dirty, valid], + [disabled, touched, dirty, valid, filled, focused], ); const contextValue: FieldRootContext = React.useMemo( @@ -94,6 +98,10 @@ const FieldRoot = React.forwardRef(function FieldRoot( setTouched, dirty, setDirty, + filled, + setFilled, + focused, + setFocused, validate, validationMode, validationDebounceTime, @@ -111,6 +119,10 @@ const FieldRoot = React.forwardRef(function FieldRoot( touched, dirty, setDirty, + filled, + setFilled, + focused, + setFocused, validate, validationMode, validationDebounceTime, @@ -161,6 +173,8 @@ namespace FieldRoot { touched: boolean; dirty: boolean; valid: boolean | null; + filled: boolean; + focused: boolean; } export interface Props extends BaseUIComponentProps<'div', State> { diff --git a/packages/react/src/field/root/FieldRootContext.ts b/packages/react/src/field/root/FieldRootContext.ts index f18cc6dae2..b5eee4859a 100644 --- a/packages/react/src/field/root/FieldRootContext.ts +++ b/packages/react/src/field/root/FieldRootContext.ts @@ -21,6 +21,10 @@ export interface FieldRootContext { setTouched: React.Dispatch>; dirty: boolean; setDirty: React.Dispatch>; + filled: boolean; + setFilled: React.Dispatch>; + focused: boolean; + setFocused: React.Dispatch>; validate: (value: unknown) => string | string[] | null | Promise; validationMode: 'onBlur' | 'onChange'; validationDebounceTime: number; @@ -50,6 +54,10 @@ export const FieldRootContext = React.createContext({ setTouched: NOOP, dirty: false, setDirty: NOOP, + filled: false, + setFilled: NOOP, + focused: false, + setFocused: NOOP, validate: () => null, validationMode: 'onBlur', validationDebounceTime: 0, @@ -58,6 +66,8 @@ export const FieldRootContext = React.createContext({ valid: null, touched: false, dirty: false, + filled: false, + focused: false, }, markedDirtyRef: { current: false }, }); diff --git a/packages/react/src/field/root/FieldRootDataAttributes.ts b/packages/react/src/field/root/FieldRootDataAttributes.ts index f484ca706d..46125622d3 100644 --- a/packages/react/src/field/root/FieldRootDataAttributes.ts +++ b/packages/react/src/field/root/FieldRootDataAttributes.ts @@ -11,4 +11,20 @@ export enum FieldRootDataAttributes { * Present when the field's value has changed. */ dirty = 'data-dirty', + /** + * Present when the field is valid. + */ + valid = 'data-valid', + /** + * Present when the field is invalid. + */ + invalid = 'data-invalid', + /** + * Present when the field is filled. + */ + filled = 'data-filled', + /** + * Present when the field control is focused. + */ + focused = 'data-focused', } diff --git a/packages/react/src/input/InputDataAttributes.ts b/packages/react/src/input/InputDataAttributes.ts index eeacc8ed74..8540f0b1c6 100644 --- a/packages/react/src/input/InputDataAttributes.ts +++ b/packages/react/src/input/InputDataAttributes.ts @@ -19,4 +19,12 @@ export enum InputDataAttributes { * Present when the input's value has changed. */ dirty = 'data-dirty', + /** + * Present when the input is filled. + */ + filled = 'data-filled', + /** + * Present when the input is focused. + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/decrement/NumberFieldDecrementDataAttributes.ts b/packages/react/src/number-field/decrement/NumberFieldDecrementDataAttributes.ts index 0e5506a471..553baf16a6 100644 --- a/packages/react/src/number-field/decrement/NumberFieldDecrementDataAttributes.ts +++ b/packages/react/src/number-field/decrement/NumberFieldDecrementDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldDecrementDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/group/NumberFieldGroupDataAttributes.ts b/packages/react/src/number-field/group/NumberFieldGroupDataAttributes.ts index 7e54f6aa70..5fee741301 100644 --- a/packages/react/src/number-field/group/NumberFieldGroupDataAttributes.ts +++ b/packages/react/src/number-field/group/NumberFieldGroupDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldGroupDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/increment/NumberFieldIncrementDataAttributes.ts b/packages/react/src/number-field/increment/NumberFieldIncrementDataAttributes.ts index bfc439f1ac..dafda83bc2 100644 --- a/packages/react/src/number-field/increment/NumberFieldIncrementDataAttributes.ts +++ b/packages/react/src/number-field/increment/NumberFieldIncrementDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldIncrementDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/input/NumberFieldInputDataAttributes.ts b/packages/react/src/number-field/input/NumberFieldInputDataAttributes.ts index ecd75d70cf..8590808740 100644 --- a/packages/react/src/number-field/input/NumberFieldInputDataAttributes.ts +++ b/packages/react/src/number-field/input/NumberFieldInputDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldInputDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/root/NumberFieldRootDataAttributes.ts b/packages/react/src/number-field/root/NumberFieldRootDataAttributes.ts index 1a6c8b50a8..d79a7d9062 100644 --- a/packages/react/src/number-field/root/NumberFieldRootDataAttributes.ts +++ b/packages/react/src/number-field/root/NumberFieldRootDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldRootDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/root/useNumberFieldRoot.ts b/packages/react/src/number-field/root/useNumberFieldRoot.ts index 98eef85a07..1ad33c6414 100644 --- a/packages/react/src/number-field/root/useNumberFieldRoot.ts +++ b/packages/react/src/number-field/root/useNumberFieldRoot.ts @@ -65,6 +65,8 @@ export function useNumberFieldRoot( validityData, setValidityData, disabled: fieldDisabled, + setFocused, + setFilled, } = useFieldRootContext(); const { @@ -103,6 +105,10 @@ export function useNumberFieldRoot( const value = valueUnwrapped ?? null; const valueRef = useLatestRef(value); + useEnhancedEffect(() => { + setFilled(value !== null); + }, [setFilled, value]); + useField({ id, commitValidation, @@ -582,6 +588,7 @@ export function useNumberFieldRoot( } hasTouchedInputRef.current = true; + setFocused(true); // Browsers set selection at the start of the input field by default. We want to set it at // the end for the first focus. @@ -595,6 +602,7 @@ export function useNumberFieldRoot( } setTouched(true); + setFocused(false); commitValidation(valueRef.current); allowInputSyncRef.current = true; @@ -759,10 +767,11 @@ export function useNumberFieldRoot( mergedRef, invalid, labelId, + setFocused, setTouched, - formatOptionsRef, commitValidation, valueRef, + formatOptionsRef, setValue, getAllowedNonNumericKeys, getStepAmount, diff --git a/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursorDataAttributes.ts b/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursorDataAttributes.ts index 88eeef5e24..5d457d425f 100644 --- a/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursorDataAttributes.ts +++ b/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursorDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldScrubAreaCursorDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/number-field/scrub-area/NumberFieldScrubAreaDataAttributes.ts b/packages/react/src/number-field/scrub-area/NumberFieldScrubAreaDataAttributes.ts index a2af4bc1b4..41ca9b35c2 100644 --- a/packages/react/src/number-field/scrub-area/NumberFieldScrubAreaDataAttributes.ts +++ b/packages/react/src/number-field/scrub-area/NumberFieldScrubAreaDataAttributes.ts @@ -31,4 +31,12 @@ export enum NumberFieldScrubAreaDataAttributes { * Present when the number field's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the number field is filled (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the number field is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/radio-group/useRadioGroup.ts b/packages/react/src/radio-group/useRadioGroup.ts index 15b315fb4d..329ec42a4a 100644 --- a/packages/react/src/radio-group/useRadioGroup.ts +++ b/packages/react/src/radio-group/useRadioGroup.ts @@ -11,7 +11,7 @@ import { useField } from '../field/useField'; export function useRadioGroup(params: useRadioGroup.Parameters) { const { disabled = false, name, defaultValue, readOnly, value: externalValue } = params; - const { labelId, setTouched: setFieldTouched } = useFieldRootContext(); + const { labelId, setTouched: setFieldTouched, setFocused } = useFieldRootContext(); const { getValidationProps, @@ -45,9 +45,13 @@ export function useRadioGroup(params: useRadioGroup.Parameters) { 'aria-disabled': disabled || undefined, 'aria-readonly': readOnly || undefined, 'aria-labelledby': labelId, + onFocus() { + setFocused(true); + }, onBlur(event) { if (!contains(event.currentTarget, event.relatedTarget)) { setFieldTouched(true); + setFocused(false); commitValidation(checkedValue); } }, @@ -55,6 +59,7 @@ export function useRadioGroup(params: useRadioGroup.Parameters) { if (event.key.startsWith('Arrow')) { setFieldTouched(true); setTouched(true); + setFocused(true); } }, }), @@ -66,6 +71,7 @@ export function useRadioGroup(params: useRadioGroup.Parameters) { labelId, readOnly, setFieldTouched, + setFocused, ], ); diff --git a/packages/react/src/radio/indicator/RadioIndicatorDataAttributes.ts b/packages/react/src/radio/indicator/RadioIndicatorDataAttributes.ts index 89e5d879fd..b487db5528 100644 --- a/packages/react/src/radio/indicator/RadioIndicatorDataAttributes.ts +++ b/packages/react/src/radio/indicator/RadioIndicatorDataAttributes.ts @@ -35,4 +35,12 @@ export enum RadioIndicatorDataAttributes { * Present when the radio's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the radio is checked (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the radio is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/radio/root/RadioRootDataAttributes.ts b/packages/react/src/radio/root/RadioRootDataAttributes.ts index 87582b3800..ffaac72f43 100644 --- a/packages/react/src/radio/root/RadioRootDataAttributes.ts +++ b/packages/react/src/radio/root/RadioRootDataAttributes.ts @@ -35,4 +35,12 @@ export enum RadioRootDataAttributes { * Present when the radio's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the radio is checked (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the radio is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/radio/root/useRadioRoot.tsx b/packages/react/src/radio/root/useRadioRoot.tsx index 8487c5af9b..80385c3a46 100644 --- a/packages/react/src/radio/root/useRadioRoot.tsx +++ b/packages/react/src/radio/root/useRadioRoot.tsx @@ -4,6 +4,7 @@ import { mergeReactProps } from '../../utils/mergeReactProps'; import { visuallyHidden } from '../../utils/visuallyHidden'; import { useRadioGroupContext } from '../../radio-group/RadioGroupContext'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; export function useRadioRoot(params: useRadioRoot.Parameters) { const { disabled, readOnly, value, required } = params; @@ -11,12 +12,18 @@ export function useRadioRoot(params: useRadioRoot.Parameters) { const { checkedValue, setCheckedValue, onValueChange, touched, setTouched } = useRadioGroupContext(); - const { setDirty, validityData, setTouched: setFieldTouched } = useFieldRootContext(); + const { setDirty, validityData, setTouched: setFieldTouched, setFilled } = useFieldRootContext(); const checked = checkedValue === value; const inputRef = React.useRef(null); + useEnhancedEffect(() => { + if (inputRef.current?.checked) { + setFilled(true); + } + }, [setFilled]); + const getRootProps: useRadioRoot.ReturnValue['getRootProps'] = React.useCallback( (externalProps = {}) => mergeReactProps<'button'>(externalProps, { @@ -79,6 +86,7 @@ export function useRadioRoot(params: useRadioRoot.Parameters) { setFieldTouched(true); setDirty(value !== validityData.initialValue); setCheckedValue(value); + setFilled(true); onValueChange?.(value, event.nativeEvent); }, }), @@ -92,6 +100,7 @@ export function useRadioRoot(params: useRadioRoot.Parameters) { setDirty, validityData.initialValue, setCheckedValue, + setFilled, onValueChange, ], ); diff --git a/packages/react/src/select/root/useSelectRoot.ts b/packages/react/src/select/root/useSelectRoot.ts index cdada0d008..ea8753af9b 100644 --- a/packages/react/src/select/root/useSelectRoot.ts +++ b/packages/react/src/select/root/useSelectRoot.ts @@ -38,7 +38,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect modal = false, } = params; - const { setDirty, validityData, validationMode, setControlId } = useFieldRootContext(); + const { setDirty, validityData, validationMode, setControlId, setFilled } = useFieldRootContext(); const fieldControlValidation = useFieldControlValidation(); const id = useBaseUiId(idProp); @@ -64,6 +64,10 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect state: 'open', }); + useEnhancedEffect(() => { + setFilled(value !== null); + }, [setFilled, value]); + const [controlledAlignItemToTrigger, setcontrolledAlignItemToTrigger] = React.useState(alignItemToTriggerParam); diff --git a/packages/react/src/select/trigger/SelectTriggerDataAttributes.ts b/packages/react/src/select/trigger/SelectTriggerDataAttributes.ts index 1fe8fd17b9..3522d2014e 100644 --- a/packages/react/src/select/trigger/SelectTriggerDataAttributes.ts +++ b/packages/react/src/select/trigger/SelectTriggerDataAttributes.ts @@ -35,4 +35,12 @@ export enum SelectTriggerDataAttributes { * Present when the select's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the select has a value (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the select trigger is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/select/trigger/useSelectTrigger.ts b/packages/react/src/select/trigger/useSelectTrigger.ts index bd0b6a5f88..60c19e9ae9 100644 --- a/packages/react/src/select/trigger/useSelectTrigger.ts +++ b/packages/react/src/select/trigger/useSelectTrigger.ts @@ -29,7 +29,7 @@ export function useSelectTrigger( readOnly, } = useSelectRootContext(); - const { labelId, setTouched } = useFieldRootContext(); + const { labelId, setTouched, setFocused } = useFieldRootContext(); const triggerRef = React.useRef(null); const timeoutRef = React.useRef(-1); @@ -80,6 +80,7 @@ export function useSelectTrigger( tabIndex: disabled ? -1 : 0, // this is needed to make the button focused after click in Safari ref: handleRef, onFocus() { + setFocused(true); // The popup element shouldn't obscure the focused trigger. if (open && alignItemToTrigger) { setOpen(false); @@ -87,6 +88,7 @@ export function useSelectTrigger( }, onBlur() { setTouched(true); + setFocused(false); fieldControlValidation.commitValidation(value); }, onPointerMove({ pointerType }) { @@ -141,19 +143,20 @@ export function useSelectTrigger( ); }, [ - alignItemToTrigger, - disabled, - fieldControlValidation, getButtonProps, - handleRef, + fieldControlValidation, labelId, - open, - positionerElement, readOnly, + disabled, + handleRef, + setFocused, + open, + alignItemToTrigger, setOpen, setTouched, - setTouchModality, value, + setTouchModality, + positionerElement, ], ); diff --git a/packages/react/src/slider/control/SliderControl.test.tsx b/packages/react/src/slider/control/SliderControl.test.tsx index c8d075ccce..1541e8648b 100644 --- a/packages/react/src/slider/control/SliderControl.test.tsx +++ b/packages/react/src/slider/control/SliderControl.test.tsx @@ -38,6 +38,8 @@ const testRootContext: SliderRootContext = { valid: null, dirty: false, touched: false, + filled: false, + focused: false, }, percentageValues: [0], registerSliderControl: NOOP, diff --git a/packages/react/src/slider/control/SliderControlDataAttributes.ts b/packages/react/src/slider/control/SliderControlDataAttributes.ts index f12107da04..5745182b8c 100644 --- a/packages/react/src/slider/control/SliderControlDataAttributes.ts +++ b/packages/react/src/slider/control/SliderControlDataAttributes.ts @@ -36,4 +36,8 @@ export enum SliderControlDataAttributes { * Present when the slider's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the slider is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/slider/indicator/SliderIndicator.test.tsx b/packages/react/src/slider/indicator/SliderIndicator.test.tsx index e37fbe7c9f..b526120ccf 100644 --- a/packages/react/src/slider/indicator/SliderIndicator.test.tsx +++ b/packages/react/src/slider/indicator/SliderIndicator.test.tsx @@ -38,6 +38,8 @@ const testRootContext: SliderRootContext = { valid: null, dirty: false, touched: false, + filled: false, + focused: false, }, percentageValues: [0], registerSliderControl: NOOP, diff --git a/packages/react/src/slider/indicator/SliderIndicatorDataAttributes.ts b/packages/react/src/slider/indicator/SliderIndicatorDataAttributes.ts index aa85af9659..7e6bafb7fc 100644 --- a/packages/react/src/slider/indicator/SliderIndicatorDataAttributes.ts +++ b/packages/react/src/slider/indicator/SliderIndicatorDataAttributes.ts @@ -36,4 +36,8 @@ export enum SliderIndicatorDataAttributes { * Present when the slider's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the slider is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/slider/root/SliderRootDataAttributes.ts b/packages/react/src/slider/root/SliderRootDataAttributes.ts index 6b0091a16e..a24e7b691d 100644 --- a/packages/react/src/slider/root/SliderRootDataAttributes.ts +++ b/packages/react/src/slider/root/SliderRootDataAttributes.ts @@ -36,4 +36,8 @@ export enum SliderRootDataAttributes { * Present when the slider's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the slider is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/slider/thumb/SliderThumb.test.tsx b/packages/react/src/slider/thumb/SliderThumb.test.tsx index 1f5f1d89cd..7209f48ee6 100644 --- a/packages/react/src/slider/thumb/SliderThumb.test.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.test.tsx @@ -38,6 +38,8 @@ const testRootContext: SliderRootContext = { valid: null, dirty: false, touched: false, + filled: false, + focused: false, }, percentageValues: [0], registerSliderControl: NOOP, diff --git a/packages/react/src/slider/thumb/SliderThumbDataAttributes.ts b/packages/react/src/slider/thumb/SliderThumbDataAttributes.ts index ec15567983..4fc0bb788c 100644 --- a/packages/react/src/slider/thumb/SliderThumbDataAttributes.ts +++ b/packages/react/src/slider/thumb/SliderThumbDataAttributes.ts @@ -36,4 +36,8 @@ export enum SliderThumbDataAttributes { * Present when the slider's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the slider is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/slider/thumb/useSliderThumb.ts b/packages/react/src/slider/thumb/useSliderThumb.ts index b0e8c1042a..9ed6308c82 100644 --- a/packages/react/src/slider/thumb/useSliderThumb.ts +++ b/packages/react/src/slider/thumb/useSliderThumb.ts @@ -81,7 +81,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider } = parameters; const direction = useDirection(); - const { setTouched } = useFieldRootContext(); + const { setTouched, setFocused } = useFieldRootContext(); const { getInputValidationProps, inputRef: inputValidationRef, @@ -138,11 +138,15 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider return mergeReactProps(externalProps, { 'data-index': index, id: thumbId, + onFocus() { + setFocused(true); + }, onBlur() { if (!thumbRef.current) { return; } setTouched(true); + setFocused(false); commitValidation( getSliderValue(thumbValue, index, min, max, sliderValues.length > 1, sliderValues), ); @@ -226,6 +230,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider mergedThumbRef, min, minStepsBetweenValues, + setFocused, setTouched, sliderValues, step, diff --git a/packages/react/src/slider/track/SliderTrack.test.tsx b/packages/react/src/slider/track/SliderTrack.test.tsx index 3a4e003c6b..19be29b312 100644 --- a/packages/react/src/slider/track/SliderTrack.test.tsx +++ b/packages/react/src/slider/track/SliderTrack.test.tsx @@ -38,6 +38,8 @@ const testRootContext: SliderRootContext = { valid: null, dirty: false, touched: false, + filled: false, + focused: false, }, percentageValues: [0], registerSliderControl: NOOP, diff --git a/packages/react/src/slider/track/SliderTrackDataAttributes.ts b/packages/react/src/slider/track/SliderTrackDataAttributes.ts index 83612b2d20..f0515c6f16 100644 --- a/packages/react/src/slider/track/SliderTrackDataAttributes.ts +++ b/packages/react/src/slider/track/SliderTrackDataAttributes.ts @@ -36,4 +36,8 @@ export enum SliderTrackDataAttributes { * Present when the slider's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the slider is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/slider/value/SliderValue.test.tsx b/packages/react/src/slider/value/SliderValue.test.tsx index 632e61484e..dcce0fc724 100644 --- a/packages/react/src/slider/value/SliderValue.test.tsx +++ b/packages/react/src/slider/value/SliderValue.test.tsx @@ -40,6 +40,8 @@ const testRootContext: SliderRootContext = { valid: null, dirty: false, touched: false, + filled: false, + focused: false, }, percentageValues: [0], registerSliderControl: NOOP, diff --git a/packages/react/src/slider/value/SliderValueDataAttributes.ts b/packages/react/src/slider/value/SliderValueDataAttributes.ts index 603203945d..81e5e50426 100644 --- a/packages/react/src/slider/value/SliderValueDataAttributes.ts +++ b/packages/react/src/slider/value/SliderValueDataAttributes.ts @@ -36,4 +36,8 @@ export enum SliderValueDataAttributes { * Present when the slider's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the slider is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/switch/root/SwitchRootDataAttributes.ts b/packages/react/src/switch/root/SwitchRootDataAttributes.ts index 524a1b134c..f57ae7e157 100644 --- a/packages/react/src/switch/root/SwitchRootDataAttributes.ts +++ b/packages/react/src/switch/root/SwitchRootDataAttributes.ts @@ -35,4 +35,12 @@ export enum SwitchRootDataAttributes { * Present when the switch's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the switch is active (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the switch is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', } diff --git a/packages/react/src/switch/root/useSwitchRoot.ts b/packages/react/src/switch/root/useSwitchRoot.ts index 8fed2f4464..4fa000ea24 100644 --- a/packages/react/src/switch/root/useSwitchRoot.ts +++ b/packages/react/src/switch/root/useSwitchRoot.ts @@ -24,7 +24,8 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R inputRef: externalInputRef, } = params; - const { labelId, setControlId, setTouched, setDirty, validityData } = useFieldRootContext(); + const { labelId, setControlId, setTouched, setDirty, validityData, setFilled, setFocused } = + useFieldRootContext(); const { getValidationProps, @@ -63,6 +64,12 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R controlRef: buttonRef, }); + useEnhancedEffect(() => { + if (inputRef.current) { + setFilled(inputRef.current.checked); + } + }, [setFilled]); + const getButtonProps = React.useCallback( (otherProps = {}) => mergeReactProps<'button'>(getValidationProps(otherProps), { @@ -74,12 +81,17 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R 'aria-checked': checked, 'aria-readonly': readOnly, 'aria-labelledby': labelId, + onFocus() { + setFocused(true); + }, onBlur() { const element = inputRef.current; if (!element) { return; } + setTouched(true); + setFocused(false); commitValidation(element.checked); }, onClick(event) { @@ -90,7 +102,17 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R inputRef.current?.click(); }, }), - [getValidationProps, id, disabled, checked, readOnly, labelId, setTouched, commitValidation], + [ + getValidationProps, + id, + disabled, + checked, + readOnly, + labelId, + setFocused, + setTouched, + commitValidation, + ], ); const getInputProps = React.useCallback( @@ -114,6 +136,7 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R const nextChecked = event.target.checked; setDirty(nextChecked !== validityData.initialValue); + setFilled(nextChecked); setCheckedState(nextChecked); onCheckedChange?.(nextChecked, event.nativeEvent); }, @@ -127,6 +150,7 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R handleInputRef, setDirty, validityData.initialValue, + setFilled, setCheckedState, onCheckedChange, ], diff --git a/packages/react/src/switch/thumb/SwitchThumb.test.tsx b/packages/react/src/switch/thumb/SwitchThumb.test.tsx index 148c1eb795..f755605073 100644 --- a/packages/react/src/switch/thumb/SwitchThumb.test.tsx +++ b/packages/react/src/switch/thumb/SwitchThumb.test.tsx @@ -10,6 +10,8 @@ const testContext: SwitchRootContext = { required: false, dirty: false, touched: false, + filled: false, + focused: false, valid: null, }; diff --git a/packages/react/src/switch/thumb/SwitchThumbDataAttributes.ts b/packages/react/src/switch/thumb/SwitchThumbDataAttributes.ts index 12ba7ecc29..1e97a98069 100644 --- a/packages/react/src/switch/thumb/SwitchThumbDataAttributes.ts +++ b/packages/react/src/switch/thumb/SwitchThumbDataAttributes.ts @@ -35,4 +35,12 @@ export enum SwitchThumbDataAttributes { * Present when the switch's value has changed (when wrapped in Field.Root). */ dirty = 'data-dirty', + /** + * Present when the switch is active (when wrapped in Field.Root). + */ + filled = 'data-filled', + /** + * Present when the switch is focused (when wrapped in Field.Root). + */ + focused = 'data-focused', }