diff --git a/src/components/Flyover.tsx b/src/components/Flyover.tsx index 324c84fc..2a6e0ba9 100644 --- a/src/components/Flyover.tsx +++ b/src/components/Flyover.tsx @@ -1,29 +1,119 @@ -import { type ReactNode, type Ref, forwardRef, useEffect } from 'react' +import { + type ComponentPropsWithoutRef, + type ReactNode, + type Ref, + forwardRef, + useCallback, +} from 'react' -import styled, { type StyledComponentPropsWithRef } from 'styled-components' +import styled from 'styled-components' -import { type ModalProps } from 'honorable' +import { VisuallyHidden } from 'react-aria' -import useLockedBody from '../hooks/useLockedBody' +import * as Dialog from '@radix-ui/react-dialog' import { CloseIcon } from '../icons' -import { HonorableModal } from './HonorableModal' import IconFrame from './IconFrame' +import { ModalWrapper } from './ModalWrapper' +import { FillLevelContext } from './contexts/FillLevelContext' -type FlyoverPropsType = Omit & { +const ANIMATION_SPEED = '300ms' + +type FlyoverProps = { + open?: boolean + onClose?: () => void header?: ReactNode - lockBody?: boolean scrollable?: boolean - asForm?: boolean - formProps?: StyledComponentPropsWithRef<'form'> - width?: string - minWidth?: number - [x: string]: unknown + width?: string | number + minWidth?: string | number + children?: ReactNode +} & ComponentPropsWithoutRef<'div'> + +function FlyoverRef( + { + open = false, + onClose, + header, + scrollable = true, + width = '40%', + minWidth = 570, + children, + ...props + }: FlyoverProps, + ref: Ref +) { + const triggerClose = useCallback( + (open: boolean) => { + if (!open) onClose?.() + }, + [onClose] + ) + + return ( + + + + + {header} + + {!!header && ( + + {header} + } + /> + + )} + + {children} + + + + + ) } -const FlyoverSC = styled.div(({ theme }) => ({ - position: 'relative', +const ModalWrapperSC = styled(ModalWrapper)<{ + $width: string | number + $minWidth: string | number +}>(({ $width, $minWidth }) => ({ + height: '100%', + width: $width, + minWidth: $minWidth, + '@keyframes popIn': { + from: { transform: 'translateX(100%)', opacity: 0 }, + to: { transform: 'translateX(0)', opacity: 1 }, + }, + '@keyframes popOut': { + from: { transform: 'translateX(0)', opacity: 1 }, + to: { transform: 'translateX(100%)', opacity: 0 }, + }, + '&[data-state="open"]': { + animation: `popIn ${ANIMATION_SPEED} ease-out`, + }, + '&[data-state="closed"]': { + animation: `popOut ${ANIMATION_SPEED} ease-out`, + }, +})) + +const FlyoverWrapperSC = styled.div(({ theme }) => ({ backgroundColor: theme.colors['fill-zero'], height: '100%', display: 'flex', @@ -34,11 +124,7 @@ const FlyoverSC = styled.div(({ theme }) => ({ const FlyoverContentSC = styled.div<{ $scrollable: boolean }>(({ theme, $scrollable }) => ({ - position: 'relative', - zIndex: 0, - margin: 0, padding: theme.spacing.large, - backgroundColor: theme.colors['fill-zero'], ...theme.partials.text.body1, flexGrow: 1, ...($scrollable @@ -51,93 +137,19 @@ const FlyoverContentSC = styled.div<{ })) const FlyoverHeaderWrapSC = styled.div(({ theme }) => ({ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', - gap: theme.spacing.small, height: 56, borderBottom: `1px solid ${theme.colors.border}`, - backgroundColor: theme.colors['fill-zero'], - display: 'flex', padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, })) const FlyoverHeaderSC = styled.h1(({ theme }) => ({ - margin: 0, ...theme.partials.text.subtitle1, - color: theme.colors.semanticDefault, + color: theme.colors.text, })) -function FlyoverRef( - { - children, - header, - open = false, - onClose, - lockBody = true, - asForm = false, - formProps = {}, - scrollable = true, - width = '40%', - minWidth = 570, - ...props - }: FlyoverPropsType, - ref: Ref -) { - const [, setBodyLocked] = useLockedBody(open && lockBody) - - useEffect(() => { - setBodyLocked(lockBody && open) - }, [lockBody, open, setBodyLocked]) - - return ( - - - {!!header && ( - - {header} - } - /> - - )} - {children} - - - ) -} - const Flyover = forwardRef(FlyoverRef) export default Flyover diff --git a/src/components/HonorableModal.tsx b/src/components/HonorableModal.tsx deleted file mode 100644 index 0a8d391f..00000000 --- a/src/components/HonorableModal.tsx +++ /dev/null @@ -1,236 +0,0 @@ -// Slight fork of honorable Modal -// https://raw.githubusercontent.com/dherault/honorable/6a7bb0773486a1610759660dfe27d42e50ca12e2/packages/honorable/src/components/Modal/Modal.tsx -// -// Fixes issue with clicks starting within the modal causing the modal to close -// if released outside the modal -import { - type MouseEvent, - type ReactElement, - type Ref, - cloneElement, - forwardRef, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { createPortal } from 'react-dom' -import { Transition, type TransitionStatus } from 'react-transition-group' - -import { Div, useTheme } from 'honorable' -import useRootStyles from 'honorable/dist/hooks/useRootStyles.js' -import { useKeyDown } from '@react-hooks-library/core' -import { type ComponentProps } from 'honorable/dist/types.js' -import resolvePartStyles from 'honorable/dist/resolvers/resolvePartStyles.js' -import filterUndefinedValues from 'honorable/dist/utils/filterUndefinedValues.js' -import { type CSSObject } from 'styled-components' - -export const modalParts = ['Backdrop'] as const - -export type ModalBaseProps = { - open?: boolean - onClose?: (event?: MouseEvent | KeyboardEvent) => void - fade?: boolean - transitionDuration?: number - InnerTransitionStyle?: transitionConfig - InnerDefaultStyle?: CSSObject - disableEscapeKey?: boolean - portal?: boolean -} - -export type ModalProps = ModalBaseProps & - ComponentProps - -type transitionConfig = { - [key in TransitionStatus]?: CSSObject -} - -const defaultInnerTransitionStyle: transitionConfig = { - entering: { opacity: 0 }, - entered: { opacity: 1 }, - exiting: { opacity: 0 }, - exited: { opacity: 0 }, -} - -function ModalRef(props: ModalProps, ref: Ref) { - const { - open = true, - fade = true, - onClose, - transitionDuration = 250, - disableEscapeKey = false, - portal = false, - InnerTransitionStyle, - InnerDefaultStyle, - ...otherProps - } = props - const theme = useTheme() - const backdropRef = useRef() - const [isOpen, setIsOpen] = useState(open) - const [isClosing, setIsClosing] = useState(false) - const rootStyles = useRootStyles('Modal', props, theme) - const portalElement = useMemo(() => document.createElement('div'), []) - - const handleClose = useCallback( - (event: MouseEvent | KeyboardEvent) => { - if (typeof onClose === 'function') { - if (fade) { - setIsClosing(true) - setTimeout(() => { - setIsClosing(false) - onClose(event) - }, transitionDuration) - } else onClose(event) - } - }, - [fade, transitionDuration, onClose] - ) - - const handleBackdropClick = useCallback( - (event: MouseEvent) => { - if (event.target === backdropRef.current) { - handleClose(event) - } - }, - [handleClose] - ) - - const handleEscapeKey = useCallback( - (event: KeyboardEvent) => - isOpen && !isClosing && !disableEscapeKey && handleClose(event), - [isOpen, isClosing, disableEscapeKey, handleClose] - ) - - useKeyDown(['Escape'], handleEscapeKey) - - useEffect(() => { - if (fade && open) { - setIsOpen(true) - } else if (fade && !open) { - setIsClosing(true) - setTimeout(() => { - setIsClosing(false) - setIsOpen(false) - }, transitionDuration) - } else { - setIsOpen(open) - } - }, [fade, open, transitionDuration]) - - useEffect(() => { - const honorablePortalElement = document.getElementById('honorable-portal') - - if (portal && honorablePortalElement) { - honorablePortalElement.appendChild(portalElement) - - return () => { - honorablePortalElement.removeChild(portalElement) - } - } - }, [portal, portalElement]) - - if (!(open || isOpen || isClosing)) return null - - function wrapFadeOutter(element: ReactElement) { - if (!fade) return element - - const defaultStyle = { - opacity: 0, - transition: `opacity ${transitionDuration}ms ease`, - ...resolvePartStyles('BackdropDefaultStyle', props, theme), - } - - const transitionStyles = { - entering: { opacity: 1 }, - entered: { opacity: 1 }, - exiting: { opacity: 0 }, - exited: { opacity: 0 }, - ...resolvePartStyles('BackdropTransitionStyle', props, theme), - } - - return ( - - {(state: string) => - cloneElement(element, { - ...element.props, - ...defaultStyle, - ...transitionStyles[state as keyof typeof transitionStyles], - }) - } - - ) - } - - function renderInPortal(element: ReactElement) { - if (!portal) return element - - return createPortal(element, portalElement) - } - - function wrapFadeInner(element: ReactElement) { - if (!fade) return element - - const defaultInnerStyle = { - opacity: 0, - transition: `opacity ${transitionDuration}ms ease`, - } - - return ( - - {(state) => - cloneElement(element, { - ...element.props, - ...(InnerDefaultStyle || defaultInnerStyle), - ...(InnerTransitionStyle || defaultInnerTransitionStyle)[state], - }) - } - - ) - } - - return renderInPortal( - wrapFadeOutter( -
- {wrapFadeInner( -
- )} -
- ) - ) -} - -const BaseModal = forwardRef(ModalRef) - -BaseModal.displayName = 'Modal' - -export const HonorableModal = memo(BaseModal) diff --git a/src/hooks/useLockedBody.ts b/src/hooks/useLockedBody.ts deleted file mode 100644 index f271e5bc..00000000 --- a/src/hooks/useLockedBody.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Adapted from https://usehooks-ts.com/react-hook/use-locked-body -// Modified to calculate scrollbar width via document.documentElement if no -// rootId is provided, and to be sure to measure scrollbar width before -// overflow is set to hidden (and thus removing the scrollbar). -import { useEffect, useState } from 'react' - -import { useIsomorphicLayoutEffect } from 'usehooks-ts' - -import canUseDOM from '../utils/canUseDOM' - -type UseLockedBodyOutput = [boolean, (locked: boolean) => void] - -function useLockedBody( - initialLocked = false, - rootId: string = undefined -): UseLockedBodyOutput { - const [locked, setLocked] = useState(initialLocked) - - // Do the side effect before render - useIsomorphicLayoutEffect(() => { - if (!locked) { - return - } - - // Save initial body style - const originalOverflow = document.body.style.overflow - const originalPaddingRight = document.body.style.paddingRight - - // Get the scrollBar width - let scrollBarWidth = 0 - - if (canUseDOM) { - if (rootId) { - const root = document.getElementById(rootId) - - scrollBarWidth = root ? root.offsetWidth - root.scrollWidth : 0 - } else { - scrollBarWidth = - window.innerWidth - document.documentElement.clientWidth - } - } - - // Lock body scroll - document.body.style.overflow = 'hidden' - - // Avoid width reflow - if (scrollBarWidth) { - document.body.style.paddingRight = `${scrollBarWidth}px` - } - - return () => { - document.body.style.overflow = originalOverflow - - if (scrollBarWidth) { - document.body.style.paddingRight = originalPaddingRight - } - } - }, [locked]) - - // Update state if initialValue changes - useEffect(() => { - if (locked !== initialLocked) { - setLocked(initialLocked) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialLocked]) - - return [locked, setLocked] -} - -export default useLockedBody diff --git a/src/index.ts b/src/index.ts index 1e79383f..ad79887b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -139,7 +139,6 @@ export { default as usePrevious } from './hooks/usePrevious' export { default as useUnmount } from './hooks/useUnmount' export { useFloatingDropdown } from './hooks/useFloatingDropdown' export { default as useResizeObserver } from './hooks/useResizeObserver' -export { default as useLockedBody } from './hooks/useLockedBody' // Contexts export { diff --git a/src/stories/Flyover.stories.tsx b/src/stories/Flyover.stories.tsx index fe0bea30..d98b9871 100644 --- a/src/stories/Flyover.stories.tsx +++ b/src/stories/Flyover.stories.tsx @@ -1,5 +1,5 @@ import { Div, Flex, H3, P } from 'honorable' -import { useState } from 'react' +import { type FormEvent, useState } from 'react' import styled from 'styled-components' @@ -49,7 +49,7 @@ function Template(args: any) { onClose={() => setOpen(false)} asForm={!!args.asForm} formProps={{ - onSubmit: (e) => { + onSubmit: (e: FormEvent) => { e.preventDefault() alert('Form submitted') }, diff --git a/src/stories/Wizard.stories.tsx b/src/stories/Wizard.stories.tsx index 1f9a3857..d1fa5e26 100644 --- a/src/stories/Wizard.stories.tsx +++ b/src/stories/Wizard.stories.tsx @@ -1,24 +1,21 @@ import { Button, Flex, P } from 'honorable' import { type ReactElement, useEffect, useMemo, useState } from 'react' -import { useTheme } from 'styled-components' - +import FormField from '../components/FormField' +import Input from '../components/Input' import { type LayerPositionType } from '../components/Layer' -import { Wizard } from '../components/wizard/Wizard' -import { Picker, type StepConfig } from '../components/wizard/Picker' -import { Stepper } from '../components/wizard/Stepper' +import Modal from '../components/Modal' +import { Toast } from '../components/Toast' import AppsIcon from '../components/icons/AppsIcon' +import GlobeIcon from '../components/icons/GlobeIcon' import InstallIcon from '../components/icons/InstallIcon' -import { Navigation } from '../components/wizard/Navigation' +import { useActive } from '../components/wizard' import { Installer } from '../components/wizard/Installer' +import { Navigation } from '../components/wizard/Navigation' +import { Picker, type StepConfig } from '../components/wizard/Picker' import { Step } from '../components/wizard/Step' -import Input from '../components/Input' -import { useActive } from '../components/wizard' -import { Toast } from '../components/Toast' -import FormField from '../components/FormField' -import Modal from '../components/Modal' -import GlobeIcon from '../components/icons/GlobeIcon' -import { HonorableModal } from '../components/HonorableModal' +import { Stepper } from '../components/wizard/Stepper' +import { Wizard } from '../components/wizard/Wizard' export default { title: 'Wizard', @@ -133,7 +130,6 @@ const DEFAULT_STEPS: Array = [ ] function ModalTemplate() { - const theme = useTheme() const [open, setOpen] = useState(true) const [confirmClose, setConfirmClose] = useState(false) const [visible, setVisible] = useState(false) @@ -143,17 +139,10 @@ function ModalTemplate() { - (inProgress ? setConfirmClose(true) : setOpen(false))} + size="large" > (inProgress ? setConfirmClose(true) : setOpen(false))} @@ -173,7 +162,7 @@ function ModalTemplate() { ), }} - +