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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .storybook/preview-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"Noto Color Emoji";
min-width: 100%;
min-height: 100%;
-webkit-overflow-scrolling: touch;
}
</style>
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,17 @@
"@chakra-ui/hooks": "^1.6.1",
"@chakra-ui/react-utils": "^1.1.2",
"@chakra-ui/utils": "^1.8.3",
"@radix-ui/popper": "^0.1.0",
"@radix-ui/react-use-rect": "^0.1.1",
"@radix-ui/react-use-size": "^0.1.0",
"@react-aria/i18n": "^3.3.2",
"@react-aria/interactions": "^3.6.0",
"@react-aria/spinbutton": "^3.0.1",
"@react-aria/utils": "^3.9.0",
"date-fns": "^2.25.0",
"raf": "^3.4.1",
"framer-motion": "^5.3.1",
"react-remove-scroll": "^2.4.3",
"react-spring": "^9.3.1",
"reakit-system": "^0.15.2",
"reakit-utils": "^0.15.2",
"reakit-warning": "^0.6.2"
Expand Down Expand Up @@ -132,7 +137,6 @@
"@types/jest-in-case": "1.0.5",
"@types/mockdate": "3.0.0",
"@types/node": "16.11.7",
"@types/raf": "3.4.0",
"@types/react": "17.0.34",
"@types/react-dom": "17.0.11",
"@types/react-transition-group": "4.4.4",
Expand Down
1 change: 0 additions & 1 deletion src/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export const useCheckbox = createHook<CheckboxOptions, CheckboxHTMLProps>({
warning(
true,
"Can't determine whether the element is a native checkbox because `ref` wasn't passed to the component",
"See https://reakit.io/docs/checkbox",
);
return;
}
Expand Down
1 change: 0 additions & 1 deletion src/checkbox/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export function useIndeterminateState(
warning(
state === "indeterminate",
"Can't set indeterminate state because `ref` wasn't passed to component.",
"See https://reakit.io/docs/checkbox/#indeterminate-state",
);
return;
}
Expand Down
234 changes: 234 additions & 0 deletions src/dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import * as React from "react";
import { RemoveScroll } from "react-remove-scroll";
import { createComponent, createHook, useCreateElement } from "reakit-system";
import { Portal } from "reakit";
import { useForkRef, useLiveRef } from "reakit-utils";
import { useWarning, warning } from "reakit-warning";

import {
DisclosureContentHTMLProps,
DisclosureContentOptions,
useDisclosureContent,
} from "../disclosure";

import { DIALOG_KEYS } from "./__keys";
import { DialogStateReturn } from "./DialogState";
import {
DialogBackdropContext,
useDisableHoverOutside,
useDisclosureRef,
useFocusOnBlur,
useFocusOnChildUnmount,
useFocusOnHide,
useFocusOnShow,
useFocusTrap,
useHideOnClickOutside,
useNestedDialogs,
} from "./helpers";

export type DialogOptions = DisclosureContentOptions &
Pick<Partial<DialogStateReturn>, "modal" | "hide" | "disclosureRef"> &
Pick<DialogStateReturn, "baseId"> & {
/**
* When enabled, user can hide the dialog by pressing `Escape`.
*/
hideOnEsc?: boolean;

/**
* When enabled, user can hide the dialog by clicking outside it.
*/
hideOnClickOutside?: boolean;

/**
* When enabled, user can't scroll on body when the dialog is visible.
* This option doesn't work if the dialog isn't modal.
*/
preventBodyScroll?: boolean;

/**
* The element that will be focused when the dialog shows.
* When not set, the first tabbable element within the dialog will be used.
*/
unstable_initialFocusRef?: React.RefObject<HTMLElement>;

/**
* The element that will be focused when the dialog hides.
* When not set, the disclosure component will be used.
*/
unstable_finalFocusRef?: React.RefObject<HTMLElement>;

/**
* Whether or not the dialog should be a child of its parent.
* Opening a nested orphan dialog will close its parent dialog if
* `hideOnClickOutside` is set to `true` on the parent.
* It will be set to `false` if `modal` is `false`.
*/
unstable_orphan?: boolean;

/**
* Whether or not to move focus when the dialog shows.
* @private
*/
unstable_autoFocusOnShow?: boolean;

/**
* Whether or not to move focus when the dialog hides.
* @private
*/
unstable_autoFocusOnHide?: boolean;
};

export type DialogHTMLProps = DisclosureContentHTMLProps;

export type DialogProps = DialogOptions & DialogHTMLProps;

export const useDialog = createHook<DialogOptions, DialogHTMLProps>({
name: "Dialog",
compose: useDisclosureContent,
keys: DIALOG_KEYS,

useOptions(options) {
const {
modal = true,
hideOnEsc = true,
hideOnClickOutside = true,
preventBodyScroll = modal,
unstable_autoFocusOnShow = true,
unstable_autoFocusOnHide = true,
unstable_orphan,
...restOptions
} = options;

return {
modal,
hideOnEsc,
hideOnClickOutside,
preventBodyScroll: modal && preventBodyScroll,
unstable_autoFocusOnShow,
unstable_autoFocusOnHide,
unstable_orphan: modal && unstable_orphan,
...restOptions,
};
},

useProps(options, htmlProps) {
const {
preventBodyScroll,
baseId,
hideOnEsc,
hide,
modal: optionsModal,
} = options;
const {
ref: htmlRef,
onKeyDown: htmlOnKeyDown,
onBlur: htmlOnBlur,
wrapElement: htmlWrapElement,
tabIndex,
...restHtmlProps
} = htmlProps;
const dialog = React.useRef<HTMLElement>(null);
const backdrop = React.useContext(DialogBackdropContext);
const hasBackdrop = backdrop && backdrop === baseId;
const disclosure = useDisclosureRef(dialog, options);
const onKeyDownRef = useLiveRef(htmlOnKeyDown);
const onBlurRef = useLiveRef(htmlOnBlur);
const focusOnBlur = useFocusOnBlur(dialog, options);
const { dialogs, visibleModals, wrap } = useNestedDialogs(dialog, options);
// VoiceOver/Safari accepts only one `aria-modal` container, so if there
// are visible child modals, then we don't want to set aria-modal on the
// parent modal (this component).
const modal = optionsModal && !visibleModals.length ? true : undefined;

useFocusTrap(dialog, visibleModals, options);
useFocusOnChildUnmount(dialog, options);
useFocusOnShow(dialog, dialogs, options);
useFocusOnHide(dialog, disclosure, options);
useHideOnClickOutside(dialog, disclosure, dialogs, options);
useDisableHoverOutside(dialog, dialogs, options);

const onKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
onKeyDownRef.current?.(event);

if (event.defaultPrevented) return;
if (event.key !== "Escape") return;
if (!hideOnEsc) return;
if (!hide) {
warning(
true,
"`hideOnEsc` prop is truthy, but `hide` prop wasn't provided.",
dialog.current,
);
return;
}
event.stopPropagation();

hide();
},
[onKeyDownRef, hideOnEsc, hide],
);

const onBlur = React.useCallback(
(event: React.FocusEvent<HTMLElement>) => {
onBlurRef.current?.(event);

focusOnBlur(event);
},
[focusOnBlur, onBlurRef],
);

const wrapElement = React.useCallback(
(element: React.ReactNode) => {
element = wrap(element);

if (optionsModal && !hasBackdrop) {
if (preventBodyScroll) {
element = (
<Portal>
<RemoveScroll>{element}</RemoveScroll>
</Portal>
);
} else {
element = <Portal>{element}</Portal>;
}
}

if (htmlWrapElement) {
element = htmlWrapElement(element);
}

// return (
// // Prevents Menu > Dialog > Menu to behave as a sub menu
// <MenuContext.Provider value={null}>{element}</MenuContext.Provider>
// );
return element;
},
[wrap, optionsModal, hasBackdrop, htmlWrapElement, preventBodyScroll],
);

return {
ref: useForkRef(dialog, htmlRef),
role: "dialog",
tabIndex: tabIndex ?? -1,
"aria-modal": modal,
"data-dialog": true,
onKeyDown,
onBlur,
wrapElement,
...restHtmlProps,
};
},
});

