npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

kokotable

v0.99.3

Published

헤드리스 테이블 코어 + MUI 렌더러 + 서버 모드 — 백엔드 QueryBuilder 와 같은 의미의 필터, 검색 상태, 확장 계약

Readme

koko-table

NewDewbellSite 의 테이블/QueryBuilder/PivotBuilder 를 material-react-table 없이 다시 세운 패키지. 클라이언트·서버 모드 모두 갖췄고(@koko-table/query), 렌더러는 @koko-table/mui 다.

두 배포가 한 저장소에서 나간다 — npm kokotable(프론트)과 koko-table-server(백엔드, 지금은 GitHub 태그에서 설치 · PyPI 는 보류). 계약이 바뀌면 한 커밋에서 양쪽이 같이 빨간불이 된다(npm run contract:verify).

packages/core   kokotable          헤드리스 (MUI·MRT 무의존) — 내부 워크스페이스 이름 @koko-table/core
packages/mui    kokotable/mui      MUI 렌더러 · 애드온 · 덮어쓰기 계층
packages/query  kokotable/query    서버 모드 (react-query)
contract/       kokotable/contract 백엔드 TableRequest JSON Schema — 두 쪽이 공유하는 유일한 것
server/         koko-table-server  백엔드 엔진 (파이썬) — TableRequest → SQLAlchemy → TableResponse
stories/                           Storybook (vite)

설치와 사용

배포 단위는 kokotable 하나다. 서브패스로 세 진입점을 낸다.

npm i kokotable
# peer: react, react-dom, @tanstack/react-table
# kokotable/mui 를 쓰면: @mui/material @mui/icons-material @emotion/react @emotion/styled
# kokotable/query 를 쓰면: @tanstack/react-query
# 행/열 가상화(kokotable/mui/virtual): @tanstack/react-virtual

| import | 내용 | 구 이름(vendor 시절) | |---|---|---| | kokotable | 헤드리스 코어 — 검색 상태·요청 조립·서버 동형 필터·컬럼 정의·확장 계약·계약 스키마·클라이언트 조립 useClientTable | @koko-table/core | | kokotable/mui | MUI 렌더러·레이아웃·애드온·KokoTableProvider·TableScreen | @koko-table/mui | | kokotable/mui/virtual | 행·열 가상화 (VirtualTableContent, VirtualTableScreen) | @koko-table/mui/virtual | | kokotable/query | 서버 모드 — 정의 조립 useTable(두 모드)·useServerTable, useServerTableData·useServerGrouping | @koko-table/query | | kokotable/contract/table-request.schema.json | 백엔드 TableRequest JSON Schema | — |

import { defineTable, createColumnHelper, buildColumns } from "kokotable";
import { useTable } from "kokotable/query";
import { KokoTableProvider, TableScreen } from "kokotable/mui";

const h = createColumnHelper<Row>();
const def = defineTable<Row>({
  key: "sales",
  columns: buildColumns([h.ColumnIsText({ key: "name", header: "이름" }), h.ColumnIsNumber({ key: "amount", header: "금액" })]),
  server: { queryFn: (req, { signal }) => api.post("/sales/query", req, { signal }), queryKey: ["sales"] },
  features: { selection: true, expand: true, rowNumbers: true },
  urlSync: true,
});

function SalesPage() {
  const bundle = useTable(def);   // 정의에 server 가 있으니 서버 조립. data 를 주면 클라이언트 조립
  return (
    <KokoTableProvider defaults={{ density: "compact" }}>
      <TableScreen bundle={bundle} layout="side" />
    </KokoTableProvider>
  );
}

서버 요청 계약(TableRequest)은 백엔드 pydantic 모델에서 뽑은 JSON Schema(kokotable/contract/table-request.schema.json) 와 1:1 이다. 백엔드(server/, 파이썬 koko-table-server)는 따로 배포되고 프론트는 그것을 직접 import 하지 않는다 — 두 쪽이 공유하는 것은 이 스키마뿐이고 CONTRACT_VERSION 이 같으면 호환된다. 같은 저장소에 두는 이유는 어긋남을 CI 가 잡게 하는 것이다: npm run contract:verify 가 프론트 → 스키마(ajv) → 백엔드 pydantic → SQLite 왕복을 한 번에 돌린다(docs/backend-contract.md).

ERP 에서 vendor alias 를 npm 의존으로 바꾸기

