diff --git a/frontend/src/features/account-panel/account-panel.css b/frontend/src/features/account-panel/account-panel.css index d4a5c4bd..7b75a1f3 100644 --- a/frontend/src/features/account-panel/account-panel.css +++ b/frontend/src/features/account-panel/account-panel.css @@ -173,41 +173,6 @@ animation: auth-copy-enter 360ms 130ms var(--auth-ease-enter) both; } -.auth-copy-cycle { - display: grid; - place-items: center; -} - -.auth-copy-line { - display: block; - overflow: hidden; - min-height: 1.55em; -} - -.auth-copy-line-inner { - display: block; - opacity: 0; - letter-spacing: 0.075em; - transform: translate3d(0, 120%, 0); - animation: auth-copy-line-enter 620ms var(--auth-ease-enter) forwards; - animation-delay: calc(210ms + var(--auth-line-index) * 90ms); -} - -.auth-copy-cycle-resting .auth-copy-line-inner { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - animation: none; -} - -.auth-copy-cycle-exiting .auth-copy-line-inner { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - animation: auth-copy-line-exit 400ms cubic-bezier(0.55, 0, 1, 0.45) forwards; - animation-delay: calc((1 - var(--auth-line-index)) * 55ms); -} - .auth-register-fields { position: relative; z-index: 5; @@ -725,35 +690,6 @@ } } -@keyframes auth-copy-line-enter { - 0% { - opacity: 0; - letter-spacing: 0.075em; - transform: translate3d(0, 120%, 0); - } - 100% { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - } -} - -@keyframes auth-copy-line-exit { - 0% { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - } - 46% { - opacity: 1; - } - 100% { - opacity: 0; - letter-spacing: 0.045em; - transform: translate3d(0, -118%, 0); - } -} - @keyframes auth-form-exit { 0% { opacity: 1; diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx index 212b7803..2f98fffb 100644 --- a/frontend/src/features/account-panel/index.tsx +++ b/frontend/src/features/account-panel/index.tsx @@ -27,6 +27,7 @@ import { useNavigate, useSearchParams } from 'react-router' import { useAuthSession } from '@/features/auth-session' import { sanitizeInternalPath } from '@/shared/navigation' +import { KineticCopy, type KineticCopyPhase } from '@/shared/ui' import messengerPigeon from '@/assets/auth/illustrations/messenger-pigeon.webp' import './account-panel.css' @@ -34,7 +35,6 @@ import './account-panel.css' type AccountEntry = 'login' | 'register' type LoginMode = 'code' | 'password' type MotionDirection = 'forward' | 'backward' -type CopyPhase = 'entering' | 'resting' | 'exiting' const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const CODE_PATTERN = /^\d{6}$/ @@ -129,36 +129,6 @@ function KineticTitle({ id, text, emphasis }: { id: string; text: string; emphas ) } -function KineticCopy({ - lines, - copyKey, - phase, -}: { - lines: readonly [string, string] - copyKey: string - phase: CopyPhase -}) { - return ( - - ) -} - type AuthFieldProps = Omit, 'onChange'> & { label: string icon: Icon @@ -247,7 +217,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { const [registerStep, setRegisterStep] = useState(0) const [motionDirection, setMotionDirection] = useState('forward') const [copyIndex, setCopyIndex] = useState(0) - const [copyPhase, setCopyPhase] = useState('entering') + const [copyPhase, setCopyPhase] = useState('entering') const [isExiting, setIsExiting] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 8aeaf703..35f90abf 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,3 +1,5 @@ +export { KineticCopy } from './kinetic-copy' +export type { KineticCopyPhase, KineticCopyProps } from './kinetic-copy' export { PageContainer } from './page-container' export type { PageContainerProps } from './page-container' export { Pagination } from './pagination' diff --git a/frontend/src/shared/ui/kinetic-copy.css b/frontend/src/shared/ui/kinetic-copy.css new file mode 100644 index 00000000..5ae2ea1e --- /dev/null +++ b/frontend/src/shared/ui/kinetic-copy.css @@ -0,0 +1,71 @@ +.auth-copy-cycle { + display: grid; + place-items: center; +} + +.auth-copy-line { + display: block; + overflow: hidden; + min-height: 1.55em; +} + +.auth-copy-line-inner { + display: block; + opacity: 0; + letter-spacing: 0.075em; + transform: translate3d(0, 120%, 0); + animation: auth-copy-line-enter 620ms var(--auth-ease-enter) forwards; + animation-delay: calc(210ms + var(--auth-line-index) * 90ms); +} + +.auth-copy-cycle-resting .auth-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: none; +} + +.auth-copy-cycle-exiting .auth-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: auth-copy-line-exit 400ms cubic-bezier(0.55, 0, 1, 0.45) forwards; + animation-delay: calc((1 - var(--auth-line-index)) * 55ms); +} + +@keyframes auth-copy-line-enter { + 0% { + opacity: 0; + letter-spacing: 0.075em; + transform: translate3d(0, 120%, 0); + } + 100% { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + } +} + +@keyframes auth-copy-line-exit { + 0% { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + } + 46% { + opacity: 1; + } + 100% { + opacity: 0; + letter-spacing: 0.045em; + transform: translate3d(0, -118%, 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-copy-line-inner { + animation: none; + filter: none; + transform: none; + } +} diff --git a/frontend/src/shared/ui/kinetic-copy.test.tsx b/frontend/src/shared/ui/kinetic-copy.test.tsx new file mode 100644 index 00000000..a1a1bc55 --- /dev/null +++ b/frontend/src/shared/ui/kinetic-copy.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { KineticCopy } from './kinetic-copy' + +afterEach(cleanup) + +describe('KineticCopy', () => { + it.each(['entering', 'resting', 'exiting'] as const)( + 'renders both copy lines in the %s phase', + (phase) => { + const { container } = render( + , + ) + + const cycle = container.querySelector('[data-copy-phase]') + const lineInners = container.querySelectorAll('.auth-copy-line-inner') + + expect(cycle?.dataset.copyPhase).toBe(phase) + expect(cycle?.className).toContain(`auth-copy-cycle-${phase}`) + expect(cycle?.getAttribute('aria-hidden')).toBe('true') + expect(screen.getByText('继续搭建,')).toBeTruthy() + expect(screen.getByText('属于你的角色世界。')).toBeTruthy() + expect(lineInners).toHaveLength(2) + expect(lineInners[0]?.style.getPropertyValue('--auth-line-index')).toBe('0') + expect(lineInners[1]?.style.getPropertyValue('--auth-line-index')).toBe('1') + }, + ) +}) diff --git a/frontend/src/shared/ui/kinetic-copy.tsx b/frontend/src/shared/ui/kinetic-copy.tsx new file mode 100644 index 00000000..85d23381 --- /dev/null +++ b/frontend/src/shared/ui/kinetic-copy.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties } from 'react' + +import './kinetic-copy.css' + +export type KineticCopyPhase = 'entering' | 'resting' | 'exiting' + +export type KineticCopyProps = { + lines: readonly [string, string] + copyKey: string + phase: KineticCopyPhase +} + +export function KineticCopy({ lines, copyKey, phase }: KineticCopyProps) { + return ( + + ) +}