export const Dialog = createComponent({
as: "div",
useHook: useDialog,
useCreateElement: (type, props, children) => {
useWarning(
!props["aria-label"] && !props["aria-labelledby"],
"You should provide either `aria-label` or `aria-labelledby` props.",
);
return useCreateElement(type, props, children);
},
});
89 changes: 89 additions & 0 deletions src/dialog/DialogBackdrop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as React from "react";
import { RemoveScroll } from "react-remove-scroll";
import { createComponent, createHook } from "reakit-system";
import { Portal } from "reakit";

import {
DisclosureContentHTMLProps,
DisclosureContentOptions,
useDisclosureContent,
} from "../disclosure/DisclosureContent";

import { DIALOG_BACKDROP_KEYS } from "./__keys";
import { DialogStateReturn } from "./DialogState";
import { DialogBackdropContext } from "./helpers";

export type DialogBackdropOptions = DisclosureContentOptions &
Pick<Partial<DialogStateReturn>, "modal"> & {
/**
* When enabled, user can't scroll on body when the dialog is visible.
* This option doesn't work if the dialog isn't modal.
*/
preventBodyScroll?: boolean;
};

export type DialogBackdropHTMLProps = DisclosureContentHTMLProps;

export type DialogBackdropProps = DialogBackdropOptions &
DialogBackdropHTMLProps;

export const useDialogBackdrop = createHook<
DialogBackdropOptions,
DialogBackdropHTMLProps
>({
name: "DialogBackdrop",
compose: useDisclosureContent,
keys: DIALOG_BACKDROP_KEYS,

useOptions({ modal = true, preventBodyScroll = modal, ...options }) {
return { modal, preventBodyScroll: modal && preventBodyScroll, ...options };
},

useProps(options, htmlProps) {
const { modal, baseId, preventBodyScroll } = options;
const { wrapElement: htmlWrapElement, ...restHtmlProps } = htmlProps;
const wrapElement = React.useCallback(
(element: React.ReactNode) => {
if (modal) {
if (preventBodyScroll) {
element = (
<Portal>
<DialogBackdropContext.Provider value={baseId}>
<RemoveScroll>{element}</RemoveScroll>
</DialogBackdropContext.Provider>
</Portal>
);
} else {
element = (
<Portal>
<DialogBackdropContext.Provider value={baseId}>
{element}
</DialogBackdropContext.Provider>
</Portal>
);
}
}

if (htmlWrapElement) {
return htmlWrapElement(element);
}

return element;
},
[modal, htmlWrapElement, preventBodyScroll, baseId],
);

return {
id: undefined,
"data-dialog-ref": baseId,
wrapElement,
...restHtmlProps,
};
},
});

export const DialogBackdrop = createComponent({
as: "div",
memo: true,
useHook: useDialogBackdrop,
});
Loading