평가 레인은 빌드본을 front/vendor/koko-table/{core,mui,query}/ 에 복사하고 vite.config.ts·vitest.config.ts 의 resolve.alias 와 tsconfig.app.json 의 paths 로 @koko-table/* 를 그 경로에 붙였다. npm 으로 바꾸는 순서:

  1. npm i kokotable (front 에서). peer 는 이미 있다(react·@mui·@emotion·@tanstack/react-table·react-query).
  2. import 치환: @koko-table/core → kokotable, @koko-table/mui → kokotable/mui, @koko-table/mui/virtual → kokotable/mui/virtual, @koko-table/query → kokotable/query. 이름만 바뀌고 export 는 같다. 또는 alias 로 @koko-table/core: "kokotable" 등을 붙여 두고 점진 치환.
  3. vite.config.ts·vitest.config.ts 의 vendor alias 4줄과 tsconfig.app.json paths 4줄 삭제, front/vendor/koko-table 삭제.
  4. React 인스턴스가 하나인지 확인(npm ls react) — npm 의존이면 peer 로 front 의 react 를 쓴다.

컬럼 정의

0.95 → 0.97 로 올릴 때 바꿔야 하는 것은 docs/migration-0.97.md 에 모았다(빌더 메서드·meta 접근자·kokotable/legacy·기본 동작 변경). 렌더러가 앱 MUI 테마에서 무엇을 읽는지(다크 모드 포함)는 docs/theming.md 에 있다.

createColumnHelper<T>() 의 ColumnIs* 는 ColumnBuilder 메서드를 묶은 프리셋이고, new ColumnBuilder(key, header, dataType) 로 직접 시작해도 같다. 메서드 열셋(setFilter / setHeader · setAlign · setSizing · setVisibility · setSort · setGroup / setCell · setEdit · setAgg / 확장 setCustom · setProps)과 옛 이름(setDisplay · setEditable · setCellWrapper …)을 새 정의로 바꿔 주는 임시 변환기 kokotable/legacy(createLegacyColumnHelper · LegacyColumnBuilder · 옛 URL 포맷 legacyUrlFallback · 옛 MRT 컬럼 모양 toLegacyColumnDefs), 그리고 프리셋이 무엇을 묶었는지, 어느 프리셋이 소비 앱 조회기(commonCodeLookup · userLookup)에 기대는지는 docs/column-definition.md 에 정리했다. meta 를 통째로 쓰는 setMeta 는 없다 — 프리셋 하나가 자기 meta 슬라이스와 읽기 접근자를 한 모듈에 갖는다(columns/presets/link.ts 의 ColumnIsLink · LinkMeta · linkOf 처럼). 읽는 쪽은 meta?.x 대신 그 접근자(headerMetaOf · cellDecorOf · editOf · linkOf · relationColumnsOf …)를 부른다.

화면은 세 구역, 계약은 넷

┌─ 필터 구역 ──────────── FilterSource     ┐
├─ 콘텐츠 구역 ────────── ContentSource    │  각 구역은 테이블이 아니라
└─ 페이지네이션 구역 ──── PaginationSource ┘  자기 계약에만 의존한다

   정렬 ───────────────── SortSource          구역이 아니라 계약

정렬은 테이블에서 헤더 안에 그려지지만 ContentSource 에 넣지 않았다. 두 축이 독립이기 때문이다 — 카드 그리드는 콘텐츠는 있는데 정렬 UI 가 없고, "정렬: 금액↓" 단독 드롭다운은 정렬은 있는데 콘텐츠가 없다.

테이블 인스턴스는 이 계약들의 구현 하나일 뿐이라, 네 계약 모두 table 을 받을 수도 search/data 를 받을 수도 있다:

// 기존 화면 — 테이블 하나면 전부 붙는다
<FilterArea source={table} />
<TableContent table={table} content={contentSource(data)} sort={sortSource(search)} />
<PaginationArea source={table} />

// 카드 화면 — 테이블 인스턴스 없음
<FilterArea source={search} />
<CardContent source={contentSource(data)} renderCard={...} />
<PaginationArea source={paginationSource(search, data)} />

검색 방식 — 버튼 / 자동

표 단위 searchMode 하나로 정한다. 컬럼마다 debounce 를 거는 방식이 아니다.

| 모드 | 동작 | 렌더러 | |---|---|---| | "button" (기본) | 조건은 draft 에만 쌓이고 apply()(검색 버튼·Enter)를 눌러야 조회 | SearchBar 에 검색 버튼 | | "auto" | 필터·전역검색 값이 바뀌면 debounceMs 뒤 자동 조회 — debounce 는 코어 한 곳이 든다 | SearchBar autoApply 는 안내 문구만, GlobalFilterField 는 search.searchMode 를 읽어 타이머를 겹치지 않는다 |

useTableSearch({ columns, searchMode: "auto", debounceMs: 300 });     // 훅
defineTable({ …, search: { mode: "auto", debounceMs: 300 } });        // 정의 한 장 → TableScreen 이 버튼을 감춘다
configureTableDefaults({ searchMode: "auto", debounceMs: 300, pageSize: 50 }); // 앱 진입점에서 한 번 — 모든 표의 기본

search.searchMode 로 현재 모드를 읽는다. 컬럼 단위 filter.applyOnChange 는 button 모드에서 특정 컬럼만 즉시 조회하게 두는 예외(auto 모드에서 false 를 주면 그 컬럼만 버튼을 기다린다)이며 보통 필요 없다.

3층 구조

| 층 | 훅 | 테이블 필요? | |---|---|---| | 1 | useTableSearch({ columns }) | ❌ | | 2 | useTableData({ columns, request, data }) | ❌ | | 3 | useDataTable({ columns, search, data }) | ✅ |

애드온: useGrouping, useUrlSync + readUrlSearchState, useSearchHistory. 피벗은 순수 함수 runClientPivot(rows, spec) / transposePivot(spec).

실행

npm install
npm test                    # 600 케이스
npm run typecheck           # 패키지 빌드
npx tsc -p packages/core/checks   # 설계 조건 타입 레벨 검증
npm run storybook           # 개발 서버 (6006)
npm run storybook:build     # 정적 덤프 → storybook-static/

client / server 의미 일치

클라이언트 필터는 백엔드 QueryBuilder 와 같은 의미로 구현돼 있다. 분기 순서(filters/registry.ts)까지 _apply_filter_by_type 을 따른다 — 순서가 어긋나면 between 처럼 여러 타입이 지원하는 연산자에서 결과가 갈린다. filter.variant 가 관계·선형·일정이면 meta.dataType 보다 먼저 판정한다 (ColumnIsRelation 컬럼은 dataType 이 text 여도 관계 필터를 탄다).

서버 SQL 의 3값 논리도 그대로 따른다 — notIn/notInArray/notEquals/notContains 는 NULL 셀을 통과시키지 않고, 관계 조건의 equals 는 PG 캐스팅처럼 숫자 셀과 문자열 값(2 와 "2")을 같게 본다. linearExact 는 수량을 == 로만 판정하고, scheduleOverlaps 상한은 반열림(start < end+1d)이다.

요청 조립(TableSearch.request)에서 맞추는 것:

  • 리스트 연산자(inArray/notInArray/in/notIn/arrIncludes*/inArrayFilter)의 스칼라 값은 [value] 로 감싼다 — 서버 ListFilter 가 문자열을 글자 단위로 쪼개 enum 컬럼에서 500 을 내기 때문이다.
  • 값 없는 연산자(empty/notEmpty/isEmpty/hasChild/hasNotChild)는 값이 비어도 항목을 true 로 싣는다 — 서버는 value None 인 필터를 통째로 건너뛴다.
  • meta.sortExpandsTo 가 있는 통합 컬럼의 정렬은 실제 필드들로 펼쳐 싣는다 (expandSorting). UI 상태는 통합 컬럼 id 를 유지한다.
  • grouping 이 있으면 meta.enableAgg/meta.aggregations 에서 파생한 aggregation 을 싣는다 (deriveAggregation). SearchState.aggregation 을 명시하면 그것이 우선한다.
  • 정렬·페이지·그룹 변경은 적용된 params 위에서 그 필드만 갱신한다 — 미적용 draft(필터·연산자·전역검색)가 함께 나가지 않는다. patchParams(partial) / patchDraft(partial) 로 부분 상태를 병합할 수 있다.

