Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@fluentui/react-button';
import { FluentProvider, FluentProviderCustomStyleHooks } from '@fluentui/react-provider';
import { makeStyles, mergeClasses, shorthands } from '@griffel/react';
import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities';
import { type StoryParameters, Steps } from 'storywright';

export default {
Expand All @@ -30,6 +31,12 @@ const useCustomStyles = makeStyles({
...shorthands.borderColor('crimson'),
...shorthands.borderRadius('0'),
},

purpleButton: {
...shorthands.borderColor('indigo'),
backgroundColor: 'purple',
color: 'lavender',
},
});

export const ButtonCustomStyles = () => {
Expand All @@ -38,7 +45,11 @@ export const ButtonCustomStyles = () => {
const customStyleHooks: FluentProviderCustomStyleHooks = {
useButtonStyles_unstable: (state: unknown) => {
const componentState = state as ButtonState;
componentState.root.className = mergeClasses(componentState.root.className, styles.button);
componentState.root.className = mergeClasses(
componentState.root.className,
styles.button,
getSlotClassNameProp_unstable(componentState.root),
);
},
};

Expand All @@ -57,7 +68,11 @@ export const CompoundButtonCustomStyles = () => {
const customStyleHooks: FluentProviderCustomStyleHooks = {
useCompoundButtonStyles_unstable: (state: unknown) => {
const componentState = state as CompoundButtonState;
componentState.root.className = mergeClasses(componentState.root.className, styles.button);
componentState.root.className = mergeClasses(
componentState.root.className,
styles.button,
getSlotClassNameProp_unstable(componentState.root),
);
},
};

Expand All @@ -76,7 +91,11 @@ export const MenuButtonCustomStyles = () => {
const customStyleHooks: FluentProviderCustomStyleHooks = {
useMenuButtonStyles_unstable: (state: unknown) => {
const componentState = state as MenuButtonState;
componentState.root.className = mergeClasses(componentState.root.className, styles.button);
componentState.root.className = mergeClasses(
componentState.root.className,
styles.button,
getSlotClassNameProp_unstable(componentState.root),
);
},
};

Expand All @@ -96,12 +115,17 @@ export const SplitButtonCustomStyles = () => {
useSplitButtonStyles_unstable: (state: unknown) => {
const componentState = state as SplitButtonState;
if (componentState.menuButton) {
componentState.menuButton.className = mergeClasses(componentState.menuButton.className, styles.button);
componentState.menuButton.className = mergeClasses(
componentState.menuButton.className,
styles.button,
getSlotClassNameProp_unstable(componentState.menuButton),
);
}
if (componentState.primaryActionButton) {
componentState.primaryActionButton.className = mergeClasses(
componentState.primaryActionButton.className,
styles.button,
getSlotClassNameProp_unstable(componentState.primaryActionButton),
);
}
},
Expand All @@ -122,7 +146,11 @@ export const ToggleButtonCustomStyles = () => {
const customStyleHooks: FluentProviderCustomStyleHooks = {
useToggleButtonStyles_unstable: (state: unknown) => {
const componentState = state as ToggleButtonState;
componentState.root.className = mergeClasses(componentState.root.className, styles.button);
componentState.root.className = mergeClasses(
componentState.root.className,
styles.button,
getSlotClassNameProp_unstable(componentState.root),
);
},
};

Expand All @@ -134,3 +162,26 @@ export const ToggleButtonCustomStyles = () => {
};

ToggleButtonCustomStyles.storyName = 'ToggleButton';

export const ClassNamePropWithCustomStyles = () => {
const styles = useCustomStyles();

const customStyleHooks: FluentProviderCustomStyleHooks = {
useButtonStyles_unstable: (state: unknown) => {
const componentState = state as ButtonState;
componentState.root.className = mergeClasses(
componentState.root.className,
styles.button,
getSlotClassNameProp_unstable(componentState.root),
);
},
};

return (
<FluentProvider customStyleHooks_unstable={customStyleHooks}>
<Button className={styles.purpleButton}>Purple button</Button>
</FluentProvider>
);
};

ClassNamePropWithCustomStyles.storyName = 'Button with className';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add getSlotClassNameProp_unstable function to allow custom style hooks to preserve the original className while overriding the component default className.",
"packageName": "@fluentui/react-components",
"email": "behowell@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Support internal slot metadata change for SLOT_CLASS_NAME_PROP_SYMBOL",
"packageName": "@fluentui/react-jsx-runtime",
"email": "behowell@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add getSlotClassNameProp_unstable function to allow custom style hooks to preserve the original className while overriding the component default className.",
"packageName": "@fluentui/react-utilities",
"email": "behowell@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ import { ForwardRefComponent } from '@fluentui/react-utilities';
import { getIntrinsicElementProps } from '@fluentui/react-utilities';
import { getNativeElementProps } from '@fluentui/react-utilities';
import { getPartitionedNativeProps } from '@fluentui/react-utilities';
import { getSlotClassNameProp_unstable } from '@fluentui/react-utilities';
import { getSlots } from '@fluentui/react-utilities';
import { GriffelRenderer } from '@griffel/react';
import { GriffelResetStyle } from '@griffel/react';
Expand Down Expand Up @@ -2754,6 +2755,8 @@ export { getNativeElementProps }

export { getPartitionedNativeProps }

export { getSlotClassNameProp_unstable }

export { getSlots }

export { GriffelRenderer }
Expand Down
1 change: 1 addition & 0 deletions packages/react-components/react-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export {
getNativeElementProps,
getIntrinsicElementProps,
getPartitionedNativeProps,
getSlotClassNameProp_unstable,
// getSlots is deprecated but removing it would be a breaking change
// eslint-disable-next-line @typescript-eslint/no-deprecated
getSlots,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type * as React from 'react';
import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from '@fluentui/react-utilities';
import {
SLOT_CLASS_NAME_PROP_SYMBOL,
SLOT_ELEMENT_TYPE_SYMBOL,
SLOT_RENDER_FUNCTION_SYMBOL,
} from '@fluentui/react-utilities';
import type { SlotComponentType, UnknownSlotProps } from '@fluentui/react-utilities';

/**
Expand All @@ -8,6 +12,7 @@ import type { SlotComponentType, UnknownSlotProps } from '@fluentui/react-utilit
export function getMetadataFromSlotComponent<Props extends UnknownSlotProps>(type: SlotComponentType<Props>) {
const {
as,
[SLOT_CLASS_NAME_PROP_SYMBOL]: _classNameProp,
[SLOT_ELEMENT_TYPE_SYMBOL]: baseElementType,
[SLOT_RENDER_FUNCTION_SYMBOL]: renderFunction,
...propsWithoutMetadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export const getPartitionedNativeProps: <Props extends Pick<React_2.HTMLAttribut
// @internal
export const getRTLSafeKey: (key: string, dir: 'ltr' | 'rtl') => string;

// @public
export const getSlotClassNameProp_unstable: (slot: UnknownSlotProps) => string | undefined;

// @public @deprecated
export function getSlots<R extends SlotPropsRecord>(state: ComponentState<R>): {
slots: Slots<R>;
Expand Down Expand Up @@ -267,6 +270,9 @@ declare namespace slot {
}
export { slot }

// @internal
export const SLOT_CLASS_NAME_PROP_SYMBOL: unique symbol;

// @internal
export const SLOT_ELEMENT_TYPE_SYMBOL: unique symbol;

Expand All @@ -283,6 +289,7 @@ export type SlotComponentType<Props> = Props & {
(props: React_2.PropsWithChildren<{}>): React_2.ReactElement | null;
[SLOT_RENDER_FUNCTION_SYMBOL]?: SlotRenderFunction<Props>;
[SLOT_ELEMENT_TYPE_SYMBOL]: React_2.ComponentType<Props> | (Props extends AsIntrinsicElement<infer As> ? As : keyof JSX.IntrinsicElements);
[SLOT_CLASS_NAME_PROP_SYMBOL]?: string;
};

// @public (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ export const SLOT_RENDER_FUNCTION_SYMBOL = Symbol.for('fui.slotRenderFunction');
* Internal reference for the render function
*/
export const SLOT_ELEMENT_TYPE_SYMBOL = Symbol.for('fui.slotElementType');
/**
* @internal
* Internal cache of the original className prop for the slot, before being modified by the useStyles hook.
*/
export const SLOT_CLASS_NAME_PROP_SYMBOL = Symbol.for('fui.slotClassNameProp');
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getSlotClassNameProp } from './getSlotClassNameProp';
import * as slot from './slot';
import type { ComponentProps, Slot } from './types';

type TestSlots = {
slotA: NonNullable<Slot<'div'>>;
};

type TestProps = ComponentProps<TestSlots>;

const mergeClasses = (...classNames: (string | false | undefined)[]) => classNames.filter(Boolean).join(' ');

describe('getSlotClassNameProp', () => {
it('returns the original class name even if the slot className is modified', () => {
const props: TestProps = { slotA: { className: 'originalClassName' } };
const slotA = slot.always(props.slotA, { elementType: 'div' });
slotA.className = mergeClasses(slotA.className, 'overrideClassName');

expect(getSlotClassNameProp(slotA)).toEqual('originalClassName');
expect(slotA.className).toEqual('originalClassName overrideClassName');
});
it('returns undefined if the slot does not have a className', () => {
const props: TestProps = { slotA: {} };
const slotA = slot.always(props.slotA, { elementType: 'div' });
slotA.className = mergeClasses(slotA.className, 'overrideClassName');

expect(getSlotClassNameProp(slotA)).toBeUndefined();
expect(slotA.className).toEqual('overrideClassName');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { SLOT_CLASS_NAME_PROP_SYMBOL } from '../compose/constants';
import type { UnknownSlotProps } from '../compose/types';

/**
* Get the className prop set on the slot by the user, without including the default classes added by the component.
* Custom style hooks should merge this className _after_ any additional classes added by the hook, to ensure that
* classes added by the user take precedence over the custom style hook.
*
* Example usage in a custom style hook:
* ```ts
* state.root.className = mergeClasses(
* state.root.className,
* customStyles.root,
* getSlotClassNameProp_unstable(state.root));
* ```
*
* @returns The className prop set on the slot by the user, or undefined if not set.
*/
export const getSlotClassNameProp = (slot: UnknownSlotProps) => {
if (SLOT_CLASS_NAME_PROP_SYMBOL in slot && typeof slot[SLOT_CLASS_NAME_PROP_SYMBOL] === 'string') {
return slot[SLOT_CLASS_NAME_PROP_SYMBOL];
}
return undefined;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ export type {
UnknownSlotProps,
} from './types';
export { isResolvedShorthand } from './isResolvedShorthand';
export { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
export { SLOT_CLASS_NAME_PROP_SYMBOL, SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
export { isSlot } from './isSlot';
export { assertSlots } from './assertSlots';
export { getIntrinsicElementProps } from './getIntrinsicElementProps';
export { getSlotClassNameProp as getSlotClassNameProp_unstable } from './getSlotClassNameProp';

// eslint-disable-next-line @typescript-eslint/no-deprecated
export type { ObjectSlotProps, Slots } from './deprecated/getSlots';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import * as slot from './slot';
import type { ComponentProps, Slot } from './types';
import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
import { SLOT_CLASS_NAME_PROP_SYMBOL, SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';

type TestSlots = {
slotA?: Slot<'div'>;
Expand Down Expand Up @@ -84,6 +84,20 @@ describe('slot', () => {
[SLOT_RENDER_FUNCTION_SYMBOL]: expect.any(Function),
});
});

it('saves the original className', () => {
const props: TestProps = { slotA: { className: 'test1' } };
const resolvedProps = slot.optional(props.slotA, { elementType: 'div' });
if (resolvedProps) {
resolvedProps.className = [resolvedProps.className, 'test2'].join(' ');
}
expect(resolvedProps).toEqual({
[SLOT_ELEMENT_TYPE_SYMBOL]: 'div',
[SLOT_CLASS_NAME_PROP_SYMBOL]: 'test1',
className: 'test1 test2',
});
});

describe('.resolveShorthand', () => {
it('resolves a string', () => {
expect(slot.resolveShorthand('hello')).toEqual({ children: 'hello' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
UnknownSlotProps,
} from './types';
import * as React from 'react';
import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
import { SLOT_CLASS_NAME_PROP_SYMBOL, SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';

export type SlotOptions<Props extends UnknownSlotProps> = {
elementType:
Expand Down Expand Up @@ -41,6 +41,7 @@ export function always<Props extends UnknownSlotProps>(
...defaultProps,
...props,
[SLOT_ELEMENT_TYPE_SYMBOL]: elementType,
[SLOT_CLASS_NAME_PROP_SYMBOL]: props?.className,
} as SlotComponentType<Props>;

if (props && typeof props.children === 'function') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
import { SLOT_CLASS_NAME_PROP_SYMBOL, SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants';
import { DistributiveOmit, ReplaceNullWithUndefined } from '../utils/types';

export type SlotRenderFunction<Props> = (
Expand Down Expand Up @@ -252,6 +252,11 @@ export type SlotComponentType<Props> = Props & {
[SLOT_ELEMENT_TYPE_SYMBOL]:
| React.ComponentType<Props>
| (Props extends AsIntrinsicElement<infer As> ? As : keyof JSX.IntrinsicElements);
/**
* @internal
* The original className prop for the slot, before being modified by the useStyles hook.
*/
[SLOT_CLASS_NAME_PROP_SYMBOL]?: string;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/react-components/react-utilities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export {
resolveShorthand,
isResolvedShorthand,
getIntrinsicElementProps,
getSlotClassNameProp_unstable,
SLOT_CLASS_NAME_PROP_SYMBOL,
SLOT_ELEMENT_TYPE_SYMBOL,
SLOT_RENDER_FUNCTION_SYMBOL,
} from './compose/index';
Expand Down
Loading