feat(component): mobile menu support (#7892)

This commit is contained in:
Cats Juice
2024-08-21 17:05:05 +08:00
committed by GitHub
parent 182b2fd62d
commit 23b0db36b9
57 changed files with 988 additions and 1482 deletions
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { Button } from '../button';
import type { InputProps } from '../input';
import { Input } from '../input';
import { RadioGroup } from '../radio';
import type { ConfirmModalProps } from './confirm-modal';
import { ConfirmModal } from './confirm-modal';
import type { ModalProps } from './modal';
@@ -105,3 +106,36 @@ export const Confirm: StoryFn<ModalProps> =
export const Overlay: StoryFn<ModalProps> =
OverlayModalTemplate.bind(undefined);
export const Animations = () => {
const animations = ['fadeScaleTop', 'slideBottom', 'none'];
const [open, setOpen] = useState(false);
const [animation, setAnimation] =
useState<ModalProps['animation']>('fadeScaleTop');
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<RadioGroup
value={animation}
onChange={setAnimation}
items={animations}
/>
<Button onClick={() => setOpen(true)}>Open dialog</Button>
<Modal
contentWrapperStyle={
animation === 'slideBottom'
? {
alignItems: 'end',
padding: 10,
}
: {}
}
open={open}
onOpenChange={setOpen}
animation={animation}
>
This is a dialog with animation: {animation}
</Modal>
</div>
);
};
@@ -9,9 +9,10 @@ import * as Dialog from '@radix-ui/react-dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import type { CSSProperties } from 'react';
import type { CSSProperties, MouseEvent } from 'react';
import { forwardRef, useCallback, useEffect, useState } from 'react';
import { isMobile } from '../../utils/env';
import type { IconButtonProps } from '../button';
import { IconButton } from '../button';
import * as styles from './styles.css';
@@ -32,6 +33,12 @@ export interface ModalProps extends DialogProps {
contentOptions?: DialogContentProps;
overlayOptions?: DialogOverlayProps;
closeButtonOptions?: IconButtonProps;
contentWrapperClassName?: string;
contentWrapperStyle?: CSSProperties;
/**
* @default 'fadeScaleTop'
*/
animation?: 'fadeScaleTop' | 'none' | 'slideBottom';
}
type PointerDownOutsideEvent = Parameters<
Exclude<DialogContentProps['onPointerDownOutside'], undefined>
@@ -128,10 +135,14 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
overlayOptions: {
className: overlayClassName,
style: overlayStyle,
onClick: onOverlayClick,
...otherOverlayOptions
} = {},
closeButtonOptions,
children,
contentWrapperClassName,
contentWrapperStyle,
animation = 'fadeScaleTop',
...otherProps
} = props;
const { className: closeButtonClassName, ...otherCloseButtonProps } =
@@ -167,6 +178,18 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
[onEscapeKeyDown, persistent]
);
const handleOverlayClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
onOverlayClick?.(e);
if (persistent) {
e.preventDefault();
} else {
onOpenChange?.(false);
}
},
[onOpenChange, onOverlayClick, persistent]
);
if (!container) {
return;
}
@@ -180,13 +203,27 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
>
<Dialog.Portal container={container} {...portalOptions}>
<Dialog.Overlay
className={clsx(styles.modalOverlay, overlayClassName)}
className={clsx(
`anim-${animation}`,
styles.modalOverlay,
overlayClassName,
{ mobile: isMobile() }
)}
style={{
...overlayStyle,
}}
onClick={handleOverlayClick}
{...otherOverlayOptions}
/>
<div data-modal={modal} className={clsx(styles.modalContentWrapper)}>
<div
data-modal={modal}
className={clsx(
`anim-${animation}`,
styles.modalContentWrapper,
contentWrapperClassName
)}
style={contentWrapperStyle}
>
<Dialog.Content
onPointerDownOutside={handlePointerDownOutSide}
onEscapeKeyDown={handleEscapeKeyDown}
@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
createVar,
generateIdentifier,
@@ -18,7 +19,7 @@ const overlayShow = keyframes({
opacity: 1,
},
});
const contentShow = keyframes({
const contentShowFadeScaleTop = keyframes({
from: {
opacity: 0,
transform: 'translateY(-2%) scale(0.96)',
@@ -28,7 +29,7 @@ const contentShow = keyframes({
transform: 'translateY(0) scale(1)',
},
});
export const contentHide = keyframes({
const contentHideFadeScaleTop = keyframes({
to: {
opacity: 0,
transform: 'translateY(-2%) scale(0.96)',
@@ -38,15 +39,35 @@ export const contentHide = keyframes({
transform: 'translateY(0) scale(1)',
},
});
const contentShowSlideBottom = keyframes({
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
});
const contentHideSlideBottom = keyframes({
from: { transform: 'translateY(0)' },
to: { transform: 'translateY(100%)' },
});
const modalContentViewTransitionNameFadeScaleTop = generateIdentifier(
'modal-content-fade-scale-top'
);
const modalContentViewTransitionNameSlideBottom = generateIdentifier(
'modal-content-slide-bottom'
);
export const modalOverlay = style({
position: 'fixed',
inset: 0,
backgroundColor: cssVar('backgroundModalColor'),
zIndex: cssVar('zIndexModal'),
animation: `${overlayShow} 150ms forwards`,
selectors: {
'&.anim-none': {
animation: 'none',
},
'&.mobile': {
backgroundColor: cssVarV2('layer/mobile/modal'),
},
},
});
const modalContentViewTransitionName = generateIdentifier('modal-content');
export const modalContentWrapper = style({
position: 'fixed',
inset: 0,
@@ -54,14 +75,37 @@ export const modalContentWrapper = style({
alignItems: 'center',
justifyContent: 'center',
zIndex: cssVar('zIndexModal'),
animation: `${contentShow} 150ms cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
viewTransitionName: modalContentViewTransitionName,
});
globalStyle(`::view-transition-old(${modalContentViewTransitionName})`, {
animation: `${contentHide} 150ms cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
selectors: {
'&.anim-none': {
animation: 'none',
},
'&.anim-fadeScaleTop': {
animation: `${contentShowFadeScaleTop} 150ms cubic-bezier(0.42, 0, 0.58, 1)`,
viewTransitionName: modalContentViewTransitionNameFadeScaleTop,
animationFillMode: 'forwards',
},
'&.anim-slideBottom': {
animation: `${contentShowSlideBottom} 0.23s ease`,
viewTransitionName: modalContentViewTransitionNameSlideBottom,
animationFillMode: 'forwards',
},
},
});
globalStyle(
`::view-transition-old(${modalContentViewTransitionNameFadeScaleTop})`,
{
animation: `${contentHideFadeScaleTop} 150ms cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
}
);
globalStyle(
`::view-transition-old(${modalContentViewTransitionNameSlideBottom})`,
{
animation: `${contentHideSlideBottom} 0.23s ease`,
animationFillMode: 'forwards',
}
);
export const modalContent = style({
vars: {
@@ -72,6 +116,8 @@ export const modalContent = style({
width: widthVar,
height: heightVar,
minHeight: minHeightVar,
maxHeight: 'calc(100vh - 32px)',
maxWidth: 'calc(100vw - 20px)',
boxSizing: 'border-box',
fontSize: cssVar('fontBase'),
fontWeight: '400',
@@ -81,7 +127,6 @@ export const modalContent = style({
backgroundColor: cssVar('backgroundOverlayPanelColor'),
boxShadow: cssVar('popoverShadow'),
borderRadius: '12px',
maxHeight: 'calc(100vh - 32px)',
// :focus-visible will set outline
outline: 'none',
});