pageSize 0 은 서버 LIMIT 0 과 같이 0건이다. "전체" 는 pagination 미지정 또는 PAGE_SIZE_ALL 로만 표현한다.

여전히 다를 수 있는 지점

| 항목 | 클라이언트 | 서버 | 맞추는 방법 | |---|---|---|---| | globalFilter 대상 | 선언된 컬럼 중 텍스트 계열(resolveDataKind === "text": text/enum/select/common_code). 숫자·날짜·boolean·관계·선형·일정 제외 | 모델의 varchar/text/enum 컬럼 전부 + FK 대상 테이블의 텍스트 컬럼 (GlobalFilterMixin._collect_auto_columns) | 화면에 선언하지 않은 컬럼이나 FK 대상 텍스트(supplier.name 등)를 검색해야 하면 useTableData({ globalFilterFields: ["name", "code", "supplier.name"] }) 로 경로를 명시한다. 서버도 columns 를 명시하면 같은 목록만 본다 | | inPeriod 기준 시각 | 브라우저 now | 서버 시각(KST, datetime.now()) | 브라우저 시간대가 KST 가 아니면 경계가 어긋날 수 있다. useTableData({ now }) / runClientQuery(rows, columns, request, { now }) 로 기준 시각을 주입한다 | | 날짜 경계 해석 실패 ("", "abc") | 경계 없음으로 통과 | 400 | 입력 단계에서 막는다 | | 텍스트 greaterThan 계열 | contains 로 동작 | _apply_term 에 분기가 없어 ILIKE (같다) | 계약 구멍 — 텍스트 컬럼에 비교 연산자를 노출하지 않는다 | | 문자열 정렬 순서 | 코드포인트 | DB collation | 한글·영문 혼합에서 다를 수 있다 |

