+++ @fox/core/components/fox-list-container/fox-list-container.tsx
... | ... | @@ -0,0 +1,245 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxPageHeader } from "../fox-page-header"; | |
| 6 | +import type { FoxBreadcrumbItem } from "../fox-breadcrumb"; | |
| 7 | +import { FoxTable } from "../fox-table"; | |
| 8 | +import { FoxTableRow } from "../fox-table-row"; | |
| 9 | +import { FoxTableCell } from "../fox-table-cell"; | |
| 10 | +import type { FoxTableCellAlign } from "../fox-table-cell"; | |
| 11 | +import { FoxPagination } from "../fox-pagination"; | |
| 12 | +import { FoxListCount, FoxListToolbar } from "../fox-list-toolbar"; | |
| 13 | +import { FoxListSearch } from "../fox-list-search"; | |
| 14 | +import type { FoxSelectItem } from "../fox-select"; | |
| 15 | + | |
| 16 | +/** 열 하나의 정의. `render`가 있으면 그 결과가 셀 내용이 된다. */ | |
| 17 | +export interface FoxListColumn<T> { | |
| 18 | + /** 열 식별자. `render`가 없으면 `row[key]`를 그대로 그린다. */ | |
| 19 | + key: string; | |
| 20 | + header: ReactNode; | |
| 21 | + /** 셀을 직접 그린다 — 배지·버튼 무엇이든 넣을 수 있다. */ | |
| 22 | + render?: (row: T, index: number) => ReactNode; | |
| 23 | + width?: string | number; | |
| 24 | + align?: FoxTableCellAlign; | |
| 25 | + /** 참이면 그 열의 본문 글자가 진해진다(시안의 강조 열). */ | |
| 26 | + emphasis?: boolean; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * 목록 상태의 변경분. 컨테이너는 **바뀐 것만** 담아 넘긴다 — 호출부가 기존 값과 합쳐 | |
| 31 | + * 쓰라는 뜻이다. 화면마다 필요한 정렬·필터 키가 달라 열려 있는 모양이다. | |
| 32 | + */ | |
| 33 | +export interface FoxListQueryPatch { | |
| 34 | + page?: number; | |
| 35 | + keyword?: string; | |
| 36 | + searchField?: string; | |
| 37 | + [key: string]: unknown; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface FoxListContainerProps<T> { | |
| 41 | + // ── 머리말 ──────────────────────────────────────────────────────────────── | |
| 42 | + title?: ReactNode; | |
| 43 | + breadcrumb?: FoxBreadcrumbItem[]; | |
| 44 | + headerActions?: ReactNode; | |
| 45 | + | |
| 46 | + // ── 표 ──────────────────────────────────────────────────────────────────── | |
| 47 | + columns: FoxListColumn<T>[]; | |
| 48 | + rows: T[]; | |
| 49 | + /** 행의 안정적인 키. 인덱스를 쓰면 정렬·페이지 이동에서 어긋난다. */ | |
| 50 | + rowKey: (row: T, index: number) => string; | |
| 51 | + caption?: string; | |
| 52 | + /** 행이 없을 때 표 안에 보여줄 말. */ | |
| 53 | + emptyMessage?: ReactNode; | |
| 54 | + | |
| 55 | + // ── 도구 줄 ─────────────────────────────────────────────────────────────── | |
| 56 | + /** 주면 "총 N건"이 나온다. 없으면 그 자리를 비운다. */ | |
| 57 | + totalCount?: number; | |
| 58 | + toolbarLeading?: ReactNode; | |
| 59 | + sorts?: ReactNode; | |
| 60 | + toolbarActions?: ReactNode; | |
| 61 | + filter?: ReactNode; | |
| 62 | + | |
| 63 | + /** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */ | |
| 64 | + searchFields?: FoxSelectItem[]; | |
| 65 | + searchField?: string; | |
| 66 | + keyword?: string; | |
| 67 | + searchPlaceholder?: string; | |
| 68 | + /** 검색 상자를 아예 숨긴다. */ | |
| 69 | + searchHidden?: boolean; | |
| 70 | + | |
| 71 | + // ── 페이지 ──────────────────────────────────────────────────────────────── | |
| 72 | + page?: number; | |
| 73 | + totalPages?: number; | |
| 74 | + | |
| 75 | + // ── 이벤트 ──────────────────────────────────────────────────────────────── | |
| 76 | + /** | |
| 77 | + * 주면 페이지 이동이 **링크**가 된다 — 목록이 Server Component로 남고 새로고침·뒤로가기· | |
| 78 | + * URL 공유가 그대로 동작한다. 이 저장소의 목록 화면들이 쓰는 방식이다. | |
| 79 | + */ | |
| 80 | + buildHref?: (patch: FoxListQueryPatch) => string; | |
| 81 | + /** `buildHref`가 없을 때(모달 안 목록 등) 쓰는 콜백. 둘 다 주면 링크가 이긴다. */ | |
| 82 | + onQueryChange?: (patch: FoxListQueryPatch) => void; | |
| 83 | + | |
| 84 | + hidden?: boolean; | |
| 85 | + className?: string; | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** | |
| 89 | + * @fox 목록 화면 뼈대 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608) | |
| 90 | + * | |
| 91 | + * 머리말 · 도구 줄 · 표 · 페이지네이션을 한 벌로 묶는다. **데이터를 가져오지도, 상태를 갖지도 | |
| 92 | + * 않는다** — 받은 것을 그리고 바뀐 것을 알릴 뿐이라 어떤 목록에도 그대로 쓸 수 있다. | |
| 93 | + * | |
| 94 | + * 표는 `columns` 정의로 그린다. `render`가 ReactNode를 돌려주므로 셀 안에 배지든 버튼 묶음이든 | |
| 95 | + * 들어간다(시안이 그렇다) — 값의 형식을 컨테이너가 알 필요가 없다. | |
| 96 | + * | |
| 97 | + * 이벤트는 두 갈래다. `buildHref`를 주면 링크로 그려 URL이 상태가 되고, `onQueryChange`만 | |
| 98 | + * 주면 콜백으로 알린다. FoxPagination이 이미 같은 방식이라 그 관례를 따랐다. | |
| 99 | + * | |
| 100 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 101 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 102 | + */ | |
| 103 | +export function FoxListContainer<T>({ | |
| 104 | + title, | |
| 105 | + breadcrumb, | |
| 106 | + headerActions, | |
| 107 | + columns, | |
| 108 | + rows, | |
| 109 | + rowKey, | |
| 110 | + caption, | |
| 111 | + emptyMessage = "조회된 내용이 없습니다.", | |
| 112 | + totalCount, | |
| 113 | + toolbarLeading, | |
| 114 | + sorts, | |
| 115 | + toolbarActions, | |
| 116 | + filter, | |
| 117 | + searchFields, | |
| 118 | + searchField, | |
| 119 | + keyword, | |
| 120 | + searchPlaceholder, | |
| 121 | + searchHidden = false, | |
| 122 | + page = 1, | |
| 123 | + totalPages = 1, | |
| 124 | + buildHref, | |
| 125 | + onQueryChange, | |
| 126 | + hidden = false, | |
| 127 | + className, | |
| 128 | +}: FoxListContainerProps<T>) { | |
| 129 | + if (hidden) { | |
| 130 | + return null; | |
| 131 | + } | |
| 132 | + | |
| 133 | + const hasToolbar = | |
| 134 | + totalCount !== undefined || | |
| 135 | + toolbarLeading !== undefined || | |
| 136 | + sorts !== undefined || | |
| 137 | + toolbarActions !== undefined || | |
| 138 | + filter !== undefined || | |
| 139 | + !searchHidden; | |
| 140 | + | |
| 141 | + return ( | |
| 142 | + <div className={cx("fox-list-container", className)}> | |
| 143 | + {(title !== undefined || breadcrumb !== undefined) && ( | |
| 144 | + <FoxPageHeader | |
| 145 | + title={title} | |
| 146 | + breadcrumb={breadcrumb} | |
| 147 | + actions={headerActions} | |
| 148 | + /> | |
| 149 | + )} | |
| 150 | + | |
| 151 | + <section className="fox-list-container__section"> | |
| 152 | + {hasToolbar && ( | |
| 153 | + <FoxListToolbar | |
| 154 | + count={ | |
| 155 | + totalCount !== undefined ? ( | |
| 156 | + <FoxListCount total={totalCount} /> | |
| 157 | + ) : undefined | |
| 158 | + } | |
| 159 | + leading={toolbarLeading} | |
| 160 | + sorts={sorts} | |
| 161 | + search={ | |
| 162 | + searchHidden ? undefined : ( | |
| 163 | + <FoxListSearch | |
| 164 | + fields={searchFields} | |
| 165 | + field={searchField} | |
| 166 | + keyword={keyword} | |
| 167 | + placeholder={searchPlaceholder} | |
| 168 | + onSearch={(nextKeyword, nextField) => | |
| 169 | + onQueryChange?.({ | |
| 170 | + keyword: nextKeyword, | |
| 171 | + searchField: nextField, | |
| 172 | + // 검색 결과는 항상 첫 장부터 본다 — 남아 있던 페이지 번호를 지운다. | |
| 173 | + page: 1, | |
| 174 | + }) | |
| 175 | + } | |
| 176 | + /> | |
| 177 | + ) | |
| 178 | + } | |
| 179 | + actions={toolbarActions} | |
| 180 | + filter={filter} | |
| 181 | + /> | |
| 182 | + )} | |
| 183 | + | |
| 184 | + {/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고 | |
| 185 | + 제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */} | |
| 186 | + <FoxTable type="list" caption={caption}> | |
| 187 | + <FoxTableRow head> | |
| 188 | + {columns.map((column) => ( | |
| 189 | + <FoxTableCell | |
| 190 | + key={column.key} | |
| 191 | + head | |
| 192 | + width={column.width} | |
| 193 | + align={column.align} | |
| 194 | + > | |
| 195 | + {column.header} | |
| 196 | + </FoxTableCell> | |
| 197 | + ))} | |
| 198 | + </FoxTableRow> | |
| 199 | + | |
| 200 | + {rows.length === 0 ? ( | |
| 201 | + <FoxTableRow> | |
| 202 | + <FoxTableCell colSpan={columns.length} align="center"> | |
| 203 | + {emptyMessage} | |
| 204 | + </FoxTableCell> | |
| 205 | + </FoxTableRow> | |
| 206 | + ) : ( | |
| 207 | + rows.map((row, index) => ( | |
| 208 | + <FoxTableRow key={rowKey(row, index)}> | |
| 209 | + {columns.map((column) => ( | |
| 210 | + <FoxTableCell | |
| 211 | + key={column.key} | |
| 212 | + align={column.align} | |
| 213 | + emphasis={column.emphasis} | |
| 214 | + label={ | |
| 215 | + typeof column.header === "string" | |
| 216 | + ? column.header | |
| 217 | + : undefined | |
| 218 | + } | |
| 219 | + > | |
| 220 | + {column.render | |
| 221 | + ? column.render(row, index) | |
| 222 | + : String( | |
| 223 | + (row as Record<string, unknown>)[column.key] ?? "" | |
| 224 | + )} | |
| 225 | + </FoxTableCell> | |
| 226 | + ))} | |
| 227 | + </FoxTableRow> | |
| 228 | + )) | |
| 229 | + )} | |
| 230 | + </FoxTable> | |
| 231 | + | |
| 232 | + {totalPages > 1 && ( | |
| 233 | + <FoxPagination | |
| 234 | + currentPage={page} | |
| 235 | + totalPages={totalPages} | |
| 236 | + buildHref={buildHref ? (next) => buildHref({ page: next }) : undefined} | |
| 237 | + onPageChange={ | |
| 238 | + buildHref ? undefined : (next) => onQueryChange?.({ page: next }) | |
| 239 | + } | |
| 240 | + /> | |
| 241 | + )} | |
| 242 | + </section> | |
| 243 | + </div> | |
| 244 | + ); | |
| 245 | +} |
+++ @fox/core/components/fox-list-container/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-list-container"; |
+++ @fox/core/components/fox-list-search/fox-list-search.tsx
... | ... | @@ -0,0 +1,123 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, type FormEvent } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxSelectText } from "../fox-select-text"; | |
| 6 | +import type { FoxSelectItem } from "../fox-select"; | |
| 7 | +import { FoxMagnifyingGlassIcon } from "../../icons"; | |
| 8 | + | |
| 9 | +export interface FoxListSearchProps { | |
| 10 | + /** 앞쪽 셀렉트에 넣을 검색 대상. 비우면 셀렉트 자체가 나오지 않는다. */ | |
| 11 | + fields?: FoxSelectItem[]; | |
| 12 | + /** 선택된 검색 대상(제어). */ | |
| 13 | + field?: string; | |
| 14 | + onFieldChange?: (field: string) => void; | |
| 15 | + /** 입력값(제어). 주지 않으면 컴포넌트가 스스로 들고 있다. */ | |
| 16 | + keyword?: string; | |
| 17 | + onKeywordChange?: (keyword: string) => void; | |
| 18 | + /** 돋보기를 누르거나 Enter를 쳤을 때. 현재 검색어와 대상을 함께 넘긴다. */ | |
| 19 | + onSearch?: (keyword: string, field?: string) => void; | |
| 20 | + placeholder?: string; | |
| 21 | + /** 글자가 없으므로 이 값이 검색 버튼의 접근성 이름이 된다. */ | |
| 22 | + label?: string; | |
| 23 | + disabled?: boolean; | |
| 24 | + hidden?: boolean; | |
| 25 | + className?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * @fox 목록 검색 상자 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar의 search | |
| 30 | + * | |
| 31 | + * 마크업 계약 (React 밖 소비자용): | |
| 32 | + * <form class="fox-list-search"> | |
| 33 | + * <div class="fox-list-search__box"> | |
| 34 | + * <span class="fox-list-search__field">…select-txt…</span> <!-- 선택 --> | |
| 35 | + * <input class="fox-list-search__input"> | |
| 36 | + * <button class="fox-list-search__submit">…svg…</button> | |
| 37 | + * </div> | |
| 38 | + * </form> | |
| 39 | + * | |
| 40 | + * `FoxInput`을 쓰지 않는다 — 시안은 입력칸 **앞쪽**에 검색 대상 셀렉트가 들어가는데 FoxInput에 | |
| 41 | + * 그 슬롯이 없다. 크기·radius·테두리는 시안이 지정한 값이 FoxInput md와 같지만, 좌우 여백만 | |
| 42 | + * `form/padding/sm`으로 더 좁다(안에 셀렉트와 버튼이 들어가기 때문이다). | |
| 43 | + * | |
| 44 | + * `<form>`이라 Enter가 그대로 제출이 된다 — 키 이벤트를 따로 듣지 않는다. | |
| 45 | + * | |
| 46 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 47 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 48 | + */ | |
| 49 | +export function FoxListSearch({ | |
| 50 | + fields, | |
| 51 | + field, | |
| 52 | + onFieldChange, | |
| 53 | + keyword, | |
| 54 | + onKeywordChange, | |
| 55 | + onSearch, | |
| 56 | + placeholder = "검색어를 입력하세요.", | |
| 57 | + label = "검색", | |
| 58 | + disabled = false, | |
| 59 | + hidden = false, | |
| 60 | + className, | |
| 61 | +}: FoxListSearchProps) { | |
| 62 | + const [innerKeyword, setInnerKeyword] = useState(keyword ?? ""); | |
| 63 | + const controlled = keyword !== undefined; | |
| 64 | + const value = controlled ? keyword : innerKeyword; | |
| 65 | + | |
| 66 | + if (hidden) { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | + | |
| 70 | + function handleChange(next: string) { | |
| 71 | + if (!controlled) { | |
| 72 | + setInnerKeyword(next); | |
| 73 | + } | |
| 74 | + onKeywordChange?.(next); | |
| 75 | + } | |
| 76 | + | |
| 77 | + function handleSubmit(event: FormEvent<HTMLFormElement>) { | |
| 78 | + event.preventDefault(); | |
| 79 | + onSearch?.(value, field); | |
| 80 | + } | |
| 81 | + | |
| 82 | + return ( | |
| 83 | + <form | |
| 84 | + className={cx("fox-list-search", className)} | |
| 85 | + role="search" | |
| 86 | + onSubmit={handleSubmit} | |
| 87 | + > | |
| 88 | + <div className="fox-list-search__box"> | |
| 89 | + {fields && fields.length > 0 && ( | |
| 90 | + <span className="fox-list-search__field"> | |
| 91 | + <FoxSelectText | |
| 92 | + size="sm" | |
| 93 | + options={fields} | |
| 94 | + value={field} | |
| 95 | + onValueChange={onFieldChange} | |
| 96 | + ariaLabel="검색 대상" | |
| 97 | + disabled={disabled} | |
| 98 | + /> | |
| 99 | + </span> | |
| 100 | + )} | |
| 101 | + <input | |
| 102 | + className="fox-list-search__input" | |
| 103 | + type="search" | |
| 104 | + value={value} | |
| 105 | + placeholder={placeholder} | |
| 106 | + disabled={disabled} | |
| 107 | + aria-label={placeholder} | |
| 108 | + onChange={(event) => handleChange(event.target.value)} | |
| 109 | + /> | |
| 110 | + {/* 시안의 이 버튼은 btn-ico 인스턴스가 아니라 로컬 프레임(32×32)이라 FoxIconButton의 | |
| 111 | + 크기 체계(24·28·40·56)와 맞지 않는다. 그래서 직접 그린다. */} | |
| 112 | + <button | |
| 113 | + className="fox-list-search__submit" | |
| 114 | + type="submit" | |
| 115 | + aria-label={label} | |
| 116 | + disabled={disabled} | |
| 117 | + > | |
| 118 | + <FoxMagnifyingGlassIcon /> | |
| 119 | + </button> | |
| 120 | + </div> | |
| 121 | + </form> | |
| 122 | + ); | |
| 123 | +} |
+++ @fox/core/components/fox-list-search/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-list-search"; |
+++ @fox/core/components/fox-list-toolbar/fox-list-toolbar.tsx
... | ... | @@ -0,0 +1,98 @@ |
| 1 | +import type { ReactNode } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxListToolbarProps { | |
| 5 | + /** 좌측 첫 자리. 보통 "총 N건"이다 — `FoxListCount`가 시안 서식을 그린다. */ | |
| 6 | + count?: ReactNode; | |
| 7 | + /** 총 건수 옆 액션(전체 선택·선택 삭제 등). */ | |
| 8 | + leading?: ReactNode; | |
| 9 | + /** 우측 정렬·조건 셀렉트 묶음. */ | |
| 10 | + sorts?: ReactNode; | |
| 11 | + /** 우측 검색 상자. */ | |
| 12 | + search?: ReactNode; | |
| 13 | + /** 우측 끝 액션(등록·엑셀 등). */ | |
| 14 | + actions?: ReactNode; | |
| 15 | + /** 아래 줄의 필터. 주지 않으면 그 줄 자체가 렌더되지 않는다(구분선도 함께 사라진다). */ | |
| 16 | + filter?: ReactNode; | |
| 17 | + hidden?: boolean; | |
| 18 | + className?: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * @fox 목록 도구 줄 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar (3002:8151) | |
| 23 | + * | |
| 24 | + * 마크업 계약 (React 밖 소비자용): | |
| 25 | + * <div class="fox-list-toolbar"> | |
| 26 | + * <div class="fox-list-toolbar__base"> | |
| 27 | + * <div class="fox-list-toolbar__left">…총 N건 · 액션…</div> | |
| 28 | + * <div class="fox-list-toolbar__right">…셀렉트 · 검색 · 액션…</div> | |
| 29 | + * </div> | |
| 30 | + * <div class="fox-list-toolbar__filter">…필터…</div> <!-- 선택 --> | |
| 31 | + * </div> | |
| 32 | + * | |
| 33 | + * **자리만 잡는다.** 무엇이 들어갈지는 화면마다 다르므로 전부 슬롯으로 받는다 — 셀렉트가 | |
| 34 | + * 몇 개인지, 액션이 버튼인지 아이콘인지 이 컴포넌트는 알지 못한다. | |
| 35 | + * | |
| 36 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 37 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 38 | + */ | |
| 39 | +export function FoxListToolbar({ | |
| 40 | + count, | |
| 41 | + leading, | |
| 42 | + sorts, | |
| 43 | + search, | |
| 44 | + actions, | |
| 45 | + filter, | |
| 46 | + hidden = false, | |
| 47 | + className, | |
| 48 | +}: FoxListToolbarProps) { | |
| 49 | + if (hidden) { | |
| 50 | + return null; | |
| 51 | + } | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <div className={cx("fox-list-toolbar", className)}> | |
| 55 | + <div className="fox-list-toolbar__base"> | |
| 56 | + <div className="fox-list-toolbar__left"> | |
| 57 | + {count} | |
| 58 | + {leading} | |
| 59 | + </div> | |
| 60 | + <div className="fox-list-toolbar__right"> | |
| 61 | + {sorts && <div className="fox-list-toolbar__sorts">{sorts}</div>} | |
| 62 | + {search} | |
| 63 | + {actions} | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + {filter && <div className="fox-list-toolbar__filter">{filter}</div>} | |
| 68 | + </div> | |
| 69 | + ); | |
| 70 | +} | |
| 71 | + | |
| 72 | +export interface FoxListCountProps { | |
| 73 | + /** 전체 건수. */ | |
| 74 | + total: number; | |
| 75 | + /** 숫자 앞뒤 문구. 기본은 시안의 "총 N건". */ | |
| 76 | + prefix?: ReactNode; | |
| 77 | + suffix?: ReactNode; | |
| 78 | + className?: string; | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** | |
| 82 | + * 총 건수 표시 — 시안이 숫자만 굵고 다른 색이라 별도 조각으로 둔다. 천 단위 구분은 | |
| 83 | + * 호출부의 로케일을 타지 않도록 `toLocaleString("ko-KR")`로 고정한다. | |
| 84 | + */ | |
| 85 | +export function FoxListCount({ | |
| 86 | + total, | |
| 87 | + prefix = "총 ", | |
| 88 | + suffix = "건", | |
| 89 | + className, | |
| 90 | +}: FoxListCountProps) { | |
| 91 | + return ( | |
| 92 | + <p className={cx("fox-list-count", className)}> | |
| 93 | + {prefix} | |
| 94 | + <b className="fox-list-count__value">{total.toLocaleString("ko-KR")}</b> | |
| 95 | + {suffix} | |
| 96 | + </p> | |
| 97 | + ); | |
| 98 | +} |
+++ @fox/core/components/fox-list-toolbar/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export * from "./fox-list-toolbar"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -52,3 +52,6 @@ |
| 52 | 52 |
export * from "./fox-text-button"; |
| 53 | 53 |
export * from "./fox-toggle-quantity"; |
| 54 | 54 |
export * from "./fox-toggle-switch"; |
| 55 |
+export * from "./fox-list-search"; |
|
| 56 |
+export * from "./fox-list-toolbar"; |
|
| 57 |
+export * from "./fox-list-container"; |
+++ @fox/styles/_fox-list-container.scss
... | ... | @@ -0,0 +1,26 @@ |
| 1 | +// FoxListContainer — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-list-container"> | |
| 5 | +// …fox-page-header… | |
| 6 | +// <section class="fox-list-container__section"> | |
| 7 | +// …fox-list-toolbar… …fox-table… …fox-pagination… | |
| 8 | +// </section> | |
| 9 | +// </div> | |
| 10 | +// | |
| 11 | +// 세로로만 쌓는다 — 도구 줄과 페이지네이션이 각자 자기 여백을 갖고 있어 여기서 간격을 더하지 | |
| 12 | +// 않는다(더하면 시안보다 벌어진다). | |
| 13 | + | |
| 14 | +@use "abstracts" as fox; | |
| 15 | + | |
| 16 | +.fox-list-container { | |
| 17 | + display: flex; | |
| 18 | + inline-size: 100%; | |
| 19 | + flex-direction: column; | |
| 20 | + | |
| 21 | + &__section { | |
| 22 | + display: flex; | |
| 23 | + inline-size: 100%; | |
| 24 | + flex-direction: column; | |
| 25 | + } | |
| 26 | +} |
+++ @fox/styles/_fox-list-search.scss
... | ... | @@ -0,0 +1,82 @@ |
| 1 | +// FoxListSearch — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar의 search (5111:4099) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <form class="fox-list-search" role="search"> | |
| 5 | +// <div class="fox-list-search__box"> | |
| 6 | +// <span class="fox-list-search__field">…select-txt…</span> <!-- 선택 --> | |
| 7 | +// <input class="fox-list-search__input"> | |
| 8 | +// <button class="fox-list-search__submit">…svg…</button> | |
| 9 | +// </div> | |
| 10 | +// </form> | |
| 11 | +// | |
| 12 | +// 상자 규격은 FoxInput md와 같지만(높이 40 · radius 8 · 테두리 input-border) 좌우 여백만 | |
| 13 | +// `form/padding/sm`으로 좁다 — 안에 셀렉트와 버튼이 들어가기 때문이다. | |
| 14 | +// | |
| 15 | +// 토큰이 아닌 값: | |
| 16 | +// - 폭 320, 제출 버튼 32: 시안 값이나 Figma가 변수로 export하지 않는다. | |
| 17 | + | |
| 18 | +@use "abstracts" as fox; | |
| 19 | +@use "form-field" as field; | |
| 20 | + | |
| 21 | +.fox-list-search { | |
| 22 | + display: flex; | |
| 23 | + flex-shrink: 0; | |
| 24 | + align-items: center; | |
| 25 | + gap: fox.gap(3); | |
| 26 | + // 시안 값이나 변수로 export되지 않는다. | |
| 27 | + inline-size: 32rem; | |
| 28 | + | |
| 29 | + &__box { | |
| 30 | + display: flex; | |
| 31 | + inline-size: 100%; | |
| 32 | + align-items: center; | |
| 33 | + gap: fox.gap(2); | |
| 34 | + block-size: fox.form(height-md); | |
| 35 | + padding-inline: fox.form(padding-sm); | |
| 36 | + border: fox.border(1) solid fox.color(input-border); | |
| 37 | + border-radius: fox.form(radius-md); | |
| 38 | + background: fox.color(input-surface); | |
| 39 | + } | |
| 40 | + | |
| 41 | + &__field { | |
| 42 | + display: inline-flex; | |
| 43 | + flex-shrink: 0; | |
| 44 | + align-items: center; | |
| 45 | + } | |
| 46 | + | |
| 47 | + &__input { | |
| 48 | + @include field.field(label-md); | |
| 49 | + | |
| 50 | + // 네이티브 `search`가 붙이는 기본 장식을 지운다 — 지우기 X는 제출 버튼과 겹친다. | |
| 51 | + &::-webkit-search-cancel-button, | |
| 52 | + &::-webkit-search-decoration { | |
| 53 | + appearance: none; | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + &__submit { | |
| 58 | + display: inline-flex; | |
| 59 | + flex-shrink: 0; | |
| 60 | + align-items: center; | |
| 61 | + justify-content: center; | |
| 62 | + // 시안 값이나 변수로 export되지 않는다. | |
| 63 | + inline-size: 3.2rem; | |
| 64 | + block-size: 3.2rem; | |
| 65 | + padding: 0; | |
| 66 | + border: none; | |
| 67 | + border-radius: fox.form(radius-xsm); | |
| 68 | + background: none; | |
| 69 | + color: fox.color(icon-neutral-default); | |
| 70 | + cursor: pointer; | |
| 71 | + | |
| 72 | + &:disabled { | |
| 73 | + color: fox.color(font-neutral-disabled-strong); | |
| 74 | + cursor: default; | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + &__submit > svg { | |
| 79 | + inline-size: fox.form(icon-md); | |
| 80 | + block-size: fox.form(icon-md); | |
| 81 | + } | |
| 82 | +} |
+++ @fox/styles/_fox-list-toolbar.scss
... | ... | @@ -0,0 +1,82 @@ |
| 1 | +// FoxListToolbar — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) list-toolbar (3002:8151) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-list-toolbar"> | |
| 5 | +// <div class="fox-list-toolbar__base"> | |
| 6 | +// <div class="fox-list-toolbar__left">…</div> | |
| 7 | +// <div class="fox-list-toolbar__right">…</div> | |
| 8 | +// </div> | |
| 9 | +// <div class="fox-list-toolbar__filter">…</div> <!-- 선택 --> | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 좌우가 각각 `flex: 1`이라 반씩 나눠 갖고, 우측 내용은 오른쪽 끝으로 몰린다. | |
| 13 | +// | |
| 14 | +// 토큰이 아닌 값: | |
| 15 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다. | |
| 16 | + | |
| 17 | +@use "abstracts" as fox; | |
| 18 | + | |
| 19 | +.fox-list-toolbar { | |
| 20 | + display: flex; | |
| 21 | + inline-size: 100%; | |
| 22 | + flex-direction: column; | |
| 23 | + align-items: flex-start; | |
| 24 | + padding-block-end: fox.spacing(bottom-md); | |
| 25 | + | |
| 26 | + &__base { | |
| 27 | + display: flex; | |
| 28 | + inline-size: 100%; | |
| 29 | + flex-shrink: 0; | |
| 30 | + align-items: center; | |
| 31 | + } | |
| 32 | + | |
| 33 | + &__left, | |
| 34 | + &__right { | |
| 35 | + display: flex; | |
| 36 | + flex: 1 0 0; | |
| 37 | + min-inline-size: 0; | |
| 38 | + align-items: center; | |
| 39 | + gap: fox.gap(4); | |
| 40 | + } | |
| 41 | + | |
| 42 | + &__right { | |
| 43 | + justify-content: flex-end; | |
| 44 | + } | |
| 45 | + | |
| 46 | + &__sorts { | |
| 47 | + display: flex; | |
| 48 | + flex-shrink: 0; | |
| 49 | + align-items: center; | |
| 50 | + gap: fox.gap(3); | |
| 51 | + } | |
| 52 | + | |
| 53 | + // 아래 줄 — 위 줄과 가로 구분선으로 나뉜다. | |
| 54 | + &__filter { | |
| 55 | + display: flex; | |
| 56 | + inline-size: 100%; | |
| 57 | + flex-shrink: 0; | |
| 58 | + align-items: center; | |
| 59 | + gap: fox.gap(3); | |
| 60 | + margin-block-start: fox.padding(5); | |
| 61 | + padding-block-start: fox.padding(5); | |
| 62 | + border-block-start: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +// 총 건수 — 숫자만 굵고 색이 다르다. | |
| 67 | +.fox-list-count { | |
| 68 | + flex-shrink: 0; | |
| 69 | + margin: 0; | |
| 70 | + color: fox.color(font-neutral-default); | |
| 71 | + font-family: fox.font-family(body); | |
| 72 | + font-size: fox.font-size(label-md); | |
| 73 | + font-weight: fox.font-weight(regular); | |
| 74 | + line-height: 1; | |
| 75 | + letter-spacing: -0.025em; | |
| 76 | + white-space: nowrap; | |
| 77 | + | |
| 78 | + &__value { | |
| 79 | + color: fox.color(font-theme-primary); | |
| 80 | + font-weight: fox.font-weight(bold); | |
| 81 | + } | |
| 82 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -55,3 +55,6 @@ |
| 55 | 55 |
@use "fox-modal"; |
| 56 | 56 |
@use "fox-accordion"; |
| 57 | 57 |
@use "fox-card"; |
| 58 |
+@use "fox-list-search"; |
|
| 59 |
+@use "fox-list-toolbar"; |
|
| 60 |
+@use "fox-list-container"; |
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?