Skip to content

Commit

Permalink
feat: Implement Toaster according to composition API (#28039)
Browse files Browse the repository at this point in the history
* feat: Implement Toaster according to composition API

Implements the Toaster using the composition hook pattern. The Toaster
component has one single root slot but it is mapped to potentially 4
different DOM elements (one for each toast position). The toaster does
not support ref forwarding for this reason.

* delete old styles

* use internal slots instead

* improve render

* slots should only render when there are toasts

* add comment

* fix test

* pr feedback
  • Loading branch information
ling1726 committed May 31, 2023
1 parent d780664 commit 93fe951
Show file tree
Hide file tree
Showing 19 changed files with 361 additions and 94 deletions.
30 changes: 29 additions & 1 deletion packages/react-components/react-toast/etc/react-toast.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import type { TriggerProps } from '@fluentui/react-utilities';
// @public (undocumented)
export const renderToastAlert_unstable: (state: ToastAlertState) => JSX.Element;

// @public
export const renderToaster_unstable: (state: ToasterState) => JSX.Element;

// @public
export const ToastAlert: ForwardRefComponent<ToastAlertProps>;

Expand All @@ -41,9 +44,28 @@ export type ToastAlertSlots = {
// @public
export type ToastAlertState = ComponentState<ToastAlertSlots> & Pick<ToastAlertProps, 'intent'> & Required<Pick<ToastAlertProps, 'appearance'>>;

// @public (undocumented)
// @public
export const Toaster: React_2.FC<ToasterProps>;

// @public (undocumented)
export const toasterClassNames: SlotClassNames<ToasterSlots>;

// @public
export type ToasterProps = Omit<ComponentProps<ToasterSlots>, 'children'> & Partial<ToasterOptions> & {
announce?: Announce;
};

// @public (undocumented)
export type ToasterSlots = {
root: Slot<'div'>;
};

// @public
export type ToasterState = ComponentState<ToasterSlotsInternal> & Pick<AriaLiveProps, 'announceRef'> & Pick<Required<ToasterProps>, 'announce'> & {
offset: ToasterOptions['offset'] | undefined;
renderAriaLive: boolean;
};

// @public (undocumented)
export type ToastId = string;

Expand Down Expand Up @@ -83,6 +105,12 @@ export function useToastController(toasterId?: ToasterId): {
updateToast: (options: UpdateToastEventDetail) => void;
};

// @public
export const useToaster_unstable: (props: ToasterProps) => ToasterState;

// @public
export const useToasterStyles_unstable: (state: ToasterState) => ToasterState;

// (No @packageDocumentation comment for this package)

```
1 change: 1 addition & 0 deletions packages/react-components/react-toast/src/Toaster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './components/Toaster/index';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { tokens } from '@fluentui/react-theme';

export const useToastStyles = makeStyles({
toast: {
pointerEvents: 'all',
boxSizing: 'border-box',
marginTop: '16px',
minHeight: '44px',
Expand Down

This file was deleted.

56 changes: 0 additions & 56 deletions packages/react-components/react-toast/src/components/Toaster.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { Toaster } from './Toaster';
import { isConformant } from '../../testing/isConformant';
import { ToasterProps } from './Toaster.types';

describe('Toaster', () => {
const testid = 'test';
isConformant<ToasterProps>({
Component: Toaster,
displayName: 'Toaster',
requiredProps: { 'data-testid': testid } as ToasterProps,
getTargetElement: result => result.getByTestId(testid),
disabledTests: [
// The component does not forward refs
'component-has-root-ref',
'component-handles-ref',
// FIXME: can't find a way to dispatch a toast during a conformance test
'component-has-static-classnames-object',
'component-handles-classname',
'make-styles-overrides-win',
],
});

it('renders a default state', () => {
const result = render(<Toaster />);
expect(result.container).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from 'react';
import { useToaster_unstable } from './useToaster';
import { renderToaster_unstable } from './renderToaster';
import { useToasterStyles_unstable } from './useToasterStyles.styles';
import type { ToasterProps } from './Toaster.types';

/**
* Toaster component - renders a collection of toasts dispatched imperatively
*/
export const Toaster: React.FC<ToasterProps> = props => {
const state = useToaster_unstable(props);

useToasterStyles_unstable(state);
return renderToaster_unstable(state);
};

Toaster.displayName = 'Toaster';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import { ToasterOptions } from '../../state/types';
import { Announce, AriaLiveProps } from '../AriaLive';

export type ToasterSlots = {
/**
* NOTE: This root slot maps in exactly the same way to the containers rendered for each toast position
* There is no intention (currently) to let users customize the div for each toast position.
*/
root: Slot<'div'>;
};

export type ToasterSlotsInternal = ToasterSlots & {
bottomRight?: Slot<'div'>;
bottomLeft?: Slot<'div'>;
topRight?: Slot<'div'>;
topLeft?: Slot<'div'>;
};

/**
* Toaster Props
*/
export type ToasterProps = Omit<ComponentProps<ToasterSlots>, 'children'> &
Partial<ToasterOptions> & {
/**
* User override API for aria-live narration for toasts
*/
announce?: Announce;
};

/**
* State used in rendering Toaster
*/
export type ToasterState = ComponentState<ToasterSlotsInternal> &
Pick<AriaLiveProps, 'announceRef'> &
Pick<Required<ToasterProps>, 'announce'> & {
offset: ToasterOptions['offset'] | undefined;
renderAriaLive: boolean;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Toaster renders a default state 1`] = `
<div>
<div
aria-live="assertive"
class="fui-AriaLive__assertive"
/>
<div
aria-live="polite"
class="fui-AriaLive__polite"
/>
</div>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './Toaster';
export * from './Toaster.types';
export * from './renderToaster';
export * from './useToaster';
export * from './useToasterStyles.styles';
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** @jsxRuntime classic */
/** @jsxFrag Fragment */
/** @jsx createElement */

import { createElement, Fragment } from '@fluentui/react-jsx-runtime';
import { getSlotsNext } from '@fluentui/react-utilities';
import { Portal } from '@fluentui/react-portal';
import type { ToasterState, ToasterSlotsInternal } from './Toaster.types';
import { AriaLive } from '../AriaLive';

/**
* Render the final JSX of Toaster
*/
export const renderToaster_unstable = (state: ToasterState) => {
const { announceRef, renderAriaLive } = state;
const { slots, slotProps } = getSlotsNext<ToasterSlotsInternal>(state);

const hasToasts = !!slots.bottomLeft || !!slots.bottomRight || !!slots.topLeft || !!slots.topRight;

return (
<>
{renderAriaLive ? <AriaLive announceRef={announceRef} /> : null}
{hasToasts ? (
<Portal>
{slots.bottomLeft ? <slots.bottomLeft {...slotProps.bottomLeft} /> : null}
{slots.bottomRight ? <slots.bottomRight {...slotProps.bottomRight} /> : null}
{slots.topLeft ? <slots.topLeft {...slotProps.topLeft} /> : null}
{slots.topRight ? <slots.topRight {...slotProps.topRight} /> : null}
</Portal>
) : null}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import { ExtractSlotProps, Slot, getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities';
import type { ToasterProps, ToasterState } from './Toaster.types';
import { TOAST_POSITIONS, ToastPosition, useToaster } from '../../state';
import { Announce } from '../AriaLive';
import { Toast } from '../Toast';

/**
* Create the state required to render Toaster.
*
* @param props - props from this instance of Toaster
*/
export const useToaster_unstable = (props: ToasterProps): ToasterState => {
const { offset, announce: announceProp, ...rest } = props;
const announceRef = React.useRef<Announce>(() => null);
const { toastsToRender, isToastVisible } = useToaster<HTMLDivElement>(rest);
const announce = React.useCallback<Announce>((message, options) => announceRef.current(message, options), []);

const rootProps = getNativeElementProps('div', rest);

const createPositionSlot = (toastPosition: ToastPosition) =>
resolveShorthand(toastsToRender.has(toastPosition) ? rootProps : null, {
defaultProps: {
children: toastsToRender.get(toastPosition)?.map(toast => (
<Toast {...toast} announce={announce} key={toast.toastId} visible={isToastVisible(toast.toastId)}>
{toast.content as React.ReactNode}
</Toast>
)),
'data-toaster-position': toastPosition,
// Explicitly casting because our slot types can't handle data attributes
} as ExtractSlotProps<Slot<'div'>>,
});

return {
components: {
root: 'div',
bottomLeft: 'div',
bottomRight: 'div',
topLeft: 'div',
topRight: 'div',
},
root: resolveShorthand(rootProps, { required: true }),
bottomLeft: createPositionSlot(TOAST_POSITIONS.bottomLeft),
bottomRight: createPositionSlot(TOAST_POSITIONS.bottomRight),
topLeft: createPositionSlot(TOAST_POSITIONS.topLeft),
topRight: createPositionSlot(TOAST_POSITIONS.topRight),
announceRef,
offset,
announce: announceProp ?? announce,
renderAriaLive: !announceProp,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { makeStyles, mergeClasses } from '@griffel/react';
import type { ToasterSlots, ToasterState } from './Toaster.types';
import type { SlotClassNames } from '@fluentui/react-utilities';
import { TOAST_POSITIONS, getPositionStyles } from '../../state/index';

export const toasterClassNames: SlotClassNames<ToasterSlots> = {
root: 'fui-Toaster',
};

/**
* Styles for the root slot
*/
const useStyles = makeStyles({
root: {
position: 'fixed',
width: '292px',
pointerEvents: 'none',
},
});

/**
* Apply styling to the Toaster slots based on the state
*/
export const useToasterStyles_unstable = (state: ToasterState): ToasterState => {
const styles = useStyles();
const className = mergeClasses(toasterClassNames.root, styles.root, state.root.className);
if (state.bottomLeft) {
state.bottomLeft.className = className;
state.bottomLeft.style ??= {};
Object.assign(state.bottomLeft.style, getPositionStyles(TOAST_POSITIONS.bottomLeft, state.offset));
}

if (state.bottomRight) {
state.bottomRight.className = className;
state.bottomRight.style ??= {};
Object.assign(state.bottomRight.style, getPositionStyles(TOAST_POSITIONS.bottomRight, state.offset));
}

if (state.topLeft) {
state.topLeft.className = className;
state.topLeft.style ??= {};
Object.assign(state.topLeft.style, getPositionStyles(TOAST_POSITIONS.topLeft, state.offset));
}

if (state.topRight) {
state.topRight.className = className;
state.topRight.style ??= {};
Object.assign(state.topRight.style, getPositionStyles(TOAST_POSITIONS.topRight, state.offset));
}

return state;
};
10 changes: 8 additions & 2 deletions packages/react-components/react-toast/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export { Toaster } from './components/Toaster';

export { useToastController } from './state';
export type { ToastPosition, ToastId, ToastOffset } from './state';
export {
Expand All @@ -13,3 +11,11 @@ export type { ToastAlertProps, ToastAlertSlots, ToastAlertState } from './ToastA

export { ToastTrigger } from './ToastTrigger';
export type { ToastTriggerChildProps, ToastTriggerProps, ToastTriggerState } from './ToastTrigger';
export {
Toaster,
useToaster_unstable,
useToasterStyles_unstable,
renderToaster_unstable,
toasterClassNames,
} from './Toaster';
export type { ToasterProps, ToasterState, ToasterSlots } from './Toaster';
Loading

0 comments on commit 93fe951

Please sign in to comment.