덮어쓰기 계층 (customize)

배포된 패키지를 쓰는 화면(ERP 등)이 포크 없이 문구·컴포넌트·셀/필터 렌더러·메뉴·기본값을 바꾸는 통로. 우선순위는 항상 컬럼 단위 선언 → KokoTableProvider → 기본값 이다. 중첩 Provider 는 바깥 위에 병합된다.

import { KokoTableProvider } from "kokotable/mui";

<KokoTableProvider
  labels={{ operators: { fuzzy: "Contains" }, ui: { search: "Search", empty: "No rows" }, aggregations: { sum: "Sum" } }}
  components={{ EmptyState: BrandEmpty, HeaderCell: BrandHeaderCell, AggregateChip: PlainChip }}
  cellRenderers={[{ id: "number", render: ({ renderedValue }) => <b>{renderedValue}</b> }, { id: "boolean", render: ({ value }) => <Chip label={value ? "ON" : "OFF"} /> }]}
  filterInputs={{ text: (field) => <MyTextInput field={field} /> }}
  defaults={{ density: "compact", pageSizeOptions: [10, 25, 50], skeletonRows: 3 }}
>
  <App />
</KokoTableProvider>

| 무엇을 | 컬럼 단위 (최우선) | Provider | 기본값 | |---|---|---|---| | 필터 입력 | filter.render (setFilter({ render })) | filterKinds[kind.id].editor → filterInputs[variant] | kind.editor(withEditor) → 내장 kind 의 기본 입력 (FilterInput) | | 셀 값 | 컬럼 cell (코어 포맷터·setCell) 결과가 renderedValue 로 전달 | cellRenderers[dataType] → chip / link / placeholder | renderCellValue 의 Chip/Link/placeholder | | 셀 정렬 | meta.align (setAlign) | defaults.cellAlign (표 옵션 TableContent cellAlign / features.cellAlign) | 표 전체 center — 헤더·본문·그룹 집계 셀이 같은 규칙, dataType 으로 갈라 놓지 않는다 | | 헤더 내용 | meta.headerTooltip / meta.headerAlign | TableContent renderHeader({ header, defaultContent }) | columnDef.header | | 헤더 정의 (슬롯·규칙) | meta.header = "compact" | defineHeader(…) | headers={[defineHeader({ when, slots, rules })]} | defaultHeader ("헤더 정의하기" 절) | | 헤더 셀 props | — | TableContent headerCellProps(header, table) | — | | 헤더 셀 컴포넌트 | — | components.HeaderCell | HeaderCell | | 컬럼 메뉴 항목 | 헤더 rules 로 항목 제외 | menus.column: { add, remove, replace, order } → TableContent columnMenuItems({ defaultItems }) 가 마지막 | sort.* · pin.* · move.* · hide · group.* (onGroupingChange) · filter.open (onOpenFilter) · resize.reset (리사이즈) · copy.header | | 컬럼 메뉴 열기 | — | 메뉴 버튼 · 헤더 우클릭 (같은 항목) | enableColumnMenu (Provider defaults.enableColumnMenu) | | 사용자 설정 기억 | features.remember | useTableMemory({ key, table, search, include, store }) · createTableMemoryStore({ storage, name }) | tableMemoryStore (localStorage koko-table-memory, peer zustand) — 복원은 key 당 한 번, 같은 값이면 no-op (렌더마다 새 table·data 를 넘겨도 루프 없음, 0.97.5) | | 폭 조절 스필 | — | contentProps.columnResizeSpill · 정의 features.columnResizeSpill — "right" 면 끄는 열의 오른쪽 열만 내준다 | defaults.columnResizeSpill ("none") | | 컬럼 순서 드래그 | — | 헤더를 끌어 다른 헤더 좌/우에 놓기 (moveColumnTo) · 메뉴 move.* | enableColumnDrag (Provider defaults.enableColumnDrag, 정의 features.columnDrag) | | 날짜 입력 | meta.dateFilterMode | filterInputs.date / DateField · DateRangeField(숫자 8자리 자동 포맷 · 달력 팝오버 · 빠른 기간 · 한쪽만 정한 열린 범위) | 브라우저 type="date" 를 쓰지 않는다 | | 행 우클릭 메뉴 | — | menus.row: { add, remove, replace, order } → TableContent rowMenuItems({ row, group, defaultItems }) 가 마지막 | 말단 copy.row / 그룹 expand · collapse · expandToDepth (onExpandGroupToDepth 있을 때) | | 그룹 줄 건수 (n) | — | TableContent groupCount="always" \| "multiple" \| "never" — "multiple" 은 한 건짜리 묶음의 (1) 을 뺀다(ERP R89). 아직 모르는 건수는 늘 그린다 | defaults.groupCount("always") | | 그룹 모드 말단 줄의 묶인 열 | — | TableContent groupedLeafCells="show" \| "hide" — "hide" 면 묶인 열의 말단 셀을 비운다(칸은 남긴다, ERP R90). 묶이지 않은 열·그룹이 없을 때는 무관 | defaults.groupedLeafCells("show") | | 페이지네이션 문구 | — | PaginationArea total="range" \| "count" · pageTooltip(페이지 번호에 행 범위) | defaults.paginationTotal("range") · defaults.paginationPageTooltip(false) | | 확장 열 헤더 "모두 펼치기" | — | 좌클릭 토글 · 우클릭 메뉴 (항목 고정 — labels.ui.expandAll · expandPage · collapseAll 로 문구만) | 모두 펼치기 / 현재 페이지만 펼치기(페이지 둘 이상) / 모두 접기 | | 그룹 행 / 상세 패널 행 | — | components.GroupRow(ctx) / components.DetailPanelRow(ctx) | TableContent 기본 배치 | | 빈 상태 / 에러 / 로딩 | TableContent renderEmpty / renderError | components.EmptyState / ErrorFallback / LoadingSkeleton | 문구 + TableErrorFallback + Skeleton 행 | | 편집 입력 / 집계 칩 | meta.edit | components.EditCell / components.AggregateChip | EditCell / AggregateChip | | 문구 (다국어) | — | labels.operators / labels.ui / labels.aggregations(Short) | 한국어 (DEFAULT_LABELS) | | 기본값 (UI) | 컴포넌트 prop | defaults.{density, cellAlign, pageSizeOptions, skeletonRows, groupRowLayout, groupedColumnMode, enableColumnMenu, enableColumnDrag, stickyHeader, maxHeight, debounceMs, paginationTotal, paginationPageTooltip, groupCount, groupedLeafCells} | DEFAULT_TABLE_DEFAULTS | | 기본값 (코어) | 훅 옵션 · defineTable | configureTableDefaults({ searchMode, debounceMs, pageSize, groupBatchSize, enableColumnResizing, columnResizeMode, remember, editActivate, dateCloseOnSelect, getRowId }) — 앱 진입점에서 한 번 (packages/core/src/defaults.ts) | button · 300ms · 30 · 100 · 폭 조절 끔 · remember 없음 · 더블클릭 · 안 닫음 · 행 순번 |

