From 772a6d8b2b1fba83f4b38fb432970c131dd491f1 Mon Sep 17 00:00:00 2001 From: Eugene Kim <67894159+eugene028@users.noreply.github.com> Date: Wed, 16 Oct 2024 17:29:12 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20Pagination=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: TableContainer 생성 * fix: 스토리북 문서 생성 * feat: Table 기본 CSS 적용 * feat: checkbox 생성 * feat:header checkbox body checkbox 구분 로직 생성 * feat: checkbox context 생성 * fix: table tree 방식 파괴 * feat: 체크박스 기능 추가 * feat: 체크박스 기능 생성 * feat: 체크박스 기능 추가 * feat: 선택시 배경 색상 변경 * fix: table 컴포넌트 접근성 관련 처리 * feat: table 컴포넌트 props 명확히 정리 * feat: 스토리북 문서 작성 * fix: rollup, package.json 설정 변경 * feat: 스크롤 가능한 table로 개선 * fix: 스토리북 문서화 잘못 되어 있는 부분 수정 * feat: 페이지네이션 컴포넌트 구현 * feat: table with pagination * refac: 테이블 컴포넌트 합성 컴포넌트로 변경 * fix: Table 코드 깔끔하게 정리 * fix: 스타일 수정 사항 반영 * fix: 사용하지 않는 컨테이너 컴포넌트 제거 * fix: 패키지 버전 변경 * fix: pagination 컴포넌트 코드리뷰 반영 * fix: 배경색 변경 변수명 깔끔하게 * fix: 코드리뷰 반영 * feat: changset 파일 작성 * fix: package 오타수정 --- .changeset/grumpy-llamas-occur.md | 5 + .../wow-icons/src/component/DoubleArrow.tsx | 48 +++ packages/wow-icons/src/component/index.ts | 1 + packages/wow-icons/src/svg/double-arrow.svg | 4 + packages/wow-ui/package.json | 36 ++ packages/wow-ui/rollup.config.js | 7 + .../Pagination/Pagination.stories.tsx | 145 +++++++ .../src/components/Pagination/index.tsx | 263 ++++++++++++ .../src/components/Table/Table.stories.tsx | 383 ++++++++++++++++++ .../wow-ui/src/components/Table/Table.tsx | 145 +++++++ .../src/components/Table/TableContext.ts | 12 + .../wow-ui/src/components/Table/Tbody.tsx | 21 + packages/wow-ui/src/components/Table/Td.tsx | 61 +++ packages/wow-ui/src/components/Table/Th.tsx | 36 ++ .../wow-ui/src/components/Table/Thead.tsx | 61 +++ packages/wow-ui/src/components/Table/Tr.tsx | 61 +++ packages/wow-ui/src/hooks/useCountRow.ts | 25 ++ packages/wow-ui/src/hooks/usePagination.ts | 100 +++++ .../wow-ui/src/hooks/useTableCheckState.ts | 51 +++ packages/wow-ui/src/types/table.ts | 19 + pnpm-lock.yaml | 45 +- 21 files changed, 1508 insertions(+), 21 deletions(-) create mode 100644 .changeset/grumpy-llamas-occur.md create mode 100644 packages/wow-icons/src/component/DoubleArrow.tsx create mode 100644 packages/wow-icons/src/svg/double-arrow.svg create mode 100644 packages/wow-ui/src/components/Pagination/Pagination.stories.tsx create mode 100644 packages/wow-ui/src/components/Pagination/index.tsx create mode 100644 packages/wow-ui/src/components/Table/Table.stories.tsx create mode 100644 packages/wow-ui/src/components/Table/Table.tsx create mode 100644 packages/wow-ui/src/components/Table/TableContext.ts create mode 100644 packages/wow-ui/src/components/Table/Tbody.tsx create mode 100644 packages/wow-ui/src/components/Table/Td.tsx create mode 100644 packages/wow-ui/src/components/Table/Th.tsx create mode 100644 packages/wow-ui/src/components/Table/Thead.tsx create mode 100644 packages/wow-ui/src/components/Table/Tr.tsx create mode 100644 packages/wow-ui/src/hooks/useCountRow.ts create mode 100644 packages/wow-ui/src/hooks/usePagination.ts create mode 100644 packages/wow-ui/src/hooks/useTableCheckState.ts create mode 100644 packages/wow-ui/src/types/table.ts diff --git a/.changeset/grumpy-llamas-occur.md b/.changeset/grumpy-llamas-occur.md new file mode 100644 index 00000000..3ff36481 --- /dev/null +++ b/.changeset/grumpy-llamas-occur.md @@ -0,0 +1,5 @@ +--- +"wowds-ui": patch +--- + +pagination 컴포넌트를 배포해요. diff --git a/packages/wow-icons/src/component/DoubleArrow.tsx b/packages/wow-icons/src/component/DoubleArrow.tsx new file mode 100644 index 00000000..9b87d008 --- /dev/null +++ b/packages/wow-icons/src/component/DoubleArrow.tsx @@ -0,0 +1,48 @@ +import { forwardRef } from "react"; +import { color } from "wowds-tokens"; + +import type { IconProps } from "@/types/Icon.ts"; + +const DoubleArrow = forwardRef( + ( + { + className, + width = "20", + height = "20", + viewBox = "0 0 20 20", + stroke = "white", + ...rest + }, + ref + ) => { + return ( + + + + + ); + } +); + +DoubleArrow.displayName = "DoubleArrow"; +export default DoubleArrow; diff --git a/packages/wow-icons/src/component/index.ts b/packages/wow-icons/src/component/index.ts index 238767d8..cec930f3 100644 --- a/packages/wow-icons/src/component/index.ts +++ b/packages/wow-icons/src/component/index.ts @@ -3,6 +3,7 @@ export { default as BlueAvatar } from "./BlueAvatar.tsx"; export { default as Calendar } from "./Calendar.tsx"; export { default as Check } from "./Check.tsx"; export { default as Close } from "./Close.tsx"; +export { default as DoubleArrow } from "./DoubleArrow.tsx"; export { default as DownArrow } from "./DownArrow.tsx"; export { default as Edit } from "./Edit.tsx"; export { default as GdscLogo } from "./GdscLogo.tsx"; diff --git a/packages/wow-icons/src/svg/double-arrow.svg b/packages/wow-icons/src/svg/double-arrow.svg new file mode 100644 index 00000000..bad1be60 --- /dev/null +++ b/packages/wow-icons/src/svg/double-arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/wow-ui/package.json b/packages/wow-ui/package.json index 896cf255..468be460 100644 --- a/packages/wow-ui/package.json +++ b/packages/wow-ui/package.json @@ -50,6 +50,36 @@ "require": "./dist/Tag.cjs", "import": "./dist/Tag.js" }, + "./Table": { + "types": "./dist/components/Table/Table.d.ts", + "require": "./dist/Table.cjs", + "import": "./dist/Table.js" + }, + "./Tbody": { + "types": "./dist/components/Table/Tbody.d.ts", + "require": "./dist/Tbody.cjs", + "import": "./dist/Tbody.js" + }, + "./Td": { + "types": "./dist/components/Table/Td.d.ts", + "require": "./dist/Td.cjs", + "import": "./dist/Td.js" + }, + "./Th": { + "types": "./dist/components/Table/Th.d.ts", + "require": "./dist/Th.cjs", + "import": "./dist/Th.js" + }, + "./Thead": { + "types": "./dist/components/Table/Thead.d.ts", + "require": "./dist/Thead.cjs", + "import": "./dist/Thead.js" + }, + "./Tr": { + "types": "./dist/components/Table/Tr.d.ts", + "require": "./dist/Tr.cjs", + "import": "./dist/Tr.js" + }, "./Tabs": { "types": "./dist/components/Tabs/index.d.ts", "require": "./dist/Tabs.cjs", @@ -130,6 +160,11 @@ "require": "./dist/TimePicker.cjs", "import": "./dist/TimePicker.js" }, + "./Pagination": { + "types": "./dist/components/Pagination/index.d.ts", + "require": "./dist/Pagination.cjs", + "import": "./dist/Pagination.js" + }, "./MultiGroup": { "types": "./dist/components/MultiGroup/index.d.ts", "require": "./dist/MultiGroup.cjs", @@ -213,6 +248,7 @@ "@types/react-dom": "^18.2.19", "axe-playwright": "^2.0.1", "chromatic": "^11.3.0", + "clsx": "^2.1.1", "eslint": "^8.57.0", "eslint-plugin-storybook": "^0.8.0", "playwright": "1.45.0", diff --git a/packages/wow-ui/rollup.config.js b/packages/wow-ui/rollup.config.js index 5a27f84a..7bb9dfa3 100644 --- a/packages/wow-ui/rollup.config.js +++ b/packages/wow-ui/rollup.config.js @@ -26,6 +26,12 @@ export default { TextField: "./src/components/TextField", TextButton: "./src/components/TextButton", Tag: "./src/components/Tag", + Table: "./src/components/Table/Table", + Tbody: "./src/components/Table/Tbody", + Td: "./src/components/Table/Td", + Th: "./src/components/Table/Th", + Thead: "./src/components/Table/Thead", + Tr: "./src/components/Table/Tr", Tabs: "./src/components/Tabs", TabsContent: "./src/components/Tabs/TabsContent", TabsItem: "./src/components/Tabs/TabsItem", @@ -42,6 +48,7 @@ export default { RangeDatePicker: "./src/components/Picker/RangeDatePicker", SingleDatePicker: "./src/components/Picker/SingleDatePicker", TimePicker: "./src/components/Picker/TimePicker", + Pagination: "./src/components/Pagination", MultiGroup: "./src/components/MultiGroup", Header: "./src/components/Header", DropDownOption: "./src/components/DropDown/DropDownOption", diff --git a/packages/wow-ui/src/components/Pagination/Pagination.stories.tsx b/packages/wow-ui/src/components/Pagination/Pagination.stories.tsx new file mode 100644 index 00000000..ffd0d7ec --- /dev/null +++ b/packages/wow-ui/src/components/Pagination/Pagination.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; + +import Button from "@/components/Button"; +import Pagination from "@/components/Pagination"; + +const meta = { + title: "UI/Pagination", + component: Pagination, + tags: ["autodocs"], + parameters: { + componentSubtitle: "페이지네이션 컴포넌트", + a11y: { + config: { + rules: [{ id: "color-contrast", enabled: false }], + }, + }, + }, + argTypes: { + totalPages: { + description: "페이지의 총 개수입니다.", + table: { + type: { summary: "number" }, + }, + control: { + type: "number", + }, + }, + currentPage: { + description: "외부에서 주입할 수 있는 현재 페이지입니다.", + table: { + type: { summary: "number" }, + }, + control: { + type: "number", + }, + }, + defaultPage: { + description: "기본 페이지입니다.", + table: { + type: { summary: "number" }, + }, + control: { + type: "number", + }, + }, + onChange: { + description: "외부에서 페이지 값의 변화를 감지할 수 있는 함수입니다.", + table: { + type: { summary: "(page: number) => void" }, + }, + }, + pageButtonBackgroundColor: { + description: "페이지네이션 컴포넌트 버튼 색을 변경합니다.", + table: { + type: { summary: "ColorToken" }, + }, + }, + style: { + description: + "페이지네이션 컴포넌트에 커스텀하게 전달할 style입니다 배경색 등을 변경할 수 있습니다.", + table: { + type: { summary: "CSSProperties" }, + defaultValue: { summary: "{}" }, + }, + control: false, + }, + className: { + description: + "페이지네이션 컴포넌트에 전달하는 커스텀 클래스를 설정합니다.", + table: { + type: { summary: "string" }, + }, + control: { + type: "text", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: { + totalPages: 13, + }, +}; + +export const DefaultPage: Story = { + args: { + totalPages: 13, + defaultPage: 6, + }, +}; + +export const ChangeBackgroundColorPage: Story = { + args: { + totalPages: 15, + pageButtonBackgroundColor: "red50", + }, +}; + +const ControlledPagination = () => { + const [selectedPage, setSelectedPage] = useState(1); + + const handleSelectionChange = () => { + if (selectedPage >= 15) { + setSelectedPage(15); + } else setSelectedPage(selectedPage + 3); + }; + return ( +
+ + +
+ ); +}; + +export const ControlledState: Story = { + args: { totalPages: 15 }, + render: () => , +}; + +const WatchPageStatePagination = () => { + const [selectedPage, setSelectedPage] = useState(1); + + const handleSelectionChange = (page: number) => { + setSelectedPage(page); + }; + return ( +
+ +
선택된 페이지는 {selectedPage}입니다
+
+ ); +}; + +export const WatchPageState: Story = { + args: { totalPages: 15 }, + render: () => , +}; diff --git a/packages/wow-ui/src/components/Pagination/index.tsx b/packages/wow-ui/src/components/Pagination/index.tsx new file mode 100644 index 00000000..bb3c1877 --- /dev/null +++ b/packages/wow-ui/src/components/Pagination/index.tsx @@ -0,0 +1,263 @@ +import { css, cva } from "@styled-system/css"; +import type { Token } from "@styled-system/tokens"; +import { token } from "@styled-system/tokens"; +import { clsx } from "clsx"; +import type { CSSProperties } from "react"; +import { forwardRef } from "react"; +import { DoubleArrow, RightArrow } from "wowds-icons"; +import type { ColorToken } from "wowds-theme"; + +import usePagination from "@/hooks/usePagination"; + +/** + * @description 페이지의 개수를 랜더링할 수 있는 페이지네이션 컴포넌트입니다. + * + * @param {number} totalPages 페이지의 총 개수입니다. + * @param {number} currentPage 외부에서 주입할 수 있는 현재 페이지입니다. + * @param {number} defaultPage 기본 페이지입니다. + * @param {ColorToken} pageButtonBackgroundColor 페이지네이션 컴포넌트 버튼 색을 변경합니다. + * @param {(page: number) => void} [onChange] 외부에서 페이지 값의 변화를 감지할 수 있는 함수입니다. + * @param {() => void} [style] 페이지네이션 컴포넌트에 커스텀하게 전달할 style입니다. + * @param {string} [className] 페이지네이션 컴포넌트에 전달하는 커스텀 클래스를 설정합니다. + * @param {Ref} [ref] ref 렌더링된 요소 또는 컴포넌트에 연결할 ref입니다. + */ + +export interface PaginationProps { + totalPages: number; + defaultPage?: number; + currentPage?: number; + pageButtonBackgroundColor?: ColorToken; + onChange?: (page: number) => void; + style?: CSSProperties; + className?: string; +} + +const Pagination = forwardRef( + ( + { + totalPages, + defaultPage, + onChange, + style, + className, + pageButtonBackgroundColor, + currentPage: currentPageProp, + }, + ref + ) => { + const { + currentPage, + getPageRange, + handleClickNextGroup, + handleClickPrevGroup, + handleClickPage, + handleClickPrevPage, + handleClickNextPage, + } = usePagination(totalPages, onChange, defaultPage, currentPageProp); + + const { start, end } = getPageRange(); + function addDotBetweenLettersAndNumbers(str: string) { + return str.replace(/([a-zA-Z]+)(\d+)/g, "$1.$2"); + } + const customBackgroundColor = (color: ColorToken | undefined) => { + if (color) { + const colorToken = color.replace(/([a-zA-Z]+)(\d+)/g, "$1.$2"); + return token.var( + `colors.${addDotBetweenLettersAndNumbers(colorToken)}` as Token + ); + } + }; + + return ( + + ); + } +); + +export default Pagination; + +const paginationContainer = css({ + display: "flex", + flexDirection: "row", + gap: "4px", + maxHeight: "24px", +}); + +const paginationButtonGroupStyle = css({ + display: "flex", + flexDirection: "row", + alignItems: "center", + minHeight: "24px", + marginX: "4px", +}); + +const paginationPageGroupStyle = css({ + display: "flex", + alignItems: "center", + gap: "4px", +}); + +const paginationButtonStyle = css({ + minHeight: "24px", + minWidth: "24px", + borderRadius: "sm", + backgroundColor: "backgroundAlternative", + color: "sub", + boxSizing: "border-box", + textStyle: "body1", + display: "flex", + justifyContent: "center", + alignItems: "center", + cursor: "pointer", + _pressed: { + backgroundColor: "monoBackgroundPressed", + borderColor: "sub", + border: "1px solid", + }, + _disabled: { + backgroundColor: "Background", + cursor: "not-allowed", + }, +}); + +const paginationItemStyle = cva({ + base: { + minWidth: "24px", + maxHeight: "24px", + borderRadius: "sm", + color: "sub", + boxSizing: "border-box", + textStyle: "body1", + display: "flex", + justifyContent: "center", + alignItems: "center", + cursor: "pointer", + _pressed: { + backgroundColor: "monoBackgroundPressed", + borderColor: "sub", + border: "1px solid", + }, + _disabled: { + backgroundColor: "Background", + cursor: "not-allowed", + color: "lightDisabled", + }, + }, + variants: { + selected: { + true: { + backgroundColor: "Background", + borderColor: "primary", + border: "1px solid", + color: "black", + }, + false: { + backgroundColor: "backgroundAlternative", + }, + }, + }, +}); + +const paginationUlStyle = css({ + listStyleType: "none", + display: "flex", +}); diff --git a/packages/wow-ui/src/components/Table/Table.stories.tsx b/packages/wow-ui/src/components/Table/Table.stories.tsx new file mode 100644 index 00000000..76ee68ef --- /dev/null +++ b/packages/wow-ui/src/components/Table/Table.stories.tsx @@ -0,0 +1,383 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { styled } from "@styled-system/jsx"; +import { useState } from "react"; + +import Button from "@/components/Button"; +import Pagination from "@/components/Pagination"; +import Table from "@/components/Table/Table"; + +const meta = { + title: "UI/Table", + tags: ["autodocs"], + parameters: { + componentSubtitle: "테이블 컴포넌트", + a11y: { + config: { + rules: [{ id: "color-contrast", enabled: false }], + }, + }, + }, + argTypes: { + tableCaption: { + description: "테이블에 대한 설명을 나타내는 캡션입니다.", + table: { + type: { summary: "string" }, + }, + control: { + type: "text", + }, + }, + showCheckbox: { + description: "테이블에 체크박스를 나타냅니다.", + table: { + type: { summary: "boolean" }, + }, + control: { + type: "boolean", + }, + }, + selectedRowsProp: { + description: + "default 값을 설정하거나, 외부에서 table의 체크 상태 관리할 수 있는 변수입니다.", + table: { + type: { summary: "number[]" }, + }, + }, + onChange: { + description: "외부 활성 상태가 변경될 때 호출되는 함수입니다.", + table: { + type: { summary: "(selectedRows: number[]) => void" }, + }, + }, + className: { + description: "테이블 컴포넌트에게 전달할 className을 정의합니다.", + table: { + type: { summary: "string" }, + }, + control: "text", + }, + fullWidth: { + description: "테이블 컴포넌트의 가로 길이를 결정할 수 있습니다.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + control: "boolean", + }, + style: { + description: "테이블 커스텀 스타일을 설정합니다.", + table: { + type: { summary: "CSSProperties" }, + defaultValue: { summary: "{}" }, + }, + control: false, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + render: () => { + const data = [ + { id: 1, name: "김유진", studyId: "C035087", birth: "2000" }, + { id: 2, name: "강해린", studyId: "C011111", birth: "2006" }, + { id: 3, name: "김민지", studyId: "C234567", birth: "2004" }, + ]; + return ( + + + 이름 + 학번 + + + {data.map(({ name, studyId, id }) => { + return ( + + {name} + {studyId} + + ); + })} + +
+ ); + }, +}; + +export const ScrollTable: Story = { + render: () => { + const data = [ + { + id: 1, + name: "김유진", + studyId: "C035087", + birth: "2000", + discordname: "eugene028", + button: ( + + + + + ), + }, + { + id: 2, + name: "강해린", + discordname: "haerin111", + studyId: "C011111", + birth: "2006", + button: ( + + + + + ), + }, + { + id: 3, + name: "김민지", + studyId: "C234567", + discordname: "minijjang", + birth: "2004", + button: ( + + + + + ), + }, + ]; + return ( + + + 이름 + 학번 + 버튼 + + + {data.map(({ name, studyId, button, id }) => { + return ( + + {name} + {studyId} + {button} + + ); + })} + +
+ ); + }, +}; + +export const CheckableTable: Story = { + render: () => { + const data = [ + { + id: 1, + name: "김유진", + studyId: "C035087", + birth: "2000", + discordname: "eugene028", + button: ( + + + + + ), + }, + { + id: 2, + name: "강해린", + discordname: "haerin111", + studyId: "C011111", + birth: "2006", + button: ( + + + + + ), + }, + { + id: 3, + name: "김민지", + studyId: "C234567", + discordname: "minijjang", + birth: "2004", + button: ( + + + + + ), + }, + ]; + return ( + + + 이름 + 학번 + 버튼 + + + {data.map(({ name, studyId, button, id }) => { + return ( + + {name} + {studyId} + {button} + + ); + })} + +
+ ); + }, +}; + +const ControlledTable = () => { + const selectedProps = [2, 3]; + const [selectedRows, setSelectedRows] = useState(selectedProps); + const data = [ + { + id: 1, + name: "김유진", + studyId: "C035087", + birth: "2000", + discordname: "eugene028", + }, + { + id: 2, + name: "강해린", + discordname: "haerin111", + studyId: "C011111", + birth: "2006", + }, + { + id: 3, + name: "김민지", + studyId: "C234567", + discordname: "minijjang", + birth: "2004", + }, + ]; + const handleSelectionChange = (rows: number[]) => { + setSelectedRows(rows); + }; + return ( + <> + + + + 이름 + 학번 + + + {data.map(({ name, studyId, id }) => { + return ( + + {name} + {studyId} + + ); + })} + +
+ + ); +}; + +export const ControlledTableState: Story = { + render: () => , +}; + +const TableWithPaginationComponent = () => { + const [selectedPage, setSelectedPage] = useState(1); + + const handleSelectionChange = (page: number) => { + setSelectedPage(page); + }; + + const data = [ + { id: 1, name: "김유진", studyId: "C035087", birth: "2000" }, + { id: 2, name: "이영지", studyId: "C023456", birth: "2001" }, + { id: 3, name: "모다니", studyId: "C045678", birth: "2005" }, + { id: 4, name: "민지", studyId: "C122222", birth: "2004" }, + { id: 5, name: "하니", studyId: "C133333", birth: "2004" }, + { id: 6, name: "다니엘", studyId: "C144444", birth: "2005" }, + { id: 7, name: "해린", studyId: "C155555", birth: "2006" }, + { id: 8, name: "혜인", studyId: "C166666", birth: "2008" }, + { id: 9, name: "카리나", studyId: "C177777", birth: "2000" }, + { id: 10, name: "윈터", studyId: "C188888", birth: "2001" }, + { id: 11, name: "지젤", studyId: "C199999", birth: "2000" }, + { id: 12, name: "이서", studyId: "C200000", birth: "2007" }, + { id: 13, name: "장원영", studyId: "C211111", birth: "2004" }, + { id: 14, name: "안유진", studyId: "C222222", birth: "2003" }, + ]; + + const itemsPerPage = 5; + const totalPages = Math.ceil(data.length / itemsPerPage); + + const currentData = data.slice( + (selectedPage - 1) * itemsPerPage, + selectedPage * itemsPerPage + ); + + return ( +
+ + + 이름 + 학번 + + + {currentData.map(({ name, studyId, id }) => { + return ( + + {name} + {studyId} + + ); + })} + +
+ +
+ ); +}; + +export const TableWithPagination: Story = { + render: () => , +}; diff --git a/packages/wow-ui/src/components/Table/Table.tsx b/packages/wow-ui/src/components/Table/Table.tsx new file mode 100644 index 00000000..aeab345c --- /dev/null +++ b/packages/wow-ui/src/components/Table/Table.tsx @@ -0,0 +1,145 @@ +"use client"; +import { css, cva } from "@styled-system/css"; +import { styled } from "@styled-system/jsx"; +import { clsx } from "clsx"; +import type { CSSProperties, ReactNode, Ref } from "react"; +import { forwardRef } from "react"; + +import { TableContext } from "@/components/Table/TableContext"; +import Tbody from "@/components/Table/Tbody"; +import Td from "@/components/Table/Td"; +import Th from "@/components/Table/Th"; +import Thead from "@/components/Table/Thead"; +import Tr from "@/components/Table/Tr"; +import useCountRow from "@/hooks/useCountRow"; +import useTableCheckState from "@/hooks/useTableCheckState"; +import type { TableComponentType } from "@/types/table"; + +/** + * @description 데이터 및 비동기 결과물을 나타낼 수 있는 테이블 컴포넌트입니다. + * @param {string} [tableCaption] 테이블에 대한 설명을 나타내는 캡션입니다. + * @param {showCheckbox} [boolean] 테이블에 대한 상세한 옵션값을 설정합니다. + * @param {number[]} [selectedRowsProp] default 값을 설정하거나, 외부에서 table의 체크 상태 관리할 수 있는 변수입니다. + * @param {(selectedRows: number[]) => void} [onChange] 외부 활성 상태가 변경될 때 호출되는 함수입니다. + * @param {() => void} [className] table 컴포넌트에게 전달할 className을 정의합니다. + * @param {() => void} [fullWidth=false] table 컴포넌트의 가로 길이를 결정할 수 있습니다. + * @param {() => void} [style] table 컴포넌트에 커스텀하게 전달할 style입니다. + * @param {Ref} [ref] ref 렌더링된 요소 또는 컴포넌트에 연결할 ref입니다. + */ + +export interface TableProps { + tableCaption?: string; + showCheckbox?: boolean; + selectedRowsProp?: number[]; + onChange?: (selectedRows: number[]) => void; + fullWidth?: boolean; + style?: CSSProperties; + children: ReactNode; + className?: string; +} + +const TableComponent = forwardRef( + function TableFunction( + { + tableCaption = "", + children, + showCheckbox, + className, + selectedRowsProp, + onChange, + fullWidth = false, + style, + ...rest + }: TableProps, + ref: Ref + ) { + const { rowValues } = useCountRow(children); + const { + handleRowCheckboxChange, + handleHeaderCheckboxChange, + selectedRows, + } = useTableCheckState(rowValues, selectedRowsProp, onChange); + + const contextValue: ReturnType & + Omit & { rowValues: number[] } = { + rowValues, + selectedRows, + showCheckbox, + handleRowCheckboxChange, + handleHeaderCheckboxChange, + }; + + return ( + +
+ + {tableCaption && ( + + {tableCaption} + + )} + {children} + +
+
+ ); + } +); + +const Table = TableComponent as TableComponentType; +Table.Thead = Thead; +Table.Th = Th; +Table.Tbody = Tbody; +Table.Tr = Tr; +Table.Td = Td; + +export default Table; + +const TableStyle = cva({ + base: { + borderCollapse: "collapse", + backgroundColor: "white", + height: "100%", + }, + variants: { + fullWidth: { + true: { + width: "100%", + }, + false: { + width: "fit-content", + }, + }, + }, +}); + +const TableContainerStyle = css({ + overflow: "auto", + position: "relative", + _scrollbar: { + width: "3px", + height: "3px", + }, + _scrollbarThumb: { + width: "5px", + height: "5px", + borderRadius: "sm", + backgroundColor: "outline", + }, + _scrollbarTrack: { + marginTop: "2px", + marginBottom: "2px", + backgroundColor: "transparent", + }, +}); diff --git a/packages/wow-ui/src/components/Table/TableContext.ts b/packages/wow-ui/src/components/Table/TableContext.ts new file mode 100644 index 00000000..cb457378 --- /dev/null +++ b/packages/wow-ui/src/components/Table/TableContext.ts @@ -0,0 +1,12 @@ +import { createContext } from "react"; + +import useSafeContext from "@/hooks/useSafeContext"; + +export const TableContext = createContext(null); + +export const useTableContext = () => { + const context = useSafeContext(TableContext); + return context; +}; + +export const TableCheckedContext = createContext(0); diff --git a/packages/wow-ui/src/components/Table/Tbody.tsx b/packages/wow-ui/src/components/Table/Tbody.tsx new file mode 100644 index 00000000..28f87088 --- /dev/null +++ b/packages/wow-ui/src/components/Table/Tbody.tsx @@ -0,0 +1,21 @@ +import { styled } from "@styled-system/jsx"; +import type { CSSProperties, PropsWithChildren } from "react"; +import React, { forwardRef } from "react"; + +interface TableBodyProps extends PropsWithChildren { + style?: CSSProperties; + className?: string; +} + +const Tbody = forwardRef( + (props, ref) => { + const { children, ...rest } = props; + return ( + + {children} + + ); + } +); + +export default Tbody; diff --git a/packages/wow-ui/src/components/Table/Td.tsx b/packages/wow-ui/src/components/Table/Td.tsx new file mode 100644 index 00000000..9b8b247b --- /dev/null +++ b/packages/wow-ui/src/components/Table/Td.tsx @@ -0,0 +1,61 @@ +import { cva } from "@styled-system/css"; +import { styled } from "@styled-system/jsx"; +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { forwardRef, useContext } from "react"; + +import { + TableCheckedContext, + useTableContext, +} from "@/components/Table/TableContext"; + +interface TableCellProps extends PropsWithChildren { + style?: CSSProperties; + className?: string; +} + +const Td = forwardRef( + (props: TableCellProps, ref: Ref) => { + const { children, ...rest } = props; + const { selectedRows } = useTableContext(); + const value = useContext(TableCheckedContext); + const isSelected = selectedRows.some((row: number) => row === value); + + return ( + + {children} + + ); + } +); + +const TableCellStyle = cva({ + base: { + maxWidth: "300px", + paddingX: "sm", + paddingY: "xxs", + minWidth: "74px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + variants: { + checked: { + true: { + backgroundColor: "backgroundAlternative", + }, + false: { + backgroundColor: "white", + }, + }, + }, + defaultVariants: { + checked: false, + }, +}); + +export default Td; diff --git a/packages/wow-ui/src/components/Table/Th.tsx b/packages/wow-ui/src/components/Table/Th.tsx new file mode 100644 index 00000000..8d6efff4 --- /dev/null +++ b/packages/wow-ui/src/components/Table/Th.tsx @@ -0,0 +1,36 @@ +import { css } from "@styled-system/css"; +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { forwardRef } from "react"; + +interface TableHeaderProps extends PropsWithChildren { + style?: CSSProperties; + className?: string; +} +const Th = forwardRef( + (props: TableHeaderProps, ref: Ref) => { + const { children, ...rest } = props; + return ( + + {children} + + ); + } +); + +const TableHeaderStyle = css({ + alignItems: "center", + backgroundColor: "backgroundAlternative", + color: "sub", + height: "44px", + letterSpacing: "wider", + maxWidth: "300px", + minWidth: "74px", + overflow: "hidden", + paddingX: "sm", + textAlign: "start", + textOverflow: "ellipsis", + textStyle: "label2", + whiteSpace: "nowrap", +}); + +export default Th; diff --git a/packages/wow-ui/src/components/Table/Thead.tsx b/packages/wow-ui/src/components/Table/Thead.tsx new file mode 100644 index 00000000..7b9936ff --- /dev/null +++ b/packages/wow-ui/src/components/Table/Thead.tsx @@ -0,0 +1,61 @@ +import { styled } from "@styled-system/jsx"; +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { forwardRef } from "react"; + +import Checkbox from "@/components/Checkbox"; +import { useTableContext } from "@/components/Table/TableContext"; +import Th from "@/components/Table/Th"; + +interface TheadProps extends PropsWithChildren { + style?: CSSProperties; + className?: string; +} + +const Thead = forwardRef( + function TheadFunction( + { children, ...rest }: TheadProps, + ref: Ref + ) { + const { + selectedRows, + showCheckbox, + handleHeaderCheckboxChange, + rowValues, + } = useTableContext(); + + const isHeaderCheckboxChecked = + selectedRows.length === rowValues.length && rowValues.length > 0; + return ( + + + {showCheckbox && ( + + + + )} + {children} + + + ); + } +); + +export default Thead; + +const TableCheckBoxStyle = { + minWidth: "15px", + display: "flex", + minHeight: "44px", + justifyContent: "center", + alignItems: "center", +}; diff --git a/packages/wow-ui/src/components/Table/Tr.tsx b/packages/wow-ui/src/components/Table/Tr.tsx new file mode 100644 index 00000000..a0a65b81 --- /dev/null +++ b/packages/wow-ui/src/components/Table/Tr.tsx @@ -0,0 +1,61 @@ +import { styled } from "@styled-system/jsx"; +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { forwardRef } from "react"; + +import Checkbox from "@/components/Checkbox"; +import { + TableCheckedContext, + useTableContext, +} from "@/components/Table/TableContext"; +import Td from "@/components/Table/Td"; + +interface TableRowProps extends PropsWithChildren { + style?: CSSProperties; + value?: number; + className?: string; +} + +const TableRow = forwardRef( + function TableRowFunction( + props: TableRowProps, + ref: Ref + ) { + const { children, value, ...rest } = props; + const { selectedRows, handleRowCheckboxChange, showCheckbox } = + useTableContext(); + const isSelected = selectedRows.some((row: number) => row === value); + return ( + + + {showCheckbox && ( + + handleRowCheckboxChange(value)} + /> + + )} + {children} + + + ); + } +); + +export default TableRow; + +const TableCheckBoxStyle = { + minWidth: "15px", + display: "flex", + minHeight: "44px", + justifyContent: "center", + alignItems: "center", +}; diff --git a/packages/wow-ui/src/hooks/useCountRow.ts b/packages/wow-ui/src/hooks/useCountRow.ts new file mode 100644 index 00000000..0bf4af9a --- /dev/null +++ b/packages/wow-ui/src/hooks/useCountRow.ts @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { Children, isValidElement, useLayoutEffect, useState } from "react"; + +import Table from "@/components/Table/Table"; + +const useCountRow = (children: ReactNode) => { + const [rowValues, setRowValues] = useState([]); + + useLayoutEffect(() => { + Children.forEach(children, (child) => { + if (isValidElement(child) && child.type === Table.Tbody) { + Children.forEach(child.props.children, (row) => { + if (isValidElement(row) && row.type === Table.Tr) { + const rowProps = row.props as { value: number }; + setRowValues((prevValues) => [...prevValues, rowProps.value]); + } + }); + } + }); + }, []); + + return { rowValues }; +}; + +export default useCountRow; diff --git a/packages/wow-ui/src/hooks/usePagination.ts b/packages/wow-ui/src/hooks/usePagination.ts new file mode 100644 index 00000000..bd54ea8d --- /dev/null +++ b/packages/wow-ui/src/hooks/usePagination.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useState } from "react"; + +const ITEM_PER_PAGE = 5; + +const usePagination = ( + totalPages: number, + onChange?: (page: number) => void, + defaultPage?: number, + currentPageProp?: number +) => { + const [startPage, setStartPage] = useState(1); + const [currentPage, setCurrentPage] = useState(defaultPage || 1); + + useEffect(() => { + if (currentPageProp) { + if (currentPageProp > totalPages) setCurrentPage(totalPages); + else if (currentPageProp <= 1) setCurrentPage(1); + else setCurrentPage(currentPageProp); + } + }, [currentPageProp, totalPages]); + + useEffect(() => { + const newStartPage = + Math.floor((currentPage - 1) / ITEM_PER_PAGE) * ITEM_PER_PAGE + 1; + setStartPage(newStartPage); + if (onChange) onChange(currentPage); + }, [currentPage, onChange]); + + const getPageRange = () => { + const endPage = Math.min(startPage + ITEM_PER_PAGE - 1, totalPages); + return { start: startPage, end: endPage }; + }; + + const { start, end } = getPageRange(); + + const updatePageState = useCallback( + (newStartPage: number, newCurrentPage: number) => { + setStartPage(newStartPage); + setCurrentPage(newCurrentPage); + if (onChange) onChange(newCurrentPage); + }, + [onChange] + ); + + const handleClickNextGroup = useCallback(() => { + if (end < totalPages) { + const nextStartPage = Math.min(startPage + ITEM_PER_PAGE, totalPages); + updatePageState(nextStartPage, nextStartPage); + } + }, [end, totalPages, startPage, updatePageState]); + + const handleClickPrevGroup = useCallback(() => { + if (start > 1) { + const prevStartPage = Math.max(startPage - ITEM_PER_PAGE, 1); + updatePageState(prevStartPage, prevStartPage + ITEM_PER_PAGE - 1); + } + }, [start, startPage, updatePageState]); + + const handleClickPage = useCallback( + (page: number) => { + setCurrentPage(page); + if (onChange) onChange(page); + }, + [onChange] + ); + + const handleClickNextPage = useCallback(() => { + if (currentPage < totalPages) { + if (currentPage === end) { + handleClickNextGroup(); + } else { + const nextPage = currentPage + 1; + handleClickPage(nextPage); + } + } + }, [currentPage, totalPages, end, handleClickNextGroup, handleClickPage]); + + const handleClickPrevPage = useCallback(() => { + if (currentPage > 1) { + if (currentPage === start) { + handleClickPrevGroup(); + } else { + const prevPage = currentPage - 1; + handleClickPage(prevPage); + } + } + }, [currentPage, start, handleClickPrevGroup, handleClickPage]); + + return { + currentPage, + getPageRange, + handleClickNextGroup, + handleClickPrevGroup, + handleClickPage, + handleClickPrevPage, + handleClickNextPage, + }; +}; + +export default usePagination; diff --git a/packages/wow-ui/src/hooks/useTableCheckState.ts b/packages/wow-ui/src/hooks/useTableCheckState.ts new file mode 100644 index 00000000..a2de586c --- /dev/null +++ b/packages/wow-ui/src/hooks/useTableCheckState.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from "react"; + +const useTableCheckState = ( + data: number[], + selectedRowsProp?: number[], + onChange?: (selectedRows: number[]) => void +) => { + const [selectedRows, setSelectedRows] = useState([]); + + useEffect(() => { + if (selectedRowsProp) { + setSelectedRows(selectedRowsProp); + } + }, [selectedRowsProp]); + + const handleRowCheckboxChange = useCallback( + (rowData: number) => { + setSelectedRows((prevSelectedRows) => { + const rowId = rowData; + const isSelected = prevSelectedRows.some((row) => row === rowId); + const newSelectedRows = isSelected + ? prevSelectedRows.filter((row) => row !== rowId) + : [...prevSelectedRows, rowData]; + if (onChange) { + onChange(newSelectedRows); + } + return newSelectedRows; + }); + }, + [onChange] + ); + + const handleHeaderCheckboxChange = useCallback(() => { + setSelectedRows((prevSelectedRows) => { + const newSelectedRows = + prevSelectedRows.length === data.length ? [] : [...data]; + if (onChange) { + onChange(newSelectedRows); + } + return newSelectedRows; + }); + }, [data, onChange]); + + return { + handleRowCheckboxChange, + handleHeaderCheckboxChange, + selectedRows, + }; +}; + +export default useTableCheckState; diff --git a/packages/wow-ui/src/types/table.ts b/packages/wow-ui/src/types/table.ts new file mode 100644 index 00000000..60f9bb7f --- /dev/null +++ b/packages/wow-ui/src/types/table.ts @@ -0,0 +1,19 @@ +import type { ForwardRefExoticComponent } from "react"; + +import type { TableProps } from "@/components/Table/Table"; +import type Tbody from "@/components/Table/Tbody"; +import type Td from "@/components/Table/Td"; +import type Th from "@/components/Table/Th"; +import type Thead from "@/components/Table/Thead"; +import type Tr from "@/components/Table/Tr"; + +export interface TableComponentType + extends ForwardRefExoticComponent< + TableProps & React.RefAttributes + > { + Thead: typeof Thead; + Th: typeof Th; + Tbody: typeof Tbody; + Tr: typeof Tr; + Td: typeof Td; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2118e5d4..f80dc987 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,7 +179,7 @@ importers: version: 4.11.0 turbo: specifier: latest - version: 2.0.9 + version: 2.0.12 typescript: specifier: ^5.3.3 version: 5.3.3 @@ -335,6 +335,9 @@ importers: chromatic: specifier: ^11.3.0 version: 11.3.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 eslint: specifier: ^8.57.0 version: 8.57.0 @@ -15239,64 +15242,64 @@ packages: yargs: 17.7.2 dev: true - /turbo-darwin-64@2.0.9: - resolution: {integrity: sha512-owlGsOaExuVGBUfrnJwjkL1BWlvefjSKczEAcpLx4BI7Oh6ttakOi+JyomkPkFlYElRpjbvlR2gP8WIn6M/+xQ==} + /turbo-darwin-64@2.0.12: + resolution: {integrity: sha512-NAgfgbXxX/JScWQmmQnGbPuFZq7LIswHfcMk5JwyBXQM/xmklNOxxac7MnGGIOf19Z2f6S3qHy17VIj0SeGfnA==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@2.0.9: - resolution: {integrity: sha512-XAXkKkePth5ZPPE/9G9tTnPQx0C8UTkGWmNGYkpmGgRr8NedW+HrPsi9N0HcjzzIH9A4TpNYvtiV+WcwdaEjKA==} + /turbo-darwin-arm64@2.0.12: + resolution: {integrity: sha512-cP02uer5KSJ+fXL+OfRRk5hnVjV0c60hxDgNcJxrZpfhun7HHoKDDR7w2xhQntiA45aC6ZZEXRqMKpj6GAmKbg==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@2.0.9: - resolution: {integrity: sha512-l9wSgEjrCFM1aG16zItBsZ206ZlhSSx1owB8Cgskfv0XyIXRGHRkluihiaxkp+UeU5WoEfz4EN5toc+ICA0q0w==} + /turbo-linux-64@2.0.12: + resolution: {integrity: sha512-+mQgGfg1eq5qF+wenK/FKJaNMNAo5DQLC4htQy+8osW+fx6U+8+6UlPQPaycAWDEqwOI7NwuqkeHfkEQLQUTyQ==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@2.0.9: - resolution: {integrity: sha512-gRnjxXRne18B27SwxXMqL3fJu7jw/8kBrOBTBNRSmZZiG1Uu3nbnP7b4lgrA/bCku6C0Wligwqurvtpq6+nFHA==} + /turbo-linux-arm64@2.0.12: + resolution: {integrity: sha512-KFyEZDXfPU1DK4zimxdCcqAcK7IIttX4mfsgB7NsSEOmH0dhHOih/YFYiyEDC1lTRx0C2RlzQ0Kjjdz48AN5Eg==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@2.0.9: - resolution: {integrity: sha512-ZVo0apxUvaRq4Vm1qhsfqKKhtRgReYlBVf9MQvVU1O9AoyydEQvLDO1ryqpXDZWpcHoFxHAQc9msjAMtE5K2lA==} + /turbo-windows-64@2.0.12: + resolution: {integrity: sha512-kJj4KCkZTkDTDCqsSw1m1dbO4WeoQq1mYUm/thXOH0OkeqYbSMt0EyoTcJOgKUDsrMnzZD2gPfYrlYHtV69lVA==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@2.0.9: - resolution: {integrity: sha512-sGRz7c5Pey6y7y9OKi8ypbWNuIRPF9y8xcMqL56OZifSUSo+X2EOsOleR9MKxQXVaqHPGOUKWsE6y8hxBi9pag==} + /turbo-windows-arm64@2.0.12: + resolution: {integrity: sha512-TY3ROxguDilN2olCwcZMaePdW01Xhma0pZU7bNhsQEqca9RGAmsZBuzfGnTMcWPmv4tpnb/PlX1hrt1Hod/44Q==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@2.0.9: - resolution: {integrity: sha512-QaLaUL1CqblSKKPgLrFW3lZWkWG4pGBQNW+q1ScJB5v1D/nFWtsrD/yZljW/bdawg90ihi4/ftQJ3h6fz1FamA==} + /turbo@2.0.12: + resolution: {integrity: sha512-8s2KwqjwQj7z8Z53SUZSKVkQOZ2/Sl4D2F440oaBY/k2lGju60dW6srEpnn8/RIDeICZmQn3pQHF79Jfnc5Skw==} hasBin: true optionalDependencies: - turbo-darwin-64: 2.0.9 - turbo-darwin-arm64: 2.0.9 - turbo-linux-64: 2.0.9 - turbo-linux-arm64: 2.0.9 - turbo-windows-64: 2.0.9 - turbo-windows-arm64: 2.0.9 + turbo-darwin-64: 2.0.12 + turbo-darwin-arm64: 2.0.12 + turbo-linux-64: 2.0.12 + turbo-linux-arm64: 2.0.12 + turbo-windows-64: 2.0.12 + turbo-windows-arm64: 2.0.12 dev: true /tween-functions@1.2.0: