diff --git a/bun.lock b/bun.lock index 1ec074892e..6b6459bee7 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "@seed-design/migration-index": "2.0.1", "@seed-design/react": "2.3.0", "@seed-design/react-icon": "^0.7.4", + "@seed-design/react-presence": "^1.0.1", "@seed-design/rootage-artifacts": "workspace:*", "@seed-design/rootage-core": "0.0.0", "@seed-design/stackflow": "2.0.3", diff --git a/docs/content/breeze/components/blur-swap.mdx b/docs/content/breeze/components/blur-swap.mdx new file mode 100644 index 0000000000..be8280cc67 --- /dev/null +++ b/docs/content/breeze/components/blur-swap.mdx @@ -0,0 +1,153 @@ +--- +title: Blur Swap +description: 두 콘텐츠를 blur crossfade로 교차하는 컴포넌트 +--- + +`BlurSwap`은 `activeKey`가 바뀔 때 이전 콘텐츠와 새 콘텐츠를 같은 자리에 겹쳐두고 교차시킵니다. 나가는 쪽은 흐려지며 밀려나고 들어오는 쪽은 선명해지며 자리를 잡습니다. 컨테이너 크기는 들어오는 콘텐츠에 맞춰 함께 움직입니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/preview.tsx", + "codeblock": true + } + ``` + + +## Installation + +Dependency: + +```package-install +npm install @seed-design/react-presence @radix-ui/react-compose-refs +``` + +Snippet: + +```package-install +npx @seed-design/cli@latest add breeze:blur-swap +``` + + + +## Usage + +지금 보여줄 콘텐츠와, 그 콘텐츠를 식별하는 `activeKey`를 함께 전달합니다. `activeKey`가 바뀔 때만 전환이 일어나므로, 콘텐츠 안쪽이 바뀌어도 키가 같으면 그대로 둡니다. + +```tsx +const [status, setStatus] = useState("ready"); + +return ( + + {STATUS_LABEL[status]} + +); +``` + +### 나가는 콘텐츠와 들어오는 콘텐츠 + +전환이 시작되면 두 콘텐츠가 잠깐 같은 자리에 겹칩니다. 둘은 같은 시간 동안 서로 반대 방향으로 움직입니다. 나가는 쪽은 제자리에서 위로 밀리며 흐려지고, 들어오는 쪽은 아래에서 올라오며 선명해집니다. + +| | 나가는 콘텐츠 | 들어오는 콘텐츠 | +| ----------- | ----------------------------------- | ------------------------------------- | +| 투명도 | `1` → `0` | `0` → `1` | +| 흐림 | `0` → `blur` | `blur` → `0` | +| 이동 | 제자리 → `offset`만큼 위로 | `offset`만큼 아래 → 제자리 | +| 배치 | `position: absolute`로 흐름 밖 | 흐름 안. 컨테이너 크기의 기준이 됩니다 | +| 상호작용 | `inert`. 포커스·포인터·스크린리더에서 모두 빠집니다 | 평소와 같습니다 | +| 수명 | 퇴장 애니메이션이 끝나면 DOM에서 제거 | `activeKey`가 다시 바뀔 때까지 유지 | + +투명도·흐림·이동 세 가지가 하나의 `@keyframes`에 함께 들어 있어 같은 타이밍 함수를 공유합니다. 그래서 흐림 정도는 crossfade 진행도를 그대로 따라갑니다 — 반쯤 사라진 콘텐츠는 반쯤 흐려져 있습니다. + +느린 속도로 돌려보면 두 콘텐츠가 겹치는 구간이 보입니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/anatomy.tsx", + "codeblock": true + } + ``` + + +### 크기 애니메이션 범위 + +`size`로 컨테이너가 어느 축을 따라갈지 정합니다. + +- `"auto"` (기본값): 너비와 높이 모두 따라갑니다. 콘텐츠 너비를 그대로 쓰기 때문에 줄바꿈이 일어나지 않습니다. 라벨, 뱃지, 숫자처럼 한 줄짜리 콘텐츠에 씁니다. +- `"height"`: 높이만 따라가고 너비는 부모를 채웁니다. 카드나 패널처럼 폭이 정해진 콘텐츠에 씁니다. +- `"none"`: 크기를 건드리지 않습니다. 바깥에서 크기를 이미 정해둔 경우에 씁니다. + +크기가 움직이면 주변 레이아웃도 함께 밀립니다. 전환을 일으키는 컨트롤이 그 영향권에 있으면 방금 누른 버튼이 손끝에서 달아나므로, 컨테이너가 한쪽으로만 자라도록 배치합니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/panel.tsx", + "codeblock": true + } + ``` + + +### 빈 상태에서 등장하기 + +`children`이 비어 있는 키도 하나의 상태입니다. 빈 쪽에서 콘텐츠 쪽으로 넘어가면 등장이 되고, 반대로 넘어가면 퇴장이 됩니다. 컨테이너도 0에서부터 자라고 0으로 돌아갑니다. + +여기서는 `size="height"`를 씁니다. `"auto"`는 너비도 0에서 시작하는데, 콘텐츠는 컨테이너의 시작 모서리에 붙어 있으므로 가운데 정렬된 자리에서는 상자가 자라는 동안 콘텐츠가 옆으로 밀립니다. 빈 상태를 오가는 경우에는 그 폭이 콘텐츠 너비의 절반이나 되어 눈에 걸립니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/appear.tsx", + "codeblock": true + } + ``` + + +### 전환 세기 조절 + +`blur`는 전환 중 최대 blur 반경(px), `offset`은 콘텐츠가 흐르는 거리(px)입니다. `offset`이 양수면 아래에서 위로, 음수면 위에서 아래로 흐릅니다. 한쪽을 `0`으로 두면 그 축만 빠집니다 — `offset`이 `0`이면 제자리에서 흐려지기만 하고, `blur`가 `0`이면 흐림 없이 밀려나기만 합니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/tuning.tsx", + "codeblock": true + } + ``` + + +`duration`은 전환 길이(ms)로 기본값은 `400`입니다. blur는 반쯤 흐려진 중간 상태가 보여야 효과가 읽히기 때문에, 여느 상태 전환보다 길게 잡혀 있습니다. 짧게 줄이면 crossfade만 남고 blur는 눈에 걸리지 않습니다. + +값은 모두 CSS 커스텀 프로퍼티로 내려가므로, prop 대신 스타일시트에서 한 번에 정해도 됩니다. + +```css +.my-swap { + --blur-swap-blur: 12px; + --blur-swap-offset: 20px; + --blur-swap-duration: 600ms; + --blur-swap-ease: cubic-bezier(0.35, 0, 0.35, 1); + --blur-swap-grow-ease: cubic-bezier(0, 0, 0.15, 1); + --blur-swap-shrink-ease: cubic-bezier(0.35, 0, 1, 1); +} +``` + +타이밍 함수는 셋으로 나뉘어 있고, 기본값은 각각 SEED의 `$timing-function.easing`, `$timing-function.enter`, `$timing-function.exit`입니다. + +- `--blur-swap-ease`: 두 콘텐츠의 crossfade. 중간 구간이 보여야 하므로 가운데에서 절반을 지나는 S자 곡선을 씁니다. +- `--blur-swap-grow-ease`: 컨테이너가 커질 때. 들어오는 콘텐츠는 첫 프레임부터 최종 크기로 그려지므로, 컨테이너가 앞서 자리를 만들어야 합니다. +- `--blur-swap-shrink-ease`: 컨테이너가 작아질 때. 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너는 그쪽이 다 사라질 때까지 버티다 뒤늦게 줄어듭니다. + +## 동작과 스타일 + +- 퇴장은 `@seed-design/react-presence`의 `usePresence`가 붙잡습니다. `data-state`가 `closed`로 바뀌면서 `animation-name`이 달라지는 것을 감지해, 애니메이션이 끝날 때까지 DOM에 남겨둡니다. 그래서 **퇴장 애니메이션을 지우면 나가는 콘텐츠가 사라지지 않습니다.** 전환을 없애고 싶다면 애니메이션을 지우는 대신 `duration`을 `0`으로 두세요. +- 나가는 콘텐츠는 `position: absolute`로 흐름 밖에 놓입니다. 들어오는 콘텐츠가 곧바로 자리를 차지하므로, 컨테이너 크기는 항상 들어오는 쪽을 기준으로 계산됩니다. +- 루트는 콘텐츠를 **자르지 않습니다.** 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿기 때문에, 자르면 그동안 넘치는 만큼이 계속 잘려 나갑니다. blur도 반경만큼 상자 밖으로 번지므로 가장자리가 흐려지는 대신 직선으로 끊깁니다. 대신 전환 중에는 콘텐츠가 컨테이너 밖으로 잠깐 비어져 나올 수 있으니, 빽빽한 자리에 놓을 때는 가장 큰 콘텐츠를 기준으로 여백을 잡아 둡니다. +- 크기는 `ResizeObserver`로 실측한 값을 루트에 적고 CSS `transition`으로 따라갑니다. `activeKey`가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 같은 전환을 탑니다. 커지는 중인지 작아지는 중인지는 루트의 `data-resize`에 적혀 타이밍 함수를 고릅니다. +- 모션 감소 설정에서는 blur와 이동을 뺀 keyframes로 바뀌어 crossfade만 남고, 크기는 전환 없이 즉시 맞춰집니다. JavaScript는 관여하지 않습니다. +- 나가는 콘텐츠에는 `inert`가 걸립니다. 전환 중 같은 내용이 두 번 읽히거나, 사라지는 쪽이 포인터를 가로채지 않습니다. +- `filter`는 새로운 stacking context를 만듭니다. 콘텐츠가 컨테이너 밖으로 겹쳐 나와야 한다면 `BlurSwap` 바깥에서 처리합니다. + +## Props + + diff --git a/docs/content/breeze/index.mdx b/docs/content/breeze/index.mdx index 873c764329..cd758ecb7a 100644 --- a/docs/content/breeze/index.mdx +++ b/docs/content/breeze/index.mdx @@ -25,11 +25,21 @@ SEED Breeze는 다양한 프로젝트에서 바로 사용할 수 있는 유용 ## 사용 가능한 컴포넌트 - + + 더 많은 유용한 컴포넌트들이 곧 추가될 예정입니다! diff --git a/docs/examples/breeze/blur-swap/anatomy.tsx b/docs/examples/breeze/blur-swap/anatomy.tsx new file mode 100644 index 0000000000..843f363124 --- /dev/null +++ b/docs/examples/breeze/blur-swap/anatomy.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; +import { SegmentedControl, SegmentedControlItem } from "seed-design/ui/segmented-control"; + +const SPEEDS = [ + { id: "1x", duration: 400 }, + { id: "0.5x", duration: 800 }, + { id: "0.25x", duration: 1600 }, +]; + +const WEATHERS = ["맑음", "흐림", "비", "천둥번개"]; + +export default function BlurSwapAnatomy() { + const [speedId, setSpeedId] = useState(SPEEDS[0].id); + const [index, setIndex] = useState(0); + + const speed = SPEEDS.find((candidate) => candidate.id === speedId) ?? SPEEDS[0]; + + return ( + + + {SPEEDS.map((candidate) => ( + + {candidate.id} + + ))} + + + + + {WEATHERS[index]} + + + + setIndex((current) => (current + 1) % WEATHERS.length)} + > + 날씨 바꾸기 + + + ); +} diff --git a/docs/examples/breeze/blur-swap/appear.tsx b/docs/examples/breeze/blur-swap/appear.tsx new file mode 100644 index 0000000000..ce0316d13d --- /dev/null +++ b/docs/examples/breeze/blur-swap/appear.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const NOTICES = [null, "저장했어요", null, "링크를 복사했어요"]; + +export default function BlurSwapAppear() { + const [index, setIndex] = useState(0); + + const notice = NOTICES[index]; + + return ( + + setIndex((current) => (current + 1) % NOTICES.length)} + > + {notice ? "메시지 지우기" : "메시지 띄우기"} + + + + {notice && ( + + {notice} + + )} + + + ); +} diff --git a/docs/examples/breeze/blur-swap/panel.tsx b/docs/examples/breeze/blur-swap/panel.tsx new file mode 100644 index 0000000000..e87742dd84 --- /dev/null +++ b/docs/examples/breeze/blur-swap/panel.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Box, Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { SegmentedControl, SegmentedControlItem } from "seed-design/ui/segmented-control"; + +const PLANS = [ + { id: "basic", name: "베이직", lines: ["월 4,900원", "기본 기능 전체"] }, + { + id: "pro", + name: "프로", + lines: ["월 9,900원", "기본 기능 전체", "우선 지원", "리포트 내보내기", "팀 멤버 5명"], + }, +]; + +export default function BlurSwapPanel() { + const [planId, setPlanId] = useState(PLANS[0].id); + + const plan = PLANS.find((candidate) => candidate.id === planId) ?? PLANS[0]; + + // 패널은 아래로만 자라야 요금제를 고르는 손끝에서 컨트롤이 달아나지 않는다. + return ( + + + {PLANS.map((candidate) => ( + + {candidate.name} + + ))} + + + + + + {plan.lines.map((line) => ( + + {line} + + ))} + + + + + ); +} diff --git a/docs/examples/breeze/blur-swap/preview.tsx b/docs/examples/breeze/blur-swap/preview.tsx new file mode 100644 index 0000000000..7987293019 --- /dev/null +++ b/docs/examples/breeze/blur-swap/preview.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const STATUSES = ["결제 완료", "상품 준비 중", "배송 중", "배송 완료"]; + +export default function BlurSwapPreview() { + const [index, setIndex] = useState(0); + + return ( + + + + {STATUSES[index]} + + + + setIndex((current) => (current + 1) % STATUSES.length)} + > + 다음 상태 + + + ); +} diff --git a/docs/examples/breeze/blur-swap/tuning.tsx b/docs/examples/breeze/blur-swap/tuning.tsx new file mode 100644 index 0000000000..1eaa11f8c0 --- /dev/null +++ b/docs/examples/breeze/blur-swap/tuning.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { HStack, Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const PRESETS = [ + { id: "no-blur", label: "blur 0 / offset 8", blur: 0, offset: 8 }, + { id: "no-offset", label: "blur 4 / offset 0", blur: 4, offset: 0 }, + { id: "default", label: "blur 4 / offset 8", blur: 4, offset: 8 }, + { id: "strong", label: "blur 12 / offset 20", blur: 12, offset: 20 }, +]; + +export default function BlurSwapTuning() { + const [count, setCount] = useState(1); + + return ( + + + {PRESETS.map((preset) => ( + + + + {count.toLocaleString()} + + + + {preset.label} + + + ))} + + + setCount((current) => current * 7 + 3)} + > + 값 바꾸기 + + + ); +} diff --git a/docs/package.json b/docs/package.json index 58c88915f1..113bd8913e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -64,6 +64,7 @@ "@seed-design/migration-index": "2.0.1", "@seed-design/react": "2.3.0", "@seed-design/react-icon": "^0.7.4", + "@seed-design/react-presence": "^1.0.1", "@seed-design/rootage-artifacts": "workspace:*", "@seed-design/rootage-core": "0.0.0", "@seed-design/stackflow": "2.0.3", diff --git a/docs/public/__docs__/index.json b/docs/public/__docs__/index.json index c18e93d5f8..f0a36826f3 100644 --- a/docs/public/__docs__/index.json +++ b/docs/public/__docs__/index.json @@ -1907,6 +1907,23 @@ } ] }, + { + "id": "blur-swap", + "title": "Blur Swap", + "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + "docUrl": "/breeze/components/blur-swap", + "snippetKey": "react/breeze:blur-swap", + "snippets": [ + { + "label": "react", + "path": "blur-swap/blur-swap.tsx" + }, + { + "label": "css", + "path": "blur-swap/blur-swap.module.css" + } + ] + }, { "id": "scroll-auto-hide", "title": "Scroll Auto Hide", diff --git a/docs/public/__registry__/react/breeze/blur-swap.json b/docs/public/__registry__/react/breeze/blur-swap.json new file mode 100644 index 0000000000..89c2b62592 --- /dev/null +++ b/docs/public/__registry__/react/breeze/blur-swap.json @@ -0,0 +1,18 @@ +{ + "id": "blur-swap", + "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@seed-design/react-presence" + ], + "snippets": [ + { + "path": "blur-swap/blur-swap.tsx", + "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { useComposedRefs } from \"@radix-ui/react-compose-refs\";\nimport { usePresence } from \"@seed-design/react-presence\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION_MS = 400;\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface Layer {\n key: React.Key;\n children: React.ReactNode;\n}\n\ninterface BlurSwapLayerProps {\n /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */\n layerKey: React.Key;\n present: boolean;\n initial: boolean;\n onExitComplete: (key: React.Key) => void;\n children: React.ReactNode;\n}\n\nfunction BlurSwapLayer({\n layerKey,\n present,\n initial,\n onExitComplete,\n children,\n}: BlurSwapLayerProps) {\n const { isPresent, ref } = usePresence(present);\n\n React.useEffect(() => {\n if (isPresent) return;\n\n onExitComplete(layerKey);\n }, [isPresent, layerKey, onExitComplete]);\n\n if (!isPresent) return null;\n\n return (\n \n {children}\n \n );\n}\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (ms)\n * @default 400\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION_MS,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const rootRef = React.useRef(null);\n const composedRefs = useComposedRefs(forwardedRef, rootRef);\n const contentRef = React.useRef(null);\n\n /**\n * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가\n * 들고 사라져야 할 옛 내용이 여기 남아 있다.\n */\n const committedChildrenRef = React.useRef(children);\n\n const [stack, setStack] = React.useState(() => ({\n currentKey: activeKey,\n exiting: [] as Layer[],\n hasSwapped: false,\n }));\n\n // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다.\n if (stack.currentKey !== activeKey) {\n setStack((previous) => ({\n currentKey: activeKey,\n exiting: [\n // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다.\n ...previous.exiting.filter(\n (layer) => layer.key !== previous.currentKey && layer.key !== activeKey,\n ),\n { key: previous.currentKey, children: committedChildrenRef.current },\n ],\n hasSwapped: true,\n }));\n }\n\n useIsomorphicLayoutEffect(() => {\n committedChildrenRef.current = children;\n });\n\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n const content = contentRef.current;\n if (!root || !content) return;\n\n if (size === \"none\") {\n root.style.width = \"\";\n root.style.height = \"\";\n delete root.dataset.resize;\n return;\n }\n\n /**\n * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는\n * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다.\n */\n const sync = () => {\n const width = size === \"auto\" ? content.offsetWidth : root.offsetWidth;\n const height = content.offsetHeight;\n\n // 어느 방향으로 가는지는 지금 그려진 컨테이너와 비교해서만 알 수 있다. 전환 도중에\n // 다시 불려도 남은 거리 기준으로 판단이 다시 선다.\n root.dataset.resize =\n width > root.offsetWidth || height > root.offsetHeight ? \"grow\" : \"shrink\";\n\n root.style.width = size === \"auto\" ? `${width}px` : \"\";\n root.style.height = `${height}px`;\n };\n\n sync();\n\n const observer = new ResizeObserver(sync);\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size]);\n\n const handleExitComplete = React.useCallback((key: React.Key) => {\n setStack((previous) => ({\n ...previous,\n exiting: previous.exiting.filter((layer) => layer.key !== key),\n }));\n }, []);\n\n const rootStyle: React.CSSProperties & Record<`--${string}`, string> = {\n ...style,\n \"--blur-swap-blur\": `${blur}px`,\n \"--blur-swap-offset\": `${offset}px`,\n \"--blur-swap-duration\": `${duration}ms`,\n };\n\n // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다.\n const layers: Array = [\n ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })),\n { key: activeKey, children, present: true, initial: !stack.hasSwapped },\n ];\n\n return (\n \n \n {layers.map((layer) => (\n \n {layer.children}\n \n ))}\n \n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" + }, + { + "path": "blur-swap/blur-swap.module.css", + "content": "/*\n * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안\n * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에\n * 가장자리가 흐려지는 대신 직선으로 끊긴다.\n */\n.root {\n --blur-swap-blur: 4px;\n --blur-swap-offset: 8px;\n --blur-swap-duration: 400ms;\n --blur-swap-ease: var(--seed-timing-function-easing, cubic-bezier(0.35, 0, 0.35, 1));\n --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1));\n --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1));\n\n position: relative;\n display: block;\n transition:\n width var(--blur-swap-duration) var(--blur-swap-grow-ease),\n height var(--blur-swap-duration) var(--blur-swap-grow-ease);\n}\n\n/*\n * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터\n * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로\n * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다.\n */\n.root[data-resize=\"shrink\"] {\n transition-timing-function: var(--blur-swap-shrink-ease);\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n position: relative;\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가\n * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n\n/*\n * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에\n * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.\n */\n.layer[data-state=\"open\"] {\n animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n/*\n * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,\n * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.\n */\n.layer[data-state=\"closed\"] {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: 0;\n width: 100%;\n animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n.contentAuto .layer[data-state=\"closed\"] {\n width: max-content;\n}\n\n/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */\n.layer[data-initial][data-state=\"open\"] {\n animation: none;\n}\n\n@keyframes blurSwapEnter {\n from {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(var(--blur-swap-offset));\n }\n to {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n}\n\n@keyframes blurSwapExit {\n from {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n to {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(calc(-1 * var(--blur-swap-offset)));\n }\n}\n\n@keyframes blurSwapEnterReduced {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n@keyframes blurSwapExitReduced {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n\n/*\n * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로\n * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.\n */\n@media (prefers-reduced-motion: reduce) {\n .root {\n transition: none;\n }\n\n .layer[data-state=\"open\"] {\n animation-name: blurSwapEnterReduced;\n }\n\n .layer[data-state=\"closed\"] {\n animation-name: blurSwapExitReduced;\n }\n}\n" + } + ] +} \ No newline at end of file diff --git a/docs/public/__registry__/react/breeze/index.json b/docs/public/__registry__/react/breeze/index.json index 84a04b22c1..9337552812 100644 --- a/docs/public/__registry__/react/breeze/index.json +++ b/docs/public/__registry__/react/breeze/index.json @@ -16,6 +16,22 @@ "motion" ] }, + { + "snippets": [ + { + "path": "blur-swap/blur-swap.tsx" + }, + { + "path": "blur-swap/blur-swap.module.css" + } + ], + "id": "blur-swap", + "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@seed-design/react-presence" + ] + }, { "snippets": [ { diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.module.css b/docs/registry/react/breeze/blur-swap/blur-swap.module.css new file mode 100644 index 0000000000..c09f42702a --- /dev/null +++ b/docs/registry/react/breeze/blur-swap/blur-swap.module.css @@ -0,0 +1,137 @@ +/* + * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안 + * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에 + * 가장자리가 흐려지는 대신 직선으로 끊긴다. + */ +.root { + --blur-swap-blur: 4px; + --blur-swap-offset: 8px; + --blur-swap-duration: 400ms; + --blur-swap-ease: var(--seed-timing-function-easing, cubic-bezier(0.35, 0, 0.35, 1)); + --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1)); + --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1)); + + position: relative; + display: block; + transition: + width var(--blur-swap-duration) var(--blur-swap-grow-ease), + height var(--blur-swap-duration) var(--blur-swap-grow-ease); +} + +/* + * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터 + * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로 + * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다. + */ +.root[data-resize="shrink"] { + transition-timing-function: var(--blur-swap-shrink-ease); +} + +.inline { + display: inline-block; + vertical-align: top; +} + +.content { + position: relative; + width: 100%; +} + +/* + * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가 + * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다. + */ +.contentAuto { + width: max-content; +} + +/* + * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에 + * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다. + */ +.layer[data-state="open"] { + animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both; +} + +/* + * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고, + * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다. + */ +.layer[data-state="closed"] { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + width: 100%; + animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both; +} + +.contentAuto .layer[data-state="closed"] { + width: max-content; +} + +/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */ +.layer[data-initial][data-state="open"] { + animation: none; +} + +@keyframes blurSwapEnter { + from { + opacity: 0; + filter: blur(var(--blur-swap-blur)); + transform: translateY(var(--blur-swap-offset)); + } + to { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + +@keyframes blurSwapExit { + from { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + to { + opacity: 0; + filter: blur(var(--blur-swap-blur)); + transform: translateY(calc(-1 * var(--blur-swap-offset))); + } +} + +@keyframes blurSwapEnterReduced { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes blurSwapExitReduced { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* + * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로 + * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다. + */ +@media (prefers-reduced-motion: reduce) { + .root { + transition: none; + } + + .layer[data-state="open"] { + animation-name: blurSwapEnterReduced; + } + + .layer[data-state="closed"] { + animation-name: blurSwapExitReduced; + } +} diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx new file mode 100644 index 0000000000..a942d0d34c --- /dev/null +++ b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx @@ -0,0 +1,167 @@ +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type * as React from "react"; + +/** + * CSS Modules는 테스트 환경에서 빈 객체로 들어오고, happy-dom은 `animation` 단축 속성을 + * `animation-name`으로 펼치지 못한다. `usePresence`가 퇴장 애니메이션을 실제로 감지하도록 + * 클래스 이름과 longhand 스타일을 대신 채워 넣는다. + */ +mock.module("./blur-swap.module.css", () => ({ + default: { + root: "root", + inline: "inline", + content: "content", + contentAuto: "contentAuto", + layer: "layer", + }, +})); + +const LAYER_STYLES = ` + .layer[data-state="open"] { animation-name: blurSwapEnter; animation-duration: 300ms; } + .layer[data-state="closed"] { animation-name: blurSwapExit; animation-duration: 300ms; position: absolute; } + .layer[data-initial][data-state="open"] { animation-name: none; } +`; + +const { BlurSwap } = await import("./blur-swap"); + +const originalResizeObserver = window.ResizeObserver; +let styleElement: HTMLStyleElement; + +class ResizeObserverMock { + observe = mock(); + unobserve = mock(); + disconnect = mock(); +} + +function renderBlurSwap(props: Partial> = {}) { + const { activeKey = "a", children = A, ...rest } = props; + + const result = render( + + {children} + , + ); + + const root = result.container.firstElementChild; + if (!(root instanceof HTMLElement)) throw new Error("root not rendered"); + + return { ...result, root }; +} + +function swapTo( + rerender: ReturnType["rerender"], + key: string, + props: Partial> = {}, +) { + rerender( + + {key.toUpperCase()} + , + ); +} + +function readLayers(root: HTMLElement) { + return [...root.querySelectorAll(".layer")].map((layer) => ({ + text: layer.textContent, + state: layer.getAttribute("data-state"), + inert: layer.hasAttribute("inert"), + })); +} + +function finishExitAnimations(root: HTMLElement) { + act(() => { + for (const layer of root.querySelectorAll('.layer[data-state="closed"]')) { + const event = new Event("animationend", { bubbles: true }); + Object.defineProperty(event, "animationName", { value: "blurSwapExit" }); + layer.dispatchEvent(event); + } + }); +} + +beforeEach(() => { + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + styleElement = document.createElement("style"); + styleElement.textContent = LAYER_STYLES; + document.head.appendChild(styleElement); +}); + +afterEach(() => { + window.ResizeObserver = originalResizeObserver; + styleElement.remove(); +}); + +describe("BlurSwap", () => { + it("className과 style을 루트 요소에 전달한다", () => { + const { root } = renderBlurSwap({ className: "consumer-class", style: { margin: "4px" } }); + + expect(root.className).toContain("consumer-class"); + expect(root.style.margin).toBe("4px"); + }); + + it("blur·offset·duration을 CSS 커스텀 프로퍼티로 내린다", () => { + const { root } = renderBlurSwap({ blur: 12, offset: -20, duration: 450 }); + + expect([ + root.style.getPropertyValue("--blur-swap-blur"), + root.style.getPropertyValue("--blur-swap-offset"), + root.style.getPropertyValue("--blur-swap-duration"), + ]).toEqual(["12px", "-20px", "450ms"]); + }); + + it("마운트 첫 레이어는 등장 애니메이션을 건너뛴다", () => { + const { root } = renderBlurSwap(); + const layer = root.querySelector(".layer"); + if (!(layer instanceof HTMLElement)) throw new Error("layer not rendered"); + + expect(layer.hasAttribute("data-initial")).toBe(true); + expect(getComputedStyle(layer).animationName).toBe("none"); + }); + + it("activeKey가 바뀌면 나가는 콘텐츠를 inert로 남긴 채 새 콘텐츠를 함께 그린다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a" }); + + swapTo(rerender, "b"); + + expect(readLayers(root)).toEqual([ + { text: "A", state: "closed", inert: true }, + { text: "B", state: "open", inert: false }, + ]); + }); + + it("퇴장 애니메이션이 끝나야 나가는 콘텐츠를 DOM에서 뗀다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a" }); + + swapTo(rerender, "b"); + expect(readLayers(root)).toHaveLength(2); + + finishExitAnimations(root); + + expect(readLayers(root)).toEqual([{ text: "B", state: "open", inert: false }]); + }); + + it("전환이 끝나기 전에 되돌아와도 같은 키를 두 번 그리지 않는다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a" }); + + swapTo(rerender, "b"); + swapTo(rerender, "a"); + + expect(readLayers(root)).toEqual([ + { text: "B", state: "closed", inert: true }, + { text: "A", state: "open", inert: false }, + ]); + }); + + it("size가 따라가기로 정한 축만 루트에 크기로 적는다", () => { + const auto = renderBlurSwap({ size: "auto" }); + expect([auto.root.style.width, auto.root.style.height]).toEqual(["0px", "0px"]); + auto.unmount(); + + const height = renderBlurSwap({ size: "height" }); + expect([height.root.style.width, height.root.style.height]).toEqual(["", "0px"]); + height.unmount(); + + const none = renderBlurSwap({ size: "none" }); + expect([none.root.style.width, none.root.style.height]).toEqual(["", ""]); + }); +}); diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.tsx new file mode 100644 index 0000000000..ffa455543b --- /dev/null +++ b/docs/registry/react/breeze/blur-swap/blur-swap.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useComposedRefs } from "@radix-ui/react-compose-refs"; +import { usePresence } from "@seed-design/react-presence"; +import * as React from "react"; +import styles from "./blur-swap.module.css"; + +const DEFAULT_BLUR = 4; +const DEFAULT_OFFSET = 8; +const DEFAULT_DURATION_MS = 400; + +const useIsomorphicLayoutEffect = + typeof document === "undefined" ? React.useEffect : React.useLayoutEffect; + +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(" "); +} + +interface Layer { + key: React.Key; + children: React.ReactNode; +} + +interface BlurSwapLayerProps { + /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */ + layerKey: React.Key; + present: boolean; + initial: boolean; + onExitComplete: (key: React.Key) => void; + children: React.ReactNode; +} + +function BlurSwapLayer({ + layerKey, + present, + initial, + onExitComplete, + children, +}: BlurSwapLayerProps) { + const { isPresent, ref } = usePresence(present); + + React.useEffect(() => { + if (isPresent) return; + + onExitComplete(layerKey); + }, [isPresent, layerKey, onExitComplete]); + + if (!isPresent) return null; + + return ( +
+ {children} +
+ ); +} + +export interface BlurSwapProps { + /** + * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다. + */ + activeKey: React.Key; + + /** + * `activeKey`에 해당하는 콘텐츠 + */ + children: React.ReactNode; + + /** + * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다. + * @default 4 + */ + blur?: number; + + /** + * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다. + * 0이면 제자리에서 crossfade만 한다. + * @default 8 + */ + offset?: number; + + /** + * 전환 길이 (ms) + * @default 400 + */ + duration?: number; + + /** + * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위 + * + * - `"auto"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다. + * - `"height"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다. + * - `"none"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우. + * + * @default "auto" + */ + size?: "auto" | "height" | "none"; + + /** + * 추가 클래스명 + */ + className?: string; + + /** + * 컨테이너 스타일 + */ + style?: React.CSSProperties; +} + +export const BlurSwap = React.forwardRef(function BlurSwap( + { + activeKey, + children, + blur = DEFAULT_BLUR, + offset = DEFAULT_OFFSET, + duration = DEFAULT_DURATION_MS, + size = "auto", + className, + style, + }, + forwardedRef, +) { + const rootRef = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, rootRef); + const contentRef = React.useRef(null); + + /** + * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가 + * 들고 사라져야 할 옛 내용이 여기 남아 있다. + */ + const committedChildrenRef = React.useRef(children); + + const [stack, setStack] = React.useState(() => ({ + currentKey: activeKey, + exiting: [] as Layer[], + hasSwapped: false, + })); + + // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다. + if (stack.currentKey !== activeKey) { + setStack((previous) => ({ + currentKey: activeKey, + exiting: [ + // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다. + ...previous.exiting.filter( + (layer) => layer.key !== previous.currentKey && layer.key !== activeKey, + ), + { key: previous.currentKey, children: committedChildrenRef.current }, + ], + hasSwapped: true, + })); + } + + useIsomorphicLayoutEffect(() => { + committedChildrenRef.current = children; + }); + + useIsomorphicLayoutEffect(() => { + const root = rootRef.current; + const content = contentRef.current; + if (!root || !content) return; + + if (size === "none") { + root.style.width = ""; + root.style.height = ""; + delete root.dataset.resize; + return; + } + + /** + * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는 + * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다. + */ + const sync = () => { + const width = size === "auto" ? content.offsetWidth : root.offsetWidth; + const height = content.offsetHeight; + + // 어느 방향으로 가는지는 지금 그려진 컨테이너와 비교해서만 알 수 있다. 전환 도중에 + // 다시 불려도 남은 거리 기준으로 판단이 다시 선다. + root.dataset.resize = + width > root.offsetWidth || height > root.offsetHeight ? "grow" : "shrink"; + + root.style.width = size === "auto" ? `${width}px` : ""; + root.style.height = `${height}px`; + }; + + sync(); + + const observer = new ResizeObserver(sync); + observer.observe(content); + + return () => observer.disconnect(); + }, [activeKey, size]); + + const handleExitComplete = React.useCallback((key: React.Key) => { + setStack((previous) => ({ + ...previous, + exiting: previous.exiting.filter((layer) => layer.key !== key), + })); + }, []); + + const rootStyle: React.CSSProperties & Record<`--${string}`, string> = { + ...style, + "--blur-swap-blur": `${blur}px`, + "--blur-swap-offset": `${offset}px`, + "--blur-swap-duration": `${duration}ms`, + }; + + // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다. + const layers: Array = [ + ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })), + { key: activeKey, children, present: true, initial: !stack.hasSwapped }, + ]; + + return ( +
+
+ {layers.map((layer) => ( + + {layer.children} + + ))} +
+
+ ); +}); + +BlurSwap.displayName = "BlurSwap"; diff --git a/docs/registry/react/registry-breeze.ts b/docs/registry/react/registry-breeze.ts index 51650e615e..76395dcde8 100644 --- a/docs/registry/react/registry-breeze.ts +++ b/docs/registry/react/registry-breeze.ts @@ -11,6 +11,11 @@ export const registryBreeze: Registry = { { path: "animate-number/animate-number.module.css" }, ], }, + { + id: "blur-swap", + description: "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + snippets: [{ path: "blur-swap/blur-swap.tsx" }, { path: "blur-swap/blur-swap.module.css" }], + }, { id: "scroll-auto-hide", description: "스크롤 방향에 따라 고정 영역을 숨기고 드러내는 컴포넌트",