useKokoTableConfig() 로 해석된 설정을 읽고, resolveTableProps(props, defaults) 로 자기 컴포넌트에도 같은 규칙을 적용할 수 있다. 모든 확장점은 { id, … } 항목의 레지스트리다(createRegistry · Registry.with({ add, remove, replace, order })) — 셀 렌더러·집계·export 포맷터도 cellRenderers={[…]} / aggregations / exportFormatters 로 받아 useKokoTableConfig().registries.{cells,aggregations,exportFormatters} 로 노출한다. 스토리 "커스터마이즈" · "확장/새 header 정의하기" · "확장/컨텍스트 메뉴 확장" 에 각 통로의 예가 있다.

헤더 정의하기

헤더 셀 안쪽은 헤더 정의가 그린다. 기본 헤더(defaultHeader)도 같은 API 다 — 다섯 슬롯 content · sortIndicator · resizeHandle · menuTrigger · pinIndicator 의 조합.

const amountHeader = defineHeader<SalesRow>({
  id: "amount", when: (ctx) => ctx.column.id === "amount",           // Provider headers[] 에서 어느 컬럼에 붙일지
  slots: { sortIndicator: (ctx) => (ctx.sortState ? "▼" : null) },   // 일부 자리만 교체 (나머지는 기본)
  rules: { canPin: () => false, canHide: (ctx) => ctx.column.id !== "idx" },   // 메뉴 항목 + 헤더 클릭 + column.getCan*() 모두 반영
  // render: (ctx, slots) => <div>{ctx.defaultContent}{slots.menuTrigger(ctx)}</div>   // 셀 내용 통째 교체 (<th> 는 유지)
});
<KokoTableProvider headers={[amountHeader]} />            // 컬럼 단위: setHeader(node, { def: amountHeader | "compact" })

