mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
refactor(core): adjust modal animation (#7606)
<div class='graphite__hidden'>
<div>🎥 Video uploaded on Graphite:</div>
<a href="https://app.graphite.dev/media/video/g3jz87HxbjOJpXV3FPT7/529d6c3f-4b23-43ac-84cc-171713d3dc72.mp4">
<img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/g3jz87HxbjOJpXV3FPT7/529d6c3f-4b23-43ac-84cc-171713d3dc72.mp4">
</a>
</div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/529d6c3f-4b23-43ac-84cc-171713d3dc72.mp4">CleanShot 2024-07-25 at 20.04.01.mp4</video>
When a modal is closed, sometimes its components are completely unmounted from the component tree, making it difficult to animate. This pr defining a custom element as the container of ReactDOM.portal, rewriting the `removeChild` function, and use `startViewTransition` when ReactDOM calls it to implement the animation.
# Save Input
Some inputs use blur event to save data, but when they are unmounted, blur event will not be triggered at all. This pr changes blur event to native addEventListener, which will be called after the DOM element is unmounted, so as to save data in time.
This commit is contained in:
3
.github/renovate.json
vendored
3
.github/renovate.json
vendored
@@ -27,7 +27,8 @@
|
|||||||
"matchPackagePatterns": ["^@blocksuite"],
|
"matchPackagePatterns": ["^@blocksuite"],
|
||||||
"excludePackageNames": ["@blocksuite/icons"],
|
"excludePackageNames": ["@blocksuite/icons"],
|
||||||
"rangeStrategy": "replace",
|
"rangeStrategy": "replace",
|
||||||
"followTag": "canary"
|
"followTag": "canary",
|
||||||
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "all non-major dependencies",
|
"groupName": "all non-major dependencies",
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
"@vanilla-extract/css": "^1.14.2",
|
"@vanilla-extract/css": "^1.14.2",
|
||||||
"fake-indexeddb": "^6.0.0",
|
"fake-indexeddb": "^6.0.0",
|
||||||
"storybook": "^7.6.17",
|
"storybook": "^7.6.17",
|
||||||
"storybook-dark-mode": "4.0.2",
|
"storybook-dark-mode": "4.0.1",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vitest": "1.6.0"
|
"vitest": "1.6.0"
|
||||||
|
|||||||
@@ -214,12 +214,12 @@ export const InlineEdit = ({
|
|||||||
className={styles.inlineEditInput}
|
className={styles.inlineEditInput}
|
||||||
value={editingValue}
|
value={editingValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onBlur={onBlur}
|
|
||||||
onEnter={onEnter}
|
onEnter={onEnter}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onChange={inputHandler}
|
onChange={inputHandler}
|
||||||
style={inputWrapperInheritsStyles}
|
style={inputWrapperInheritsStyles}
|
||||||
inputStyle={inputInheritsStyles}
|
inputStyle={inputInheritsStyles}
|
||||||
|
onBlur={onBlur}
|
||||||
{...inputAttrs}
|
{...inputAttrs}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,26 @@ import clsx from 'clsx';
|
|||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
FocusEventHandler,
|
|
||||||
ForwardedRef,
|
ForwardedRef,
|
||||||
InputHTMLAttributes,
|
InputHTMLAttributes,
|
||||||
KeyboardEvent,
|
KeyboardEvent,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { input, inputWrapper } from './style.css';
|
import { input, inputWrapper } from './style.css';
|
||||||
|
|
||||||
export type InputProps = {
|
export type InputProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: (ev: FocusEvent & { currentTarget: HTMLInputElement }) => void;
|
||||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||||
autoSelect?: boolean;
|
autoSelect?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
@@ -27,7 +32,7 @@ export type InputProps = {
|
|||||||
type?: HTMLInputElement['type'];
|
type?: HTMLInputElement['type'];
|
||||||
inputStyle?: CSSProperties;
|
inputStyle?: CSSProperties;
|
||||||
onEnter?: () => void;
|
onEnter?: () => void;
|
||||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size'>;
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||||
{
|
{
|
||||||
@@ -43,6 +48,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
endFix,
|
endFix,
|
||||||
onEnter,
|
onEnter,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
|
onBlur,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
autoSelect,
|
autoSelect,
|
||||||
...otherProps
|
...otherProps
|
||||||
@@ -59,6 +65,17 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
}
|
}
|
||||||
}, [autoFocus, autoSelect, upstreamRef]);
|
}, [autoFocus, autoSelect, upstreamRef]);
|
||||||
|
|
||||||
|
// use native blur event to get event after unmount
|
||||||
|
// don't use useLayoutEffect here, because the cleanup function will be called before unmount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onBlur) return;
|
||||||
|
inputRef.current?.addEventListener('blur', onBlur as any);
|
||||||
|
return () => {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
inputRef.current?.removeEventListener('blur', onBlur as any);
|
||||||
|
};
|
||||||
|
}, [onBlur]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(inputWrapper, className, {
|
className={clsx(inputWrapper, className, {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
@@ -16,6 +15,7 @@ export interface MenuProps {
|
|||||||
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
|
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
|
||||||
rootOptions?: Omit<DropdownMenuProps, 'children'>;
|
rootOptions?: Omit<DropdownMenuProps, 'children'>;
|
||||||
contentOptions?: Omit<DropdownMenuContentProps, 'children'>;
|
contentOptions?: Omit<DropdownMenuContentProps, 'children'>;
|
||||||
|
noPortal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Menu = ({
|
export const Menu = ({
|
||||||
@@ -23,6 +23,7 @@ export const Menu = ({
|
|||||||
items,
|
items,
|
||||||
portalOptions,
|
portalOptions,
|
||||||
rootOptions,
|
rootOptions,
|
||||||
|
noPortal,
|
||||||
contentOptions: {
|
contentOptions: {
|
||||||
className = '',
|
className = '',
|
||||||
style: contentStyle = {},
|
style: contentStyle = {},
|
||||||
@@ -33,12 +34,9 @@ export const Menu = ({
|
|||||||
<DropdownMenu.Root {...rootOptions}>
|
<DropdownMenu.Root {...rootOptions}>
|
||||||
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
|
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
|
||||||
|
|
||||||
<DropdownMenu.Portal {...portalOptions}>
|
{noPortal ? (
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
className={useMemo(
|
className={clsx(styles.menuContent, className)}
|
||||||
() => clsx(styles.menuContent, className),
|
|
||||||
[className]
|
|
||||||
)}
|
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
align="start"
|
align="start"
|
||||||
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
||||||
@@ -46,7 +44,19 @@ export const Menu = ({
|
|||||||
>
|
>
|
||||||
{items}
|
{items}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
) : (
|
||||||
|
<DropdownMenu.Portal {...portalOptions}>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className={clsx(styles.menuContent, className)}
|
||||||
|
sideOffset={5}
|
||||||
|
align="start"
|
||||||
|
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
||||||
|
{...otherContentOptions}
|
||||||
|
>
|
||||||
|
{items}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
)}
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
|
|||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { forwardRef, useCallback, useEffect } from 'react';
|
import { forwardRef, useCallback } from 'react';
|
||||||
import { type TransitionState, useTransition } from 'react-transition-state';
|
|
||||||
|
|
||||||
import type { IconButtonProps } from '../button';
|
import type { IconButtonProps } from '../button';
|
||||||
import { IconButton } from '../button';
|
import { IconButton } from '../button';
|
||||||
@@ -29,8 +28,6 @@ export interface ModalProps extends DialogProps {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
// animation for modal open/close
|
|
||||||
animationTimeout?: number;
|
|
||||||
portalOptions?: DialogPortalProps;
|
portalOptions?: DialogPortalProps;
|
||||||
contentOptions?: DialogContentProps;
|
contentOptions?: DialogContentProps;
|
||||||
overlayOptions?: DialogOverlayProps;
|
overlayOptions?: DialogOverlayProps;
|
||||||
@@ -48,141 +45,174 @@ const getVar = (style: number | string = '', defaultValue = '') => {
|
|||||||
: defaultValue;
|
: defaultValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Modal = forwardRef<HTMLDivElement, ModalProps>(
|
/**
|
||||||
(
|
* This component is a hack to support `startViewTransition` in the modal.
|
||||||
{
|
*/
|
||||||
width,
|
class ModalTransitionContainer extends HTMLElement {
|
||||||
height,
|
pendingTransitionNodes: Node[] = [];
|
||||||
minHeight = 194,
|
animationFrame: number | null = null;
|
||||||
title,
|
|
||||||
description,
|
|
||||||
withoutCloseButton = false,
|
|
||||||
modal,
|
|
||||||
persistent,
|
|
||||||
animationTimeout = 120,
|
|
||||||
portalOptions,
|
|
||||||
open: customOpen,
|
|
||||||
onOpenChange: customOnOpenChange,
|
|
||||||
contentOptions: {
|
|
||||||
style: contentStyle,
|
|
||||||
className: contentClassName,
|
|
||||||
onPointerDownOutside,
|
|
||||||
onEscapeKeyDown,
|
|
||||||
...otherContentOptions
|
|
||||||
} = {},
|
|
||||||
overlayOptions: {
|
|
||||||
className: overlayClassName,
|
|
||||||
style: overlayStyle,
|
|
||||||
...otherOverlayOptions
|
|
||||||
} = {},
|
|
||||||
closeButtonOptions = {},
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const [{ status }, toggle] = useTransition({
|
|
||||||
timeout: animationTimeout,
|
|
||||||
onStateChange: useCallback(
|
|
||||||
({ current }: { current: TransitionState }) => {
|
|
||||||
// add more status if needed
|
|
||||||
if (current.status === 'exited') customOnOpenChange?.(false);
|
|
||||||
if (current.status === 'entered') customOnOpenChange?.(true);
|
|
||||||
},
|
|
||||||
[customOnOpenChange]
|
|
||||||
),
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
toggle(customOpen);
|
|
||||||
}, [customOpen]);
|
|
||||||
|
|
||||||
return (
|
/**
|
||||||
<Dialog.Root
|
* This method will be called when the modal is removed from the DOM
|
||||||
modal={modal}
|
* https://github.com/facebook/react/blob/e4b4aac2a01b53f8151ca85148873096368a7de2/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L833
|
||||||
open={status !== 'exited'}
|
*/
|
||||||
onOpenChange={toggle}
|
override removeChild<T extends Node>(child: T): T {
|
||||||
{...props}
|
if (typeof document.startViewTransition === 'function') {
|
||||||
>
|
this.pendingTransitionNodes.push(child);
|
||||||
<Dialog.Portal {...portalOptions}>
|
this.requestTransition();
|
||||||
<Dialog.Overlay
|
return child;
|
||||||
className={clsx(styles.modalOverlay, overlayClassName)}
|
} else {
|
||||||
data-state={status}
|
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
||||||
|
return super.removeChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We collect all the nodes that are removed in the single frame and then trigger the transition.
|
||||||
|
*/
|
||||||
|
private requestTransition() {
|
||||||
|
if (this.animationFrame) {
|
||||||
|
cancelAnimationFrame(this.animationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationFrame = requestAnimationFrame(() => {
|
||||||
|
if (typeof document.startViewTransition === 'function') {
|
||||||
|
const nodes = this.pendingTransitionNodes;
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
nodes.forEach(child => {
|
||||||
|
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
||||||
|
super.removeChild(child);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.pendingTransitionNodes = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let container: ModalTransitionContainer | null = null;
|
||||||
|
function prepareContainer() {
|
||||||
|
if (!container) {
|
||||||
|
customElements.define(
|
||||||
|
'modal-transition-container',
|
||||||
|
ModalTransitionContainer
|
||||||
|
);
|
||||||
|
container = new ModalTransitionContainer();
|
||||||
|
document.body.append(container);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal = forwardRef<HTMLDivElement, ModalProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
modal,
|
||||||
|
portalOptions,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
minHeight = 194,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
withoutCloseButton = false,
|
||||||
|
persistent,
|
||||||
|
contentOptions: {
|
||||||
|
style: contentStyle,
|
||||||
|
className: contentClassName,
|
||||||
|
onPointerDownOutside,
|
||||||
|
onEscapeKeyDown,
|
||||||
|
...otherContentOptions
|
||||||
|
} = {},
|
||||||
|
overlayOptions: {
|
||||||
|
className: overlayClassName,
|
||||||
|
style: overlayStyle,
|
||||||
|
...otherOverlayOptions
|
||||||
|
} = {},
|
||||||
|
closeButtonOptions = {},
|
||||||
|
children,
|
||||||
|
...otherProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root
|
||||||
|
modal={modal}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<Dialog.Portal container={prepareContainer()} {...portalOptions}>
|
||||||
|
<Dialog.Overlay
|
||||||
|
className={clsx(styles.modalOverlay, overlayClassName)}
|
||||||
|
style={{
|
||||||
|
...overlayStyle,
|
||||||
|
}}
|
||||||
|
{...otherOverlayOptions}
|
||||||
|
/>
|
||||||
|
<div data-modal={modal} className={clsx(styles.modalContentWrapper)}>
|
||||||
|
<Dialog.Content
|
||||||
|
onPointerDownOutside={useCallback(
|
||||||
|
(e: PointerDownOutsideEvent) => {
|
||||||
|
onPointerDownOutside?.(e);
|
||||||
|
persistent && e.preventDefault();
|
||||||
|
},
|
||||||
|
[onPointerDownOutside, persistent]
|
||||||
|
)}
|
||||||
|
onEscapeKeyDown={useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
onEscapeKeyDown?.(e);
|
||||||
|
persistent && e.preventDefault();
|
||||||
|
},
|
||||||
|
[onEscapeKeyDown, persistent]
|
||||||
|
)}
|
||||||
|
className={clsx(styles.modalContent, contentClassName)}
|
||||||
style={{
|
style={{
|
||||||
...assignInlineVars({
|
...assignInlineVars({
|
||||||
[styles.animationTimeout]: `${animationTimeout}ms`,
|
[styles.widthVar]: getVar(width, '50vw'),
|
||||||
|
[styles.heightVar]: getVar(height, 'unset'),
|
||||||
|
[styles.minHeightVar]: getVar(minHeight, '26px'),
|
||||||
}),
|
}),
|
||||||
...overlayStyle,
|
...contentStyle,
|
||||||
}}
|
}}
|
||||||
{...otherOverlayOptions}
|
{...(description ? {} : { 'aria-describedby': undefined })}
|
||||||
/>
|
{...otherContentOptions}
|
||||||
<div data-modal={modal} className={clsx(styles.modalContentWrapper)}>
|
ref={ref}
|
||||||
<Dialog.Content
|
>
|
||||||
onPointerDownOutside={useCallback(
|
{withoutCloseButton ? null : (
|
||||||
(e: PointerDownOutsideEvent) => {
|
<Dialog.Close asChild>
|
||||||
onPointerDownOutside?.(e);
|
<IconButton
|
||||||
persistent && e.preventDefault();
|
className={styles.closeButton}
|
||||||
},
|
aria-label="Close"
|
||||||
[onPointerDownOutside, persistent]
|
type="plain"
|
||||||
)}
|
data-testid="modal-close-button"
|
||||||
onEscapeKeyDown={useCallback(
|
{...closeButtonOptions}
|
||||||
(e: KeyboardEvent) => {
|
>
|
||||||
onEscapeKeyDown?.(e);
|
<CloseIcon />
|
||||||
persistent && e.preventDefault();
|
</IconButton>
|
||||||
},
|
</Dialog.Close>
|
||||||
[onEscapeKeyDown, persistent]
|
)}
|
||||||
)}
|
{title ? (
|
||||||
className={clsx(styles.modalContent, contentClassName)}
|
<Dialog.Title className={styles.modalHeader}>
|
||||||
data-state={status}
|
{title}
|
||||||
style={{
|
</Dialog.Title>
|
||||||
...assignInlineVars({
|
) : (
|
||||||
[styles.widthVar]: getVar(width, '50vw'),
|
// Refer: https://www.radix-ui.com/primitives/docs/components/dialog#title
|
||||||
[styles.heightVar]: getVar(height, 'unset'),
|
// If you want to hide the title, wrap it inside our Visually Hidden utility like this <VisuallyHidden asChild>.
|
||||||
[styles.minHeightVar]: getVar(minHeight, '26px'),
|
<VisuallyHidden.Root asChild>
|
||||||
[styles.animationTimeout]: `${animationTimeout}ms`,
|
<Dialog.Title></Dialog.Title>
|
||||||
}),
|
</VisuallyHidden.Root>
|
||||||
...contentStyle,
|
)}
|
||||||
}}
|
{description ? (
|
||||||
{...(description ? {} : { 'aria-describedby': undefined })}
|
<Dialog.Description className={styles.modalDescription}>
|
||||||
{...otherContentOptions}
|
{description}
|
||||||
ref={ref}
|
</Dialog.Description>
|
||||||
>
|
) : null}
|
||||||
{withoutCloseButton ? null : (
|
|
||||||
<Dialog.Close asChild>
|
|
||||||
<IconButton
|
|
||||||
className={styles.closeButton}
|
|
||||||
aria-label="Close"
|
|
||||||
type="plain"
|
|
||||||
data-testid="modal-close-button"
|
|
||||||
{...closeButtonOptions}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Dialog.Close>
|
|
||||||
)}
|
|
||||||
{title ? (
|
|
||||||
<Dialog.Title className={styles.modalHeader}>
|
|
||||||
{title}
|
|
||||||
</Dialog.Title>
|
|
||||||
) : (
|
|
||||||
// Refer: https://www.radix-ui.com/primitives/docs/components/dialog#title
|
|
||||||
// If you want to hide the title, wrap it inside our Visually Hidden utility like this <VisuallyHidden asChild>.
|
|
||||||
<VisuallyHidden.Root asChild>
|
|
||||||
<Dialog.Title></Dialog.Title>
|
|
||||||
</VisuallyHidden.Root>
|
|
||||||
)}
|
|
||||||
{description ? (
|
|
||||||
<Dialog.Description className={styles.modalDescription}>
|
|
||||||
{description}
|
|
||||||
</Dialog.Description>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
Modal.displayName = 'Modal';
|
Modal.displayName = 'Modal';
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { createVar, globalStyle, keyframes, style } from '@vanilla-extract/css';
|
import {
|
||||||
|
createVar,
|
||||||
|
generateIdentifier,
|
||||||
|
globalStyle,
|
||||||
|
keyframes,
|
||||||
|
style,
|
||||||
|
} from '@vanilla-extract/css';
|
||||||
export const widthVar = createVar('widthVar');
|
export const widthVar = createVar('widthVar');
|
||||||
export const heightVar = createVar('heightVar');
|
export const heightVar = createVar('heightVar');
|
||||||
export const minHeightVar = createVar('minHeightVar');
|
export const minHeightVar = createVar('minHeightVar');
|
||||||
export const animationTimeout = createVar();
|
|
||||||
|
|
||||||
const overlayShow = keyframes({
|
const overlayShow = keyframes({
|
||||||
from: {
|
from: {
|
||||||
@@ -13,15 +18,6 @@ const overlayShow = keyframes({
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const overlayHide = keyframes({
|
|
||||||
to: {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentShow = keyframes({
|
const contentShow = keyframes({
|
||||||
from: {
|
from: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
@@ -32,7 +28,7 @@ const contentShow = keyframes({
|
|||||||
transform: 'translateY(0) scale(1)',
|
transform: 'translateY(0) scale(1)',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const contentHide = keyframes({
|
export const contentHide = keyframes({
|
||||||
to: {
|
to: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transform: 'translateY(-2%) scale(0.96)',
|
transform: 'translateY(-2%) scale(0.96)',
|
||||||
@@ -48,15 +44,9 @@ export const modalOverlay = style({
|
|||||||
inset: 0,
|
inset: 0,
|
||||||
backgroundColor: cssVar('backgroundModalColor'),
|
backgroundColor: cssVar('backgroundModalColor'),
|
||||||
zIndex: cssVar('zIndexModal'),
|
zIndex: cssVar('zIndexModal'),
|
||||||
selectors: {
|
animation: `${overlayShow} 150ms forwards`,
|
||||||
'&[data-state=entered], &[data-state=entering]': {
|
|
||||||
animation: `${overlayShow} ${animationTimeout} forwards`,
|
|
||||||
},
|
|
||||||
'&[data-state=exited], &[data-state=exiting]': {
|
|
||||||
animation: `${overlayHide} ${animationTimeout} forwards`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const modalContentViewTransitionName = generateIdentifier('modal-content');
|
||||||
export const modalContentWrapper = style({
|
export const modalContentWrapper = style({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -64,6 +54,13 @@ export const modalContentWrapper = style({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
zIndex: cssVar('zIndexModal'),
|
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',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const modalContent = style({
|
export const modalContent = style({
|
||||||
@@ -87,16 +84,6 @@ export const modalContent = style({
|
|||||||
maxHeight: 'calc(100vh - 32px)',
|
maxHeight: 'calc(100vh - 32px)',
|
||||||
// :focus-visible will set outline
|
// :focus-visible will set outline
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
selectors: {
|
|
||||||
'&[data-state=entered], &[data-state=entering]': {
|
|
||||||
animation: `${contentShow} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
|
|
||||||
animationFillMode: 'forwards',
|
|
||||||
},
|
|
||||||
'&[data-state=exited], &[data-state=exiting]': {
|
|
||||||
animation: `${contentHide} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
|
|
||||||
animationFillMode: 'forwards',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
export const closeButton = style({
|
export const closeButton = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ export const EditPropertyNameMenuItem = ({
|
|||||||
[onBlur]
|
[onBlur]
|
||||||
);
|
);
|
||||||
const handleBlur = useCallback(
|
const handleBlur = useCallback(
|
||||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
(e: FocusEvent & { currentTarget: HTMLInputElement }) => {
|
||||||
onBlur(e.target.value);
|
onBlur(e.currentTarget.value);
|
||||||
},
|
},
|
||||||
[onBlur]
|
[onBlur]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { i18nTime, useI18n } from '@affine/i18n';
|
|||||||
import { DocService, useService } from '@toeverything/infra';
|
import { DocService, useService } from '@toeverything/infra';
|
||||||
import { noop } from 'lodash-es';
|
import { noop } from 'lodash-es';
|
||||||
import type { ChangeEventHandler } from 'react';
|
import type { ChangeEventHandler } from 'react';
|
||||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { managerContext } from './common';
|
import { managerContext } from './common';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
@@ -87,14 +87,24 @@ export const TextValue = ({ property }: PropertyRowValueProps) => {
|
|||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
const handleBlur = useCallback(
|
const handleBlur = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(e: FocusEvent) => {
|
||||||
manager.updateCustomProperty(property.id, {
|
manager.updateCustomProperty(property.id, {
|
||||||
value: e.target.value.trim(),
|
value: (e.currentTarget as HTMLTextAreaElement).value.trim(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[manager, property.id]
|
[manager, property.id]
|
||||||
);
|
);
|
||||||
|
// use native blur event to get event after unmount
|
||||||
|
// don't use useLayoutEffect here, cause the cleanup function will be called before unmount
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.addEventListener('blur', handleBlur);
|
||||||
|
return () => {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
ref.current?.removeEventListener('blur', handleBlur);
|
||||||
|
};
|
||||||
|
}, [handleBlur]);
|
||||||
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
const handleOnChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||||
e => {
|
e => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
@@ -109,11 +119,11 @@ export const TextValue = ({ property }: PropertyRowValueProps) => {
|
|||||||
return (
|
return (
|
||||||
<div onClick={handleClick} className={styles.propertyRowValueTextCell}>
|
<div onClick={handleClick} className={styles.propertyRowValueTextCell}>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={ref}
|
||||||
className={styles.propertyRowValueTextarea}
|
className={styles.propertyRowValueTextarea}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={handleOnChange}
|
onChange={handleOnChange}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onBlur={handleBlur}
|
|
||||||
data-empty={!value}
|
data-empty={!value}
|
||||||
placeholder={t[
|
placeholder={t[
|
||||||
'com.affine.page-properties.property-value-placeholder'
|
'com.affine.page-properties.property-value-placeholder'
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export const EditCollectionModal = ({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [onOpenChange]);
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
if (!(open && init)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -59,16 +63,14 @@ export const EditCollectionModal = ({
|
|||||||
contentOptions={contentOptions}
|
contentOptions={contentOptions}
|
||||||
persistent
|
persistent
|
||||||
>
|
>
|
||||||
{open && init ? (
|
<EditCollection
|
||||||
<EditCollection
|
title={title}
|
||||||
title={title}
|
onConfirmText={t['com.affine.editCollection.save']()}
|
||||||
onConfirmText={t['com.affine.editCollection.save']()}
|
init={init}
|
||||||
init={init}
|
mode={mode}
|
||||||
mode={mode}
|
onCancel={onCancel}
|
||||||
onCancel={onCancel}
|
onConfirm={onConfirmOnCollection}
|
||||||
onConfirm={onConfirmOnCollection}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export const Component = () => {
|
|||||||
open: true,
|
open: true,
|
||||||
}}
|
}}
|
||||||
items={<UserWithWorkspaceList />}
|
items={<UserWithWorkspaceList />}
|
||||||
|
noPortal
|
||||||
contentOptions={{
|
contentOptions={{
|
||||||
style: {
|
style: {
|
||||||
width: 300,
|
width: 300,
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@@ -357,7 +357,7 @@ __metadata:
|
|||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
sonner: "npm:^1.4.41"
|
sonner: "npm:^1.4.41"
|
||||||
storybook: "npm:^7.6.17"
|
storybook: "npm:^7.6.17"
|
||||||
storybook-dark-mode: "npm:4.0.2"
|
storybook-dark-mode: "npm:4.0.1"
|
||||||
swr: "npm:^2.2.5"
|
swr: "npm:^2.2.5"
|
||||||
typescript: "npm:^5.4.5"
|
typescript: "npm:^5.4.5"
|
||||||
uuid: "npm:^10.0.0"
|
uuid: "npm:^10.0.0"
|
||||||
@@ -35557,9 +35557,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"storybook-dark-mode@npm:4.0.2":
|
"storybook-dark-mode@npm:4.0.1":
|
||||||
version: 4.0.2
|
version: 4.0.1
|
||||||
resolution: "storybook-dark-mode@npm:4.0.2"
|
resolution: "storybook-dark-mode@npm:4.0.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@storybook/components": "npm:^8.0.0"
|
"@storybook/components": "npm:^8.0.0"
|
||||||
"@storybook/core-events": "npm:^8.0.0"
|
"@storybook/core-events": "npm:^8.0.0"
|
||||||
@@ -35569,7 +35569,7 @@ __metadata:
|
|||||||
"@storybook/theming": "npm:^8.0.0"
|
"@storybook/theming": "npm:^8.0.0"
|
||||||
fast-deep-equal: "npm:^3.1.3"
|
fast-deep-equal: "npm:^3.1.3"
|
||||||
memoizerific: "npm:^1.11.3"
|
memoizerific: "npm:^1.11.3"
|
||||||
checksum: 10/c9ef7bc6734df7486ff763c9da3c69505269eaf5fd7b5b489553f023b363ea892862241e6d701ad647ca5d1e64fd9a2646b8985c7ea8ac97a3bca87891db6fe5
|
checksum: 10/3225e5bdaba0ea76b65d642202d9712d7de234e3b5673fb46e444892ab114be207dd287778e2002b662ec35bb8153d2624ff280ce51c5299fb13c711431dad40
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user