From caee9f0c5e588a0007cde3b1f78be20f0f48d4b9 Mon Sep 17 00:00:00 2001 From: EnricoGianoglio Date: Wed, 6 May 2026 19:28:55 +0200 Subject: [PATCH 1/4] add react-combobox components --- .../react-cap-theme/.storybook/preview.tsx | 3 + packages/react-cap-theme/src/capStyleHooks.ts | 28 ++ .../components/Combobox/Combobox.types.ts | 54 +++ .../Combobox/useComboboxStyles.styles.ts | 381 ++++++++++++++++ .../components/Dropdown/Dropdown.types.ts | 52 +++ .../Dropdown/useDropdownStyles.styles.ts | 420 ++++++++++++++++++ .../Listbox/useListboxStyles.styles.ts | 31 ++ .../Option/useOptionStyles.styles.ts | 119 +++++ .../useOptionGroupStyles.styles.ts | 50 +++ .../src/components/react-combobox/index.ts | 11 + 10 files changed, 1149 insertions(+) create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Combobox/Combobox.types.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Dropdown/Dropdown.types.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts create mode 100644 packages/react-cap-theme/src/components/react-combobox/index.ts diff --git a/packages/react-cap-theme/.storybook/preview.tsx b/packages/react-cap-theme/.storybook/preview.tsx index a02d2b63..b864c707 100644 --- a/packages/react-cap-theme/.storybook/preview.tsx +++ b/packages/react-cap-theme/.storybook/preview.tsx @@ -20,6 +20,9 @@ const capTheme: Record = { colorNeutralStroke4Hover: '#e0e0e0', colorNeutralStroke4Pressed: '#d6d6d6', colorNeutralStroke4Selected: '#ebebeb', + colorNeutralForeground5: '#616161', + colorNeutralForeground5Hover: '#242424', + colorNeutralForeground5Pressed: '#242424', }; const preview: Preview = { diff --git a/packages/react-cap-theme/src/capStyleHooks.ts b/packages/react-cap-theme/src/capStyleHooks.ts index f0f0c9e2..ba6b282c 100644 --- a/packages/react-cap-theme/src/capStyleHooks.ts +++ b/packages/react-cap-theme/src/capStyleHooks.ts @@ -67,6 +67,19 @@ import type { } from '@fluentui/react-carousel'; import { useCheckboxStyles } from './components/react-checkbox'; import type { CheckboxState } from './components/react-checkbox'; +import { + useComboboxStyles, + useDropdownStyles, + useListboxStyles, + useOptionGroupStyles, + useOptionStyles, +} from './components/react-combobox'; +import type { ComboboxState, DropdownState } from './components/react-combobox'; +import type { + ListboxState, + OptionGroupState, + OptionState, +} from '@fluentui/react-combobox'; import { useDialogActionsStyles, useDialogBodyStyles, @@ -213,6 +226,9 @@ export const CAP_STYLE_HOOKS: NonNullable< useCompoundButtonStyles_unstable: (state) => { return useCompoundButtonStyles(state as CompoundButtonState); }, + useComboboxStyles_unstable: (state) => { + return useComboboxStyles(state as ComboboxState); + }, useDialogActionsStyles_unstable: (state) => { return useDialogActionsStyles(state as DialogActionsState); }, @@ -242,6 +258,9 @@ export const CAP_STYLE_HOOKS: NonNullable< useDrawerHeaderTitleStyles_unstable: (state) => { return useDrawerHeaderTitleStyles(state as DrawerHeaderTitleState); }, + useDropdownStyles_unstable: (state) => { + return useDropdownStyles(state as DropdownState); + }, useImageStyles_unstable: (state) => { return useImageStyles(state as ImageState); }, @@ -268,9 +287,18 @@ export const CAP_STYLE_HOOKS: NonNullable< useLinkStyles_unstable: (state) => { return useLinkStyles(state as LinkState); }, + useListboxStyles_unstable: (state) => { + return useListboxStyles(state as ListboxState); + }, useMenuButtonStyles_unstable: (state) => { return useMenuButtonStyles(state as MenuButtonState); }, + useOptionGroupStyles_unstable: (state) => { + return useOptionGroupStyles(state as OptionGroupState); + }, + useOptionStyles_unstable: (state) => { + return useOptionStyles(state as OptionState); + }, useOverlayDrawerStyles_unstable: (state) => { return useOverlayDrawerStyles(state as OverlayDrawerState); }, diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Combobox/Combobox.types.ts b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/Combobox.types.ts new file mode 100644 index 00000000..ed7ad7ef --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/Combobox.types.ts @@ -0,0 +1,54 @@ +import type { + ComboboxProps as BaseProps, + ComboboxSlots as BaseSlots, + ComboboxState as BaseState, +} from '@fluentui/react-combobox'; +import type { + ComponentProps, + ComponentState, + Slot, +} from '@fluentui/react-utilities'; + +/** + * Slot configuration for the Combobox component. + * Adds the SP-specific contentBefore slot. The listbox slot is inherited + * from Fluent and restyled in `useComboboxStyles`. + * @alpha + */ +export type ComboboxSlots = BaseSlots & { + /** + * Element before the input text, e.g. an icon or avatar. + */ + contentBefore?: Slot<'span'>; +}; + +/** + * Properties for configuring the Combobox component. + * @alpha + */ +export type ComboboxProps = ComponentProps< + Pick +> & + Omit & { + /** + * The color variant. + * + * - 'brand' (default): Primary emphasis using brand colors. + * - 'neutral': Secondary emphasis using neutral colors. + * + * @default 'brand' + */ + // FIXME: Must not graduate to beta. Style implementation references Fluent tokens directly. + // `color` and `NeutralThemeProvider` solve the same brand/neutral switching problem at different + // levels of the stack with no defined interaction. Graduating locks in an API contract that may + // need to change once a proper token-based theming approach is defined. + color?: 'neutral' | 'brand'; + }; + +/** + * State used in rendering the Combobox component. + * @alpha + */ +export type ComboboxState = ComponentState & + Omit & + Required>; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts new file mode 100644 index 00000000..7c6a56ea --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts @@ -0,0 +1,381 @@ +import { comboboxClassNames } from '@fluentui/react-combobox'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { + iconFilledClassName, + iconRegularClassName, +} from '@fluentui/react-icons'; +import { tokens, typographyStyles } from '@fluentui/tokens'; +import { capTokens } from '../../../tokens/tokens'; +import type { ComboboxState } from './Combobox.types'; + +const paddingHorizontalSmall = tokens.spacingHorizontalS; +const paddingHorizontalMedium = tokens.spacingHorizontalMNudge; +const paddingHorizontalLarge = tokens.spacingHorizontalM; + +const useStyles = makeStyles({ + root: { + minWidth: '250px', + minHeight: '36px', + boxSizing: 'border-box', + display: 'inline-flex', + flexWrap: 'nowrap', + alignItems: 'center', + verticalAlign: 'middle', + padding: `${tokens.spacingVerticalNone} ${paddingHorizontalMedium}`, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAccessible}`, + borderRadius: capTokens.borderRadius2XLarge, + ...typographyStyles.body1, + }, + + small: { + minHeight: '28px', + padding: `${tokens.spacingVerticalNone} ${paddingHorizontalSmall}`, + ...typographyStyles.caption1, + borderRadius: tokens.borderRadiusXLarge, + }, + medium: { + /* same as root */ + }, + large: { + minHeight: '44px', + ...typographyStyles.body2, + padding: `${tokens.spacingVerticalNone} ${paddingHorizontalLarge}`, + }, + invalid: { + ...shorthands.borderColor(tokens.colorStatusDangerBorder2), + ':hover': shorthands.borderColor(tokens.colorStatusDangerBorder2), + }, + + listboxCollapsed: { display: 'none' }, + // When rendering inline, the popupSurface will be rendered under relatively positioned elements + // such as Input. zIndex: 1 ensures that won't happen. + inlineListbox: { zIndex: 1 }, + hidden: { display: 'none' }, +}); + +const useIsEditableStyles = makeStyles({ + base: { + ':hover': { + [`& .${comboboxClassNames.clearIcon}`]: { + color: capTokens.colorNeutralForeground5Hover, + }, + [`& .${comboboxClassNames.expandIcon}`]: { + color: capTokens.colorNeutralForeground5Hover, + }, + + [`& .${comboboxClassNames.expandIcon} .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${comboboxClassNames.expandIcon} .${iconFilledClassName}`]: { + display: 'inline', + }, + + // Don't update sub component icons (e.g Button) + // [`& .${comboboxClassNames.contentBefore} > .${iconRegularClassName}`]: { + // display: 'none', + // }, + // [`& .${comboboxClassNames.contentBefore} > .${iconFilledClassName}`]: { + // display: 'inline', + // }, + // End -- + }, + ':active,:focus-within': { + [`& .${comboboxClassNames.clearIcon}`]: { + color: capTokens.colorNeutralForeground5Pressed, + }, + [`& .${comboboxClassNames.expandIcon}`]: { + color: capTokens.colorNeutralForeground5Pressed, + }, + + [`& .${comboboxClassNames.expandIcon} .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${comboboxClassNames.expandIcon} .${iconFilledClassName}`]: { + display: 'inline', + }, + + // Don't update sub component icons (e.g Button) + // [`& .${comboboxClassNames.contentBefore} > .${iconRegularClassName}`]: { + // display: 'none', + // }, + // [`& .${comboboxClassNames.contentBefore} > .${iconFilledClassName}`]: { + // display: 'inline', + // }, + }, + + ':focus-within': { + outline: `${tokens.strokeWidthThick} solid ${tokens.colorTransparentStroke}`, // For high contrast + }, + }, + hasValue: { + color: tokens.colorNeutralForeground1, + ':hover': { color: tokens.colorNeutralForeground1Hover }, + ':active,:focus-within': { color: tokens.colorNeutralForeground1Pressed }, + }, + noValue: { + color: capTokens.colorNeutralForeground5, + ':hover': { color: capTokens.colorNeutralForeground5Hover }, + ':active,:focus-within': { + color: capTokens.colorNeutralForeground5Pressed, + }, + }, + brand: { + ':active,:focus-within': { + ...shorthands.borderColor(tokens.colorBrandStroke1), + // [`& .${comboboxClassNames.contentBefore}`]: { + // color: tokens.colorBrandForeground2, + // }, + }, + }, + neutral: { + ':active,:focus-within': shorthands.borderColor( + tokens.colorNeutralStrokeAccessiblePressed + ), + }, +}); + +const useAppearanceStyles = makeStyles({ + outline: { + ':hover': shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), + }, + underline: { + backgroundColor: tokens.colorTransparentBackground, + borderRadius: tokens.borderRadiusNone, + borderTopStyle: 'none', + borderRightStyle: 'none', + borderLeftStyle: 'none', + ':hover': shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), + '@media (forced-colors: active)': { + ':focus-within': { borderBottomColor: 'Canvas' }, + }, + }, + 'filled-lighter': { + border: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + + // FIXME awaiting design. Temp fix to support contrast + ':active,:focus-within': { + boxShadow: `0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus1}`, + }, + }, + 'filled-darker': { + backgroundColor: tokens.colorNeutralBackground3, + border: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + + // FIXME awaiting design. Temp fix to support contrast + ':active,:focus-within': { + boxShadow: `0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus1}`, + }, + }, +}); + +const useDisabledStyles = makeStyles({ + base: { + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + backgroundColor: tokens.colorTransparentBackground, + color: tokens.colorNeutralForegroundDisabled, + cursor: 'not-allowed', + + '@media (forced-colors: active)': shorthands.borderColor('GrayText'), + }, + outline: { + /* same as base */ + }, + underline: { + borderRadius: tokens.borderRadiusNone, + borderTopStyle: 'none', + borderRightStyle: 'none', + borderLeftStyle: 'none', + }, + 'filled-lighter': { + /* same as base */ + }, + 'filled-darker': { + /* same as base */ + }, +}); + +const useInputStyles = makeStyles({ + base: { + // Remove browser styling + alignSelf: 'stretch', + boxSizing: 'border-box', + flexGrow: 1, + minWidth: 0, // required to make the input shrink to fit the wrapper + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + padding: 0, + outline: 'none', + color: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + fontWeight: 'inherit', + lineHeight: 'inherit', + + '::placeholder': { + opacity: 1, + color: 'inherit', + }, + }, + small: { paddingRight: tokens.spacingHorizontalS }, + medium: { paddingRight: tokens.spacingHorizontalMNudge }, + large: { paddingRight: tokens.spacingHorizontalM }, + disabled: { cursor: 'inherit' }, +}); + +const useContentBeforeStyles = makeStyles({ + base: { display: 'flex' }, + small: { + marginRight: tokens.spacingHorizontalXS, + '> svg': { fontSize: tokens.fontSizeBase400 }, + }, + medium: { + marginRight: tokens.spacingHorizontalSNudge, + '> svg': { fontSize: tokens.fontSizeBase500 }, + }, + large: { + marginRight: tokens.spacingHorizontalS, + '> svg': { fontSize: tokens.fontSizeBase600 }, + }, +}); + +// Expand and clear icon styles +const useIconStyles = makeStyles({ + base: { + display: 'flex', + color: capTokens.colorNeutralForeground5, + cursor: 'pointer', + position: 'relative', + + // Extend the clickable area to cover root's paddingRight "dead zone" and meet target size requirements. + // Without this, clicking between the icon and the right border does not trigger the icon's handler. + '::after': { + content: '""', + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + }, + small: { + fontSize: tokens.fontSizeBase400, + '::after': { right: `calc(-1 * ${paddingHorizontalSmall})` }, // Match root's spacing. + }, + medium: { + fontSize: tokens.fontSizeBase500, + '::after': { right: `calc(-1 * ${paddingHorizontalMedium})` }, // Match root's spacing. + }, + large: { + fontSize: tokens.fontSizeBase500, + '::after': { right: `calc(-1 * ${paddingHorizontalLarge})` }, // Match root's spacing. + }, + disabled: { color: 'inherit', cursor: 'inherit' }, +}); + +const useClearIconStyles = makeStyles({ + isEditable: { + ':hover': { + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${iconFilledClassName}`]: { display: 'inline' }, + }, + ':active,:focus-within': { + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${iconFilledClassName}`]: { display: 'inline' }, + }, + }, +}); + +/** + * Apply styling to the Combobox slots based on the state. + * @param state - The current Combobox state + * @returns The updated Combobox state with applied styles + * @alpha + */ +export const useComboboxStyles = (state: ComboboxState): ComboboxState => { + const styles = useStyles(); + const isEditableStyles = useIsEditableStyles(); + const appearanceStyles = useAppearanceStyles(); + const inputStyles = useInputStyles(); + const contentBeforeStyles = useContentBeforeStyles(); + const iconStyles = useIconStyles(); + const clearIconStyles = useClearIconStyles(); + const disabledStyles = useDisabledStyles(); + + const { appearance, color, disabled, open, size, showClearIcon } = state; + const isEditable = !disabled; + const hasValue = !!state.value; + const invalid = `${state.input['aria-invalid']}` === 'true'; + + state.root.className = mergeClasses( + state.root.className, + comboboxClassNames.root, + styles.root, + styles[size], + isEditable && isEditableStyles.base, + isEditable && + (hasValue ? isEditableStyles.hasValue : isEditableStyles.noValue), + isEditable && appearanceStyles[appearance], + isEditable && isEditableStyles[color], + invalid && styles.invalid, + disabled && disabledStyles.base, + disabled && disabledStyles[appearance], + getSlotClassNameProp_unstable(state.root) + ); + + state.input.className = mergeClasses( + state.input.className, + comboboxClassNames.input, + inputStyles.base, + inputStyles[size], + disabled && inputStyles.disabled, + getSlotClassNameProp_unstable(state.input) + ); + + if (state.contentBefore) { + state.contentBefore.className = mergeClasses( + state.contentBefore.className, + // comboboxClassNames.contentBefore, + contentBeforeStyles.base, + contentBeforeStyles[size], + getSlotClassNameProp_unstable(state.contentBefore) + ); + } + + if (state.expandIcon) { + state.expandIcon.className = mergeClasses( + state.expandIcon.className, + comboboxClassNames.expandIcon, + iconStyles.base, + iconStyles[size], + disabled && iconStyles.disabled, + showClearIcon && styles.hidden, + getSlotClassNameProp_unstable(state.expandIcon) + ); + } + + if (state.clearIcon) { + state.clearIcon.className = mergeClasses( + state.clearIcon.className, + comboboxClassNames.clearIcon, + iconStyles.base, + isEditable && clearIconStyles.isEditable, + iconStyles[size], + !showClearIcon && styles.hidden, + getSlotClassNameProp_unstable(state.clearIcon) + ); + } + + if (state.listbox) { + state.listbox.className = mergeClasses( + state.listbox.className, + comboboxClassNames.listbox, + state.inlinePopup && styles.inlineListbox, + !open && styles.listboxCollapsed, + getSlotClassNameProp_unstable(state.listbox) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/Dropdown.types.ts b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/Dropdown.types.ts new file mode 100644 index 00000000..93fe837a --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/Dropdown.types.ts @@ -0,0 +1,52 @@ +import type { + DropdownProps as BaseProps, + DropdownSlots as BaseSlots, + DropdownState as BaseState, +} from '@fluentui/react-combobox'; +import type { + ComponentProps, + ComponentState, + Slot, +} from '@fluentui/react-utilities'; + +/** + * Slot configuration for the Dropdown component. + * @alpha + */ +export type DropdownSlots = BaseSlots & { + /** + * Element before the Dropdown text. + */ + contentBefore?: Slot<'span'>; +}; + +/** + * Properties for configuring the Dropdown component. + * @alpha + */ +export type DropdownProps = ComponentProps< + Pick +> & + Omit & { + /** + * The color variant. + * + * - 'brand' (default): Primary emphasis using brand colors. + * - 'neutral': Secondary emphasis using neutral colors. + * + * @default 'brand' + */ + // FIXME: Must not graduate to beta. Style implementation references Fluent tokens directly. + // `color` and `NeutralThemeProvider` solve the same brand/neutral switching problem at different + // levels of the stack with no defined interaction. Graduating locks in an API contract that may + // need to change once a proper token-based theming approach is defined. + color?: 'neutral' | 'brand'; + }; + +/** + * State used in rendering the Dropdown component. + * @alpha + */ +export type DropdownState = ComponentState & + Omit & + Required>; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts new file mode 100644 index 00000000..35f77e7a --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts @@ -0,0 +1,420 @@ +import { dropdownClassNames } from '@fluentui/react-combobox'; +import { createFocusOutlineStyle } from '@fluentui/react-tabster'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; +import { + makeResetStyles, + makeStyles, + mergeClasses, + shorthands, +} from '@griffel/react'; +import { + iconFilledClassName, + iconRegularClassName, +} from '@fluentui/react-icons'; +import { tokens, typographyStyles } from '@fluentui/tokens'; +import { capTokens } from '../../../tokens/tokens'; +import type { DropdownState } from './Dropdown.types'; + +const defaultGridTemplate = '[content] 1fr [icon] auto [end]'; +const paddingHorizontalSmall = tokens.spacingHorizontalS; +const paddingHorizontalMedium = tokens.spacingHorizontalMNudge; +const paddingHorizontalLarge = tokens.spacingHorizontalM; + +// Adjust clearButton's target size for accessibility +const clearButtonMarginAdjust = tokens.spacingHorizontalXXS; + +const useStyles = makeStyles({ + root: { + minWidth: '250px', + boxSizing: 'border-box', + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'middle', + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAccessible}`, + borderRadius: capTokens.borderRadius2XLarge, + ...typographyStyles.body1, + }, + + small: { + ...typographyStyles.caption1, + borderRadius: tokens.borderRadiusXLarge, + }, + medium: { + /* same as root */ + }, + large: typographyStyles.body2, + invalid: { + ...shorthands.borderColor(tokens.colorStatusDangerBorder2), + ':hover': shorthands.borderColor(tokens.colorStatusDangerBorder2), + }, + + listboxCollapsed: { display: 'none' }, + // When rendering inline, the popupSurface will be rendered under relatively positioned elements such as Input. + // This is due to the surface being positioned as absolute, therefore zIndex: 1 ensures that won't happen. + inlineListbox: { zIndex: 1 }, + hidden: { display: 'none' }, +}); + +const useIsEditableStyles = makeStyles({ + base: { + ':hover': { + [`& .${dropdownClassNames.clearButton}`]: { + color: capTokens.colorNeutralForeground5Hover, + }, + [`& .${dropdownClassNames.expandIcon}`]: { + color: capTokens.colorNeutralForeground5Hover, + }, + + [`& .${dropdownClassNames.expandIcon} .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${dropdownClassNames.expandIcon} .${iconFilledClassName}`]: { + display: 'inline', + }, + + // Don't update sub component icons (e.g Button) + // [`& .${dropdownClassNames.contentBefore} > .${iconRegularClassName}`]: { + // display: 'none', + // }, + // [`& .${dropdownClassNames.contentBefore} > .${iconFilledClassName}`]: { + // display: 'inline', + // }, + }, + ':active,:focus-within': { + [`& .${dropdownClassNames.clearButton}`]: { + color: capTokens.colorNeutralForeground5Pressed, + }, + [`& .${dropdownClassNames.expandIcon}`]: { + color: capTokens.colorNeutralForeground5Pressed, + }, + + [`& .${dropdownClassNames.expandIcon} .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${dropdownClassNames.expandIcon} .${iconFilledClassName}`]: { + display: 'inline', + }, + + // Don't update sub component icons (e.g Button) + // [`& .${dropdownClassNames.contentBefore} > .${iconRegularClassName}`]: { + // display: 'none', + // }, + // [`& .${dropdownClassNames.contentBefore} > .${iconFilledClassName}`]: { + // display: 'inline', + // }, + }, + + ':focus-within': { + outline: `${tokens.strokeWidthThick} solid ${tokens.colorTransparentStroke}`, // For high contrast + }, + }, + hasValue: { + color: tokens.colorNeutralForeground1, + ':hover': { color: tokens.colorNeutralForeground1Hover }, + ':active,:focus-within': { color: tokens.colorNeutralForeground1Pressed }, + }, + noValue: { + color: capTokens.colorNeutralForeground5, + ':hover': { color: capTokens.colorNeutralForeground5Hover }, + ':active,:focus-within': { + color: capTokens.colorNeutralForeground5Pressed, + }, + }, + brand: { + ':active,:focus-within': { + ...shorthands.borderColor(tokens.colorBrandStroke1), + // [`& .${dropdownClassNames.contentBefore}`]: { + // color: tokens.colorBrandForeground2, + // }, + }, + }, + neutral: { + ':active,:focus-within': shorthands.borderColor( + tokens.colorNeutralStrokeAccessiblePressed + ), + }, +}); + +const useAppearanceStyles = makeStyles({ + outline: { + ':hover': shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), + }, + underline: { + backgroundColor: tokens.colorTransparentBackground, + borderRadius: tokens.borderRadiusNone, + borderTopStyle: 'none', + borderRightStyle: 'none', + borderLeftStyle: 'none', + ':hover': shorthands.borderColor(tokens.colorNeutralStrokeAccessibleHover), + '@media (forced-colors: active)': { + ':focus-within': { borderBottomColor: 'Canvas' }, + }, + }, + 'filled-lighter': { + border: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + + // FIXME awaiting design. Temp fix to support contrast + ':active,:focus-within': { + boxShadow: `0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus1}`, + }, + }, + 'filled-darker': { + backgroundColor: tokens.colorNeutralBackground3, + border: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + + // FIXME awaiting design. Temp fix to support contrast + ':active,:focus-within': { + boxShadow: `0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus1}`, + }, + }, +}); + +const useDisabledStyles = makeStyles({ + base: { + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + backgroundColor: tokens.colorTransparentBackground, + color: tokens.colorNeutralForegroundDisabled, + cursor: 'not-allowed', + + '@media (forced-colors: active)': shorthands.borderColor('GrayText'), + }, + outline: { + /* same as base */ + }, + underline: { + borderRadius: tokens.borderRadiusNone, + borderTopStyle: 'none', + borderRightStyle: 'none', + borderLeftStyle: 'none', + }, + 'filled-lighter': { + /* same as base */ + }, + 'filled-darker': { + /* same as base */ + }, +}); + +const useContentBeforeStyles = makeStyles({ + base: { + display: 'flex', + gridColumnStart: 'contentBefore', + gridColumnEnd: 'contentBefore', + }, + small: { + marginRight: tokens.spacingHorizontalXS, + '> svg': { fontSize: tokens.fontSizeBase400 }, + }, + medium: { + marginRight: tokens.spacingHorizontalSNudge, + '> svg': { fontSize: tokens.fontSizeBase500 }, + }, + large: { + marginRight: tokens.spacingHorizontalS, + '> svg': { fontSize: tokens.fontSizeBase600 }, + }, +}); + +const useButtonStyles = makeStyles({ + base: { + display: 'grid', + gridTemplateColumns: defaultGridTemplate, + justifyContent: 'space-between', + textAlign: 'left', + alignItems: 'center', + width: '100%', + + '&:focus': { outlineStyle: 'none' }, + + boxSizing: 'border-box', + + // Remove browser styling + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + outline: 'none', + color: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + fontWeight: 'inherit', + lineHeight: 'inherit', + }, + hasContentBefore: { + gridTemplateColumns: `[contentBefore] auto ${defaultGridTemplate}`, + }, + + small: { padding: `5px ${paddingHorizontalSmall}` }, + medium: { padding: `7px ${paddingHorizontalMedium}` }, + large: { padding: `9px ${paddingHorizontalLarge}` }, + + disabled: { cursor: 'inherit' }, + isEditable: { cursor: 'pointer' }, +}); + +// Reduce space between Dropdown button and clearButton to accommodate for clearButton's target size increase. +const useButtonShowClearButtonStyles = makeStyles({ + small: { paddingRight: tokens.spacingVerticalSNudge }, + medium: { paddingRight: tokens.spacingVerticalS }, + large: { paddingRight: tokens.spacingVerticalMNudge }, +}); + +// Expand and Clear icon styles +const useIconStyles = makeStyles({ + base: { + color: capTokens.colorNeutralForeground5, + display: 'flex', + gridColumnStart: 'icon', + gridColumnEnd: 'end', + }, + disabled: { color: 'inherit' }, + small: { fontSize: tokens.fontSizeBase400 }, + medium: { fontSize: tokens.fontSizeBase500 }, + large: { fontSize: tokens.fontSizeBase500 }, +}); + +const useExpandButtonStyles = makeStyles({ + small: { marginLeft: tokens.spacingHorizontalS }, + medium: { marginLeft: tokens.spacingHorizontalMNudge }, + large: { marginLeft: tokens.spacingHorizontalM }, +}); + +const useClearButtonClassName = makeResetStyles({ + marginLeft: clearButtonMarginAdjust, + + // remove browser styling + alignSelf: 'center', + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + cursor: 'pointer', + height: 'fit-content', + padding: 0, + position: 'relative', + ...createFocusOutlineStyle(), +}); + +const useClearButtonStyles = makeStyles({ + isEditable: { + ':hover': { + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${iconFilledClassName}`]: { display: 'inline' }, + }, + ':active,:focus-within': { + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${iconFilledClassName}`]: { display: 'inline' }, + }, + }, + small: { marginRight: paddingHorizontalSmall }, + medium: { marginRight: paddingHorizontalMedium }, + large: { marginRight: paddingHorizontalLarge }, +}); + +/** + * Apply styling to the Dropdown slots based on the state + * @param state - The current Dropdown state + * @returns The updated Dropdown state with applied styles + * @alpha + */ +export const useDropdownStyles = (state: DropdownState): DropdownState => { + const clearButtonClassName = useClearButtonClassName(); + + const styles = useStyles(); + const isEditableStyles = useIsEditableStyles(); + const appearanceStyles = useAppearanceStyles(); + const contentBeforeStyles = useContentBeforeStyles(); + const buttonStyles = useButtonStyles(); + const buttonShowClearButtonStyles = useButtonShowClearButtonStyles(); + const iconStyles = useIconStyles(); + const expandButtonStyles = useExpandButtonStyles(); + const clearButtonStyles = useClearButtonStyles(); + const disabledStyles = useDisabledStyles(); + + const { + appearance, + color, + disabled, + open, + placeholderVisible, + size, + showClearButton, + } = state; + const isEditable = !disabled; + const invalid = `${state.button['aria-invalid']}` === 'true'; + + state.root.className = mergeClasses( + state.root.className, + dropdownClassNames.root, + styles.root, + styles[size], + isEditable && isEditableStyles.base, + isEditable && + (placeholderVisible + ? isEditableStyles.noValue + : isEditableStyles.hasValue), + isEditable && appearanceStyles[appearance], + isEditable && isEditableStyles[color], + invalid && styles.invalid, + disabled && disabledStyles.base, + disabled && disabledStyles[appearance], + getSlotClassNameProp_unstable(state.root) + ); + + state.button.className = mergeClasses( + state.button.className, + dropdownClassNames.button, + buttonStyles.base, + state.contentBefore && buttonStyles.hasContentBefore, + buttonStyles[size], + showClearButton && buttonShowClearButtonStyles[size], + isEditable ? buttonStyles.isEditable : buttonStyles.disabled, + getSlotClassNameProp_unstable(state.button) + ); + + if (state.contentBefore) { + state.contentBefore.className = mergeClasses( + state.contentBefore.className, + // dropdownClassNames.contentBefore, + contentBeforeStyles.base, + contentBeforeStyles[size], + getSlotClassNameProp_unstable(state.contentBefore) + ); + } + + if (state.expandIcon) { + state.expandIcon.className = mergeClasses( + state.expandIcon.className, + dropdownClassNames.expandIcon, + iconStyles.base, + iconStyles[size], + expandButtonStyles[size], + disabled && iconStyles.disabled, + showClearButton && styles.hidden, + getSlotClassNameProp_unstable(state.expandIcon) + ); + } + + if (state.clearButton) { + state.clearButton.className = mergeClasses( + state.clearButton.className, + dropdownClassNames.clearButton, + clearButtonClassName, + iconStyles.base, + isEditable && clearButtonStyles.isEditable, + iconStyles[size], + clearButtonStyles[size], + !showClearButton && styles.hidden, + getSlotClassNameProp_unstable(state.clearButton) + ); + } + + if (state.listbox) { + state.listbox.className = mergeClasses( + state.listbox.className, + dropdownClassNames.listbox, + state.inlinePopup && styles.inlineListbox, + !open && styles.listboxCollapsed, + getSlotClassNameProp_unstable(state.listbox) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts new file mode 100644 index 00000000..62f13550 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts @@ -0,0 +1,31 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { ListboxState } from '@fluentui/react-combobox'; +import { tokens } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; + +const useStyles = makeStyles({ + root: { + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAlpha}`, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow4, + maxHeight: '80vh', + padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalSNudge}`, + }, +}); + +/** + * Apply styling to the Listbox slots based on the state + * @param state - The current Listbox state + * @returns The updated Listbox state with applied styles + * @alpha + */ +export const useListboxStyles = (state: ListboxState): ListboxState => { + const styles = useStyles(); + state.root.className = mergeClasses( + state.root.className, + styles.root, + getSlotClassNameProp_unstable(state.root) + ); + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts new file mode 100644 index 00000000..29189c27 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts @@ -0,0 +1,119 @@ +import { ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE } from '@fluentui/react-aria'; +import { optionClassNames, type OptionState } from '@fluentui/react-combobox'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { + iconFilledClassName, + iconRegularClassName, +} from '@fluentui/react-icons'; +import { tokens, typographyStyles } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; + +const useStyles = makeStyles({ + root: { + borderRadius: tokens.borderRadiusLarge, + color: tokens.colorNeutralForeground3, + columnGap: tokens.spacingHorizontalSNudge, + padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalSNudge}`, + ...typographyStyles.body1, + + ':hover': { + [`& .${iconFilledClassName}`]: { display: 'block' }, + [`& .${iconRegularClassName}`]: { display: 'none' }, + [`& .${optionClassNames.checkIcon}`]: shorthands.borderColor( + tokens.colorTransparentStroke + ), + }, + ':active': { + [`& .${optionClassNames.checkIcon}`]: shorthands.borderColor( + tokens.colorTransparentStroke + ), + }, + }, + + active: { + [`[${ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE}]::after`]: { + borderRadius: 'inherit', + boxShadow: `0 0 0 1px ${tokens.colorStrokeFocus1} inset`, + }, + }, + + disabled: { color: tokens.colorNeutralForegroundDisabled }, + disabledNotSelected: { + ':hover': { + [`& .${iconFilledClassName}`]: { display: 'none' }, + [`& .${iconRegularClassName}`]: { display: 'block' }, + }, + }, +}); + +const useCheckIconStyles = makeStyles({ + base: { + color: 'inherit', + margin: 0, + + // override Fluent '& svg' style + [`& .${iconFilledClassName}`]: { display: 'none' }, + [`& .${iconRegularClassName}`]: { display: 'block' }, + }, + radio: { + visibility: 'hidden', + '@media (forced-colors: active)': { visibility: 'visible' }, + }, + checkmark: { + backgroundColor: 'none', + ...shorthands.borderColor(tokens.colorTransparentStroke), // Keep border for high contrast + visibility: 'hidden', + '@media (forced-colors: active)': { visibility: 'visible' }, + }, + disabled: { color: 'inherit' }, + selected: { + [`& .${iconFilledClassName}`]: { display: 'block' }, + [`& .${iconRegularClassName}`]: { display: 'none' }, + }, + selectedRadio: { + color: tokens.colorCompoundBrandForeground1Pressed, + visibility: 'visible', + }, + selectedCheckmark: { visibility: 'visible' }, +}); + +/** + * Apply styling to the Option slots based on the state + * @param state - The current Option state + * @returns The updated Option state with applied styles + * @alpha + */ +export const useOptionStyles = (state: OptionState): OptionState => { + const styles = useStyles(); + const checkIconStyles = useCheckIconStyles(); + + const { disabled, multiselect, selected } = state; + + state.root.className = mergeClasses( + state.root.className, + styles.root, + styles.active, + disabled && styles.disabled, + disabled && !selected && styles.disabledNotSelected, + getSlotClassNameProp_unstable(state.root) + ); + + if (state.checkIcon) { + state.checkIcon.className = mergeClasses( + state.checkIcon.className, + checkIconStyles.base, + multiselect ? checkIconStyles.checkmark : checkIconStyles.radio, + selected && + mergeClasses( + checkIconStyles.selected, + multiselect + ? checkIconStyles.selectedCheckmark + : checkIconStyles.selectedRadio + ), + disabled && checkIconStyles.disabled, + getSlotClassNameProp_unstable(state.checkIcon) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts new file mode 100644 index 00000000..6497cf42 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts @@ -0,0 +1,50 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { OptionGroupState } from '@fluentui/react-combobox'; +import { tokens, typographyStyles } from '@fluentui/tokens'; +import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; + +const useStyles = makeStyles({ + root: { + rowGap: 0, + + '&:not(:last-child)::after': { + borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke3}`, + paddingBottom: 0, + margin: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalXS}`, + }, + }, + + label: { + borderRadius: tokens.borderRadiusLarge, + color: tokens.colorNeutralForeground2, + padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalS}`, + ...typographyStyles.caption1Strong, + }, +}); + +/** + * Apply styling to the OptionGroup slots based on the state + * @param state - The current OptionGroup state + * @returns The updated OptionGroup state with applied styles + * @alpha + */ +export const useOptionGroupStyles = ( + state: OptionGroupState +): OptionGroupState => { + const styles = useStyles(); + state.root.className = mergeClasses( + state.root.className, + styles.root, + getSlotClassNameProp_unstable(state.root) + ); + + if (state.label) { + state.label.className = mergeClasses( + state.label.className, + styles.label, + getSlotClassNameProp_unstable(state.label) + ); + } + + return state; +}; diff --git a/packages/react-cap-theme/src/components/react-combobox/index.ts b/packages/react-cap-theme/src/components/react-combobox/index.ts new file mode 100644 index 00000000..d85cd306 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/index.ts @@ -0,0 +1,11 @@ +export { useComboboxStyles } from './components/Combobox/useComboboxStyles.styles'; +export type { ComboboxState } from './components/Combobox/Combobox.types'; + +export { useDropdownStyles } from './components/Dropdown/useDropdownStyles.styles'; +export type { DropdownState } from './components/Dropdown/Dropdown.types'; + +export { useListboxStyles } from './components/Listbox/useListboxStyles.styles'; + +export { useOptionStyles } from './components/Option/useOptionStyles.styles'; + +export { useOptionGroupStyles } from './components/OptionGroup/useOptionGroupStyles.styles'; From 2933ebfe1eb33d8f37d54c9caa8fd2d68a867f5a Mon Sep 17 00:00:00 2001 From: EnricoGianoglio Date: Wed, 6 May 2026 19:29:11 +0200 Subject: [PATCH 2/4] Change files --- ...act-cap-theme-d963b118-5dff-48e8-9c7a-a99724185c4e.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-contrib-react-cap-theme-d963b118-5dff-48e8-9c7a-a99724185c4e.json diff --git a/change/@fluentui-contrib-react-cap-theme-d963b118-5dff-48e8-9c7a-a99724185c4e.json b/change/@fluentui-contrib-react-cap-theme-d963b118-5dff-48e8-9c7a-a99724185c4e.json new file mode 100644 index 00000000..19b49fbd --- /dev/null +++ b/change/@fluentui-contrib-react-cap-theme-d963b118-5dff-48e8-9c7a-a99724185c4e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "add react-combobox components", + "packageName": "@fluentui-contrib/react-cap-theme", + "email": "egianoglio@microsoft.com", + "dependentChangeType": "patch" +} From 0ea063c37461c3506d0ceb732e6d690ff93c42c7 Mon Sep 17 00:00:00 2001 From: EnricoGianoglio Date: Mon, 11 May 2026 13:14:34 +0200 Subject: [PATCH 3/4] removed Listbox, Option and OptionGroup since are not ready --- packages/react-cap-theme/src/capStyleHooks.ts | 17 --- .../Listbox/useListboxStyles.styles.ts | 31 ----- .../Option/useOptionStyles.styles.ts | 119 ------------------ .../useOptionGroupStyles.styles.ts | 50 -------- .../src/components/react-combobox/index.ts | 6 - 5 files changed, 223 deletions(-) delete mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts delete mode 100644 packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts delete mode 100644 packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts diff --git a/packages/react-cap-theme/src/capStyleHooks.ts b/packages/react-cap-theme/src/capStyleHooks.ts index ba6b282c..e97d7670 100644 --- a/packages/react-cap-theme/src/capStyleHooks.ts +++ b/packages/react-cap-theme/src/capStyleHooks.ts @@ -70,16 +70,8 @@ import type { CheckboxState } from './components/react-checkbox'; import { useComboboxStyles, useDropdownStyles, - useListboxStyles, - useOptionGroupStyles, - useOptionStyles, } from './components/react-combobox'; import type { ComboboxState, DropdownState } from './components/react-combobox'; -import type { - ListboxState, - OptionGroupState, - OptionState, -} from '@fluentui/react-combobox'; import { useDialogActionsStyles, useDialogBodyStyles, @@ -287,18 +279,9 @@ export const CAP_STYLE_HOOKS: NonNullable< useLinkStyles_unstable: (state) => { return useLinkStyles(state as LinkState); }, - useListboxStyles_unstable: (state) => { - return useListboxStyles(state as ListboxState); - }, useMenuButtonStyles_unstable: (state) => { return useMenuButtonStyles(state as MenuButtonState); }, - useOptionGroupStyles_unstable: (state) => { - return useOptionGroupStyles(state as OptionGroupState); - }, - useOptionStyles_unstable: (state) => { - return useOptionStyles(state as OptionState); - }, useOverlayDrawerStyles_unstable: (state) => { return useOverlayDrawerStyles(state as OverlayDrawerState); }, diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts deleted file mode 100644 index 62f13550..00000000 --- a/packages/react-cap-theme/src/components/react-combobox/components/Listbox/useListboxStyles.styles.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; -import type { ListboxState } from '@fluentui/react-combobox'; -import { tokens } from '@fluentui/tokens'; -import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; - -const useStyles = makeStyles({ - root: { - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAlpha}`, - borderRadius: tokens.borderRadiusXLarge, - boxShadow: tokens.shadow4, - maxHeight: '80vh', - padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalSNudge}`, - }, -}); - -/** - * Apply styling to the Listbox slots based on the state - * @param state - The current Listbox state - * @returns The updated Listbox state with applied styles - * @alpha - */ -export const useListboxStyles = (state: ListboxState): ListboxState => { - const styles = useStyles(); - state.root.className = mergeClasses( - state.root.className, - styles.root, - getSlotClassNameProp_unstable(state.root) - ); - - return state; -}; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts deleted file mode 100644 index 29189c27..00000000 --- a/packages/react-cap-theme/src/components/react-combobox/components/Option/useOptionStyles.styles.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE } from '@fluentui/react-aria'; -import { optionClassNames, type OptionState } from '@fluentui/react-combobox'; -import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; -import { - iconFilledClassName, - iconRegularClassName, -} from '@fluentui/react-icons'; -import { tokens, typographyStyles } from '@fluentui/tokens'; -import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; - -const useStyles = makeStyles({ - root: { - borderRadius: tokens.borderRadiusLarge, - color: tokens.colorNeutralForeground3, - columnGap: tokens.spacingHorizontalSNudge, - padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalSNudge}`, - ...typographyStyles.body1, - - ':hover': { - [`& .${iconFilledClassName}`]: { display: 'block' }, - [`& .${iconRegularClassName}`]: { display: 'none' }, - [`& .${optionClassNames.checkIcon}`]: shorthands.borderColor( - tokens.colorTransparentStroke - ), - }, - ':active': { - [`& .${optionClassNames.checkIcon}`]: shorthands.borderColor( - tokens.colorTransparentStroke - ), - }, - }, - - active: { - [`[${ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE}]::after`]: { - borderRadius: 'inherit', - boxShadow: `0 0 0 1px ${tokens.colorStrokeFocus1} inset`, - }, - }, - - disabled: { color: tokens.colorNeutralForegroundDisabled }, - disabledNotSelected: { - ':hover': { - [`& .${iconFilledClassName}`]: { display: 'none' }, - [`& .${iconRegularClassName}`]: { display: 'block' }, - }, - }, -}); - -const useCheckIconStyles = makeStyles({ - base: { - color: 'inherit', - margin: 0, - - // override Fluent '& svg' style - [`& .${iconFilledClassName}`]: { display: 'none' }, - [`& .${iconRegularClassName}`]: { display: 'block' }, - }, - radio: { - visibility: 'hidden', - '@media (forced-colors: active)': { visibility: 'visible' }, - }, - checkmark: { - backgroundColor: 'none', - ...shorthands.borderColor(tokens.colorTransparentStroke), // Keep border for high contrast - visibility: 'hidden', - '@media (forced-colors: active)': { visibility: 'visible' }, - }, - disabled: { color: 'inherit' }, - selected: { - [`& .${iconFilledClassName}`]: { display: 'block' }, - [`& .${iconRegularClassName}`]: { display: 'none' }, - }, - selectedRadio: { - color: tokens.colorCompoundBrandForeground1Pressed, - visibility: 'visible', - }, - selectedCheckmark: { visibility: 'visible' }, -}); - -/** - * Apply styling to the Option slots based on the state - * @param state - The current Option state - * @returns The updated Option state with applied styles - * @alpha - */ -export const useOptionStyles = (state: OptionState): OptionState => { - const styles = useStyles(); - const checkIconStyles = useCheckIconStyles(); - - const { disabled, multiselect, selected } = state; - - state.root.className = mergeClasses( - state.root.className, - styles.root, - styles.active, - disabled && styles.disabled, - disabled && !selected && styles.disabledNotSelected, - getSlotClassNameProp_unstable(state.root) - ); - - if (state.checkIcon) { - state.checkIcon.className = mergeClasses( - state.checkIcon.className, - checkIconStyles.base, - multiselect ? checkIconStyles.checkmark : checkIconStyles.radio, - selected && - mergeClasses( - checkIconStyles.selected, - multiselect - ? checkIconStyles.selectedCheckmark - : checkIconStyles.selectedRadio - ), - disabled && checkIconStyles.disabled, - getSlotClassNameProp_unstable(state.checkIcon) - ); - } - - return state; -}; diff --git a/packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts deleted file mode 100644 index 6497cf42..00000000 --- a/packages/react-cap-theme/src/components/react-combobox/components/OptionGroup/useOptionGroupStyles.styles.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; -import type { OptionGroupState } from '@fluentui/react-combobox'; -import { tokens, typographyStyles } from '@fluentui/tokens'; -import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities'; - -const useStyles = makeStyles({ - root: { - rowGap: 0, - - '&:not(:last-child)::after': { - borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke3}`, - paddingBottom: 0, - margin: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalXS}`, - }, - }, - - label: { - borderRadius: tokens.borderRadiusLarge, - color: tokens.colorNeutralForeground2, - padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalS}`, - ...typographyStyles.caption1Strong, - }, -}); - -/** - * Apply styling to the OptionGroup slots based on the state - * @param state - The current OptionGroup state - * @returns The updated OptionGroup state with applied styles - * @alpha - */ -export const useOptionGroupStyles = ( - state: OptionGroupState -): OptionGroupState => { - const styles = useStyles(); - state.root.className = mergeClasses( - state.root.className, - styles.root, - getSlotClassNameProp_unstable(state.root) - ); - - if (state.label) { - state.label.className = mergeClasses( - state.label.className, - styles.label, - getSlotClassNameProp_unstable(state.label) - ); - } - - return state; -}; diff --git a/packages/react-cap-theme/src/components/react-combobox/index.ts b/packages/react-cap-theme/src/components/react-combobox/index.ts index d85cd306..7e6a0f34 100644 --- a/packages/react-cap-theme/src/components/react-combobox/index.ts +++ b/packages/react-cap-theme/src/components/react-combobox/index.ts @@ -3,9 +3,3 @@ export type { ComboboxState } from './components/Combobox/Combobox.types'; export { useDropdownStyles } from './components/Dropdown/useDropdownStyles.styles'; export type { DropdownState } from './components/Dropdown/Dropdown.types'; - -export { useListboxStyles } from './components/Listbox/useListboxStyles.styles'; - -export { useOptionStyles } from './components/Option/useOptionStyles.styles'; - -export { useOptionGroupStyles } from './components/OptionGroup/useOptionGroupStyles.styles'; From 79d9aa2e5ba1b80e8d93027c48a3e50c0d24ae8b Mon Sep 17 00:00:00 2001 From: EnricoGianoglio Date: Mon, 11 May 2026 16:01:58 +0200 Subject: [PATCH 4/4] remove Fluent's ::after focus underline --- .../components/Combobox/useComboboxStyles.styles.ts | 2 ++ .../components/Dropdown/useDropdownStyles.styles.ts | 2 ++ .../react-input/components/Input/useInputStyles.styles.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts index 7c6a56ea..23283634 100644 --- a/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts +++ b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts @@ -27,6 +27,7 @@ const useStyles = makeStyles({ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAccessible}`, borderRadius: capTokens.borderRadius2XLarge, ...typographyStyles.body1, + '::after': { content: 'unset' }, }, small: { @@ -103,6 +104,7 @@ const useIsEditableStyles = makeStyles({ // [`& .${comboboxClassNames.contentBefore} > .${iconFilledClassName}`]: { // display: 'inline', // }, + ...shorthands.borderColor(tokens.colorBrandStroke1), }, ':focus-within': { diff --git a/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts index 35f77e7a..2dbb38ec 100644 --- a/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts +++ b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts @@ -34,6 +34,7 @@ const useStyles = makeStyles({ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAccessible}`, borderRadius: capTokens.borderRadius2XLarge, ...typographyStyles.body1, + '::after': { content: 'unset' }, }, small: { @@ -103,6 +104,7 @@ const useIsEditableStyles = makeStyles({ // [`& .${dropdownClassNames.contentBefore} > .${iconFilledClassName}`]: { // display: 'inline', // }, + ...shorthands.borderColor(tokens.colorBrandStroke1), }, ':focus-within': { diff --git a/packages/react-cap-theme/src/components/react-input/components/Input/useInputStyles.styles.ts b/packages/react-cap-theme/src/components/react-input/components/Input/useInputStyles.styles.ts index 6b932816..9356be6c 100644 --- a/packages/react-cap-theme/src/components/react-input/components/Input/useInputStyles.styles.ts +++ b/packages/react-cap-theme/src/components/react-input/components/Input/useInputStyles.styles.ts @@ -22,6 +22,7 @@ const useStyles = makeStyles({ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStrokeAccessible}`, borderRadius: capTokens.borderRadius2XLarge, ...typographyStyles.body1, + '::after': { content: 'unset' }, }, small: { minHeight: '28px',