적용 순서 meta.header > Provider headers[] 중 when 이 참인 첫 정의 > 기본. ctx 는 { header, column, table, sort, labels, sortState, multiSortIndex }, 슬롯·render 는 여기에 can(규칙 해석 결과) · defaultContent · openMenu(anchor) · menuEnabled 를 더 받는다. 내장 id: default · compact(메뉴 버튼·리사이즈 없음).

컨텍스트 메뉴 확장

컬럼 메뉴(헤더 버튼·우클릭)와 행 메뉴(본문 우클릭)는 MenuItemDef 레지스트리다 — { id, label, icon?, when?(ctx), run(ctx), disabled?(ctx), group?, order?, divider? }.

<KokoTableProvider
  menus={{
    column: { add: [{ id: "group.only", label: "이 컬럼으로 그룹핑", when: (ctx) => !!ctx.setGrouping, run: (ctx) => ctx.setGrouping?.([ctx.column.id]) }],
              remove: ["hide"], replace: [{ id: "copy.header", label: "헤더+값 복사", run: (ctx) => ctx.copy(…) }], order: ["group.only"] },
    row: { add: [{ id: "detail", label: "상세 보기", when: (ctx) => !!ctx.row, run: (ctx) => open(ctx.row!.original) }] },
  }}
  onOpenFilter={(column) => …}   // 있을 때만 filter.open 이 보인다 (TableContent onOpenFilter 도 같다)
/>

ColumnMenuCtx = 헤더 ctx + { can, grouping, setGrouping?, openFilter?, copy }, RowMenuCtx = { row?, group?, table, copy, labels } (kind 로 구분). 기본 id — 컬럼 sort.asc · sort.desc · sort.clear · pin.left · pin.right · pin.clear · move.left · move.right · hide · group.add · group.remove · filter.open · resize.reset · copy.header, 행 copy.row · expand · collapse · expandToDepth. 중첩 Provider 패치는 바깥 위에 쌓이고, columnMenuItems/rowMenuItems prop 이 해석 결과(defaultItems)를 마지막으로 손본다.

셀·집계·export 확장

셀 렌더러·집계 함수·export 포맷터는 모두 { id, when?, … } 항목의 레지스트리다(createRegistry, core). 내장 세트도 같은 API 로 등록돼 있다.

const p90 = defineAggregation({ id: "p90", label: "90분위", shortLabel: "P90", applies: (t) => t === "number", compute: (values) => percentile(values, 0.9) });
const badge = defineCellRenderer({ id: "status-badge", when: ({ columnId }) => columnId === "status", render: ({ value }) => <Chip label={String(value)} /> });
aggregations.register(p90);   // 앱 전체 — core `aggregations` · `exportFormatters`, mui `cellRenderers`
<KokoTableProvider cellRenderers={[badge]} aggregations={[p90]} exportFormatters={[defineExportFormatter({ id: "won", when: (c) => c.id === "amount", format: (v) => `${v}원` })]} />

