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" +} 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..e97d7670 100644 --- a/packages/react-cap-theme/src/capStyleHooks.ts +++ b/packages/react-cap-theme/src/capStyleHooks.ts @@ -67,6 +67,11 @@ import type { } from '@fluentui/react-carousel'; import { useCheckboxStyles } from './components/react-checkbox'; import type { CheckboxState } from './components/react-checkbox'; +import { + useComboboxStyles, + useDropdownStyles, +} from './components/react-combobox'; +import type { ComboboxState, DropdownState } from './components/react-combobox'; import { useDialogActionsStyles, useDialogBodyStyles, @@ -213,6 +218,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 +250,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); }, 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..23283634 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Combobox/useComboboxStyles.styles.ts @@ -0,0 +1,383 @@ +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, + '::after': { content: 'unset' }, + }, + + 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', + // }, + ...shorthands.borderColor(tokens.colorBrandStroke1), + }, + + ':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..2dbb38ec --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/components/Dropdown/useDropdownStyles.styles.ts @@ -0,0 +1,422 @@ +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, + '::after': { content: 'unset' }, + }, + + 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', + // }, + ...shorthands.borderColor(tokens.colorBrandStroke1), + }, + + ':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/index.ts b/packages/react-cap-theme/src/components/react-combobox/index.ts new file mode 100644 index 00000000..7e6a0f34 --- /dev/null +++ b/packages/react-cap-theme/src/components/react-combobox/index.ts @@ -0,0 +1,5 @@ +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'; 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',