diff --git a/change/@fluentui-react-checkbox-743b0c0b-b9af-4859-be74-1f5f4473d72d.json b/change/@fluentui-react-checkbox-743b0c0b-b9af-4859-be74-1f5f4473d72d.json new file mode 100644 index 00000000000000..005b860b856ad0 --- /dev/null +++ b/change/@fluentui-react-checkbox-743b0c0b-b9af-4859-be74-1f5f4473d72d.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Improve ability to override Checkbox and Radio styles", + "packageName": "@fluentui/react-checkbox", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-radio-033602a9-4a99-4b67-9dcd-bf63ee4025b7.json b/change/@fluentui-react-radio-033602a9-4a99-4b67-9dcd-bf63ee4025b7.json new file mode 100644 index 00000000000000..68ff7a4933f210 --- /dev/null +++ b/change/@fluentui-react-radio-033602a9-4a99-4b67-9dcd-bf63ee4025b7.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Improve ability to override Checkbox and Radio styles", + "packageName": "@fluentui/react-radio", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-checkbox/src/components/Checkbox/useCheckboxStyles.ts b/packages/react-components/react-checkbox/src/components/Checkbox/useCheckboxStyles.ts index 11e4e12821e97d..57b065d526c4ec 100644 --- a/packages/react-components/react-checkbox/src/components/Checkbox/useCheckboxStyles.ts +++ b/packages/react-components/react-checkbox/src/components/Checkbox/useCheckboxStyles.ts @@ -11,19 +11,41 @@ export const checkboxClassNames: SlotClassNames = { indicator: 'fui-Checkbox__indicator', }; -// The indicator size is used by the indicator and label styles -const indicatorSizeMedium = '16px'; -const indicatorSizeLarge = '20px'; +const vars = { + labelColor: '--UNSTABLE__fui-Checkbox__label--color', + indicatorBackgroundColor: '--UNSTABLE__fui-Checkbox__indicator--backgroundColor', + indicatorBorderColor: '--UNSTABLE__fui-Checkbox__indicator--borderColor', + indicatorColor: '--UNSTABLE__fui-Checkbox__indicator--color', + indicatorSize: '--UNSTABLE__fui-Checkbox__indicator--size', +} as const; + +const values = { + labelColor: `var(${vars.labelColor}, ${tokens.colorNeutralForeground3})`, + indicatorBackgroundColor: `var(${vars.indicatorBackgroundColor})`, + indicatorBorderColor: `var(${vars.indicatorBorderColor}, ${tokens.colorNeutralStrokeAccessible})`, + indicatorColor: `var(${vars.indicatorColor}, transparent)`, + indicatorSize: `var(${vars.indicatorSize}, 16px)`, +} as const; const useRootBaseClassName = makeResetStyles({ position: 'relative', display: 'inline-flex', + cursor: 'pointer', ...createFocusOutlineStyle({ style: {}, selector: 'focus-within' }), }); +const useRootStyles = makeStyles({ + large: { + [vars.indicatorSize]: '20px', + }, + disabled: { + cursor: 'default', + }, +}); + const useInputBaseClassName = makeResetStyles({ boxSizing: 'border-box', - cursor: 'pointer', + cursor: 'inherit', height: '100%', margin: 0, opacity: 0, @@ -31,32 +53,32 @@ const useInputBaseClassName = makeResetStyles({ top: 0, // Calculate the width of the hidden input by taking into account the size of the indicator + the padding around it. // This is done so that clicking on that "empty space" still toggles the checkbox. - width: `calc(${indicatorSizeMedium} + 2 * ${tokens.spacingHorizontalS})`, + width: `calc(${values.indicatorSize} + 2 * ${tokens.spacingHorizontalS})`, // Colors for the unchecked state ':enabled:not(:checked):not(:indeterminate)': { [`& ~ .${checkboxClassNames.label}`]: { - color: tokens.colorNeutralForeground3, + [vars.labelColor]: tokens.colorNeutralForeground3, }, [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeAccessible, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeAccessible, }, ':hover': { [`& ~ .${checkboxClassNames.label}`]: { - color: tokens.colorNeutralForeground2, + [vars.labelColor]: tokens.colorNeutralForeground2, }, [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeAccessibleHover, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeAccessibleHover, }, }, ':active:hover': { [`& ~ .${checkboxClassNames.label}`]: { - color: tokens.colorNeutralForeground1, + [vars.labelColor]: tokens.colorNeutralForeground1, }, [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeAccessiblePressed, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeAccessiblePressed, }, }, }, @@ -64,25 +86,25 @@ const useInputBaseClassName = makeResetStyles({ // Colors for the checked state ':enabled:checked:not(:indeterminate)': { [`& ~ .${checkboxClassNames.label}`]: { - color: tokens.colorNeutralForeground1, + [vars.labelColor]: tokens.colorNeutralForeground1, }, [`& ~ .${checkboxClassNames.indicator}`]: { - backgroundColor: tokens.colorCompoundBrandBackground, - color: tokens.colorNeutralForegroundInverted, - borderColor: tokens.colorCompoundBrandBackground, + [vars.indicatorBackgroundColor]: tokens.colorCompoundBrandBackground, + [vars.indicatorColor]: tokens.colorNeutralForegroundInverted, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandBackground, }, ':hover': { [`& ~ .${checkboxClassNames.indicator}`]: { - backgroundColor: tokens.colorCompoundBrandBackgroundHover, - borderColor: tokens.colorCompoundBrandBackgroundHover, + [vars.indicatorBackgroundColor]: tokens.colorCompoundBrandBackgroundHover, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandBackgroundHover, }, }, ':active:hover': { [`& ~ .${checkboxClassNames.indicator}`]: { - backgroundColor: tokens.colorCompoundBrandBackgroundPressed, - borderColor: tokens.colorCompoundBrandBackgroundPressed, + [vars.indicatorBackgroundColor]: tokens.colorCompoundBrandBackgroundPressed, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandBackgroundPressed, }, }, }, @@ -90,48 +112,40 @@ const useInputBaseClassName = makeResetStyles({ // Colors for the mixed state ':enabled:indeterminate': { [`& ~ .${checkboxClassNames.label}`]: { - color: tokens.colorNeutralForeground1, + [vars.labelColor]: tokens.colorNeutralForeground1, }, [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorCompoundBrandStroke, - color: tokens.colorCompoundBrandForeground1, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandStroke, + [vars.indicatorColor]: tokens.colorCompoundBrandForeground1, }, ':hover': { [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorCompoundBrandStrokeHover, - color: tokens.colorCompoundBrandForeground1Hover, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandStrokeHover, + [vars.indicatorColor]: tokens.colorCompoundBrandForeground1Hover, }, }, ':active:hover': { [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorCompoundBrandStrokePressed, - color: tokens.colorCompoundBrandForeground1Pressed, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandStrokePressed, + [vars.indicatorColor]: tokens.colorCompoundBrandForeground1Pressed, }, }, }, ':disabled': { - cursor: 'default', - [`& ~ .${checkboxClassNames.label}`]: { - cursor: 'default', - color: tokens.colorNeutralForegroundDisabled, + [vars.labelColor]: tokens.colorNeutralForegroundDisabled, '@media (forced-colors: active)': { - color: 'GrayText', + [vars.labelColor]: 'GrayText', }, }, [`& ~ .${checkboxClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeDisabled, - color: tokens.colorNeutralForegroundDisabled, - '@media (forced-colors: active)': { - color: 'GrayText', - }, - }, - [`& ~ .${checkboxClassNames.indicator} svg`]: { + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeDisabled, + [vars.indicatorColor]: tokens.colorNeutralForegroundDisabled, '@media (forced-colors: active)': { - color: 'GrayText', + [vars.indicatorColor]: 'GrayText', }, }, }, @@ -144,10 +158,6 @@ const useInputStyles = makeStyles({ after: { left: 0, }, - - large: { - width: `calc(${indicatorSizeLarge} + 2 * ${tokens.spacingHorizontalS})`, - }, }); const useIndicatorBaseClassName = makeResetStyles({ @@ -160,24 +170,20 @@ const useIndicatorBaseClassName = makeResetStyles({ justifyContent: 'center', overflow: 'hidden', - border: tokens.strokeWidthThin + ' solid', + color: values.indicatorColor, + backgroundColor: values.indicatorBackgroundColor, + border: tokens.strokeWidthThin + ' solid ' + values.indicatorBorderColor, borderRadius: tokens.borderRadiusSmall, margin: tokens.spacingVerticalS + ' ' + tokens.spacingHorizontalS, fill: 'currentColor', pointerEvents: 'none', - fontSize: '12px', - height: indicatorSizeMedium, - width: indicatorSizeMedium, + fontSize: `calc(${values.indicatorSize} - 4px)`, + height: values.indicatorSize, + width: values.indicatorSize, }); const useIndicatorStyles = makeStyles({ - large: { - fontSize: '16px', - height: indicatorSizeLarge, - width: indicatorSizeLarge, - }, - circular: { ...shorthands.borderRadius(tokens.borderRadiusCircular), }, @@ -187,8 +193,14 @@ const useIndicatorStyles = makeStyles({ const useLabelStyles = makeStyles({ base: { alignSelf: 'center', - cursor: 'pointer', + color: values.labelColor, + cursor: 'inherit', ...shorthands.padding(tokens.spacingVerticalS, tokens.spacingHorizontalS), + + // Use a (negative) margin to account for the difference between the indicator's height and the label's line height. + // This prevents the label from expanding the height of the checkbox, but preserves line height if the label wraps. + marginTop: `calc((${values.indicatorSize} - ${tokens.lineHeightBase300}) / 2)`, + marginBottom: `calc((${values.indicatorSize} - ${tokens.lineHeightBase300}) / 2)`, }, before: { @@ -197,17 +209,6 @@ const useLabelStyles = makeStyles({ after: { paddingLeft: tokens.spacingHorizontalXS, }, - - // Use a (negative) margin to account for the difference between the indicator's height and the label's line height. - // This prevents the label from expanding the height of the checkbox, but preserves line height if the label wraps. - medium: { - marginTop: `calc((${indicatorSizeMedium} - ${tokens.lineHeightBase300}) / 2)`, - marginBottom: `calc((${indicatorSizeMedium} - ${tokens.lineHeightBase300}) / 2)`, - }, - large: { - marginTop: `calc((${indicatorSizeLarge} - ${tokens.lineHeightBase300}) / 2)`, - marginBottom: `calc((${indicatorSizeLarge} - ${tokens.lineHeightBase300}) / 2)`, - }, }); /** @@ -217,14 +218,20 @@ export const useCheckboxStyles_unstable = (state: CheckboxState): CheckboxState const { labelPosition, shape, size } = state; const rootBaseClassName = useRootBaseClassName(); - state.root.className = mergeClasses(checkboxClassNames.root, rootBaseClassName, state.root.className); + const rootStyles = useRootStyles(); + state.root.className = mergeClasses( + checkboxClassNames.root, + rootBaseClassName, + size === 'large' && rootStyles.large, + state.input.disabled && rootStyles.disabled, + state.root.className, + ); const inputBaseClassName = useInputBaseClassName(); const inputStyles = useInputStyles(); state.input.className = mergeClasses( checkboxClassNames.input, inputBaseClassName, - size === 'large' && inputStyles.large, inputStyles[labelPosition], state.input.className, ); @@ -235,7 +242,6 @@ export const useCheckboxStyles_unstable = (state: CheckboxState): CheckboxState state.indicator.className = mergeClasses( checkboxClassNames.indicator, indicatorBaseClassName, - size === 'large' && indicatorStyles.large, shape === 'circular' && indicatorStyles.circular, state.indicator.className, ); @@ -246,7 +252,6 @@ export const useCheckboxStyles_unstable = (state: CheckboxState): CheckboxState state.label.className = mergeClasses( checkboxClassNames.label, labelStyles.base, - labelStyles[size], labelStyles[labelPosition], state.label.className, ); diff --git a/packages/react-components/react-radio/src/components/Radio/useRadioStyles.ts b/packages/react-components/react-radio/src/components/Radio/useRadioStyles.ts index 4f5d2c1ad84e2c..79c35d7e300ac5 100644 --- a/packages/react-components/react-radio/src/components/Radio/useRadioStyles.ts +++ b/packages/react-components/react-radio/src/components/Radio/useRadioStyles.ts @@ -11,12 +11,27 @@ export const radioClassNames: SlotClassNames = { label: 'fui-Radio__label', }; +const vars = { + labelColor: '--UNSTABLE__fui-Radio__label--color', + indicatorBorderColor: '--UNSTABLE__fui-Radio__indicator--borderColor', + indicatorColor: '--UNSTABLE__fui-Radio__indicator--color', + iconOpacity: '--UNSTABLE__fui-Radio__icon--opacity', +} as const; + +const values = { + labelColor: `var(${vars.labelColor}, ${tokens.colorNeutralForeground3})`, + indicatorBorderColor: `var(${vars.indicatorBorderColor}, ${tokens.colorNeutralStrokeAccessible})`, + indicatorColor: `var(${vars.indicatorColor}, transparent)`, + iconOpacity: `var(${vars.iconOpacity})`, +} as const; + // The indicator size is used by the indicator and label styles const indicatorSize = '16px'; const useRootBaseClassName = makeResetStyles({ display: 'inline-flex', position: 'relative', + cursor: 'pointer', ...createFocusOutlineStyle({ style: {}, selector: 'focus-within' }), }); @@ -25,6 +40,9 @@ const useRootStyles = makeStyles({ flexDirection: 'column', alignItems: 'center', }, + disabled: { + cursor: 'default', + }, }); const useInputBaseClassName = makeResetStyles({ @@ -36,43 +54,37 @@ const useInputBaseClassName = makeResetStyles({ boxSizing: 'border-box', margin: 0, opacity: 0, + cursor: 'inherit', - ':enabled': { - cursor: 'pointer', - [`& ~ .${radioClassNames.label}`]: { - cursor: 'pointer', - }, - }, - - // When unchecked, hide the circle icon (child of the indicator) - [`:not(:checked) ~ .${radioClassNames.indicator} > *`]: { - opacity: '0', + // When unchecked, hide the circle icon + [`:not(:checked) ~ .${radioClassNames.indicator}`]: { + [vars.iconOpacity]: 0, }, // Colors for the unchecked state ':enabled:not(:checked)': { [`& ~ .${radioClassNames.label}`]: { - color: tokens.colorNeutralForeground3, + [vars.labelColor]: tokens.colorNeutralForeground3, }, [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeAccessible, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeAccessible, }, ':hover': { [`& ~ .${radioClassNames.label}`]: { - color: tokens.colorNeutralForeground2, + [vars.labelColor]: tokens.colorNeutralForeground2, }, [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeAccessibleHover, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeAccessibleHover, }, }, ':hover:active': { [`& ~ .${radioClassNames.label}`]: { - color: tokens.colorNeutralForeground1, + [vars.labelColor]: tokens.colorNeutralForeground1, }, [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeAccessiblePressed, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeAccessiblePressed, }, }, }, @@ -80,24 +92,24 @@ const useInputBaseClassName = makeResetStyles({ // Colors for the checked state ':enabled:checked': { [`& ~ .${radioClassNames.label}`]: { - color: tokens.colorNeutralForeground1, + [vars.labelColor]: tokens.colorNeutralForeground1, }, [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorCompoundBrandStroke, - color: tokens.colorCompoundBrandForeground1, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandStroke, + [vars.indicatorColor]: tokens.colorCompoundBrandForeground1, }, ':hover': { [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorCompoundBrandStrokeHover, - color: tokens.colorCompoundBrandForeground1Hover, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandStrokeHover, + [vars.indicatorColor]: tokens.colorCompoundBrandForeground1Hover, }, }, ':hover:active': { [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorCompoundBrandStrokePressed, - color: tokens.colorCompoundBrandForeground1Pressed, + [vars.indicatorBorderColor]: tokens.colorCompoundBrandStrokePressed, + [vars.indicatorColor]: tokens.colorCompoundBrandForeground1Pressed, }, }, }, @@ -105,12 +117,17 @@ const useInputBaseClassName = makeResetStyles({ // Colors for the disabled state ':disabled': { [`& ~ .${radioClassNames.label}`]: { - color: tokens.colorNeutralForegroundDisabled, - cursor: 'default', + [vars.labelColor]: tokens.colorNeutralForegroundDisabled, + '@media (forced-colors: active)': { + [vars.labelColor]: 'GrayText', + }, }, [`& ~ .${radioClassNames.indicator}`]: { - borderColor: tokens.colorNeutralStrokeDisabled, - color: tokens.colorNeutralForegroundDisabled, + [vars.indicatorBorderColor]: tokens.colorNeutralStrokeDisabled, + [vars.indicatorColor]: tokens.colorNeutralForegroundDisabled, + '@media (forced-colors: active)': { + [vars.indicatorColor]: 'GrayText', + }, }, }, }); @@ -134,17 +151,24 @@ const useIndicatorBaseClassName = makeResetStyles({ justifyContent: 'center', overflow: 'hidden', - border: tokens.strokeWidthThin + ' solid', + color: values.indicatorColor, + border: tokens.strokeWidthThin + ' solid ' + values.indicatorBorderColor, borderRadius: tokens.borderRadiusCircular, margin: tokens.spacingVerticalS + ' ' + tokens.spacingHorizontalS, fill: 'currentColor', pointerEvents: 'none', + + '> *': { + opacity: values.iconOpacity, + }, }); // Can't use makeResetStyles here because Label is a component that may itself use makeResetStyles. const useLabelStyles = makeStyles({ base: { alignSelf: 'center', + color: values.labelColor, + cursor: 'inherit', ...shorthands.padding(tokens.spacingVerticalS, tokens.spacingHorizontalS), }, @@ -175,6 +199,7 @@ export const useRadioStyles_unstable = (state: RadioState) => { radioClassNames.root, rootBaseClassName, labelPosition === 'below' && rootStyles.vertical, + state.input.disabled && rootStyles.disabled, state.root.className, );