| 확장점 | 해석 순서 | 소비처 | |---|---|---| | 셀 (CellRendererDef) | 컬럼 cell 결과가 renderedValue → when 참 중 order 순 → id === dataType → 그대로; meta.chip/link/placeholder 는 같은 id 슬롯이 감싼다 | renderCellValue(cell, { registry: useCellRenderers() }) | | 집계 (AggregationDef, core) | applies(dataType) 가 칩 후보, compute(values, ctx) 가 계산, server 없으면 점선(클라이언트 전용), format 이 칩 문자열 | useGrouping({ aggregations: useAggregations() }) · 그룹 행 칩(클릭 전환) · 헤더 메뉴 aggregate(그룹핑 중) · GroupingSettingsButton(그룹핑 바 오른쪽 톱니 — 그룹 컬럼 배치·더 보기 배치 크기·집계 함수를 한 팝오버에) | | export (ExportFormatterDef, core) | when(column) 참 → id === dataType → formatValueForExport | useTableExport(table, adapter, { formatters: useExportFormatters() }) · toCsv(data, { formatters }) · exp.formatRow |

Provider 항목은 내장 위에 같은 id 로 얹힌다(빼려면 registry.with({ remove }) 를 직접 넘긴다). AGG_LABELS 등 상수는 레지스트리에서 파생된다. 스토리 "확장/셀·집계·export 레지스트리".

필터 kind 정의하기

컬럼의 filter.kind 에 끼우는 정의 하나가 연산자·클라이언트 판정·요청 값·문구·URL codec·입력을 정한다. 내장 text · number · date · period · boolean · enum · commonCode · cascade · relation · schedule · linear 도 같은 defineFilterKind 로 정의돼 있고 helper 프리셋이 그것을 쓴다.

// core — 의미 부분. 값·연산자 타입은 operators 에서 추론된다
const moneyKind = defineFilterKind({
  id: "money",
  operators: {
    between: { client: (cell, v: MoneyValue) => …, serialize: (v) => [v.lo, v.hi], wire: "betweenInclusive", describe: (v) => `${v.lo} ~ ${v.hi} ${v.currency}` },
    gte:     { client: (cell, v: MoneyValue) => …, wire: "greaterThanOrEqualTo", describe: (v) => `${v.lo} ${v.currency} 이상` },
  },
  defaultOperator: "between",
  isEmpty: (v) => !v || (v.lo == null && v.hi == null),
  codec: { encode: (v) => [v.lo, v.hi, v.currency], decode: (raw) => … },
});
// mui — 표시 부분. Provider `filterKinds={{ money: withEditor(moneyKind, { editor: MoneyInput }) }}` 로도 끼운다
helper.ColumnIsNumber({ key: "amount", header: "금액" }).setFilter({ kind: withEditor(moneyKind, { editor: (f) => <MoneyInput field={f} /> }), defaultOperator: "gte" });
// 문자열 id 로 좁히려면 레지스트리를 병합한다 → filter: { kind: "money", defaultOperator: "gte" } (틀리면 컴파일 에러)
declare module "@koko-table/core" { interface FilterKindRegistry { money: typeof moneyKind } }

kind 가 없는 컬럼은 지금의 variant 경로 그대로다. 입력 우선순위는 filter.render → Provider(filterKinds[id].editor → filterInputs[variant]) → kind.editor → 내장. 스토리 "확장/필터 kind 정의하기".

없는 것 (의도적)

  • XLSX/PDF/print 변환 — useTableExport 가 행·컬럼을 내주고 변환은 소비 앱이 동적 import 한다.
  • 프리셋 서버 API — PresetStore 인터페이스만 두고 저장소는 소비 앱이 구현한다(localStorage·memory 구현은 제공).
  • 라우터 — 링크 셀은 onNavigate 콜백으로 연결한다.
  • 날짜 열의 기간 그룹핑(groupBy: "day" | "week" | "month") — 요청 계약의 grouping 은 컬럼 키 배열이라 기간 버킷은 백엔드와 같이 정해야 한다. 클라이언트에만 넣으면 같은 정의가 모드에 따라 다르게 동작한다 ("client / server 의미 일치"). 계약에 transform 이 들어갈 때 ColumnIsDate({ groupBy }) 로 함께 낸다. 그 전에는 날짜·주·월 문자열 열을 파생시켜(setGroup(true)) 숨겨 두고 그룹핑 바에서 꺼내 쓴다.

테이블 정의 한 장

배포를 가정하면 "테이블 하나를 정의하는 타입" 이 있어야 소비자가 필요한 것만 채워 쓴다. TableDefinition<T> 가 그 자리다 — 컬럼·기본값·서버 연결·기능 토글·그룹핑·URL 동기화를 객체 하나에 선언하고, 조립 훅이 1·2·3층 + 애드온을 TableBundle 로 묶어 주며, TableScreen 이 features 대로 화면을 배치한다.

