diff --git a/packages/react-components/react-message-bar-preview/etc/react-message-bar-preview.api.md b/packages/react-components/react-message-bar-preview/etc/react-message-bar-preview.api.md index 7fe7c5e7925947..4b38d3f78ee27b 100644 --- a/packages/react-components/react-message-bar-preview/etc/react-message-bar-preview.api.md +++ b/packages/react-components/react-message-bar-preview/etc/react-message-bar-preview.api.md @@ -62,6 +62,30 @@ export type MessageBarContextValue = { layout?: 'multiline' | 'singleline' | 'auto'; }; +// @public +export const MessageBarGroup: ForwardRefComponent; + +// @public (undocumented) +export const messageBarGroupClassNames: SlotClassNames; + +// @public +export type MessageBarGroupProps = ComponentProps & { + children: React_2.ReactElement[] | React_2.ReactElement; + animate?: 'exit-only' | 'both'; +}; + +// @public (undocumented) +export type MessageBarGroupSlots = { + root: Slot<'div'>; +}; + +// @public +export type MessageBarGroupState = ComponentState & Pick & { + enterStyles: string; + exitStyles: string; + children: React_2.ReactElement[]; +}; + // @public export type MessageBarProps = ComponentProps & Pick & { intent?: 'info' | 'success' | 'warning' | 'error'; @@ -74,7 +98,9 @@ export type MessageBarSlots = { }; // @public -export type MessageBarState = ComponentState & Required>; +export type MessageBarState = ComponentState & Required> & { + transitionClassName: string; +}; // @public export const MessageBarTitle: ForwardRefComponent; @@ -102,6 +128,9 @@ export const renderMessageBarActions_unstable: (state: MessageBarActionsState) = // @public export const renderMessageBarBody_unstable: (state: MessageBarBodyState) => JSX.Element; +// @public +export const renderMessageBarGroup_unstable: (state: MessageBarGroupState) => JSX.Element; + // @public export const renderMessageBarTitle_unstable: (state: MessageBarTitleState) => JSX.Element; @@ -123,6 +152,12 @@ export const useMessageBarBodyStyles_unstable: (state: MessageBarBodyState) => M // @public (undocumented) export const useMessageBarContext: () => MessageBarContextValue; +// @public +export const useMessageBarGroup_unstable: (props: MessageBarGroupProps, ref: React_2.Ref) => MessageBarGroupState; + +// @public +export const useMessageBarGroupStyles_unstable: (state: MessageBarGroupState) => MessageBarGroupState; + // @public export const useMessageBarStyles_unstable: (state: MessageBarState) => MessageBarState; diff --git a/packages/react-components/react-message-bar-preview/package.json b/packages/react-components/react-message-bar-preview/package.json index 41ae3a47b94fad..1ae1df022bc72d 100644 --- a/packages/react-components/react-message-bar-preview/package.json +++ b/packages/react-components/react-message-bar-preview/package.json @@ -38,7 +38,8 @@ "@fluentui/react-theme": "^9.1.14", "@fluentui/react-utilities": "^9.13.5", "@griffel/react": "^1.5.14", - "@swc/helpers": "^0.5.1" + "@swc/helpers": "^0.5.1", + "react-transition-group": "^4.4.1" }, "peerDependencies": { "@types/react": ">=16.8.0 <19.0.0", diff --git a/packages/react-components/react-message-bar-preview/src/MessageBarGroup.ts b/packages/react-components/react-message-bar-preview/src/MessageBarGroup.ts new file mode 100644 index 00000000000000..3515bfed8ae631 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/MessageBarGroup.ts @@ -0,0 +1 @@ +export * from './components/MessageBarGroup/index'; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBar/MessageBar.types.ts b/packages/react-components/react-message-bar-preview/src/components/MessageBar/MessageBar.types.ts index 9a1689458def95..9de1424f0a04f4 100644 --- a/packages/react-components/react-message-bar-preview/src/components/MessageBar/MessageBar.types.ts +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBar/MessageBar.types.ts @@ -1,5 +1,5 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; -import { MessageBarContextValue } from '../../contexts/messageBarContext'; +import type { MessageBarContextValue } from '../../contexts/messageBarContext'; export type MessageBarSlots = { root: Slot<'div'>; @@ -21,4 +21,7 @@ export type MessageBarProps = ComponentProps & /** * State used in rendering MessageBar */ -export type MessageBarState = ComponentState & Required>; +export type MessageBarState = ComponentState & + Required> & { + transitionClassName: string; + }; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBar/useMessageBar.ts b/packages/react-components/react-message-bar-preview/src/components/MessageBar/useMessageBar.ts index c65ec5e43ef920..c296460ad70060 100644 --- a/packages/react-components/react-message-bar-preview/src/components/MessageBar/useMessageBar.ts +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBar/useMessageBar.ts @@ -3,6 +3,7 @@ import { getNativeElementProps, slot, useMergedRefs } from '@fluentui/react-util import type { MessageBarProps, MessageBarState } from './MessageBar.types'; import { getIntentIcon } from './getIntentIcon'; import { useMessageBarReflow } from './useMessageBarReflow'; +import { useMessageBarTransitionContext } from '../../contexts/messageBarTransitionContext'; /** * Create the state required to render MessageBar. @@ -15,11 +16,10 @@ import { useMessageBarReflow } from './useMessageBarReflow'; */ export const useMessageBar_unstable = (props: MessageBarProps, ref: React.Ref): MessageBarState => { const { layout = 'auto', intent = 'info' } = props; - const autoReflow = layout === 'auto'; const { ref: reflowRef, reflowing } = useMessageBarReflow(autoReflow); - const computedLayout = autoReflow ? (reflowing ? 'multiline' : 'singleline') : layout; + const { className: transitionClassName, nodeRef } = useMessageBarTransitionContext(); return { components: { @@ -28,7 +28,7 @@ export const useMessageBar_unstable = (props: MessageBarProps, ref: React.Ref { + isConformant({ + Component: MessageBarGroup, + displayName: 'MessageBarGroup', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render( + + Default MessageBarGroup + , + ); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarGroup.tsx b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarGroup.tsx new file mode 100644 index 00000000000000..7eeaa3baaa364b --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarGroup.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMessageBarGroup_unstable } from './useMessageBarGroup'; +import { renderMessageBarGroup_unstable } from './renderMessageBarGroup'; +import { useMessageBarGroupStyles_unstable } from './useMessageBarGroupStyles.styles'; +import type { MessageBarGroupProps } from './MessageBarGroup.types'; + +/** + * MessageBarGroup component + */ +export const MessageBarGroup: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMessageBarGroup_unstable(props, ref); + + useMessageBarGroupStyles_unstable(state); + return renderMessageBarGroup_unstable(state); +}); + +MessageBarGroup.displayName = 'MessageBarGroup'; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarGroup.types.ts b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarGroup.types.ts new file mode 100644 index 00000000000000..dc0cbc00b1a751 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarGroup.types.ts @@ -0,0 +1,24 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import * as React from 'react'; + +export type MessageBarGroupSlots = { + root: Slot<'div'>; +}; + +/** + * MessageBarGroup Props + */ +export type MessageBarGroupProps = ComponentProps & { + children: React.ReactElement[] | React.ReactElement; + animate?: 'exit-only' | 'both'; +}; + +/** + * State used in rendering MessageBarGroup + */ +export type MessageBarGroupState = ComponentState & + Pick & { + enterStyles: string; + exitStyles: string; + children: React.ReactElement[]; + }; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarTransition.tsx b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarTransition.tsx new file mode 100644 index 00000000000000..a6e5c4ab95794d --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/MessageBarTransition.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Transition, TransitionStatus } from 'react-transition-group'; +import { MessageBarTransitionContextProvider } from '../../contexts/messageBarTransitionContext'; +import { MessageBarGroupProps } from './MessageBarGroup.types'; + +const getClassName = ( + status: TransitionStatus, + enterClassName: string, + exitClassName: string, + animate: MessageBarGroupProps['animate'], +) => { + switch (status) { + case 'entering': + case 'entered': + return animate === 'both' ? enterClassName : ''; + case 'exiting': + case 'exited': + return exitClassName; + default: + return ''; + } +}; + +/** + * Internal component that controls the animation transition for MessageBar components + * @internal + */ +export const MessageBarTransition: React.FC<{ + children: React.ReactElement; + enterClassName: string; + exitClassName: string; + animate: MessageBarGroupProps['animate']; +}> = ({ children, enterClassName, exitClassName, animate, ...rest }) => { + const nodeRef = React.useRef(null); + + return ( + + {state => ( + + {children} + + )} + + ); +}; + +const MessageBarTransitionInner: React.FC<{ + children: React.ReactElement; + enterClassName: string; + exitClassName: string; + animate: MessageBarGroupProps['animate']; + nodeRef: React.Ref; + state: TransitionStatus; +}> = ({ children, state, enterClassName, exitClassName, animate, nodeRef }) => { + const className = getClassName(state, enterClassName, exitClassName, animate); + const context = React.useMemo( + () => ({ + className, + nodeRef, + }), + [className, nodeRef], + ); + + return {children}; +}; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/__snapshots__/MessageBarGroup.test.tsx.snap b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/__snapshots__/MessageBarGroup.test.tsx.snap new file mode 100644 index 00000000000000..fc1e020af85b30 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/__snapshots__/MessageBarGroup.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessageBarGroup renders a default state 1`] = ` +
+
+ + Default MessageBarGroup + +
+
+`; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/index.ts b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/index.ts new file mode 100644 index 00000000000000..21c912d11b842c --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/index.ts @@ -0,0 +1,5 @@ +export * from './MessageBarGroup'; +export * from './MessageBarGroup.types'; +export * from './renderMessageBarGroup'; +export * from './useMessageBarGroup'; +export * from './useMessageBarGroupStyles.styles'; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/renderMessageBarGroup.tsx b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/renderMessageBarGroup.tsx new file mode 100644 index 00000000000000..ddd4c6d29760d5 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/renderMessageBarGroup.tsx @@ -0,0 +1,31 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { MessageBarGroupState, MessageBarGroupSlots } from './MessageBarGroup.types'; +import { TransitionGroup } from 'react-transition-group'; +import { MessageBarTransition } from './MessageBarTransition'; + +/** + * Render the final JSX of MessageBarGroup + */ +export const renderMessageBarGroup_unstable = (state: MessageBarGroupState) => { + assertSlots(state); + + return ( + + + {state.children.map(child => ( + + {child} + + ))} + + + ); +}; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/useMessageBarGroup.ts b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/useMessageBarGroup.ts new file mode 100644 index 00000000000000..6adb9280d95b10 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/useMessageBarGroup.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; +import type { MessageBarGroupProps, MessageBarGroupState } from './MessageBarGroup.types'; +import { getNativeElementProps, slot } from '@fluentui/react-utilities'; + +/** + * Create the state required to render MessageBarGroup. + * + * The returned state can be modified with hooks such as useMessageBarGroupStyles_unstable, + * before being passed to renderMessageBarGroup_unstable. + * + * @param props - props from this instance of MessageBarGroup + * @param ref - reference to root HTMLElement of MessageBarGroup + */ +export const useMessageBarGroup_unstable = ( + props: MessageBarGroupProps, + ref: React.Ref, +): MessageBarGroupState => { + if (process.env.NODE_ENV !== 'production') { + React.Children.forEach(props.children, c => { + if (!React.isValidElement(c) || c.type === React.Fragment) { + throw new Error( + "MessageBarGroup: children must be valid MessageBar components. Please ensure you're not using fragments. ", + ); + } + }); + } + + const children = React.Children.map(props.children ?? [], c => + React.isValidElement(c) && c.type !== React.Fragment ? c : null, + ).filter(Boolean); + + return { + components: { + root: 'div', + }, + + root: slot.always( + getNativeElementProps('div', { + ref, + ...props, + }), + { elementType: 'div' }, + ), + children, + animate: props.animate ?? 'exit-only', + enterStyles: '', + exitStyles: '', + }; +}; diff --git a/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/useMessageBarGroupStyles.styles.ts b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/useMessageBarGroupStyles.styles.ts new file mode 100644 index 00000000000000..0c1b62059a810c --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/components/MessageBarGroup/useMessageBarGroupStyles.styles.ts @@ -0,0 +1,53 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { MessageBarGroupSlots, MessageBarGroupState } from './MessageBarGroup.types'; + +export const messageBarGroupClassNames: SlotClassNames = { + root: 'fui-MessageBarGroup', +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + base: { + animationFillMode: 'forwards', + animationDuration: tokens.durationNormal, + }, + + enter: { + animationName: { + from: { + opacity: 0, + transform: 'translateY(-100%)', + }, + to: { + opacity: 1, + transform: 'translateY(0)', + }, + }, + }, + + exit: { + animationName: { + from: { + opacity: 1, + }, + to: { + opacity: 0, + }, + }, + }, +}); + +/** + * Apply styling to the MessageBarGroup slots based on the state + */ +export const useMessageBarGroupStyles_unstable = (state: MessageBarGroupState): MessageBarGroupState => { + const styles = useStyles(); + state.root.className = mergeClasses(messageBarGroupClassNames.root, state.root.className); + state.enterStyles = mergeClasses(styles.base, styles.enter); + state.exitStyles = mergeClasses(styles.base, styles.exit); + return state; +}; diff --git a/packages/react-components/react-message-bar-preview/src/contexts/messageBarTransitionContext.ts b/packages/react-components/react-message-bar-preview/src/contexts/messageBarTransitionContext.ts new file mode 100644 index 00000000000000..0a023b4b0e46ca --- /dev/null +++ b/packages/react-components/react-message-bar-preview/src/contexts/messageBarTransitionContext.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export type MessageBarTransitionContextValue = { + className: string; + nodeRef: React.Ref; +}; + +const messageBarTransitionContext = React.createContext(undefined); + +/** + * @internal + */ +export const messageBarTransitionContextDefaultValue: MessageBarTransitionContextValue = { + className: '', + nodeRef: React.createRef(), +}; + +/** + * Context to pass animation className to MessageBar components + * @internal + */ +export const MessageBarTransitionContextProvider = messageBarTransitionContext.Provider; +/** + * @internal + */ +export const useMessageBarTransitionContext = () => + React.useContext(messageBarTransitionContext) ?? messageBarTransitionContextDefaultValue; diff --git a/packages/react-components/react-message-bar-preview/src/index.ts b/packages/react-components/react-message-bar-preview/src/index.ts index e4d035efe8584d..03ef9aa77eca80 100644 --- a/packages/react-components/react-message-bar-preview/src/index.ts +++ b/packages/react-components/react-message-bar-preview/src/index.ts @@ -40,3 +40,12 @@ export type { MessageBarBodyProps, MessageBarBodySlots, MessageBarBodyState } fr export { MessageBarContextProvider, useMessageBarContext } from './contexts/messageBarContext'; export type { MessageBarContextValue } from './contexts/messageBarContext'; +export { + MessageBarGroup, + useMessageBarGroupStyles_unstable, + useMessageBarGroup_unstable, + renderMessageBarGroup_unstable, + messageBarGroupClassNames, +} from './MessageBarGroup'; + +export type { MessageBarGroupProps, MessageBarGroupSlots, MessageBarGroupState } from './MessageBarGroup'; diff --git a/packages/react-components/react-message-bar-preview/stories/MessageBar/Animation.stories.tsx b/packages/react-components/react-message-bar-preview/stories/MessageBar/Animation.stories.tsx new file mode 100644 index 00000000000000..2f0378d969af85 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/stories/MessageBar/Animation.stories.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { Button, Link, makeStyles, shorthands, tokens, Field, RadioGroup, Radio } from '@fluentui/react-components'; +import { DismissRegular } from '@fluentui/react-icons'; +import { + MessageBar, + MessageBarActions, + MessageBarTitle, + MessageBarBody, + MessageBarGroup, + MessageBarGroupProps, +} from '@fluentui/react-message-bar-preview'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + marginTop: '10px', + ...shorthands.gap('10px'), + height: '300px', + ...shorthands.overflow('auto'), + ...shorthands.border('2px', 'solid', tokens.colorBrandForeground1), + }, +}); + +const intents = ['info', 'warning', 'error', 'success'] as const; + +interface Entry { + intent: (typeof intents)[number]; + id: number; +} + +export const Animation = () => { + const styles = useStyles(); + const counterRef = React.useRef(0); + const [animate, setAnimate] = React.useState('exit-only'); + const [messages, setMessages] = React.useState([]); + const prepend = () => { + const intentPos = Math.floor(Math.random() * intents.length); + const newEntry = { + intent: intents[intentPos], + id: counterRef.current++, + }; + + setMessages(s => [newEntry, ...s]); + }; + + const clear = () => { + setMessages([]); + }; + + const dismiss = (id: number) => () => { + setMessages(s => { + return s.filter(entry => entry.id !== id); + }); + }; + + return ( + <> + + + + setAnimate(value as MessageBarGroupProps['animate'])}> + + + + + + {messages.map(({ intent, id }) => ( + + + Descriptive title + Message providing information to the user with actionable insights. Link + + } />} + /> + + ))} + + + ); +}; diff --git a/packages/react-components/react-message-bar-preview/stories/MessageBar/Dismiss.stories.tsx b/packages/react-components/react-message-bar-preview/stories/MessageBar/Dismiss.stories.tsx new file mode 100644 index 00000000000000..da6f35d2bf44b6 --- /dev/null +++ b/packages/react-components/react-message-bar-preview/stories/MessageBar/Dismiss.stories.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { Button, Link, makeStyles, shorthands, tokens } from '@fluentui/react-components'; +import { DismissRegular } from '@fluentui/react-icons'; +import { + MessageBar, + MessageBarActions, + MessageBarTitle, + MessageBarBody, + MessageBarGroup, +} from '@fluentui/react-message-bar-preview'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + marginTop: '10px', + ...shorthands.gap('10px'), + height: '300px', + ...shorthands.overflow('auto'), + ...shorthands.border('2px', 'solid', tokens.colorBrandForeground1), + }, +}); + +const intents = ['info', 'warning', 'error', 'success'] as const; + +interface Entry { + intent: (typeof intents)[number]; + id: number; +} + +export const Dismiss = () => { + const styles = useStyles(); + const counterRef = React.useRef(0); + const [messages, setMessages] = React.useState([]); + const prepend = () => { + const intentPos = Math.floor(Math.random() * intents.length); + const newEntry = { + intent: intents[intentPos], + id: counterRef.current++, + }; + + setMessages(s => [newEntry, ...s]); + }; + + const clear = () => { + setMessages([]); + }; + + const dismiss = (id: number) => () => { + setMessages(s => { + return s.filter(entry => entry.id !== id); + }); + }; + + return ( + <> + + + + {messages.map(({ intent, id }) => ( + + + Descriptive title + Message providing information to the user with actionable insights. Link + + } />} + /> + + ))} + + + ); +}; diff --git a/packages/react-components/react-message-bar-preview/stories/MessageBar/index.stories.tsx b/packages/react-components/react-message-bar-preview/stories/MessageBar/index.stories.tsx index 24d5580dba1ebd..4791dd2c83a592 100644 --- a/packages/react-components/react-message-bar-preview/stories/MessageBar/index.stories.tsx +++ b/packages/react-components/react-message-bar-preview/stories/MessageBar/index.stories.tsx @@ -4,8 +4,10 @@ import descriptionMd from './MessageBarDescription.md'; import bestPracticesMd from './MessageBarBestPractices.md'; export { Default } from './Default.stories'; +export { Dismiss } from './Dismiss.stories'; export { Reflow } from './Reflow.stories'; export { ManualLayout } from './ManualLayout.stories'; +export { Animation } from './Animation.stories'; export default { title: 'Preview Components/MessageBar',