feat: Table 컴포넌트 3종 추가
이라 스크린리더가 행·열을 읽어 주고 colSpan·rowSpan이 그대로 동작한다.
본문 행의 제목 칸은 그 줄의 이름이라 scope="row", 머리행 칸은 열 이름이라 scope="col"이
붙는다. 호출부는 행을 늘어놓기만 하면 되고 head 여부로 thead·tbody가 갈린다.
타입 차이를 클래스가 아니라 커스텀 프로퍼티로 내려보낸다. `.fox-table--basic
.fox-table-cell`처럼 후손 선택자를 쓰면 표 안에 표가 들어갔을 때 바깥 규칙이 안쪽까지
덮어쓴다 — 글머리 목록에서 겪은 것과 같은 문제다. 커스텀 프로퍼티는 안쪽 표가 자기
값으로 다시 덮으므로 그 일이 생기지 않는다.
좁은 화면 대응도 시안대로 넣었다. list는 머리행을 감추고 한 줄이 카드로 접히며, 이때
열 이름을 알 수 없으므로 칸이 label을 받아 글자로 보여 준다. form은 레이블이 입력 위로
올라가고 레이블 칸의 회색 바탕이 사라진다. basic은 시안에 모바일 변형이 없어 그대로 둔다.
승인된 비토큰 값: form 머리칸 최소 크기 12rem×4.8rem.
검증: 여백·바탕·글자·정렬 18항목이 시안과 일치(나머지 1건은 내 예상값 오류였고 코드가
맞다 — font/weight/medium은 SemiBold 600이다). 모바일에서 list 카드 접힘·열 이름 노출,
form 위아래 쌓임, basic 유지를 확인했다. sass 524규칙 · tsc · eslint 통과.
Co-Authored-By: Claude Opus 5
Figma 표 시안 9종(tbl-basic·tbl-list·tbl-form과 각각의 cell-head·cell-body)을 FoxTable / FoxTableRow / FoxTableCell 셋으로 옮겼다. 세 타입이 뼈대가 같고 여백·글자· 기본 정렬만 달라 한 벌로 충분하다. 진짜
@6cbc8be8523648b572e2ab14bb23fc92d40f289a
+++ @fox/core/components/fox-table-cell/fox-table-cell.tsx
... | ... | @@ -0,0 +1,83 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxTableCellAlign = "start" | "center" | "end"; | |
| 7 | + | |
| 8 | +export interface FoxTableCellProps { | |
| 9 | + children?: ReactNode; | |
| 10 | + /** 참이면 `<th>`가 된다. 머리행 안에서는 주지 않아도 행이 채운다. */ | |
| 11 | + head?: boolean; | |
| 12 | + align?: FoxTableCellAlign; | |
| 13 | + colSpan?: number; | |
| 14 | + rowSpan?: number; | |
| 15 | + /** 열 너비. `<col>` 없이 한 칸만 잡을 때 쓴다. */ | |
| 16 | + width?: string | number; | |
| 17 | + /** | |
| 18 | + * 모바일에서 칸 앞에 붙는 열 이름. `list` 표가 카드로 접히면 머리행이 사라져서, | |
| 19 | + * 이 값이 없으면 무슨 값인지 알 수 없다. | |
| 20 | + */ | |
| 21 | + label?: string; | |
| 22 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 23 | + hidden?: boolean; | |
| 24 | + /** 배치 조정용. */ | |
| 25 | + className?: string; | |
| 26 | + | |
| 27 | + /** FoxTableRow가 채운다 — 머리행인지. 직접 넘기지 않는다. */ | |
| 28 | + inHead?: boolean; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const ALIGN_CLASS: Record<FoxTableCellAlign, string> = { | |
| 32 | + start: "fox-table-cell--start", | |
| 33 | + center: "fox-table-cell--center", | |
| 34 | + end: "fox-table-cell--end", | |
| 35 | +}; | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * @fox 표의 칸. 머리칸이면 `<th>`, 아니면 `<td>`다. | |
| 39 | + * | |
| 40 | + * 본문 행에 놓인 머리칸은 그 줄의 이름이라 `scope="row"`가 붙는다 — 시안 basic 표의 첫 | |
| 41 | + * 열이 그렇다. 머리행 안이면 열 이름이라 `scope="col"`이다. | |
| 42 | + * | |
| 43 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 44 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 45 | + */ | |
| 46 | +export function FoxTableCell({ | |
| 47 | + children, | |
| 48 | + head, | |
| 49 | + align, | |
| 50 | + colSpan, | |
| 51 | + rowSpan, | |
| 52 | + width, | |
| 53 | + label, | |
| 54 | + hidden = false, | |
| 55 | + className, | |
| 56 | + inHead = false, | |
| 57 | +}: FoxTableCellProps) { | |
| 58 | + if (hidden) { | |
| 59 | + return null; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const isHead = head ?? inHead; | |
| 63 | + const Tag = isHead ? "th" : "td"; | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <Tag | |
| 67 | + className={cx( | |
| 68 | + "fox-table-cell", | |
| 69 | + isHead ? "fox-table-cell--head" : "fox-table-cell--body", | |
| 70 | + align && ALIGN_CLASS[align], | |
| 71 | + className | |
| 72 | + )} | |
| 73 | + scope={isHead ? (inHead ? "col" : "row") : undefined} | |
| 74 | + colSpan={colSpan} | |
| 75 | + rowSpan={rowSpan} | |
| 76 | + style={width === undefined ? undefined : { inlineSize: width }} | |
| 77 | + > | |
| 78 | + {/* 카드로 접혔을 때만 보인다 — 표로 있을 때는 머리행이 이미 같은 말을 한다. */} | |
| 79 | + {label && <span className="fox-table-cell__label">{label}</span>} | |
| 80 | + <span className="fox-table-cell__content">{children}</span> | |
| 81 | + </Tag> | |
| 82 | + ); | |
| 83 | +} |
+++ @fox/core/components/fox-table-cell/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxTableCell, | |
| 3 | + type FoxTableCellProps, | |
| 4 | + type FoxTableCellAlign, | |
| 5 | +} from "./fox-table-cell"; |
+++ @fox/core/components/fox-table-row/fox-table-row.tsx
... | ... | @@ -0,0 +1,59 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Children, | |
| 5 | + Fragment, | |
| 6 | + cloneElement, | |
| 7 | + isValidElement, | |
| 8 | + type ReactElement, | |
| 9 | + type ReactNode, | |
| 10 | +} from "react"; | |
| 11 | +import { cx } from "../../utils"; | |
| 12 | + | |
| 13 | +export interface FoxTableRowProps { | |
| 14 | + children?: ReactNode; | |
| 15 | + /** 참이면 머리행이다. 안의 칸이 모두 `<th scope="col">`이 된다. */ | |
| 16 | + head?: boolean; | |
| 17 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 18 | + hidden?: boolean; | |
| 19 | + /** 배치 조정용. */ | |
| 20 | + className?: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** 머리행이라는 사실을 칸에 내려 준다. 프래그먼트는 `Children.map`이 들어가지 않아 직접 내려간다. */ | |
| 24 | +function markCells(children: ReactNode): ReactNode { | |
| 25 | + return Children.map(children, (child) => { | |
| 26 | + if (!isValidElement(child)) { | |
| 27 | + return child; | |
| 28 | + } | |
| 29 | + | |
| 30 | + if (child.type === Fragment) { | |
| 31 | + const fragment = child as ReactElement<{ children?: ReactNode }>; | |
| 32 | + return markCells(fragment.props.children); | |
| 33 | + } | |
| 34 | + | |
| 35 | + if (typeof child.type === "string") { | |
| 36 | + return child; | |
| 37 | + } | |
| 38 | + | |
| 39 | + return cloneElement(child as ReactElement<Record<string, unknown>>, { inHead: true }); | |
| 40 | + }); | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * @fox 표의 한 줄. | |
| 45 | + * | |
| 46 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 47 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 48 | + */ | |
| 49 | +export function FoxTableRow({ children, head = false, hidden = false, className }: FoxTableRowProps) { | |
| 50 | + if (hidden) { | |
| 51 | + return null; | |
| 52 | + } | |
| 53 | + | |
| 54 | + return ( | |
| 55 | + <tr className={cx("fox-table-row", head && "fox-table-row--head", className)}> | |
| 56 | + {head ? markCells(children) : children} | |
| 57 | + </tr> | |
| 58 | + ); | |
| 59 | +} |
+++ @fox/core/components/fox-table-row/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxTableRow, type FoxTableRowProps } from "./fox-table-row"; |
+++ @fox/core/components/fox-table/fox-table.tsx
... | ... | @@ -0,0 +1,85 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Children, | |
| 5 | + Fragment, | |
| 6 | + isValidElement, | |
| 7 | + type ReactElement, | |
| 8 | + type ReactNode, | |
| 9 | +} from "react"; | |
| 10 | +import { cx } from "../../utils"; | |
| 11 | +import type { FoxTableRowProps } from "../fox-table-row"; | |
| 12 | + | |
| 13 | +export type FoxTableType = "basic" | "list" | "form"; | |
| 14 | + | |
| 15 | +export interface FoxTableProps { | |
| 16 | + /** `FoxTableRow`들. 머리행은 `head`를 주면 되고 순서는 신경 쓰지 않아도 된다. */ | |
| 17 | + children?: ReactNode; | |
| 18 | + /** | |
| 19 | + * `basic`은 제목 열이 있는 상세표, `list`는 목록표, `form`은 레이블 + 입력표다. | |
| 20 | + * 여백·글자·기본 정렬이 이 값에 따라 달라진다. | |
| 21 | + */ | |
| 22 | + type?: FoxTableType; | |
| 23 | + /** 표가 무엇을 담는지. `<caption>`으로 나가며 화면에는 보이지 않는다. */ | |
| 24 | + caption?: string; | |
| 25 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 26 | + hidden?: boolean; | |
| 27 | + /** 배치 조정용. */ | |
| 28 | + className?: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +type RowElement = ReactElement<FoxTableRowProps>; | |
| 32 | + | |
| 33 | +/** 프래그먼트 안까지 훑어 행을 모은다. */ | |
| 34 | +function collectRows(children: ReactNode): RowElement[] { | |
| 35 | + const rows: RowElement[] = []; | |
| 36 | + Children.forEach(children, (child) => { | |
| 37 | + if (!isValidElement(child)) { | |
| 38 | + return; | |
| 39 | + } | |
| 40 | + if (child.type === Fragment) { | |
| 41 | + const fragment = child as ReactElement<{ children?: ReactNode }>; | |
| 42 | + rows.push(...collectRows(fragment.props.children)); | |
| 43 | + return; | |
| 44 | + } | |
| 45 | + rows.push(child as RowElement); | |
| 46 | + }); | |
| 47 | + return rows; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * @fox 표. 진짜 `<table>`이라 스크린리더가 몇 행 몇 열인지 읽어 주고 `colSpan`·`rowSpan`이 | |
| 52 | + * 그대로 동작한다. | |
| 53 | + * | |
| 54 | + * 호출부는 행을 늘어놓기만 하면 된다 — 머리행(`head`)은 `<thead>`로, 나머지는 `<tbody>`로 | |
| 55 | + * 알아서 갈린다. | |
| 56 | + * | |
| 57 | + * 좁은 화면에서는 `list`가 한 줄씩 카드로 접히고 `form`은 레이블이 입력 위로 올라간다 | |
| 58 | + * (시안의 device 축). 그 전환은 CSS가 화면 폭을 보고 하므로 프롭이 없다. | |
| 59 | + * | |
| 60 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 61 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 62 | + */ | |
| 63 | +export function FoxTable({ | |
| 64 | + children, | |
| 65 | + type = "basic", | |
| 66 | + caption, | |
| 67 | + hidden = false, | |
| 68 | + className, | |
| 69 | +}: FoxTableProps) { | |
| 70 | + if (hidden) { | |
| 71 | + return null; | |
| 72 | + } | |
| 73 | + | |
| 74 | + const rows = collectRows(children); | |
| 75 | + const headRows = rows.filter((row) => row.props.head); | |
| 76 | + const bodyRows = rows.filter((row) => !row.props.head); | |
| 77 | + | |
| 78 | + return ( | |
| 79 | + <table className={cx("fox-table", `fox-table--${type}`, className)}> | |
| 80 | + {caption && <caption className="fox-table__caption">{caption}</caption>} | |
| 81 | + {headRows.length > 0 && <thead className="fox-table__head">{headRows}</thead>} | |
| 82 | + {bodyRows.length > 0 && <tbody className="fox-table__body">{bodyRows}</tbody>} | |
| 83 | + </table> | |
| 84 | + ); | |
| 85 | +} |
+++ @fox/core/components/fox-table/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxTable, type FoxTableProps, type FoxTableType } from "./fox-table"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -30,6 +30,9 @@ |
| 30 | 30 |
export * from "./fox-select"; |
| 31 | 31 |
export * from "./fox-select-option"; |
| 32 | 32 |
export * from "./fox-select-option-item"; |
| 33 |
+export * from "./fox-table"; |
|
| 34 |
+export * from "./fox-table-cell"; |
|
| 35 |
+export * from "./fox-table-row"; |
|
| 33 | 36 |
export * from "./fox-tag"; |
| 34 | 37 |
export * from "./fox-tag-list"; |
| 35 | 38 |
export * from "./fox-text-area"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -42,6 +42,9 @@ |
| 42 | 42 |
import { FoxBadge } from "../core/components/fox-badge";
|
| 43 | 43 |
import { FoxBadgeDot } from "../core/components/fox-badge-dot";
|
| 44 | 44 |
import { FoxBadgeGroup } from "../core/components/fox-badge-group";
|
| 45 |
+import { FoxTable } from "../core/components/fox-table";
|
|
| 46 |
+import { FoxTableCell } from "../core/components/fox-table-cell";
|
|
| 47 |
+import { FoxTableRow } from "../core/components/fox-table-row";
|
|
| 45 | 48 |
import styles from "./dev-test.module.scss"; |
| 46 | 49 |
|
| 47 | 50 |
export interface ComponentExample {
|
... | ... | @@ -2545,4 +2548,112 @@ |
| 2545 | 2548 |
}, |
| 2546 | 2549 |
], |
| 2547 | 2550 |
}, |
| 2551 |
+ {
|
|
| 2552 |
+ id: "fox-table", |
|
| 2553 |
+ name: "FoxTable", |
|
| 2554 |
+ description: |
|
| 2555 |
+ "Figma 시안 9종(tbl-basic · tbl-list · tbl-form과 각각의 행·칸)을 FoxTable / FoxTableRow / FoxTableCell 셋으로 옮겼습니다. 진짜 <table>이라 스크린리더가 행·열을 읽어 주고 colSpan·rowSpan이 그대로 동작합니다. 머리행은 head만 주면 <thead>로 알아서 갈립니다. 창을 768px 아래로 좁히면 list는 카드로 접히고 form은 레이블이 입력 위로 올라갑니다.", |
|
| 2556 |
+ variants: [ |
|
| 2557 |
+ {
|
|
| 2558 |
+ label: "basic — 제목 열이 있는 상세표", |
|
| 2559 |
+ node: ( |
|
| 2560 |
+ <FoxTable caption="basic 표 예시"> |
|
| 2561 |
+ <FoxTableRow head> |
|
| 2562 |
+ <FoxTableCell>제목</FoxTableCell> |
|
| 2563 |
+ <FoxTableCell>제목</FoxTableCell> |
|
| 2564 |
+ <FoxTableCell>제목</FoxTableCell> |
|
| 2565 |
+ <FoxTableCell>제목</FoxTableCell> |
|
| 2566 |
+ </FoxTableRow> |
|
| 2567 |
+ <FoxTableRow> |
|
| 2568 |
+ <FoxTableCell head>제목</FoxTableCell> |
|
| 2569 |
+ <FoxTableCell>길어서 2줄 되는 내용 긴 내용</FoxTableCell> |
|
| 2570 |
+ <FoxTableCell>내용</FoxTableCell> |
|
| 2571 |
+ <FoxTableCell>승인</FoxTableCell> |
|
| 2572 |
+ </FoxTableRow> |
|
| 2573 |
+ <FoxTableRow> |
|
| 2574 |
+ <FoxTableCell head>제목</FoxTableCell> |
|
| 2575 |
+ <FoxTableCell colSpan={2}>두 칸을 합친 내용 (colSpan)</FoxTableCell>
|
|
| 2576 |
+ <FoxTableCell>검토</FoxTableCell> |
|
| 2577 |
+ </FoxTableRow> |
|
| 2578 |
+ </FoxTable> |
|
| 2579 |
+ ), |
|
| 2580 |
+ }, |
|
| 2581 |
+ {
|
|
| 2582 |
+ label: "list — 목록표. 좁히면 카드로 접힙니다", |
|
| 2583 |
+ node: ( |
|
| 2584 |
+ <FoxTable type="list" caption="list 표 예시"> |
|
| 2585 |
+ <FoxTableRow head> |
|
| 2586 |
+ <FoxTableCell width={64}>번호</FoxTableCell>
|
|
| 2587 |
+ <FoxTableCell>제목</FoxTableCell> |
|
| 2588 |
+ <FoxTableCell width={120}>작성자</FoxTableCell>
|
|
| 2589 |
+ <FoxTableCell width={120}>등록일</FoxTableCell>
|
|
| 2590 |
+ </FoxTableRow> |
|
| 2591 |
+ {[1, 2, 3].map((n) => (
|
|
| 2592 |
+ <FoxTableRow key={n}>
|
|
| 2593 |
+ <FoxTableCell label="번호">{n}</FoxTableCell>
|
|
| 2594 |
+ <FoxTableCell label="제목" align="start"> |
|
| 2595 |
+ 목록 제목 {n}
|
|
| 2596 |
+ </FoxTableCell> |
|
| 2597 |
+ <FoxTableCell label="작성자">홍길동</FoxTableCell> |
|
| 2598 |
+ <FoxTableCell label="등록일">2026-08-18</FoxTableCell> |
|
| 2599 |
+ </FoxTableRow> |
|
| 2600 |
+ ))} |
|
| 2601 |
+ </FoxTable> |
|
| 2602 |
+ ), |
|
| 2603 |
+ }, |
|
| 2604 |
+ {
|
|
| 2605 |
+ label: "form — 레이블 + 입력. 좁히면 위아래로 쌓입니다", |
|
| 2606 |
+ node: ( |
|
| 2607 |
+ <FoxTable type="form" caption="form 표 예시"> |
|
| 2608 |
+ <FoxTableRow> |
|
| 2609 |
+ <FoxTableCell head>레이블</FoxTableCell> |
|
| 2610 |
+ <FoxTableCell>내용</FoxTableCell> |
|
| 2611 |
+ </FoxTableRow> |
|
| 2612 |
+ <FoxTableRow> |
|
| 2613 |
+ <FoxTableCell head> |
|
| 2614 |
+ <FoxFormLabel as="span" requirement="required"> |
|
| 2615 |
+ 레이블 |
|
| 2616 |
+ </FoxFormLabel> |
|
| 2617 |
+ </FoxTableCell> |
|
| 2618 |
+ <FoxTableCell> |
|
| 2619 |
+ <InputStage> |
|
| 2620 |
+ <FoxInput size="md" placeholder="내용을 입력하세요." aria-label="내용" /> |
|
| 2621 |
+ </InputStage> |
|
| 2622 |
+ </FoxTableCell> |
|
| 2623 |
+ </FoxTableRow> |
|
| 2624 |
+ <FoxTableRow> |
|
| 2625 |
+ <FoxTableCell head>레이블</FoxTableCell> |
|
| 2626 |
+ <FoxTableCell> |
|
| 2627 |
+ <FoxBadgeGroup size="md"> |
|
| 2628 |
+ <FoxBadge color="primary" type="pastel"> |
|
| 2629 |
+ 태그 |
|
| 2630 |
+ </FoxBadge> |
|
| 2631 |
+ <FoxBadge color="success" type="pastel"> |
|
| 2632 |
+ 태그 |
|
| 2633 |
+ </FoxBadge> |
|
| 2634 |
+ </FoxBadgeGroup> |
|
| 2635 |
+ </FoxTableCell> |
|
| 2636 |
+ </FoxTableRow> |
|
| 2637 |
+ </FoxTable> |
|
| 2638 |
+ ), |
|
| 2639 |
+ }, |
|
| 2640 |
+ {
|
|
| 2641 |
+ label: "칸 정렬 (align) — 표 기본값을 이깁니다", |
|
| 2642 |
+ node: ( |
|
| 2643 |
+ <FoxTable type="list" caption="정렬 예시"> |
|
| 2644 |
+ <FoxTableRow head> |
|
| 2645 |
+ <FoxTableCell align="start">start</FoxTableCell> |
|
| 2646 |
+ <FoxTableCell align="center">center</FoxTableCell> |
|
| 2647 |
+ <FoxTableCell align="end">end</FoxTableCell> |
|
| 2648 |
+ </FoxTableRow> |
|
| 2649 |
+ <FoxTableRow> |
|
| 2650 |
+ <FoxTableCell align="start">왼쪽</FoxTableCell> |
|
| 2651 |
+ <FoxTableCell align="center">가운데</FoxTableCell> |
|
| 2652 |
+ <FoxTableCell align="end">오른쪽</FoxTableCell> |
|
| 2653 |
+ </FoxTableRow> |
|
| 2654 |
+ </FoxTable> |
|
| 2655 |
+ ), |
|
| 2656 |
+ }, |
|
| 2657 |
+ ], |
|
| 2658 |
+ }, |
|
| 2548 | 2659 |
]; |
+++ @fox/styles/_fox-table-cell.scss
... | ... | @@ -0,0 +1,83 @@ |
| 1 | +// FoxTableCell — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// tbl-basic-cell-head (425:5694) · tbl-basic-cell-body (425:5701) | |
| 3 | +// tbl-list-cell-head (391:4116) · tbl-list-cell-body (391:4150) | |
| 4 | +// tbl-form-cell-head (391:4307) · tbl-form-cell-body (391:4337) | |
| 5 | +// | |
| 6 | +// 마크업 계약: `_fox-table.scss` 상단 참고. | |
| 7 | +// | |
| 8 | +// 여섯 시안이 한 컴포넌트다 — 바탕(머리 회색 / 본문 흰색)과 아래 선이 모두 같고, 여백· | |
| 9 | +// 글자·기본 정렬만 표 타입에 따라 달라진다. 그 값은 표가 커스텀 프로퍼티로 내려보낸다. | |
| 10 | +// | |
| 11 | +// 열 이름(`__label`)은 카드로 접혔을 때만 보인다 — 표로 있을 때는 머리행이 이미 같은 말을 | |
| 12 | +// 한다. | |
| 13 | +// | |
| 14 | +// 토큰이 아닌 값: | |
| 15 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 16 | +// export하지 않는다. | |
| 17 | + | |
| 18 | +@use "abstracts" as fox; | |
| 19 | + | |
| 20 | +.fox-table-cell { | |
| 21 | + padding-inline: var(--fox-table-cell-padding-inline); | |
| 22 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 23 | + color: fox.color(font-neutral-default); | |
| 24 | + font-family: fox.font-family(body); | |
| 25 | + font-size: fox.font-size(body-md); | |
| 26 | + font-weight: fox.font-weight(regular); | |
| 27 | + line-height: 1.5; | |
| 28 | + letter-spacing: -0.025em; | |
| 29 | + text-align: var(--fox-table-body-align); | |
| 30 | + vertical-align: middle; | |
| 31 | + | |
| 32 | + &--head { | |
| 33 | + min-inline-size: var(--fox-table-head-min-inline-size); | |
| 34 | + min-block-size: var(--fox-table-cell-min-block-size); | |
| 35 | + padding-block: var(--fox-table-head-padding-block); | |
| 36 | + background: fox.color(surface-neutral-gray); | |
| 37 | + font-family: var(--fox-table-head-font-family); | |
| 38 | + font-size: var(--fox-table-head-font-size); | |
| 39 | + font-weight: var(--fox-table-head-font-weight); | |
| 40 | + line-height: var(--fox-table-head-line-height); | |
| 41 | + text-align: var(--fox-table-head-align); | |
| 42 | + } | |
| 43 | + | |
| 44 | + &--body { | |
| 45 | + padding-block: var(--fox-table-body-padding-block); | |
| 46 | + background: fox.color(surface-neutral-default); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 칸이 직접 정한 정렬은 표 기본값을 이긴다. | |
| 50 | + &--start { text-align: start; } | |
| 51 | + &--center { text-align: center; } | |
| 52 | + &--end { text-align: end; } | |
| 53 | + | |
| 54 | + // 표로 있을 때는 머리행이 열 이름을 말하므로 감춘다. | |
| 55 | + &__label { | |
| 56 | + display: none; | |
| 57 | + } | |
| 58 | + | |
| 59 | + @include fox.mobile { | |
| 60 | + // 카드로 접힌 줄 안에서만 열 이름이 보인다. | |
| 61 | + .fox-table--list & { | |
| 62 | + display: flex; | |
| 63 | + gap: fox.gap(3); | |
| 64 | + align-items: baseline; | |
| 65 | + text-align: start; | |
| 66 | + } | |
| 67 | + | |
| 68 | + .fox-table--list &__label { | |
| 69 | + display: block; | |
| 70 | + flex-shrink: 0; | |
| 71 | + color: fox.color(font-neutral-subtle); | |
| 72 | + font-family: fox.font-family(title); | |
| 73 | + font-size: fox.font-size(heading-2xsm); | |
| 74 | + font-weight: fox.font-weight(bold); | |
| 75 | + line-height: 1.4; | |
| 76 | + } | |
| 77 | + | |
| 78 | + .fox-table--list &__content { | |
| 79 | + flex: 1 0 0; | |
| 80 | + min-inline-size: 0; | |
| 81 | + } | |
| 82 | + } | |
| 83 | +} |
+++ @fox/styles/_fox-table-row.scss
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +// FoxTableRow — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// tbl-*-row-head · tbl-*-row-body | |
| 3 | +// | |
| 4 | +// 마크업 계약: `_fox-table.scss` 상단 참고. | |
| 5 | +// | |
| 6 | +// 줄 자체는 모양을 갖지 않는다 — 시안에서 바탕과 아래 선이 모두 칸에 걸려 있다. | |
| 7 | +// 좁은 화면에서 카드가 될 때만 줄이 테두리를 갖는다(그 규칙은 `_fox-table.scss`에 있다). | |
| 8 | + | |
| 9 | +@use "abstracts" as fox; | |
| 10 | + | |
| 11 | +.fox-table-row { | |
| 12 | + // 마지막 줄도 아래 선을 갖는다(시안). 칸이 그리므로 여기서는 둘 것이 없다. | |
| 13 | + border: none; | |
| 14 | +} |
+++ @fox/styles/_fox-table.scss
... | ... | @@ -0,0 +1,139 @@ |
| 1 | +// FoxTable — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// tbl-basic (425:5781) · tbl-list (391:4283) · tbl-form (391:4389) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <table class="fox-table fox-table--basic"> | |
| 6 | +// <caption class="fox-table__caption">…</caption> <!-- 선택 · 눈에 안 보인다 --> | |
| 7 | +// <thead class="fox-table__head"> | |
| 8 | +// <tr class="fox-table-row fox-table-row--head"> | |
| 9 | +// <th class="fox-table-cell fox-table-cell--head" scope="col">…</th> | |
| 10 | +// </tr> | |
| 11 | +// </thead> | |
| 12 | +// <tbody class="fox-table__body"> | |
| 13 | +// <tr class="fox-table-row"> | |
| 14 | +// <th class="fox-table-cell fox-table-cell--head" scope="row">…</th> | |
| 15 | +// <td class="fox-table-cell fox-table-cell--body">…</td> | |
| 16 | +// </tr> | |
| 17 | +// </tbody> | |
| 18 | +// </table> | |
| 19 | +// | |
| 20 | +// 세 타입은 뼈대가 같고 여백·글자·기본 정렬만 다르다. 그 차이를 클래스가 아니라 커스텀 | |
| 21 | +// 프로퍼티로 내려보낸다 — 칸에 `.fox-table--basic .fox-table-cell`처럼 후손 선택자를 쓰면 | |
| 22 | +// 표 안에 표가 들어갔을 때 바깥 표의 규칙이 안쪽까지 덮어쓴다. 커스텀 프로퍼티는 안쪽 | |
| 23 | +// 표가 자기 값으로 다시 덮으므로 그 문제가 없다. | |
| 24 | +// | |
| 25 | +// 좁은 화면에서 `list`는 한 줄이 카드로 접히고 `form`은 레이블이 입력 위로 올라간다 | |
| 26 | +// (시안 device 축). 표 요소를 `display: block`으로 바꿔 만드는데, 그러면 표 의미가 사라져 | |
| 27 | +// 스크린리더가 행·열을 읽지 못한다. 그래서 `list`의 칸은 `label`을 함께 받아 카드에서 | |
| 28 | +// 열 이름을 글자로 보여 준다. | |
| 29 | +// | |
| 30 | +// 토큰이 아닌 값: | |
| 31 | +// - form 머리칸의 최소 크기 12rem×4.8rem: 시안 값이나 Figma가 변수로 export하지 | |
| 32 | +// 않는다(사용자 승인). | |
| 33 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 34 | +// export하지 않는다. | |
| 35 | +// - border-collapse / table-layout / display 전환: 구조 규칙. | |
| 36 | + | |
| 37 | +@use "abstracts" as fox; | |
| 38 | + | |
| 39 | +.fox-table { | |
| 40 | + // 타입이 칸에 내려보내는 값. 기본은 시안 basic이다. | |
| 41 | + --fox-table-cell-padding-inline: #{fox.padding(5)}; | |
| 42 | + --fox-table-head-padding-block: #{fox.padding(3)}; | |
| 43 | + --fox-table-body-padding-block: #{fox.padding(6)}; | |
| 44 | + --fox-table-head-font-size: #{fox.font-size(heading-2xsm)}; | |
| 45 | + --fox-table-head-font-family: #{fox.font-family(title)}; | |
| 46 | + --fox-table-head-font-weight: #{fox.font-weight(bold)}; | |
| 47 | + --fox-table-head-line-height: 1.4; | |
| 48 | + --fox-table-head-align: center; | |
| 49 | + --fox-table-body-align: center; | |
| 50 | + --fox-table-head-min-inline-size: 0; | |
| 51 | + --fox-table-cell-min-block-size: 0; | |
| 52 | + | |
| 53 | + inline-size: 100%; | |
| 54 | + border-collapse: collapse; | |
| 55 | + border-block-start: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 56 | + | |
| 57 | + // 목록표는 줄이 촘촘하다. | |
| 58 | + &--list { | |
| 59 | + --fox-table-body-padding-block: #{fox.padding(3)}; | |
| 60 | + } | |
| 61 | + | |
| 62 | + // 입력표는 레이블 열이 왼쪽에 서고 여백이 넓다. | |
| 63 | + &--form { | |
| 64 | + --fox-table-cell-padding-inline: #{fox.padding(6)}; | |
| 65 | + --fox-table-head-padding-block: #{fox.padding(6)}; | |
| 66 | + --fox-table-body-padding-block: #{fox.padding(3)}; | |
| 67 | + --fox-table-head-font-size: #{fox.font-size(label-md)}; | |
| 68 | + --fox-table-head-font-family: #{fox.font-family(body)}; | |
| 69 | + --fox-table-head-font-weight: #{fox.font-weight(medium)}; | |
| 70 | + --fox-table-head-line-height: 1; | |
| 71 | + --fox-table-head-align: start; | |
| 72 | + --fox-table-body-align: start; | |
| 73 | + // 시안 값이나 변수로 export되지 않는다 — 파일 상단 참고. | |
| 74 | + --fox-table-head-min-inline-size: 12rem; | |
| 75 | + --fox-table-cell-min-block-size: 4.8rem; | |
| 76 | + } | |
| 77 | + | |
| 78 | + // 표가 무엇을 담는지 스크린리더에만 알린다. | |
| 79 | + &__caption { | |
| 80 | + position: absolute; | |
| 81 | + inline-size: fox.border(1); | |
| 82 | + block-size: fox.border(1); | |
| 83 | + margin: calc(-1 * #{fox.border(1)}); | |
| 84 | + padding: 0; | |
| 85 | + overflow: hidden; | |
| 86 | + clip-path: inset(50%); | |
| 87 | + white-space: nowrap; | |
| 88 | + } | |
| 89 | + | |
| 90 | + // ── 좁은 화면 ───────────────────────────────────────────────────────────── | |
| 91 | + @include fox.mobile { | |
| 92 | + // 목록표는 한 줄이 카드가 된다. 머리행은 카드마다 열 이름이 붙으므로 감춘다. | |
| 93 | + &--list { | |
| 94 | + border-block-start: none; | |
| 95 | + | |
| 96 | + .fox-table__head { | |
| 97 | + display: none; | |
| 98 | + } | |
| 99 | + | |
| 100 | + .fox-table__body, | |
| 101 | + .fox-table-row, | |
| 102 | + .fox-table-cell { | |
| 103 | + display: block; | |
| 104 | + inline-size: 100%; | |
| 105 | + } | |
| 106 | + | |
| 107 | + .fox-table-row { | |
| 108 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 109 | + } | |
| 110 | + | |
| 111 | + .fox-table-cell { | |
| 112 | + border-block-end: none; | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + // 입력표는 레이블이 입력 위로 올라간다. | |
| 117 | + &--form { | |
| 118 | + .fox-table__body, | |
| 119 | + .fox-table-row, | |
| 120 | + .fox-table-cell { | |
| 121 | + display: block; | |
| 122 | + inline-size: 100%; | |
| 123 | + } | |
| 124 | + | |
| 125 | + .fox-table-row { | |
| 126 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 127 | + } | |
| 128 | + | |
| 129 | + .fox-table-cell { | |
| 130 | + border-block-end: none; | |
| 131 | + } | |
| 132 | + | |
| 133 | + .fox-table-cell--head { | |
| 134 | + min-inline-size: 0; | |
| 135 | + background: fox.color(surface-neutral-default); | |
| 136 | + } | |
| 137 | + } | |
| 138 | + } | |
| 139 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -38,3 +38,6 @@ |
| 38 | 38 |
@use "fox-badge"; |
| 39 | 39 |
@use "fox-badge-dot"; |
| 40 | 40 |
@use "fox-badge-group"; |
| 41 |
+@use "fox-table"; |
|
| 42 |
+@use "fox-table-row"; |
|
| 43 |
+@use "fox-table-cell"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?