diff --git a/packages/react-components/react-dialog/Spec.md b/packages/react-components/react-dialog/Spec.md index 3164c5dc923ea..411bb04b94ab9 100644 --- a/packages/react-components/react-dialog/Spec.md +++ b/packages/react-components/react-dialog/Spec.md @@ -84,10 +84,6 @@ The root level component serves as an interface for interaction with all possibl ```tsx type DialogSlots = { - /** - * The dialog element itself - */ - root: Slot<'div'>; /** * Dimmed background of dialog. * The default overlay is rendered as a `
` with styling. @@ -110,8 +106,10 @@ type DialogProps = ComponentProps & { * `non-modal`: When a non-modal dialog is open, the rest of the page is not dimmed out and users can interact with the rest of the page. This also implies that the tab focus can move outside the dialog when it reaches the last focusable element. * * `alert`: is a special type of modal dialogs that interrupts the user's workflow to communicate an important message or ask for a decision. Unlike a typical modal dialog, the user must take an action through the options given to dismiss the dialog, and it cannot be dismissed through the dimmed background or escape key. + * + * @default 'modal' */ - type?: 'modal' | 'non-modal' | 'alert'; + modalType?: 'modal' | 'non-modal' | 'alert'; /** * Controls the open state of the dialog * @default undefined @@ -152,9 +150,10 @@ export type DialogTriggerProps = { /** * Explicitly declare if the trigger is responsible for opening, * closing or toggling a Dialog visibility state. + * * @default 'toggle' */ - type?: 'open' | 'close' | 'toggle'; + action?: 'open' | 'close' | 'toggle'; /** * Explicitly require single child or render function * to inject properties @@ -166,7 +165,6 @@ export type DialogTriggerProps = { ### DialogContent The `DialogContent` component represents the visual part of a `Dialog` as a whole, it contains everything that should be visible. -By itself it has no style, but it's responsible of showing/hiding content when `Dialog` visibility state changes, also it'll ensure a `Portal` is properly created for the content being provided as well as for the `overlay` element provided by `Dialog` ```tsx type DialogTitleSlots = { diff --git a/packages/react-components/react-dialog/config/api-extractor.local.json b/packages/react-components/react-dialog/config/api-extractor.local.json index f8d7afe5e2cd1..69e764bce3a59 100644 --- a/packages/react-components/react-dialog/config/api-extractor.local.json +++ b/packages/react-components/react-dialog/config/api-extractor.local.json @@ -1,5 +1,5 @@ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "extends": "./api-extractor.json", - "mainEntryPointFilePath": "/dist/packages/react-components//src/index.d.ts" + "mainEntryPointFilePath": "/dist/types/packages/react-components//src/index.d.ts" } diff --git a/packages/react-components/react-dialog/etc/react-dialog.api.md b/packages/react-components/react-dialog/etc/react-dialog.api.md index b2fb41fd1dc44..55b5233175626 100644 --- a/packages/react-components/react-dialog/etc/react-dialog.api.md +++ b/packages/react-components/react-dialog/etc/react-dialog.api.md @@ -4,39 +4,193 @@ ```ts +/// + +import { ARIAButtonSlotProps } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { FluentTriggerComponent } from '@fluentui/react-utilities'; +import { ForwardRefComponent } from '@fluentui/react-utilities'; +import { JSXElementConstructor } from 'react'; import * as React_2 from 'react'; +import { ReactElement } from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; -// @public +// @public (undocumented) export const Dialog: ForwardRefComponent; +// @public +export const DialogActions: ForwardRefComponent; + +// @public (undocumented) +export const dialogActionsClassName = "fui-DialogActions"; + +// @public (undocumented) +export const dialogActionsClassNames: SlotClassNames; + +// @public +export type DialogActionsProps = ComponentProps & {}; + +// @public (undocumented) +export type DialogActionsSlots = { + root: Slot<'div'>; +}; + +// @public +export type DialogActionsState = ComponentState; + +// @public +export const DialogBody: ForwardRefComponent; + +// @public (undocumented) +export const dialogBodyClassNames: SlotClassNames; + +// @public +export type DialogBodyProps = ComponentProps & {}; + +// @public (undocumented) +export type DialogBodySlots = { + root: Slot<'div'>; +}; + +// @public +export type DialogBodyState = ComponentState; + // @public (undocumented) export const dialogClassNames: SlotClassNames; // @public -export type DialogProps = ComponentProps; +export const DialogContent: ForwardRefComponent; + +// @public (undocumented) +export const dialogContentClassNames: SlotClassNames; + +// @public +export type DialogContentProps = ComponentProps; + +// @public (undocumented) +export type DialogContentSlots = { + root: Slot<'div', 'main'>; +}; + +// @public +export type DialogContentState = ComponentState; + +// @public (undocumented) +export type DialogProps = ComponentProps> & { + modalType?: DialogModalType; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: DialogOnOpenChange; + children: [JSX.Element, JSX.Element] | JSX.Element; +}; // @public (undocumented) export type DialogSlots = { - root: Slot<'div'>; + overlay?: Slot<'div'>; + root: NonNullable>; +}; + +// @public (undocumented) +export type DialogState = ComponentState & DialogContextValue & { + content: React_2.ReactNode; + trigger: React_2.ReactNode; }; // @public -export type DialogState = ComponentState; +export const DialogTitle: ForwardRefComponent; + +// @public (undocumented) +export const dialogTitleClassNames: SlotClassNames; // @public -export const renderDialog_unstable: (state: DialogState) => JSX.Element; +export type DialogTitleProps = ComponentProps & {}; + +// @public (undocumented) +export type DialogTitleSlots = { + root: Slot<'div', 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>; + closeButton?: Slot; +}; + +// @public +export type DialogTitleState = ComponentState; + +// @public +export const DialogTrigger: React_2.FC & FluentTriggerComponent; + +// @public (undocumented) +export type DialogTriggerAction = 'open' | 'close' | 'toggle'; + +// @public +export type DialogTriggerChildProps = Required, 'onClick' | 'aria-haspopup'>> & { + ref?: React_2.Ref; +}; + +// @public (undocumented) +export type DialogTriggerProps = { + action?: DialogTriggerAction; + children: (React_2.ReactElement & { + ref?: React_2.Ref; + }) | ((props: DialogTriggerChildProps) => React_2.ReactElement | null); +}; + +// @public (undocumented) +export type DialogTriggerState = { + children: React_2.ReactElement | null; +}; + +// @public +export const renderDialog_unstable: (state: DialogState, contextValues: DialogContextValues) => JSX.Element; + +// @public +export const renderDialogActions_unstable: (state: DialogActionsState) => JSX.Element; + +// @public +export const renderDialogBody_unstable: (state: DialogBodyState) => JSX.Element; + +// @public +export const renderDialogContent_unstable: (state: DialogContentState) => JSX.Element; + +// @public +export const renderDialogTitle_unstable: (state: DialogTitleState) => JSX.Element; + +// @public +export const renderDialogTrigger_unstable: (state: DialogTriggerState) => ReactElement> | null; // @public export const useDialog_unstable: (props: DialogProps, ref: React_2.Ref) => DialogState; +// @public +export const useDialogActions_unstable: (props: DialogActionsProps, ref: React_2.Ref) => DialogActionsState; + +// @public +export const useDialogActionsStyles_unstable: (state: DialogActionsState) => DialogActionsState; + +// @public +export const useDialogBody_unstable: (props: DialogBodyProps, ref: React_2.Ref) => DialogBodyState; + +// @public +export const useDialogBodyStyles_unstable: (state: DialogBodyState) => DialogBodyState; + +// @public +export const useDialogContent_unstable: (props: DialogContentProps, ref: React_2.Ref) => DialogContentState; + +// @public +export const useDialogContentStyles_unstable: (state: DialogContentState) => DialogContentState; + // @public export const useDialogStyles_unstable: (state: DialogState) => DialogState; +// @public +export const useDialogTitle_unstable: (props: DialogTitleProps, ref: React_2.Ref) => DialogTitleState; + +// @public +export const useDialogTitleStyles_unstable: (state: DialogTitleState) => DialogTitleState; + +// @public +export const useDialogTrigger_unstable: (props: DialogTriggerProps) => DialogTriggerState; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-dialog/package.json b/packages/react-components/react-dialog/package.json index 70a26e5a80d4d..cf4551ac566cb 100644 --- a/packages/react-components/react-dialog/package.json +++ b/packages/react-components/react-dialog/package.json @@ -21,7 +21,7 @@ "start": "yarn storybook", "test": "jest --passWithNoTests", "docs": "api-extractor run --config=config/api-extractor.local.json --local", - "build:local": "tsc -p ./tsconfig.lib.json --module esnext --emitDeclarationOnly && node ../../../scripts/typescript/normalize-import --output ./dist/packages/react-components/react-dialog/src && yarn docs", + "build:local": "tsc -p ./tsconfig.lib.json --module esnext --emitDeclarationOnly && node ../../../scripts/typescript/normalize-import --output ./dist/types/packages/react-components/react-dialog/src && yarn docs", "storybook": "node ../../../scripts/storybook/runner", "type-check": "tsc -b tsconfig.json" }, @@ -33,7 +33,15 @@ }, "dependencies": { "@griffel/react": "^1.2.0", - "@fluentui/react-utilities": "^9.0.0", + "@fluentui/keyboard-keys": "9.0.0", + "@fluentui/react-context-selector": "9.0.0", + "@fluentui/react-shared-contexts": "9.0.0", + "@fluentui/react-aria": "9.0.0", + "@fluentui/react-icons": "^2.0.175", + "@fluentui/react-tabster": "9.0.1", + "@fluentui/react-theme": "9.0.0", + "@fluentui/react-portal": "9.0.1", + "@fluentui/react-utilities": "9.0.0", "tslib": "^2.1.0" }, "peerDependencies": { diff --git a/packages/react-components/react-dialog/src/DialogActions.ts b/packages/react-components/react-dialog/src/DialogActions.ts new file mode 100644 index 0000000000000..1d8ac02365d2d --- /dev/null +++ b/packages/react-components/react-dialog/src/DialogActions.ts @@ -0,0 +1 @@ +export * from './components/DialogActions/index'; diff --git a/packages/react-components/react-dialog/src/DialogBody.ts b/packages/react-components/react-dialog/src/DialogBody.ts new file mode 100644 index 0000000000000..9256da141252b --- /dev/null +++ b/packages/react-components/react-dialog/src/DialogBody.ts @@ -0,0 +1 @@ +export * from './components/DialogBody/index'; diff --git a/packages/react-components/react-dialog/src/DialogContent.ts b/packages/react-components/react-dialog/src/DialogContent.ts new file mode 100644 index 0000000000000..e3c3fa077442b --- /dev/null +++ b/packages/react-components/react-dialog/src/DialogContent.ts @@ -0,0 +1 @@ +export * from './components/DialogContent/index'; diff --git a/packages/react-components/react-dialog/src/DialogTitle.ts b/packages/react-components/react-dialog/src/DialogTitle.ts new file mode 100644 index 0000000000000..925cc7018a65f --- /dev/null +++ b/packages/react-components/react-dialog/src/DialogTitle.ts @@ -0,0 +1 @@ +export * from './components/DialogTitle/index'; diff --git a/packages/react-components/react-dialog/src/DialogTrigger.ts b/packages/react-components/react-dialog/src/DialogTrigger.ts new file mode 100644 index 0000000000000..593f5f4186cad --- /dev/null +++ b/packages/react-components/react-dialog/src/DialogTrigger.ts @@ -0,0 +1 @@ +export * from './components/DialogTrigger/index'; diff --git a/packages/react-components/react-dialog/src/components/Dialog/Dialog.test.tsx b/packages/react-components/react-dialog/src/components/Dialog/Dialog.test.tsx index 1f9d7d94fa1f4..4c559e21ef637 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/Dialog.test.tsx +++ b/packages/react-components/react-dialog/src/components/Dialog/Dialog.test.tsx @@ -7,13 +7,27 @@ describe('Dialog', () => { isConformant({ Component: Dialog, displayName: 'Dialog', - disabledTests: ['component-has-static-classname-exported'], + disabledTests: [ + // Menu does not render DOM elements + 'component-handles-ref', + 'component-has-root-ref', + 'component-handles-classname', + 'component-has-static-classname', + 'component-has-static-classnames-object', + 'component-has-static-classname-exported', + // Menu does not have own styles + 'make-styles-overrides-win', + ], }); // TODO add more tests here, and create visual regression tests in /apps/vr-tests it('renders a default state', () => { - const result = render(Default Dialog); + const result = render( + +
Default Dialog
+
, + ); expect(result.container).toMatchSnapshot(); }); }); diff --git a/packages/react-components/react-dialog/src/components/Dialog/Dialog.tsx b/packages/react-components/react-dialog/src/components/Dialog/Dialog.tsx index f7bda8c1396b4..6edfaedc645d3 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/Dialog.tsx +++ b/packages/react-components/react-dialog/src/components/Dialog/Dialog.tsx @@ -3,16 +3,21 @@ import { useDialog_unstable } from './useDialog'; import { renderDialog_unstable } from './renderDialog'; import { useDialogStyles_unstable } from './useDialogStyles'; import type { DialogProps } from './Dialog.types'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useDialogContextValues_unstable } from './useDialogContextValues'; /** - * A Dialog is an elevated Card triggered by a user’s action. + * The `Dialog` root level component serves as an interface for interaction with all possible behaviors exposed. + * It provides context down the hierarchy to `children` compound components to allow functionality. + * This component expects to receive as children either a `DialogContent` or a `DialogTrigger` + * and a `DialogContent` (or some component that will eventually render one of those compound components) + * in this specific order */ -export const Dialog: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useDialog_unstable(props, ref); +export const Dialog: React.FC = React.memo(props => { + const state = useDialog_unstable(props); + const contextValues = useDialogContextValues_unstable(state); useDialogStyles_unstable(state); - return renderDialog_unstable(state); + return renderDialog_unstable(state, contextValues); }); Dialog.displayName = 'Dialog'; diff --git a/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts b/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts index ba7f19111618a..509f82d465ac7 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts +++ b/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts @@ -1,17 +1,78 @@ +import type * as React from 'react'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { DialogContextValue } from '../../contexts/dialogContext'; export type DialogSlots = { - root: Slot<'div'>; + /** + * Dimmed background of dialog. + * The default overlay is rendered as a `
` with styling. + * This slot expects a `
` element which will replace the default overlay. + * The overlay should have `aria-hidden="true"`. + */ + overlay?: Slot<'div'>; }; -/** - * Dialog Props - */ -export type DialogProps = ComponentProps; +export type DialogOpenChangeArgs = + | [event: React.KeyboardEvent, data: { type: 'escapeKeyDown'; open: boolean }] + /** + * document escape keydown defers from internal escape keydown events because of the synthetic event API + */ + | [event: KeyboardEvent, data: { type: 'documentEscapeKeyDown'; open: boolean }] + | [event: React.MouseEvent, data: { type: 'overlayClick'; open: boolean }] + | [event: React.MouseEvent, data: { type: 'triggerClick'; open: boolean }]; -/** - * State used in rendering Dialog - */ -// TODO: Add union of props to pick from DialogProps once they're implemented. -// i.e. Required>; -export type DialogState = ComponentState; +export type DialogModalType = 'modal' | 'non-modal' | 'alert'; + +export type DialogContextValues = { + dialog: DialogContextValue; +}; + +export type DialogOpenChangeListener = (...args: DialogOpenChangeArgs) => void; + +export type DialogProps = ComponentProps> & { + /** + * Dialog variations. + * + * `modal`: When this type of dialog is open, the rest of the page is dimmed out and cannot be interacted with. + * The tab sequence is kept within the dialog and moving the focus outside + * the dialog will imply closing it. This is the default type of the component. + * + * `non-modal`: When a non-modal dialog is open, the rest of the page is not dimmed out + * and users can interact with the rest of the page. + * This also implies that the tab focus can move outside the dialog when it reaches the last focusable element. + * + * `alert`: is a special type of modal dialogs that interrupts the user's workflow + * to communicate an important message or ask for a decision. + * Unlike a typical modal dialog, the user must take an action through the options given to dismiss the dialog, + * and it cannot be dismissed through the dimmed background or escape key. + * + * @default 'modal' + */ + modalType?: DialogModalType; + /** + * Controls the open state of the dialog + * @default undefined + */ + open?: boolean; + /** + * Default value for the uncontrolled open state of the dialog. + * @default false + */ + defaultOpen?: boolean; + /** + * Callback fired when the component changes value from open state. + * @default undefined + */ + onOpenChange?: DialogOpenChangeListener; + /** + * Can contain two children including {@link DialogTrigger} and {@link DialogContent}. + * Alternatively can only contain {@link DialogContent} if using trigger outside dialog, or controlling state. + */ + children: [JSX.Element, JSX.Element] | JSX.Element; +}; + +export type DialogState = ComponentState & + DialogContextValue & { + content: React.ReactNode; + trigger: React.ReactNode; + }; diff --git a/packages/react-components/react-dialog/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap b/packages/react-components/react-dialog/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap index 5483ee1e275b8..9d308e29bd3f4 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap +++ b/packages/react-components/react-dialog/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap @@ -2,10 +2,8 @@ exports[`Dialog renders a default state 1`] = `
-
- Default Dialog -
+
`; diff --git a/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx b/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx index eaf76b9f803e6..632dadaacd8c8 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx +++ b/packages/react-components/react-dialog/src/components/Dialog/renderDialog.tsx @@ -1,13 +1,25 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utilities'; -import type { DialogState, DialogSlots } from './Dialog.types'; +import { Portal } from '@fluentui/react-portal'; +import type { DialogState, DialogSlots, DialogContextValues } from './Dialog.types'; +import { DialogProvider } from '../../contexts/dialogContext'; /** * Render the final JSX of Dialog */ -export const renderDialog_unstable = (state: DialogState) => { +export const renderDialog_unstable = (state: DialogState, contextValues: DialogContextValues) => { + const { content, trigger, open } = state; const { slots, slotProps } = getSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + {trigger} + {open && ( + + {slots.overlay && } + {content} + + )} + + ); }; diff --git a/packages/react-components/react-dialog/src/components/Dialog/useDialog.test.ts b/packages/react-components/react-dialog/src/components/Dialog/useDialog.test.ts new file mode 100644 index 0000000000000..4c9cbe595c77d --- /dev/null +++ b/packages/react-components/react-dialog/src/components/Dialog/useDialog.test.ts @@ -0,0 +1,20 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; +import type { DialogOpenChangeArgs } from './Dialog.types'; + +import { useDialog_unstable } from './useDialog'; + +describe('useAccordion_unstable', () => { + it('handle open behavior', () => { + const ref = React.createRef(); + const { result } = renderHook(() => + useDialog_unstable({ children: [React.createElement('div'), React.createElement('div')] }, ref), + ); + + expect(result.current.open).toEqual(false); + const fakeEvent = { defaultPrevented: false } as DialogOpenChangeArgs[0]; + act(() => result.current.requestOpenChange({ open: true, type: 'triggerClick', event: fakeEvent })); + + expect(result.current.open).toEqual(true); + }); +}); diff --git a/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts b/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts index a476cae8dccbb..137e66fbb9628 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts +++ b/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts @@ -1,6 +1,12 @@ import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { DialogProps, DialogState } from './Dialog.types'; +import { resolveShorthand, useControllableState, useEventCallback } from '@fluentui/react-utilities'; +import type { DialogOpenChangeArgs, DialogProps, DialogState, DialogModalType } from './Dialog.types'; +import { DialogRequestOpenChangeData } from '../../contexts/dialogContext'; +import { useFocusFinders } from '@fluentui/react-tabster'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { normalizeDefaultPrevented } from '../../utils/normalizeDefaultPrevented'; +import { normalizeSetStateAction } from '../../utils/normalizeSetStateAction'; +import { isEscapeKeyDismiss } from '../../utils/isEscapeKeyDown'; /** * Create the state required to render Dialog. @@ -9,20 +15,177 @@ import type { DialogProps, DialogState } from './Dialog.types'; * before being passed to renderDialog_unstable. * * @param props - props from this instance of Dialog - * @param ref - reference to root HTMLElement of Dialog */ -export const useDialog_unstable = (props: DialogProps, ref: React.Ref): DialogState => { +export const useDialog_unstable = (props: DialogProps): DialogState => { + const { children, overlay, modalType = 'modal', onOpenChange } = props; + + const [trigger, content] = childrenToTriggerAndContent(children); + + const [open, setOpen] = useControllableState({ + state: props.open, + defaultState: props.defaultOpen, + initialState: false, + }); + + const overlayShorthand = resolveShorthand(overlay, { + required: modalType !== 'non-modal', + defaultProps: { + 'aria-hidden': 'true', + }, + }); + + const requestOpenChange = useEventCallback((data: DialogRequestOpenChangeData) => { + const getNextOpen = normalizeSetStateAction(data.open); + const isDefaultPrevented = normalizeDefaultPrevented(data.event); + + onOpenChange?.( + ...([ + data.event, + { + type: data.type, + open: getNextOpen(open), + }, + ] as DialogOpenChangeArgs), + ); + + // if user prevents default then do not change state value + if (!isDefaultPrevented()) { + // updates trigger reference + (triggerRef as React.MutableRefObject).current = isOpening(open, getNextOpen) + ? (data.event.currentTarget as HTMLElement) + : null; + // updates value + setOpen(getNextOpen); + } + }); + + const { contentRef, triggerRef } = useFocusFirstElement({ + open, + modalType, + requestOpenChange, + }); + + const handleOverLayClick = useEventCallback((event: React.MouseEvent) => { + overlayShorthand?.onClick?.(event); + if (isOverlayClickDismiss(event, modalType)) { + requestOpenChange({ event, open: false, type: 'overlayClick' }); + } + }); + return { - // TODO add appropriate props/defaults components: { - // TODO add slot types here if needed (div is the default) - root: 'div', + overlay: 'div', }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: getNativeElementProps('div', { - ref, - ...props, - }), + overlay: overlayShorthand && { + ...overlayShorthand, + onClick: handleOverLayClick, + }, + open, + modalType, + content, + trigger, + triggerRef, + contentRef, + requestOpenChange, }; }; + +/** + * Extracts trigger and content from children + */ +function childrenToTriggerAndContent( + children: React.ReactNode, +): readonly [trigger: React.ReactNode, content: React.ReactNode] { + const childrenArray = React.Children.toArray(children) as React.ReactElement[]; + if (process.env.NODE_ENV !== 'production') { + if (childrenArray.length !== 1 && childrenArray.length !== 2) { + // eslint-disable-next-line no-console + console.warn( + 'Dialog must contain at least one child ,\n' + + 'and at most two children (in this order)', + ); + } + } + switch (childrenArray.length) { + // case where there's a trigger followed by content + case 2: + return childrenArray as [trigger: React.ReactNode, content: React.ReactNode]; + // case where there's only content + case 1: + return [undefined, childrenArray[1]]; + // unknown case + default: + return [undefined, undefined]; + } +} + +/** + * Checks is click event is a proper Overlay click dismiss + */ +function isOverlayClickDismiss(event: React.MouseEvent, type: DialogModalType): boolean { + const isDefaultPrevented = normalizeDefaultPrevented(event); + return type === 'modal' && !isDefaultPrevented(); +} + +/** + * Checks if dialog is opening + */ +function isOpening(current: boolean, next: Extract, Function>) { + return !current && next(current) === true; +} + +/** + * Focus first element on content when dialog is opened, + * in case there's no focusable element, then a eventlistener is added to document + * to ensure Escape keydown functionality + */ +function useFocusFirstElement({ + open, + requestOpenChange, + modalType, +}: Pick) { + const { findFirstFocusable } = useFocusFinders(); + const { targetDocument } = useFluent_unstable(); + const contentRef = React.useRef(null); + const triggerRef = React.useRef(null); + + React.useEffect(() => { + if (open) { + const element = contentRef.current && findFirstFocusable(contentRef.current); + if (element) { + element.focus(); + // NOTE: if it's non-modal global listener to escape is necessary + if (modalType !== 'non-modal') { + return; + } + } else { + // eslint-disable-next-line no-console + console.warn('A Dialog should have at least one focusable element inside DialogContent'); + } + + if (triggerRef.current && targetDocument) { + const trigger = triggerRef.current; + if (targetDocument.activeElement === trigger) { + trigger.blur(); + } + const listener = (event: KeyboardEvent) => { + if (isEscapeKeyDismiss(event, modalType)) { + requestOpenChange({ + event, + open: false, + type: 'documentEscapeKeyDown', + }); + trigger.focus(); + event.stopImmediatePropagation(); + } + }; + targetDocument.addEventListener('keydown', listener, { passive: false }); + return () => { + targetDocument.removeEventListener('keydown', listener); + }; + } + } + }, [findFirstFocusable, requestOpenChange, open, modalType, targetDocument]); + + return { contentRef, triggerRef }; +} diff --git a/packages/react-components/react-dialog/src/components/Dialog/useDialogContextValues.ts b/packages/react-components/react-dialog/src/components/Dialog/useDialogContextValues.ts new file mode 100644 index 0000000000000..f5742e1b62153 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/Dialog/useDialogContextValues.ts @@ -0,0 +1,20 @@ +import { DialogContextValue } from '../../contexts/dialogContext'; +import type { DialogContextValues, DialogState } from './Dialog.types'; + +export function useDialogContextValues_unstable(state: DialogState): DialogContextValues { + const { modalType, open, requestOpenChange, triggerRef, contentRef } = state; + + /** + * This context is created with "@fluentui/react-context-selector", + * there is no sense to memoize it + */ + const dialog: DialogContextValue = { + modalType, + open, + requestOpenChange, + triggerRef, + contentRef, + }; + + return { dialog }; +} diff --git a/packages/react-components/react-dialog/src/components/Dialog/useDialogStyles.ts b/packages/react-components/react-dialog/src/components/Dialog/useDialogStyles.ts index 15b3ef6765b1f..ab739db412c4b 100644 --- a/packages/react-components/react-dialog/src/components/Dialog/useDialogStyles.ts +++ b/packages/react-components/react-dialog/src/components/Dialog/useDialogStyles.ts @@ -1,19 +1,24 @@ import type { SlotClassNames } from '@fluentui/react-utilities'; -import { makeStyles, mergeClasses } from '@griffel/react'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { useHasParentContext } from '@fluentui/react-context-selector'; +import { DialogContext } from '../../contexts/dialogContext'; import type { DialogSlots, DialogState } from './Dialog.types'; export const dialogClassNames: SlotClassNames = { - root: 'fui-Dialog', + overlay: 'fui-Dialog__overlay', }; /** * Styles for the root slot */ const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element + overlay: { + position: 'fixed', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + ...shorthands.inset('0px'), + }, + subDialogOverlay: { + backgroundColor: 'transparent', }, - - // TODO add additional classes for different states and/or slots }); /** @@ -21,10 +26,16 @@ const useStyles = makeStyles({ */ export const useDialogStyles_unstable = (state: DialogState): DialogState => { const styles = useStyles(); - state.root.className = mergeClasses(dialogClassNames.root, styles.root, state.root.className); + const isSubDialog = useHasParentContext(DialogContext); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + if (state.overlay) { + state.overlay.className = mergeClasses( + dialogClassNames.overlay, + styles.overlay, + isSubDialog && styles.subDialogOverlay, + state.overlay.className, + ); + } return state; }; diff --git a/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.test.tsx b/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.test.tsx new file mode 100644 index 0000000000000..38a29a26516da --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { DialogActions } from './DialogActions'; +import { isConformant } from '../../common/isConformant'; + +describe('DialogActions', () => { + isConformant({ + Component: DialogActions, + displayName: 'DialogActions', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default DialogActions); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.tsx b/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.tsx new file mode 100644 index 0000000000000..a835a297ffd09 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { useDialogActions_unstable } from './useDialogActions'; +import { renderDialogActions_unstable } from './renderDialogActions'; +import { useDialogActionsStyles_unstable } from './useDialogActionsStyles'; +import type { DialogActionsProps } from './DialogActions.types'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +/** + * `DialogActions` is a container for the actions of the dialog. + * Apart from styling, this component does not have other behavior. + */ +export const DialogActions: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useDialogActions_unstable(props, ref); + + useDialogActionsStyles_unstable(state); + return renderDialogActions_unstable(state); +}); + +DialogActions.displayName = 'DialogActions'; diff --git a/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.types.ts b/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.types.ts new file mode 100644 index 0000000000000..41184f91097cc --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/DialogActions.types.ts @@ -0,0 +1,17 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; + +export type DialogActionsSlots = { + root: Slot<'div'>; +}; + +/** + * DialogActions Props + */ +export type DialogActionsProps = ComponentProps & {}; + +/** + * State used in rendering DialogActions + */ +export type DialogActionsState = ComponentState; +// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from DialogActionsProps. +// & Required> diff --git a/packages/react-components/react-dialog/src/components/DialogActions/index.ts b/packages/react-components/react-dialog/src/components/DialogActions/index.ts new file mode 100644 index 0000000000000..5b6fc48233a89 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/index.ts @@ -0,0 +1,5 @@ +export * from './DialogActions'; +export * from './DialogActions.types'; +export * from './renderDialogActions'; +export * from './useDialogActions'; +export * from './useDialogActionsStyles'; diff --git a/packages/react-components/react-dialog/src/components/DialogActions/renderDialogActions.tsx b/packages/react-components/react-dialog/src/components/DialogActions/renderDialogActions.tsx new file mode 100644 index 0000000000000..edc1d553f7820 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/renderDialogActions.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import type { DialogActionsState, DialogActionsSlots } from './DialogActions.types'; + +/** + * Render the final JSX of DialogActions + */ +export const renderDialogActions_unstable = (state: DialogActionsState) => { + const { slots, slotProps } = getSlots(state); + + // TODO Add additional slots in the appropriate place + return ; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogActions/useDialogActions.ts b/packages/react-components/react-dialog/src/components/DialogActions/useDialogActions.ts new file mode 100644 index 0000000000000..04a8ec6878ca8 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/useDialogActions.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { getNativeElementProps } from '@fluentui/react-utilities'; +import type { DialogActionsProps, DialogActionsState } from './DialogActions.types'; + +/** + * Create the state required to render DialogActions. + * + * The returned state can be modified with hooks such as useDialogActionsStyles_unstable, + * before being passed to renderDialogActions_unstable. + * + * @param props - props from this instance of DialogActions + * @param ref - reference to root HTMLElement of DialogActions + */ +export const useDialogActions_unstable = ( + props: DialogActionsProps, + ref: React.Ref, +): DialogActionsState => { + return { + // TODO add appropriate props/defaults + components: { + // TODO add each slot's element type or component + root: 'div', + }, + // TODO add appropriate slots, for example: + // mySlot: resolveShorthand(props.mySlot), + root: getNativeElementProps('div', { + ref, + ...props, + }), + }; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogActions/useDialogActionsStyles.ts b/packages/react-components/react-dialog/src/components/DialogActions/useDialogActionsStyles.ts new file mode 100644 index 0000000000000..ac7061dd586d8 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogActions/useDialogActionsStyles.ts @@ -0,0 +1,42 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import type { DialogActionsSlots, DialogActionsState } from './DialogActions.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { DIALOG_CONTENT_PADDING, MEDIA_QUERY_BREAKPOINT_SELECTOR } from '../../contexts/constants'; + +export const dialogActionsClassName = 'fui-DialogActions'; +export const dialogActionsClassNames: SlotClassNames = { + root: 'fui-DialogActions', +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'row', + alignItems: 'end', + justifyContent: 'end', + height: 'fit-content', + width: '100%', + boxSizing: 'border-box', + ...shorthands.gap('8px'), + ...shorthands.padding('0', DIALOG_CONTENT_PADDING, DIALOG_CONTENT_PADDING, DIALOG_CONTENT_PADDING), + [MEDIA_QUERY_BREAKPOINT_SELECTOR]: { + flexDirection: 'column', + alignItems: 'stretch', + '> .fui-Button': { + maxWidth: '100%', + }, + }, + }, +}); + +/** + * Apply styling to the DialogActions slots based on the state + */ +export const useDialogActionsStyles_unstable = (state: DialogActionsState): DialogActionsState => { + const styles = useStyles(); + state.root.className = mergeClasses(dialogActionsClassName, styles.root, state.root.className); + return state; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.test.tsx b/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.test.tsx new file mode 100644 index 0000000000000..40ac2009d38ea --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { DialogBody } from './DialogBody'; +import { isConformant } from '../../common/isConformant'; + +describe('DialogBody', () => { + isConformant({ + Component: DialogBody, + displayName: 'DialogBody', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default DialogBody); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.tsx b/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.tsx new file mode 100644 index 0000000000000..2cbdb20506fe5 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { useDialogBody_unstable } from './useDialogBody'; +import { renderDialogBody_unstable } from './renderDialogBody'; +import { useDialogBodyStyles_unstable } from './useDialogBodyStyles'; +import type { DialogBodyProps } from './DialogBody.types'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +/** + * The `DialogBody` is a container where the content of the dialog is rendered. + * Apart from styling, this component does not have other behavior. + */ +export const DialogBody: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useDialogBody_unstable(props, ref); + + useDialogBodyStyles_unstable(state); + return renderDialogBody_unstable(state); +}); + +DialogBody.displayName = 'DialogBody'; diff --git a/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.types.ts b/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.types.ts new file mode 100644 index 0000000000000..6cfdec68aa8d7 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/DialogBody.types.ts @@ -0,0 +1,17 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; + +export type DialogBodySlots = { + root: Slot<'div'>; +}; + +/** + * DialogBody Props + */ +export type DialogBodyProps = ComponentProps & {}; + +/** + * State used in rendering DialogBody + */ +export type DialogBodyState = ComponentState; +// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from DialogBodyProps. +// & Required> diff --git a/packages/react-components/react-dialog/src/components/DialogBody/index.ts b/packages/react-components/react-dialog/src/components/DialogBody/index.ts new file mode 100644 index 0000000000000..caade527cc447 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/index.ts @@ -0,0 +1,5 @@ +export * from './DialogBody'; +export * from './DialogBody.types'; +export * from './renderDialogBody'; +export * from './useDialogBody'; +export * from './useDialogBodyStyles'; diff --git a/packages/react-components/react-dialog/src/components/DialogBody/renderDialogBody.tsx b/packages/react-components/react-dialog/src/components/DialogBody/renderDialogBody.tsx new file mode 100644 index 0000000000000..22d43bee81226 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/renderDialogBody.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import type { DialogBodyState, DialogBodySlots } from './DialogBody.types'; + +/** + * Render the final JSX of DialogBody + */ +export const renderDialogBody_unstable = (state: DialogBodyState) => { + const { slots, slotProps } = getSlots(state); + + // TODO Add additional slots in the appropriate place + return ; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogBody/useDialogBody.ts b/packages/react-components/react-dialog/src/components/DialogBody/useDialogBody.ts new file mode 100644 index 0000000000000..e902284abc4db --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/useDialogBody.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { getNativeElementProps } from '@fluentui/react-utilities'; +import type { DialogBodyProps, DialogBodyState } from './DialogBody.types'; + +/** + * Create the state required to render DialogBody. + * + * The returned state can be modified with hooks such as useDialogBodyStyles_unstable, + * before being passed to renderDialogBody_unstable. + * + * @param props - props from this instance of DialogBody + * @param ref - reference to root HTMLElement of DialogBody + */ +export const useDialogBody_unstable = (props: DialogBodyProps, ref: React.Ref): DialogBodyState => { + return { + // TODO add appropriate props/defaults + components: { + // TODO add each slot's element type or component + root: 'div', + }, + // TODO add appropriate slots, for example: + // mySlot: resolveShorthand(props.mySlot), + root: getNativeElementProps('div', { + ref, + ...props, + }), + }; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogBody/useDialogBodyStyles.ts b/packages/react-components/react-dialog/src/components/DialogBody/useDialogBodyStyles.ts new file mode 100644 index 0000000000000..9f364cf85833d --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogBody/useDialogBodyStyles.ts @@ -0,0 +1,40 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import type { DialogBodySlots, DialogBodyState } from './DialogBody.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { typographyStyles } from '@fluentui/react-theme'; +import { DIALOG_CONTENT_PADDING } from '../../contexts/constants'; + +export const dialogBodyClassName = 'fui-DialogBody'; +export const dialogBodyClassNames: SlotClassNames = { + root: 'fui-DialogBody', +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + // display: 'flex', + // flexDirection: 'row', + alignItems: 'start', + width: '100%', + height: 'fit-content', + minHeight: '32px', + boxSizing: 'border-box', + ...shorthands.padding('0', DIALOG_CONTENT_PADDING, '12px'), + ...typographyStyles.body1, + }, +}); + +/** + * Apply styling to the DialogBody slots based on the state + */ +export const useDialogBodyStyles_unstable = (state: DialogBodyState): DialogBodyState => { + const styles = useStyles(); + state.root.className = mergeClasses(dialogBodyClassName, styles.root, state.root.className); + + // TODO Add class names to slots, for example: + // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + + return state; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.test.tsx b/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.test.tsx new file mode 100644 index 0000000000000..6ebbb8f1efd35 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { DialogContent } from './DialogContent'; +import { isConformant } from '../../common/isConformant'; + +describe('DialogContent', () => { + isConformant({ + Component: DialogContent, + displayName: 'DialogContent', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default DialogContent); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.tsx b/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.tsx new file mode 100644 index 0000000000000..474add2c8109a --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { useDialogContent_unstable } from './useDialogContent'; +import { renderDialogContent_unstable } from './renderDialogContent'; +import { useDialogContentStyles_unstable } from './useDialogContentStyles'; +import type { DialogContentProps } from './DialogContent.types'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +/** + * DialogContent component represents the visual part of a `Dialog` as a whole, + * it contains everything that should be visible. + */ +export const DialogContent: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useDialogContent_unstable(props, ref); + + useDialogContentStyles_unstable(state); + return renderDialogContent_unstable(state); +}); + +DialogContent.displayName = 'DialogContent'; diff --git a/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.types.ts b/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.types.ts new file mode 100644 index 0000000000000..3142ada984df3 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/DialogContent.types.ts @@ -0,0 +1,15 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; + +export type DialogContentSlots = { + root: Slot<'div', 'main'>; +}; + +/** + * DialogContent Props + */ +export type DialogContentProps = ComponentProps; + +/** + * State used in rendering DialogContent + */ +export type DialogContentState = ComponentState; diff --git a/packages/react-components/react-dialog/src/components/DialogContent/index.ts b/packages/react-components/react-dialog/src/components/DialogContent/index.ts new file mode 100644 index 0000000000000..ccedb8165f7d7 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/index.ts @@ -0,0 +1,5 @@ +export * from './DialogContent'; +export * from './DialogContent.types'; +export * from './renderDialogContent'; +export * from './useDialogContent'; +export * from './useDialogContentStyles'; diff --git a/packages/react-components/react-dialog/src/components/DialogContent/renderDialogContent.tsx b/packages/react-components/react-dialog/src/components/DialogContent/renderDialogContent.tsx new file mode 100644 index 0000000000000..56ef46582797d --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/renderDialogContent.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import type { DialogContentState, DialogContentSlots } from './DialogContent.types'; + +/** + * Render the final JSX of DialogContent + */ +export const renderDialogContent_unstable = (state: DialogContentState) => { + const { slots, slotProps } = getSlots(state); + + return ; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogContent/useDialogContent.ts b/packages/react-components/react-dialog/src/components/DialogContent/useDialogContent.ts new file mode 100644 index 0000000000000..98fbac9de263f --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/useDialogContent.ts @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { getNativeElementProps, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import type { DialogContentProps, DialogContentState } from './DialogContent.types'; +import { useDialogContext_unstable } from '../../contexts/dialogContext'; +import { useModalAttributes } from '@fluentui/react-tabster'; +import { isEscapeKeyDismiss } from '../../utils/isEscapeKeyDown'; + +/** + * Create the state required to render DialogContent. + * + * The returned state can be modified with hooks such as useDialogContentStyles_unstable, + * before being passed to renderDialogContent_unstable. + * + * @param props - props from this instance of DialogContent + * @param ref - reference to root HTMLElement of DialogContent + */ +export const useDialogContent_unstable = ( + props: DialogContentProps, + ref: React.Ref, +): DialogContentState => { + const { as = 'div' } = props; + + const contentRef = useDialogContext_unstable(ctx => ctx.contentRef); + const requestOpenChange = useDialogContext_unstable(ctx => ctx.requestOpenChange); + const modalType = useDialogContext_unstable(ctx => ctx.modalType); + + const { modalAttributes } = useModalAttributes({ + trapFocus: modalType !== 'non-modal', + }); + + const handleRootKeyDown = useEventCallback((event: React.KeyboardEvent) => { + props.onKeyDown?.(event); + if (isEscapeKeyDismiss(event, modalType)) { + requestOpenChange({ event, open: false, type: 'escapeKeyDown' }); + event.preventDefault(); + } + }); + + return { + components: { + root: 'div', + }, + root: getNativeElementProps(as, { + ref: useMergedRefs(ref, contentRef), + ...props, + ...modalAttributes, + onKeyDown: handleRootKeyDown, + }), + }; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogContent/useDialogContentStyles.ts b/packages/react-components/react-dialog/src/components/DialogContent/useDialogContentStyles.ts new file mode 100644 index 0000000000000..85586c02ff4b5 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogContent/useDialogContentStyles.ts @@ -0,0 +1,45 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import type { DialogContentSlots, DialogContentState } from './DialogContent.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/react-theme'; +import { MEDIA_QUERY_BREAKPOINT_SELECTOR } from '../../contexts/constants'; + +export const dialogContentClassNames: SlotClassNames = { + root: 'fui-DialogContent', +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + display: 'flex', + flexDirection: 'column', + width: '100%', + height: 'fit-content', + maxWidth: '600px', + maxHeight: '100vh', + boxShadow: tokens.shadow64, + backgroundColor: tokens.colorNeutralBackground1, + ...shorthands.border('1px', 'solid', tokens.colorTransparentStroke), + ...shorthands.borderRadius('8px'), + ...shorthands.margin('auto'), + [MEDIA_QUERY_BREAKPOINT_SELECTOR]: { + maxWidth: '100vw', + width: '100%', + }, + }, +}); + +/** + * Apply styling to the DialogContent slots based on the state + */ +export const useDialogContentStyles_unstable = (state: DialogContentState): DialogContentState => { + const styles = useStyles(); + state.root.className = mergeClasses(dialogContentClassNames.root, styles.root, state.root.className); + return state; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.test.tsx b/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.test.tsx new file mode 100644 index 0000000000000..120d2b3d88947 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { DialogTitle } from './DialogTitle'; +import { isConformant } from '../../common/isConformant'; + +describe('DialogTitle', () => { + isConformant({ + Component: DialogTitle, + displayName: 'DialogTitle', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default DialogTitle); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.tsx b/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.tsx new file mode 100644 index 0000000000000..8286668919c2a --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { useDialogTitle_unstable } from './useDialogTitle'; +import { renderDialogTitle_unstable } from './renderDialogTitle'; +import { useDialogTitleStyles_unstable } from './useDialogTitleStyles'; +import type { DialogTitleProps } from './DialogTitle.types'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +/** + * The `DialogTitle` component will expect to have a dialog title/header + * and will show the close (X icon) button if specified so. + * Apart from styling and presenting `closeButton`, this component does not have other behavior. + */ +export const DialogTitle: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useDialogTitle_unstable(props, ref); + + useDialogTitleStyles_unstable(state); + return renderDialogTitle_unstable(state); +}); + +DialogTitle.displayName = 'DialogTitle'; diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.types.ts b/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.types.ts new file mode 100644 index 0000000000000..115cee6d28c5d --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/DialogTitle.types.ts @@ -0,0 +1,20 @@ +import { ARIAButtonSlotProps } from '@fluentui/react-aria'; +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; + +export type DialogTitleSlots = { + /** + * By default this is a div, but can be a heading. + */ + root: Slot<'div', 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>; + closeButton?: Slot; +}; + +/** + * DialogTitle Props + */ +export type DialogTitleProps = ComponentProps & {}; + +/** + * State used in rendering DialogTitle + */ +export type DialogTitleState = ComponentState; diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/index.ts b/packages/react-components/react-dialog/src/components/DialogTitle/index.ts new file mode 100644 index 0000000000000..047ee38dc730a --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/index.ts @@ -0,0 +1,5 @@ +export * from './DialogTitle'; +export * from './DialogTitle.types'; +export * from './renderDialogTitle'; +export * from './useDialogTitle'; +export * from './useDialogTitleStyles'; diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/renderDialogTitle.tsx b/packages/react-components/react-dialog/src/components/DialogTitle/renderDialogTitle.tsx new file mode 100644 index 0000000000000..d0ba716a79655 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/renderDialogTitle.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import type { DialogTitleState, DialogTitleSlots } from './DialogTitle.types'; +import { DialogTrigger } from '../DialogTrigger'; + +/** + * Render the final JSX of DialogTitle + */ +export const renderDialogTitle_unstable = (state: DialogTitleState) => { + const { slots, slotProps } = getSlots(state); + + return ( + + {slotProps.root.children} + {slots.closeButton && ( + + + + )} + + ); +}; diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/useDialogTitle.tsx b/packages/react-components/react-dialog/src/components/DialogTitle/useDialogTitle.tsx new file mode 100644 index 0000000000000..31ddc0f5ec388 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/useDialogTitle.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { getNativeElementProps } from '@fluentui/react-utilities'; +import type { DialogTitleProps, DialogTitleState } from './DialogTitle.types'; +import { useARIAButton } from '@fluentui/react-aria'; +import { useDialogContext_unstable } from '../../contexts/dialogContext'; +import { Dismiss24Regular } from '@fluentui/react-icons'; + +/** + * Create the state required to render DialogTitle. + * + * The returned state can be modified with hooks such as useDialogTitleStyles_unstable, + * before being passed to renderDialogTitle_unstable. + * + * @param props - props from this instance of DialogTitle + * @param ref - reference to root HTMLElement of DialogTitle + */ +export const useDialogTitle_unstable = (props: DialogTitleProps, ref: React.Ref): DialogTitleState => { + const { as = 'div', closeButton } = props; + const modalType = useDialogContext_unstable(ctx => ctx.modalType); + + return { + components: { + root: 'div', + closeButton: 'button', + }, + root: getNativeElementProps(as, { + ref, + ...props, + }), + closeButton: useARIAButton(closeButton, { + required: modalType === 'non-modal', + defaultProps: { + type: 'button', // This is added because the default for type is 'submit' + 'aria-label': 'close', + children: , + }, + }), + }; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogTitle/useDialogTitleStyles.ts b/packages/react-components/react-dialog/src/components/DialogTitle/useDialogTitleStyles.ts new file mode 100644 index 0000000000000..a7aaea823b1a7 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTitle/useDialogTitleStyles.ts @@ -0,0 +1,74 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import type { DialogTitleSlots, DialogTitleState } from './DialogTitle.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { createFocusOutlineStyle } from '@fluentui/react-tabster'; +import { typographyStyles } from '@fluentui/react-theme'; +import { DIALOG_CONTENT_PADDING } from '../../contexts/constants'; + +export const dialogTitleClassNames: SlotClassNames = { + root: 'fui-DialogTitle', + closeButton: 'fui-DialogTitle__closeButton', +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + display: 'flex', + alignItems: 'start', + columnGap: '8px', + justifyContent: 'space-between', + ...typographyStyles.subtitle1, + }, + rootWithoutCloseButton: { + ...shorthands.padding(DIALOG_CONTENT_PADDING, DIALOG_CONTENT_PADDING, '8px', DIALOG_CONTENT_PADDING), + }, + rootWithCloseButton: { + ...shorthands.padding(DIALOG_CONTENT_PADDING, '20px', '8px', DIALOG_CONTENT_PADDING), + }, + closeButton: { + position: 'relative', + lineHeight: '0', + cursor: 'pointer', + }, + closeButtonFocusIndicator: createFocusOutlineStyle(), + // TODO: this should be extracted to another package + resetButton: { + boxSizing: 'content-box', + backgroundColor: 'inherit', + color: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'normal', + ...shorthands.overflow('visible'), + ...shorthands.padding(0), + ...shorthands.borderStyle('none'), + WebkitAppearance: 'button', + textAlign: 'unset', + }, +}); + +/** + * Apply styling to the DialogTitle slots based on the state + */ +export const useDialogTitleStyles_unstable = (state: DialogTitleState): DialogTitleState => { + const styles = useStyles(); + state.root.className = mergeClasses( + dialogTitleClassNames.root, + styles.root, + state.closeButton && styles.rootWithCloseButton, + !state.closeButton && styles.rootWithoutCloseButton, + state.root.className, + ); + if (state.closeButton) { + state.closeButton.className = mergeClasses( + dialogTitleClassNames.closeButton, + styles.resetButton, + styles.closeButton, + styles.closeButtonFocusIndicator, + state.closeButton.className, + ); + } + return state; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx new file mode 100644 index 0000000000000..ce6c16aeec2e3 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; +import { DialogTrigger } from './DialogTrigger'; +import * as renderer from 'react-test-renderer'; +import { createEvent, fireEvent, render } from '@testing-library/react'; +import { isConformant } from '../../common/isConformant'; +import { mockUseMenuContext } from '../../common/mockUseMenuContext'; +import { useMenuTriggerContext_unstable } from '../../contexts/menuTriggerContext'; +import { Enter } from '@fluentui/keyboard-keys'; + +jest.mock('../../contexts/menuContext.ts'); + +describe('MenuTrigger', () => { + beforeEach(() => mockUseMenuContext()); + + isConformant({ + disabledTests: [ + // MenuTrigger does not render DOM elements + 'component-handles-ref', + 'component-has-root-ref', + 'component-handles-classname', + 'component-has-static-classname', + 'component-has-static-classnames-object', + 'component-has-static-classname-exported', + // MenuTrigger does not have own styles + 'make-styles-overrides-win', + ], + Component: DialogTrigger, + displayName: 'MenuTrigger', + requiredProps: { + children: , + }, + }); + + /** + * Note: see more visual regression tests for MenuTrigger in /apps/vr-tests. + */ + it('renders a default state', () => { + const component = renderer.create( + + + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should retain original child callback ref', () => { + // Arrange + const ref = jest.fn(); + render( + + + , + ); + + // Assert + expect(ref).toHaveBeenCalledTimes(1); + expect(ref.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + , + ] + `); + }); + + it('should retain original child ref', () => { + // Arrange + const cb = jest.fn(); + const TestComponent = () => { + const ref = React.useRef(null); + React.useEffect(() => { + cb(ref.current); + }, []); + return ( + + + + ); + }; + render(); + + // Assert + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + , + ] + `); + }); + + it('should not open menu when aria-disabled is true', () => { + const setOpen = jest.fn(); + mockUseMenuContext({ setOpen }); + + const { getByRole } = render( + + + , + ); + fireEvent.click(getByRole('button')); + + expect(setOpen).toBeCalledTimes(0); + }); + + it('should open menu when aria-disabled is false', () => { + const setOpen = jest.fn(); + mockUseMenuContext({ setOpen }); + + const { getByRole } = render( + + + , + ); + fireEvent.click(getByRole('button')); + + expect(setOpen).toBeCalledTimes(1); + expect(setOpen).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ open: true })); + }); + + it('should open menu when trigger is disabled', () => { + const setOpen = jest.fn(); + mockUseMenuContext({ setOpen }); + + const { getByRole } = render( + + + , + ); + fireEvent.click(getByRole('button')); + + expect(setOpen).toBeCalledTimes(0); + }); + + it('should set MenuTriggerContext to false if not a submenu', () => { + mockUseMenuContext({ isSubmenu: false }); + let contextValue: boolean | undefined; + const TestComponent = () => { + contextValue = useMenuTriggerContext_unstable(); + + return null; + }; + + render( + + + , + ); + + expect(contextValue).toBe(false); + }); + + it('should set MenuTriggerContext to true if in a submenu', () => { + mockUseMenuContext({ isSubmenu: true }); + let contextValue: boolean | undefined; + const TestComponent = () => { + contextValue = useMenuTriggerContext_unstable(); + + return null; + }; + + render( + + + , + ); + + expect(contextValue).toBe(true); + }); + + it('should not keyboard click when event is default prevented', () => { + const onClick = jest.fn(); + const { getByRole } = render( + +
+ trigger +
+
, + ); + const event = createEvent.keyDown(getByRole('button'), { key: Enter }); + event.preventDefault(); + fireEvent(getByRole('button'), event); + + expect(onClick).toBeCalledTimes(0); + }); +}); diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.tsx b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.tsx new file mode 100644 index 0000000000000..84b2a6b486339 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { useDialogTrigger_unstable } from './useDialogTrigger'; +import { renderDialogTrigger_unstable } from './renderDialogTrigger'; +import type { DialogTriggerProps } from './DialogTrigger.types'; +import type { FluentTriggerComponent } from '@fluentui/react-utilities'; + +/** + * A non-visual component that wraps its child + * and configures them to be the trigger that will open or close a `Dialog`. + * This component should only accept one child. + * + * In case the trigger is used outside `Dialog` component + * it'll still provide basic ARIA related attributes + * to it's wrapped child, but it won't be able to alter the dialog `open` state anymore, + * in that case the user must provide a `controlled state` + */ +export const DialogTrigger: React.FC & FluentTriggerComponent = props => { + const state = useDialogTrigger_unstable(props); + + return renderDialogTrigger_unstable(state); +}; + +DialogTrigger.displayName = 'DialogTrigger'; +DialogTrigger.isFluentTriggerComponent = true; diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.types.ts b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.types.ts new file mode 100644 index 0000000000000..4d5bccb07bb6d --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.types.ts @@ -0,0 +1,30 @@ +import * as React from 'react'; + +export type DialogTriggerAction = 'open' | 'close' | 'toggle'; + +export type DialogTriggerProps = { + /** + * Explicitly declare if the trigger is responsible for opening, + * closing or toggling a Dialog visibility state. + * @default 'toggle' + */ + action?: DialogTriggerAction; + /** + * Explicitly require single child or render function + * to inject properties + */ + children: + | (React.ReactElement & { ref?: React.Ref }) + | ((props: DialogTriggerChildProps) => React.ReactElement | null); +}; + +/** + * Props that are passed to the child of the DialogTrigger when cloned to ensure correct behaviour for the Dialog + */ +export type DialogTriggerChildProps = Required, 'onClick' | 'aria-haspopup'>> & { + ref?: React.Ref; +}; + +export type DialogTriggerState = { + children: React.ReactElement | null; +}; diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/MenuTrigger.test.tsx.snap b/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/MenuTrigger.test.tsx.snap new file mode 100644 index 0000000000000..7826501c5a172 --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/MenuTrigger.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuTrigger renders a default state 1`] = ` + +`; diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/index.ts b/packages/react-components/react-dialog/src/components/DialogTrigger/index.ts new file mode 100644 index 0000000000000..cefe8176cb56b --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/index.ts @@ -0,0 +1,4 @@ +export * from './DialogTrigger'; +export * from './DialogTrigger.types'; +export * from './renderDialogTrigger'; +export * from './useDialogTrigger'; diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/renderDialogTrigger.tsx b/packages/react-components/react-dialog/src/components/DialogTrigger/renderDialogTrigger.tsx new file mode 100644 index 0000000000000..6cd376795dd8c --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/renderDialogTrigger.tsx @@ -0,0 +1,8 @@ +import type { DialogTriggerState } from './DialogTrigger.types'; + +/** + * Render the final JSX of MenuTrigger + * + * Only renders children + */ +export const renderDialogTrigger_unstable = (state: DialogTriggerState) => state.children; diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts b/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts new file mode 100644 index 0000000000000..3b0b4419525bd --- /dev/null +++ b/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts @@ -0,0 +1,57 @@ +import { useModalAttributes } from '@fluentui/react-tabster'; +import { applyTriggerPropsToChildren, getTriggerChild, useEventCallback } from '@fluentui/react-utilities'; +import * as React from 'react'; +import { useDialogContext_unstable } from '../../contexts/dialogContext'; +import { + DialogTriggerChildProps, + DialogTriggerProps, + DialogTriggerState, + DialogTriggerAction, +} from './DialogTrigger.types'; + +/** + * Create the state required to render DialogTrigger. + * Clones the only child component and adds necessary event handling behaviours to open a popup Dialog + * + * @param props - props from this instance of DialogTrigger + */ +export const useDialogTrigger_unstable = (props: DialogTriggerProps): DialogTriggerState => { + const { children, action = 'toggle' } = props; + + const child = React.isValidElement(children) ? getTriggerChild(children) : undefined; + + const requestOpenChange = useDialogContext_unstable(ctx => ctx.requestOpenChange); + + const { triggerAttributes } = useModalAttributes(); + + const handleClick = useEventCallback((event: React.MouseEvent) => { + child?.props.onClick?.(event); + if (!event.isDefaultPrevented()) { + requestOpenChange({ + event, + type: 'triggerClick', + open: updateOpen(action), + }); + } + }); + + return { + children: applyTriggerPropsToChildren(children, { + 'aria-haspopup': 'dialog', + ref: child?.ref as React.Ref, + onClick: handleClick, + ...triggerAttributes, + }), + }; +}; + +function updateOpen(type: DialogTriggerAction): React.SetStateAction { + switch (type) { + case 'close': + return false; + case 'open': + return true; + case 'toggle': + return curr => !curr; + } +} diff --git a/packages/react-components/react-dialog/src/contexts/constants.ts b/packages/react-components/react-dialog/src/contexts/constants.ts new file mode 100644 index 0000000000000..ddde25ad12518 --- /dev/null +++ b/packages/react-components/react-dialog/src/contexts/constants.ts @@ -0,0 +1,2 @@ +export const MEDIA_QUERY_BREAKPOINT_SELECTOR = '@media screen and (max-width: 480px)'; +export const DIALOG_CONTENT_PADDING = '24px'; diff --git a/packages/react-components/react-dialog/src/contexts/dialogContext.ts b/packages/react-components/react-dialog/src/contexts/dialogContext.ts new file mode 100644 index 0000000000000..bd301215454ef --- /dev/null +++ b/packages/react-components/react-dialog/src/contexts/dialogContext.ts @@ -0,0 +1,38 @@ +import { createContext, ContextSelector, useContextSelector } from '@fluentui/react-context-selector'; +import type { Context } from '@fluentui/react-context-selector'; +import type { DialogOpenChangeArgs, DialogModalType } from '../Dialog'; +import * as React from 'react'; + +export type DialogRequestOpenChangeData = { + event: DialogOpenChangeArgs[0]; + open: React.SetStateAction; +} & Pick; + +export type DialogContextValue = { + /** + * Reference to trigger element that opened the Dialog + * null if Dialog is closed + */ + triggerRef: React.RefObject; + contentRef: React.RefObject; + modalType: DialogModalType; + open: boolean; + /** + * Requests dialog main component to update it's internal open state + */ + requestOpenChange: (data: DialogRequestOpenChangeData) => void; +}; + +export const DialogContext: Context = createContext({ + open: false, + modalType: 'modal', + triggerRef: { current: null }, + contentRef: { current: null }, + requestOpenChange() { + /* noop */ + }, +}); + +export const DialogProvider = DialogContext.Provider; +export const useDialogContext_unstable = (selector: ContextSelector): T => + useContextSelector(DialogContext, selector); diff --git a/packages/react-components/react-dialog/src/index.ts b/packages/react-components/react-dialog/src/index.ts index 0de05da73985b..f11d9d6d2ddc2 100644 --- a/packages/react-components/react-dialog/src/index.ts +++ b/packages/react-components/react-dialog/src/index.ts @@ -5,4 +5,49 @@ export { useDialogStyles_unstable, useDialog_unstable, } from './Dialog'; -export type { DialogProps, DialogSlots, DialogState } from './Dialog'; +export type { DialogProps, DialogOpenChangeListener, DialogSlots, DialogState } from './Dialog'; + +export { DialogTrigger, useDialogTrigger_unstable, renderDialogTrigger_unstable } from './DialogTrigger'; +export type { + DialogTriggerProps, + DialogTriggerChildProps, + DialogTriggerState, + DialogTriggerAction, +} from './DialogTrigger'; + +export { + DialogContent, + dialogContentClassNames, + useDialogContentStyles_unstable, + useDialogContent_unstable, + renderDialogContent_unstable, +} from './DialogContent'; + +export type { DialogContentProps, DialogContentSlots, DialogContentState } from './DialogContent'; + +export { + DialogTitle, + dialogTitleClassNames, + useDialogTitleStyles_unstable, + useDialogTitle_unstable, + renderDialogTitle_unstable, +} from './DialogTitle'; +export type { DialogTitleProps, DialogTitleSlots, DialogTitleState } from './DialogTitle'; + +export { + DialogBody, + dialogBodyClassNames, + useDialogBodyStyles_unstable, + useDialogBody_unstable, + renderDialogBody_unstable, +} from './DialogBody'; +export type { DialogBodyProps, DialogBodySlots, DialogBodyState } from './DialogBody'; + +export { + DialogActions, + dialogActionsClassNames, + useDialogActionsStyles_unstable, + useDialogActions_unstable, + renderDialogActions_unstable, +} from './DialogActions'; +export type { DialogActionsProps, DialogActionsSlots, DialogActionsState } from './DialogActions'; diff --git a/packages/react-components/react-dialog/src/stories/Dialog/DialogDefault.stories.tsx b/packages/react-components/react-dialog/src/stories/Dialog/DialogDefault.stories.tsx deleted file mode 100644 index e98f4e75232f4..0000000000000 --- a/packages/react-components/react-dialog/src/stories/Dialog/DialogDefault.stories.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from 'react'; -import { Dialog } from '@fluentui/react-dialog'; -import type { DialogProps } from '@fluentui/react-dialog'; - -export const Default = (props: Partial) => ; diff --git a/packages/react-components/react-dialog/src/stories/Dialog/DialogDescription.md b/packages/react-components/react-dialog/src/stories/Dialog/DialogDescription.md deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/react-components/react-dialog/src/stories/DialogAlert.stories.tsx b/packages/react-components/react-dialog/src/stories/DialogAlert.stories.tsx new file mode 100644 index 0000000000000..63e2246aa5250 --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogAlert.stories.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogBody, DialogActions } from '@fluentui/react-dialog'; +import type { DialogProps } from '@fluentui/react-dialog'; +import { Button } from '@fluentui/react-components'; + +export const Alert = (props: Partial) => { + return ( + <> + + + + + + Alert dialog title + + This dialog cannot be dismissed by clicking on the overlay nor by pressing Escape. Close button should be + pressed to dismiss this Alert + + + + + + + + + + + ); +}; diff --git a/packages/react-components/react-dialog/src/stories/Dialog/DialogBestPractices.md b/packages/react-components/react-dialog/src/stories/DialogBestPractices.md similarity index 100% rename from packages/react-components/react-dialog/src/stories/Dialog/DialogBestPractices.md rename to packages/react-components/react-dialog/src/stories/DialogBestPractices.md diff --git a/packages/react-components/react-dialog/src/stories/DialogChangeFocus.stories.tsx b/packages/react-components/react-dialog/src/stories/DialogChangeFocus.stories.tsx new file mode 100644 index 0000000000000..e2bc57b73bed6 --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogChangeFocus.stories.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { + Dialog, + DialogOpenChangeListener, + DialogTrigger, + DialogContent, + DialogTitle, + DialogBody, + DialogActions, +} from '@fluentui/react-dialog'; +import type { DialogProps } from '@fluentui/react-dialog'; +import { Button } from '@fluentui/react-components'; + +import { makeStyles } from '@griffel/react'; + +const useStyles = makeStyles({ + thirdAction: { + marginRight: 'auto', + '@media screen and (max-width: 480px)': { + marginRight: 'unset', + }, + }, +}); + +export const ChangeFocus = (props: Partial) => { + const styles = useStyles(); + const buttonRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const handleOpenChange: DialogOpenChangeListener = (_, data) => setOpen(data.open); + React.useEffect(() => { + if (open && buttonRef.current) { + buttonRef.current.focus(); + } + }, [open]); + return ( + <> + + + + + + Dialog title + This dialog focus on the second button instead of first + + + + + + + + + + + ); +}; diff --git a/packages/react-components/react-dialog/src/stories/DialogDefault.stories.tsx b/packages/react-components/react-dialog/src/stories/DialogDefault.stories.tsx new file mode 100644 index 0000000000000..d49c38ae670e5 --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogDefault.stories.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogBody, DialogActions } from '@fluentui/react-dialog'; +import type { DialogProps } from '@fluentui/react-dialog'; +import { Button } from '@fluentui/react-components'; + +export const Default = (props: Partial) => { + return ( + <> + + + + + + Dialog title + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam exercitationem cumque repellendus eaque + est dolor eius expedita nulla ullam? Tenetur reprehenderit aut voluptatum impedit voluptates in natus iure + cumque eaque? + + + + + + + + + + + ); +}; diff --git a/packages/react-components/react-dialog/src/stories/DialogDescription.md b/packages/react-components/react-dialog/src/stories/DialogDescription.md new file mode 100644 index 0000000000000..041ae3715e4f9 --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogDescription.md @@ -0,0 +1 @@ +`Dialog` is a window overlaid on either the primary window or another dialog window. Windows under a modal dialog are inert. That is, users cannot interact with content outside an active dialog window. Inert content outside an active dialog is typically visually obscured or dimmed so it is difficult to discern, and in some implementations, attempts to interact with the inert content cause the dialog to close. diff --git a/packages/react-components/react-dialog/src/stories/DialogNested.stories.tsx b/packages/react-components/react-dialog/src/stories/DialogNested.stories.tsx new file mode 100644 index 0000000000000..766ffd930519a --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogNested.stories.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogActions } from '@fluentui/react-dialog'; +import type { DialogProps } from '@fluentui/react-dialog'; +import { Button } from '@fluentui/react-components'; + +export const Nested = (props: Partial) => { + return ( + <> + + + + + + Dialog title + + + + + + + Inner dialog title + + + + + + + + + + + + ); +}; diff --git a/packages/react-components/react-dialog/src/stories/DialogNoFocusableElement.stories.tsx b/packages/react-components/react-dialog/src/stories/DialogNoFocusableElement.stories.tsx new file mode 100644 index 0000000000000..9da62284f72ca --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogNoFocusableElement.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogBody } from '@fluentui/react-dialog'; +import type { DialogProps } from '@fluentui/react-dialog'; +import { Button } from '@fluentui/react-components'; + +export const NoFocusableElement = (props: Partial) => { + return ( + <> + + + + + + Dialog Title + +

⚠️A Dialog without focusable elements is not recommended!⚠️

+

Escape key and overlay click still works to ensure this modal can be closed

+
+
+
+ + + + + + Dialog Title + +

⚠️A Dialog without focusable elements is not recommended!⚠️

+

Escape key still works to ensure this modal can be closed

+
+
+
+ + ); +}; diff --git a/packages/react-components/react-dialog/src/stories/DialogNonModal.stories.tsx b/packages/react-components/react-dialog/src/stories/DialogNonModal.stories.tsx new file mode 100644 index 0000000000000..a99c41aacc448 --- /dev/null +++ b/packages/react-components/react-dialog/src/stories/DialogNonModal.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogBody } from '@fluentui/react-dialog'; +import type { DialogProps } from '@fluentui/react-dialog'; +import { Button } from '@fluentui/react-components'; + +export const NonModal = (props: Partial) => { + return ( + <> + + + + + + Non-modal dialog title + + Lorem, ipsum dolor sit amet consectetur adipisicing elit. Aliquid, explicabo repudiandae impedit doloribus + laborum quidem maxime dolores perspiciatis non ipsam, nostrum commodi quis autem sequi, incidunt cum? + Consequuntur, repellendus nostrum? + + + + + ); +}; diff --git a/packages/react-components/react-dialog/src/stories/Dialog/index.stories.tsx b/packages/react-components/react-dialog/src/stories/index.stories.tsx similarity index 59% rename from packages/react-components/react-dialog/src/stories/Dialog/index.stories.tsx rename to packages/react-components/react-dialog/src/stories/index.stories.tsx index 34ebfa88e1c52..74e32f42cf242 100644 --- a/packages/react-components/react-dialog/src/stories/Dialog/index.stories.tsx +++ b/packages/react-components/react-dialog/src/stories/index.stories.tsx @@ -4,6 +4,11 @@ import descriptionMd from './DialogDescription.md'; import bestPracticesMd from './DialogBestPractices.md'; export { Default } from './DialogDefault.stories'; +export { NonModal } from './DialogNonModal.stories'; +export { Alert } from './DialogAlert.stories'; +export { Nested } from './DialogNested.stories'; +export { NoFocusableElement } from './DialogNoFocusableElement.stories'; +export { ChangeFocus } from './DialogChangeFocus.stories'; export default { title: 'Components/Dialog', diff --git a/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts b/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts new file mode 100644 index 0000000000000..4d649898200b6 --- /dev/null +++ b/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Escape } from '@fluentui/keyboard-keys'; +import { DialogModalType } from '../components/Dialog/Dialog.types'; +import { normalizeDefaultPrevented } from './normalizeDefaultPrevented'; + +/** + * Checks if keydown event is a proper Escape key dismiss + */ +export function isEscapeKeyDismiss(event: React.KeyboardEvent | KeyboardEvent, type: DialogModalType): boolean { + const isDefaultPrevented = normalizeDefaultPrevented(event); + return event.key === Escape && type !== 'alert' && !isDefaultPrevented(); +} diff --git a/packages/react-components/react-dialog/src/utils/normalizeDefaultPrevented.ts b/packages/react-components/react-dialog/src/utils/normalizeDefaultPrevented.ts new file mode 100644 index 0000000000000..33e1851da1881 --- /dev/null +++ b/packages/react-components/react-dialog/src/utils/normalizeDefaultPrevented.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; + +/** + * normalizes defaultPrevented to work the same way between synthetic events and regular event + */ +export function normalizeDefaultPrevented(event: React.SyntheticEvent | Event) { + if (event instanceof Event) { + return () => event.defaultPrevented; + } + return event.isDefaultPrevented; +} diff --git a/packages/react-components/react-dialog/src/utils/normalizeSetStateAction.ts b/packages/react-components/react-dialog/src/utils/normalizeSetStateAction.ts new file mode 100644 index 0000000000000..f12710a3fbdfe --- /dev/null +++ b/packages/react-components/react-dialog/src/utils/normalizeSetStateAction.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +/** + * Normalizes a set state action into a setter function + */ +export function normalizeSetStateAction(setOpen: React.SetStateAction): (prev: T) => T { + return typeof setOpen === 'function' ? (setOpen as (prevState: T) => T) : () => setOpen; +}