// 1. 정의 — 필요한 것만 채운다 (모듈 상수나 useMemo 로 참조를 고정한다)
const salesTable = defineTable<SalesRow>({
  key: "sales", columns, data,                       // 서버 모드면 data 대신 server: { queryFn, queryKey }
  features: { rowNumbers: true, selection: true },   // expand · rowActions · globalFilter · filterStatus · virtualColumns …
  grouping: { enabled: true }, urlSync: true, getRowId: (row) => String(row.idx),
});
// 2. 조립 — 정의의 모드(data / server)를 보고 고른다. react-query 없이 클라이언트만 쓰면 kokotable 의 useClientTable
const bundle = useTable(salesTable);
// 3. 화면 — 슬롯으로 구역을 갈아끼운다 (filter / content / pagination / groupingBar / selectionActions / emptyState / detailPanel / rowActions)
return <TableScreen bundle={bundle} title="판매 주문" slots={{ detailPanel: (row) => <Items row={row.original} /> }} />;

| 정의 항목 | 뜻 | |---|---| | columns · data / server · mode | 컬럼, 클라이언트 배열 또는 서버 조회(queryFn·queryKey·staleTime·transformResponse·keepPreviousData·refetchInterval·retry). mode 미지정이면 server 유무로 판정 | | search: { mode, debounceMs } | 검색 방식 — "button"(기본, 검색 버튼을 눌러야 조회) / "auto"(입력하면 debounceMs 뒤 자동 조회, 검색 버튼 없음). 미지정이면 configureTableDefaults 값 | | initial · initialOperators · globalFilterFields · now | 1·2층 옵션 그대로 | | urlSync · history · grouping | 애드온 — bundle.urlSync / bundle.history / bundle.grouping(items·expand·loadMore·rows·batchSize) | | features | 렌더러가 읽는 토글: rowNumbers selection{multi,selectOnRowClick,canSelect} expand{canExpand} rowActions groupedColumnMode density cellAlign columnMenu columnDrag globalFilter(기본 true, 검색 바 왼쪽 · "toolbar") filterStatus columnVisibilityMenu virtualColumns virtualize remember(사용자 설정 기억 — 컬럼 폭·순서·표시·고정·페이지 크기, zustand persist, 프리셋과 별개) | | getRowId · fieldErrors · tableOptions | 3층. 서버 모드에서 선택·확장을 재조회 뒤에도 유지하려면 getRowId 가 필수다. 표마다 같은 식이면 앱 기본 configureTableDefaults({ getRowId }) 로 한 번 — 정의 → tableOptions.getRowId → 앱 기본 → 행 순번 순이고, 셋 다 없으면 조립 때 한 번 경고한다 |

defineTable 은 타입 추론용 identity 이면서 검증한다 — 중복 컬럼 id, 서버 모드인데 queryFn 없음은 마운트 전에 던진다. 조립 훅은 셋이다 — useTable(kokotable/query, 정의의 모드로 둘 중 하나를 부른다) · useClientTable(kokotable) · useServerTable(kokotable/query). 옛 이름 useTableFromDefinition 은 useClientTable 의 별칭으로 남아 있다. useTable 은 마운트된 동안 정의의 모드가 바뀌면 던진다(훅 순서가 달라진다 — key 로 새로 마운트한다).

반환 bundle 은 조립 결과 묶음 — TableScreen 에 통째로 넘기는 손잡이이고, 화면 밖에서 조각을 꺼내 쓴다 (bundle.table.getSelectedRowModel() · bundle.search.apply() · bundle.fetchAllRows() · 서버면 bundle.query.refetch()). TableBundle 은 { definition, mode, search, data, table, grouping?, urlSync?, history?, request, tableColumns, setTableColumns, fetchAllRows } 이고 서버 조립은 query 를 더한다 — fetchAllRows() 는 현재 조건의 전체 행(서버는 pagination 없이 한 번 더 조회)이라 extractExportData 에 그대로 넘긴다. 표시 전용 열·가상 컬럼 값 열은 TableScreen 이 bundle.setTableColumns(withDisplayColumns(...)) 로 3층에 등록한다(코어는 MUI 를 모른다). 가상화(features.virtualize)는 import { VirtualTableScreen } from "@koko-table/mui/virtual" 로 그린다 — 메인 TableScreen 은 무시하고 경고만 낸다. 스토리 "테이블 정의" 와 "서버 모드 기능" 에 기능별 예가 있다.