From 116acd5254f45d3c608e6c12977ea73d24e22324 Mon Sep 17 00:00:00 2001 From: Q Kim Date: Thu, 5 Oct 2023 14:14:03 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 역순으로 쌓이는 스택 구현 * feat: 떨어지는 시간 조절 기능 * refactor: 컴포넌트명 Stack으로 변경 * feat: 내부 요소 정렬 기능 * feat: 방향 설정 기능 * feat: row-gap 조절 기능 * test: 스토리북 고도화 * chore: 스토리북 자동 배포 설정 * fix: 애니메이션 버그 - 리렌더링 시 random값이 바뀌어 계속 애니메이션 출력 - 같은 높이의 컴포넌트가 연속했을 때 random값이 없으면 애니메이션이 바뀌지 않아 출력되지 않음 * chore: npm 배포 관련 설정 * docs: update readme * refactor: translate3D -> translateY 굳이 3d를 쓰지 않아도 GPU를 사용해서 변경 * test: 스토리북 textbox 예제 추가 * refactor: 무작위 배열 생성 로직 컴포넌트 밖으로 뺌 --- .eslintrc.cjs | 1 + .github/workflows/storybook-deploy.yml | 34 ++++++ .gitignore | 1 + .npmignore | 14 +++ README.md | 66 ++++++++++- index.d.ts | 43 +++++++ package-lock.json | 155 ++++++++++++++++++++++++ package.json | 25 +++- src/Stack/Stack.style.ts | 55 +++++++++ src/Stack/index.tsx | 105 +++++++++++++++++ src/Stack/stories/Stack.mdx | 45 +++++++ src/Stack/stories/Stack.stories.tsx | 157 +++++++++++++++++++++++++ src/Stack/stories/fixtures.ts | 10 ++ src/index.ts | 3 + src/utils/clip.ts | 14 +++ src/utils/getUniqueRandomFloatArray.ts | 14 +++ src/utils/toPixelIfNumber.ts | 11 ++ vite.config.ts | 27 ++++- 18 files changed, 770 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/storybook-deploy.yml create mode 100644 .npmignore create mode 100644 index.d.ts create mode 100644 src/Stack/Stack.style.ts create mode 100644 src/Stack/index.tsx create mode 100644 src/Stack/stories/Stack.mdx create mode 100644 src/Stack/stories/Stack.stories.tsx create mode 100644 src/Stack/stories/fixtures.ts create mode 100644 src/index.ts create mode 100644 src/utils/clip.ts create mode 100644 src/utils/getUniqueRandomFloatArray.ts create mode 100644 src/utils/toPixelIfNumber.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2b5eb3c..318cca4 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -24,6 +24,7 @@ module.exports = { 'error', { endOfLine: 'auto' }, ], + 'import/no-named-as-default': 'off', }, settings: { 'import/parsers': { diff --git a/.github/workflows/storybook-deploy.yml b/.github/workflows/storybook-deploy.yml new file mode 100644 index 0000000..d456489 --- /dev/null +++ b/.github/workflows/storybook-deploy.yml @@ -0,0 +1,34 @@ +name: Deploy Storybook to Github Pages + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Set up Repository + uses: actions/checkout@v3 + + - name : Set up Node 18.16.0 + uses: actions/setup-node@v3 + with: + node-version: 18.16.0 + + - name: Cache node_modules + id: cache + uses: actions/cache@v3 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: npm clean install + run: npm ci + if: steps.cache.outputs.cache-hit != 'true' + + - name: deploy + run: npm run deploy \ No newline at end of file diff --git a/.gitignore b/.gitignore index a547bf3..a526948 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +storybook-static # Editor directories and files .vscode/* diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..1dffeb6 --- /dev/null +++ b/.npmignore @@ -0,0 +1,14 @@ +.github/ +.storybook/ +node_modules/ +src/ +storybook-static/ + +.git* +vite-env.d.ts +.eslintrc.cjs +.prettierrc +.stylelintrc +tsconfig.json +tsconfig.node.json +vite.config.ts diff --git a/README.md b/README.md index a2723fc..71f88be 100644 --- a/README.md +++ b/README.md @@ -1 +1,65 @@ -# Stack Layout Component \ No newline at end of file +# Stack Layout Component + +리액트와 스타일드 컴포넌트를 이용해서 구현한 스택 형태의 레이아웃 컴포넌트. + +- [npm](https://www.npmjs.com/package/@pium/stack-component) +- [demo: storybook](https://blog.pium.life/stack-component/) + +## 사용 전 주의사항 + +프로젝트에 다음 세 가지가 설치되어 있어야 해요. +- react +- react-dom +- styled-components + +## 설치 + +```bash +npm install @pium/stack-component +``` + +## 컴포넌트 설명 + +### 기능 + +Stack은 주어진 자식 요소들을 세로로 정렬한 후, 원하는 만큼의 개수만 보여줍니다. + +### 주의사항 + +- `Stack`은 자식 요소에 애니메이션을 삽입하므로 겹치지 않게 주의해주세요! +- JSX 상에서 `Stack`의 직속 자녀들만을 대상으로 합니다. + +### Props + +|이름|설명|가능한 값|기본값| +|:-:|:-:|:-:|:-:| +|`showCount`*|보여줄 요소의 개수.|`number`|-| +|`as`|Stack에 적용할 semantic tag|`"div"`, `"ul"`, `"ol"`, `"main"`, `"section"`, `"article"`|`"div"`| +|`flow`|`normal`: JSX에 나타난 순서대로 DOM에 표시됩니다. 새로운 요소는 아래에서 위로 올라옵니다.

`reverse`: JSX에 나타난 순서의 반대로 DOM에 표시됩니다. 새로운 요소는 위에서 아래로 떨어집니다.|`"normal"`, `"reverse"`|`"reverse"`| +|`time`|새로운 요소가 생길 때 나타나는 애니메이션 시간(밀리초).|`number`|`400`| +|`justifyItems`|CSS의 `justify-items`|`React.CSSProperties['justifyItems']`|`"normal"`| +|`rowGap`|CSS의 `row-gap`|`React.CSSProperties['rowGap']`|`0`| + +### 예제 + +```tsx +import { Stack } from '@pium/stack-component'; + + +

새는 자기 길을 안다

+

김종해

+

하늘에 길이 있다는 것을

+

새들이 먼저 안다

+

하늘에 길을 내며 날던 새는

+

길을 또 지운다

+

새들이 하늘 높이 길을 내지 않는 것은

+

그 위에 별들이 가는 길이 있기 때문이다

+
+``` \ No newline at end of file diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..885cc30 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,43 @@ +declare type StackProps = { + /** + * 보여줄 요소의 개수. 음이 아닌 정수여야 합니다. + * + * 음수는 0으로, 실수는 소수 첫째 자리에서 반올림하여 사용합니다. + */ + showCount: number; + /** + * Stack에 적용할 semantic tag + * @defaultValue 'div' + */ + as?: 'div' | 'ul' | 'ol' | 'main' | 'section' | 'article'; + /** + * 쌓이는 방향. + * + * `normal`: JSX에 나타난 순서대로 DOM에 표시됩니다. 새로운 요소는 아래에서 위로 올라옵니다. + * + * `reverse`: JSX에 나타난 순서의 반대로 DOM에 표시됩니다. 새로운 요소는 위에서 아래로 떨어집니다. + * @defaultValue 'reverse' + */ + flow?: 'normal' | 'reverse'; + /** + * 각 요소를 정렬할 방향. + * @defaultValue 'normal' + */ + justifyItems?: React.CSSProperties['justifyItems']; + /** + * 새로운 요소가 생길 때 애니메이션 시간(밀리초). 음이 아닌 정수여야 합니다. + * + * 음수는 0으로, 실수는 소수 첫째 자리에서 반올림하여 사용합니다. + * @defaultValue 400 + */ + time?: number; + /** + * 내부 요소들 사이의 간격. + * @defaultValue 0 + */ + rowGap?: React.CSSProperties['rowGap']; +}; + +declare const Stack: (props: React.PropsWithChildren) => JSX.Element; + +export { Stack }; diff --git a/package-lock.json b/package-lock.json index 87c2f93..46fb193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", "eslint-plugin-storybook": "^0.6.14", + "gh-pages": "^6.0.0", "postcss-styled-syntax": "^0.5.0", "prettier": "^3.0.3", "storybook": "^7.4.5", @@ -6709,6 +6710,15 @@ "node": ">=8" } }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", @@ -8402,6 +8412,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.537.tgz", "integrity": "sha512-W1+g9qs9hviII0HAwOdehGYkr+zt7KKdmCcJcjH0mYg6oL8+ioT3Skjmt7BLoAQqXhjf40AXd+HlR4oAWMlXjA==" }, + "node_modules/email-addresses": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz", + "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", + "dev": true + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -9672,6 +9688,32 @@ "node": ">=10" } }, + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "dev": true, + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -10138,6 +10180,74 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gh-pages": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.0.0.tgz", + "integrity": "sha512-FXZWJRsvP/fK2HJGY+Di6FRNHvqFF6gOIELaopDjXXgjeOYSNURcuYwEO/6bwuq6koP5Lnkvnr5GViXzuOB89g==", + "dev": true, + "dependencies": { + "async": "^3.2.4", + "commander": "^11.0.0", + "email-addresses": "^5.0.0", + "filenamify": "^4.3.0", + "find-cache-dir": "^3.3.1", + "fs-extra": "^11.1.1", + "globby": "^6.1.0" + }, + "bin": { + "gh-pages": "bin/gh-pages.js", + "gh-pages-clean": "bin/gh-pages-clean.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gh-pages/node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gh-pages/node_modules/commander": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", + "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/gh-pages/node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "dev": true, + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gh-pages/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/giget": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/giget/-/giget-1.1.2.tgz", @@ -13256,6 +13366,27 @@ "node": ">=6" } }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -15234,6 +15365,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-search": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", @@ -15845,6 +15988,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", diff --git a/package.json b/package.json index 2a733c2..321171b 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,28 @@ { - "name": "@pium-official/stack-component", + "name": "@pium/stack-component", + "homepage": "https://pium-official.github.io/stack-component", "private": false, - "version": "0.0.0", + "version": "0.1.5", "type": "module", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/pium-official/stack-component" + }, + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, "scripts": { - "dev": "vite", "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "predeploy": "npm run storybook && touch storybook-static/.nojekyll", + "deploy": "gh-pages -d storybook-static -t true" }, "dependencies": { "react": "^18.2.0", @@ -39,6 +51,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", "eslint-plugin-storybook": "^0.6.14", + "gh-pages": "^6.0.0", "postcss-styled-syntax": "^0.5.0", "prettier": "^3.0.3", "storybook": "^7.4.5", diff --git a/src/Stack/Stack.style.ts b/src/Stack/Stack.style.ts new file mode 100644 index 0000000..45aa059 --- /dev/null +++ b/src/Stack/Stack.style.ts @@ -0,0 +1,55 @@ +import styled, { css, keyframes } from 'styled-components'; + +type StackWrapperProps = { + $flow: 'normal' | 'reverse'; + $showCount: number; + $newChildHeight: React.CSSProperties['height']; + $animationTime: number; + $justifyItems: React.CSSProperties['justifyItems']; + $rowGap: React.CSSProperties['rowGap']; +}; + +const appear = keyframes` + from { opacity: 0 } + to { opacity: 1 } +`; + +const rise = (height: StackWrapperProps['$newChildHeight']) => keyframes` + from { transform: translateY(${height}) } + to { transform: translateY(0) } +`; + +const fall = (height: StackWrapperProps['$newChildHeight']) => keyframes` + from { transform: translateY(calc(-1 * ${height})) } + to { transform: translateY(0) } +`; + +const firstChildAnimation = css>` + & > *:first-child { + animation: ${appear} ${({ $animationTime }) => $animationTime}s linear; + } +`; + +const lastChildAnimation = css>` + & > *:last-child { + animation: + ${({ $newChildHeight }) => rise($newChildHeight)} ${({ $animationTime }) => $animationTime}s + linear, + ${appear} ${({ $animationTime }) => $animationTime}s linear; + } +`; + +export const Wrapper = styled.div` + display: grid; + row-gap: ${({ $rowGap }) => $rowGap}; + justify-items: ${({ $justifyItems }) => $justifyItems}; + + width: 100%; + + animation-name: ${({ $newChildHeight, $flow }) => + $flow === 'reverse' ? fall($newChildHeight) : ''}; + animation-duration: ${({ $animationTime }) => $animationTime}s; + animation-timing-function: linear; + + ${({ $flow }) => ($flow === 'normal' ? lastChildAnimation : firstChildAnimation)} +`; diff --git a/src/Stack/index.tsx b/src/Stack/index.tsx new file mode 100644 index 0000000..d6fbe7d --- /dev/null +++ b/src/Stack/index.tsx @@ -0,0 +1,105 @@ +import { Children, useRef, useState, useEffect } from 'react'; +import { flushSync } from 'react-dom'; +import { Wrapper } from './Stack.style'; +import clip from '../utils/clip'; +import toPixelIfNumber from '../utils/toPixelIfNumber'; +import getUniqueRandomFloatArray from '../utils/getUniqueRandomFloatArray'; + +type StackProps = { + /** + * 보여줄 요소의 개수. 음이 아닌 정수여야 합니다. + * + * 음수는 0으로, 실수는 소수 첫째 자리에서 반올림하여 사용합니다. + */ + showCount: number; + /** + * Stack에 적용할 semantic tag + * @defaultValue 'div' + */ + as?: 'div' | 'ul' | 'ol' | 'main' | 'section' | 'article'; + /** + * 쌓이는 방향. + * + * `normal`: JSX에 나타난 순서대로 DOM에 표시됩니다. 새로운 요소는 아래에서 위로 올라옵니다. + * + * `reverse`: JSX에 나타난 순서의 반대로 DOM에 표시됩니다. 새로운 요소는 위에서 아래로 떨어집니다. + * @defaultValue 'reverse' + */ + flow?: 'normal' | 'reverse'; + /** + * 각 요소를 정렬할 방향. + * @defaultValue 'normal' + */ + justifyItems?: React.CSSProperties['justifyItems']; + /** + * 새로운 요소가 생길 때 애니메이션 시간(밀리초). 음이 아닌 정수여야 합니다. + * + * 음수는 0으로, 실수는 소수 첫째 자리에서 반올림하여 사용합니다. + * @defaultValue 400 + */ + time?: number; + /** + * 내부 요소들 사이의 간격. + * @defaultValue 0 + */ + rowGap?: React.CSSProperties['rowGap']; +}; + +/** + * 자식 요소들이 연속해서 같은 높이를 가지는 경우 `$newChildHeight`에 변화가 없기 때문에 + * css 애니메이션의 이름이 변하지 않습니다. + * 따라서 html 역시 이미 실행한 애니메이션이라고 판단, + * 맨 처음 1회만 애니메이션을 실행하는 문제 해결을 위한 코드입니다. + * + * 배열의 길이는 2로도 가능하지만, 피움은 일곱 명이라 7로 했습니다. + * @see https://github.com/pium-official/stack-component/pull/2#pullrequestreview-1656634631 + */ +const randomOffsets = getUniqueRandomFloatArray(7); + +const Stack = (props: React.PropsWithChildren) => { + const { + as: tag = 'div', + time = 400, + justifyItems = 'normal', + flow = 'reverse', + rowGap = 0, + showCount, + children, + } = props; + + const container = useRef(null); + const [height, setHeight] = useState({ previous: 0, current: 0 }); + + useEffect(() => { + const resizeObserver = new ResizeObserver((entries) => { + const [self] = entries; + const { height: resizedHeight } = self.target.getBoundingClientRect(); + flushSync(() => setHeight(({ current }) => ({ previous: current, current: resizedHeight }))); + }); + + if (container.current) resizeObserver.observe(container.current); + return () => resizeObserver.disconnect(); + }, []); + + const clippedShowCount = clip(showCount, 0); + const animationTime = clip(time, 0) / 1000; + const visibleChildren = Children.toArray(children).slice(0, clippedShowCount); + const animationOffset = randomOffsets[showCount % 7]; + + return ( + + {flow === 'normal' ? visibleChildren : visibleChildren.reverse()} + + ); +}; + +export default Stack; diff --git a/src/Stack/stories/Stack.mdx b/src/Stack/stories/Stack.mdx new file mode 100644 index 0000000..c567316 --- /dev/null +++ b/src/Stack/stories/Stack.mdx @@ -0,0 +1,45 @@ +import { Meta, Description, Canvas, Controls } from '@storybook/blocks'; +import * as StackStory from './Stack.stories'; + + + +# Stack + +## 기능 + +Stack은 주어진 자식 요소들을 세로로 정렬한 후, 원하는 만큼의 개수만 보여줍니다. + +## 주의사항 + +- `Stack`은 자식 요소에 애니메이션을 삽입하므로 겹치지 않게 주의해주세요! +- JSX 상에서 `Stack`의 직속 자녀들만을 대상으로 합니다. + +## Props + +Control의 결과는 Playground에서 확인 가능합니다! + + + +## 예제 + +```tsx +import { Stack } from '@pium/stack-component'; + + +

새는 자기 길을 안다

+

김종해

+

하늘에 길이 있다는 것을

+

새들이 먼저 안다

+

하늘에 길을 내며 날던 새는

+

길을 또 지운다

+

새들이 하늘 높이 길을 내지 않는 것은

+

그 위에 별들이 가는 길이 있기 때문이다

+
+``` \ No newline at end of file diff --git a/src/Stack/stories/Stack.stories.tsx b/src/Stack/stories/Stack.stories.tsx new file mode 100644 index 0000000..fc3bd7f --- /dev/null +++ b/src/Stack/stories/Stack.stories.tsx @@ -0,0 +1,157 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import Stack from '..'; +import { POEMS } from './fixtures'; + +const meta: Meta = { + component: Stack, + + argTypes: { + showCount: { + control: { type: 'range', min: 0, max: POEMS.length, step: 1 }, + }, + as: { defaultValue: { summary: '"div"' } }, + flow: { defaultValue: { summary: '"reverse"' } }, + time: { defaultValue: { summary: 400 } }, + justifyItems: { + table: { type: { detail: 'CSS의 `justify-items`' } }, + defaultValue: { summary: '"normal"' }, + }, + rowGap: { + table: { type: { detail: 'CSS의 `row-gap`' } }, + defaultValue: { summary: 0 }, + }, + }, + + args: { + showCount: 1, + as: 'div', + flow: 'reverse', + time: 400, + justifyItems: 'normal', + rowGap: 0, + }, +}; + +export default meta; + +type Story = StoryObj; + +const getRandomInteger = (min: number, max: number) => + min + Math.floor(Math.random() * (max - min)); +const getRandomColor = () => `hsl(${getRandomInteger(0, 360)}, 66%, 77%)`; + +const RandomBox = (props: React.PropsWithChildren) => { + const { children } = props; + const [height] = useState(getRandomInteger(50, 150)); + const [color] = useState(getRandomColor); + return ( +
+ {children} +
+ ); +}; + +const Counter = () => { + const [count, setCount] = useState(0); + return ( + + ); +}; + +const Example = ( + props: { type: 'box' | 'text' } & Pick, 'flow'> +) => { + const { flow = 'reverse', type } = props; + const [showCount, setShowCount] = useState(0); + const showNext = () => setShowCount(showCount + 1); + const MAX_STACK_SIZE = type === 'box' ? 10 : POEMS.length; + + return ( + <> + + {type === 'box' + ? Array.from({ length: MAX_STACK_SIZE }).map((_, index) => ( + + {index + 1}번 상자   + + )) + : POEMS.map((poem, index) => ( +

+ {index + 1}번
{poem} +

+ ))} +
+ + + ); +}; + +export const Playground: Story = { + render: (args) => ( + + {POEMS.map((poem, index) => ( +

+ {index + 1}번
{poem} +

+ ))} +
+ ), +}; + +export const NormalFlowExample: Story = { + render: () => , + argTypes: { + showCount: { table: { disable: true } }, + as: { table: { disable: true } }, + flow: { table: { disable: true } }, + time: { table: { disable: true } }, + justifyItems: { table: { disable: true } }, + rowGap: { table: { disable: true } }, + }, +}; + +export const ReverseFlowExample: Story = { + render: () => , + argTypes: { + showCount: { table: { disable: true } }, + as: { table: { disable: true } }, + flow: { table: { disable: true } }, + time: { table: { disable: true } }, + justifyItems: { table: { disable: true } }, + rowGap: { table: { disable: true } }, + }, +}; + +export const TextboxExample: Story = { + render: () => , + argTypes: { + showCount: { table: { disable: true } }, + as: { table: { disable: true } }, + flow: { table: { disable: true } }, + time: { table: { disable: true } }, + justifyItems: { table: { disable: true } }, + rowGap: { table: { disable: true } }, + }, +}; diff --git a/src/Stack/stories/fixtures.ts b/src/Stack/stories/fixtures.ts new file mode 100644 index 0000000..d1a38e3 --- /dev/null +++ b/src/Stack/stories/fixtures.ts @@ -0,0 +1,10 @@ +export const POEMS = [ + '깃발. 유치환. 이것은 소리 없는 아우성 저 푸른 해원을 향하여 흔드는 영원한 노스탤지어의 손수건 순정은 물결같이 바람에 나부끼고 오로지 맑고 곧은 이념의 푯대 끝에 애수는 백로처럼 날개를 펴다. 아아 누구던가. 이렇게 슬프고도 애달픈 마음을 맨 처음 공중에 달 줄을 안 그는.', + '질투는 나의 힘. 기형도. 아주 오랜 세월이 흐른 뒤에 힘 없는 책갈피는 이 종이를 떨어뜨리리 그때 내 마음은 너무나 많은 공장을 세웠으니 어리석게도 그토록 기록할 것이 많았구나 구름 밑을 천천히 쏘다니는 개처럼 지칠 줄 모르고 공중에서 머뭇거렸구나 나 가진 것 탄식밖에 없어 저녁 거리마다 물끄러미 청춘을 세워 두고 살아온 날들을 신기하게 세어 보았으니 그 누구도 나를 두려워하지 않았으니 내 희망의 내용은 질투뿐이었구나 그리하여 나는 우선 여기에 짧은 글을 남겨 둔다 나의 생은 미친 듯이 사랑을 찾아 헤메었으나 단 한 번도 스스로를 사랑하지 않았노라', + '너에게 묻는다. 안도현. 연탄재 함부로 발로 차지 마라. 너는 누구에게 한번이라도 뜨거운 사람이었느냐', + '사모. 조지훈. 사랑을 다해 사랑하였노라고 정작 할 말이 남아있음을 알았을 때 당신은 이미 남의 사람이 되어 있었다. 불러야 할 뜨거운 노래를 가슴으로 죽이며 당신은 멀리로 잃어지고 있었다. 하마 곱스런 웃음이 사라지기 전 두고두고 아름다운 여인으로 잊어 달라지만 남자에게서 여자란 기쁨 아니면 슬픔 다섯 손가락 끝을 잘라 핏물 오선을 그려 혼자라도 외롭지 않을 밤에 울어 보리라 울어서 멍든 눈흘김을 미워서 미워지도록 사랑하리라 한 잔은 떠나버린 너를 위하여 또 한 잔은 너와의 영원한 사랑을 위하여 그리고 또 한 잔은 이미 초라해진 나를 위하여 마지막 한 잔은 미리 알고 정하신 하나님을 위하여', + '반쯤 깨진 연탄. 안도현. 언젠가는 나도 활활 타오르고 싶은 것이다 나를 끝 닿는데까지 한번 밀어붙여 보고 싶은 것이다 타고 왔던 트럭에 실려 다시 돌아가면 연탄, 처음으로 붙여진 너의 이름도 으깨어져 나의 존재도 까마득히 뭉개질 터이니 지금은 인정머리 없이 차가운, 갈라진 내 몸을 얹고 아래쪽부터 불이 건너와 옮겨 붙기를 시간의 바통을 내가 넘겨 받는 순간이 오기를 그리하여 서서히 온몸이 벌겋게 달아오르기를 나도 느껴보고 싶은 것이다 나도 보고 싶은 것이다 모두들 잠든 깊은 밤에 눈에 빨갛게 불을 켜고 구들장 속이 얼마나 침침한지 손을 뻗어보고 싶은 것이다 나로 하여 푸근한 잠자는 처녀의 등허리를 밤새도록 슬금슬금 만져도 보고 싶은 것이다', + '우주를 건너는 법. 박찬일. 달팽이와 함께! 달팽이는 움직이지 않는다 다만 도달할 뿐이다', + '거대한 착각. 박노해. 나만은 다르다 이번은 다르다 우리는 다르다', + '꽃의 결심. 류시화. 꽃은 피어도 죽고 피지 않아도 죽는다 어차피 죽을 것이면 죽을힘 다해 끝까지 피었다 죽으리', +]; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f54f0f5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import Stack from './Stack'; + +export { Stack }; diff --git a/src/utils/clip.ts b/src/utils/clip.ts new file mode 100644 index 0000000..33ee44f --- /dev/null +++ b/src/utils/clip.ts @@ -0,0 +1,14 @@ +/** + * 주어진 수를 특정 범위를 벗어나지 않는 정수로 반올림합니다. + * @param value 변환할 값 + * @param min 최솟값. 기본값은 `-Infinity` + * @param max 최댓값. 기본값은 `Infinity` + * @returns number + */ +const clip = (value: number, min = -Infinity, max = Infinity) => { + const minInt = Math.ceil(min); + const maxInt = Math.floor(max); + return Math.min(Math.max(minInt, Math.round(value)), maxInt); +}; + +export default clip; diff --git a/src/utils/getUniqueRandomFloatArray.ts b/src/utils/getUniqueRandomFloatArray.ts new file mode 100644 index 0000000..a359d67 --- /dev/null +++ b/src/utils/getUniqueRandomFloatArray.ts @@ -0,0 +1,14 @@ +/** + * 0 이상 1 미만인 무작위 실수값의 배열을 반환합니다. 배열의 각 숫자끼리 겹치지 않습니다. + * @param length 배열의 길이 + * @returns 서로 다른 0 이상 1 미만의 무작위 실수가 들어 있는 배열 + */ +const getUniqueRandomFloatArray = (length: number) => { + const numbers = new Set(); + while (numbers.size < length) { + numbers.add(Math.random()); + } + return Array.from(numbers); +}; + +export default getUniqueRandomFloatArray; diff --git a/src/utils/toPixelIfNumber.ts b/src/utils/toPixelIfNumber.ts new file mode 100644 index 0000000..bead681 --- /dev/null +++ b/src/utils/toPixelIfNumber.ts @@ -0,0 +1,11 @@ +/** + * 주어진 값이 숫자 자료형이면 px을 붙인 문자열을 반환합니다. + * @param value CSS에 들어갈 어떤 값 + * @returns string + */ +const toPixelIfNumber = (value: string | number) => { + if (typeof value === 'number') return `${value}px`; + return value; +}; + +export default toPixelIfNumber; diff --git a/vite.config.ts b/vite.config.ts index 5a33944..4bc2885 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,28 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: '@pium/stack-component', + fileName: 'index', + formats: ['es', 'cjs'], + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', 'styled-components'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react/jsx-runtime': 'react/jsx-runtime', + 'styled-components': 'styled', + }, + }, + }, + }, +});