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',
}