hyper-xl 레퍼런스

웹에서 엑셀과 동일한 그리드 UX를 제공하는 React 라이브러리. 출시된 모든 기능을 한 페이지에서 다룹니다.

▶ 라이브 데모 · 실제 hyper-xl로 구동되는 인터랙티브 데모(그리드 + 라이브 코드 에디터)는 데모 보기 →
라이선스 · hyper-xl은 HyperEZ Source-Available License로 배포됩니다. 평가·학습·비상업 용도는 무료입니다. 상업적/프로덕션 용도는 별도 라이선스가 필요합니다. 문의: support@hyperez.io. LICENSE 참조.

소개

hyper-xl는 React 그리드 컴포넌트입니다. 엑셀의 키보드 / 마우스 / 클립보드 / 채우기 / 정렬·필터 / 배율 / 주석 / 보호 동작을 그대로 재현합니다. 데이터는 컨슈머가 소유하며, 라이브러리는 사용자 제스처를 감지해 타입드 페이로드로 콜백을 통해 다시 전달합니다.

10,000+ 행에서도 60fps를 유지하도록 가상화되어 있고, ESM 단일 번들로 배포되며, 디자인 토큰은 --xl-react-* CSS 커스텀 프로퍼티로 노출됩니다.

주요 특징

  • 두 축 가상 스크롤링: 10k 행 × 30 열에서 60fps 유지
  • 엑셀 TSV 양방향 호환 클립보드 (외부 엑셀 ↔ 그리드 직접 복붙)
  • Excel 등가 키보드 매핑 + 우클릭 컨텍스트 메뉴
  • 다중 컬럼 정렬, 컬럼별 값 필터 (체크박스 패널)
  • 10% ~ 400% 시트 배율, Ctrl+휠 + 우하단 위젯
  • 셀별 글꼴 / 정렬 / 채우기 / 테두리 시각 서식 + 편집 toolbar
  • 셀 단위 read-only 보호 (편집 / 붙여넣기 / 채우기 / 삭제 차단)
  • 선택 영역 실시간 SUM / AVG / COUNT 상태 표시줄

설치

npm 레지스트리에 게시되어 있습니다. 사전 빌드된 번들이 포함되어 있어 별도 빌드 단계 없이 바로 사용할 수 있습니다.

npm i hyper-xl
# 또는
pnpm add hyper-xl
# 또는
yarn add hyper-xl

피어 의존성

  • react ^18 || ^19
  • react-dom ^18 || ^19
  • exceljs ^4.4.0: 선택적 (peerDependenciesMeta.exceljs.optional: true). exportToXlsx / importFromXlsx / exportMultiSheetXlsx / ImportDialog를 호출하는 경우에만 설치하세요. CSV / TSV만 쓰거나 그리드만 쓴다면 설치할 필요가 없습니다. ExcelJS는 await import('exceljs')로 지연 로드되므로 메인 번들에 포함되지 않으며, 루트 엔트리('hyper-xl')의 타입 선언 파일도 ExcelJS 타입을 노출하지 않습니다. 저수준 헬퍼 (buildWorkbookFromSnapshot / parseWorkbookToSnapshot / cellFormatToExcelStyle / cellFormatFromExcelStyle)가 필요하면 'hyper-xl/exceljs' 서브패스에서 import 하세요. 이 경로는 ExcelJS 타입을 노출하므로 ExcelJS가 반드시 설치되어 있어야 합니다.

빠른 시작

가장 작은 그리드: 컬럼 정의와 로우 데이터만 넘기면 됩니다.

import { XlReact, type Column, type Row } from 'hyper-xl';
import 'hyper-xl/styles.css';
import 'hyper-xl/themes/light.css';

const columns: Column[] = [
  { id: 'name', accessor: (r) => r.data.name },
  { id: 'qty',  accessor: (r) => r.data.qty, dataType: 'number' },
];

const rows: Row[] = [
  { id: 1, data: { name: 'Container A', qty: 12 } },
  { id: 2, data: { name: 'Container B', qty: 8  } },
];

export function Page() {
  return <XlReact columns={columns} rows={rows} />;
}
편집 가능한 컨트롤드 그리드 예제 tsx · ~30줄

onCellChange를 와이어하면 그리드가 편집 모드로 진입합니다. 컨슈머는 자체 상태(useState / Redux / Zustand 등)에 변경 사항을 반영합니다.

import { useState } from 'react';
import { XlReact, type Column, type Row, type CellChange } from 'hyper-xl';

const columns: Column[] = [
  { id: 'name', accessor: (r) => r.data.name },
  { id: 'qty',  accessor: (r) => r.data.qty, dataType: 'number' },
];

export function EditableGrid() {
  const [rows, setRows] = useState<Row[]>([
    { id: 1, data: { name: 'Container A', qty: 12 } },
    { id: 2, data: { name: 'Container B', qty: 8  } },
  ]);

  const onCellChange = (change: CellChange) => {
    setRows((prev) =>
      prev.map((r, i) =>
        i === change.coord.row
          ? { ...r, data: { ...r.data, [change.columnId]: change.nextValue } }
          : r,
      ),
    );
  };

  return <XlReact columns={columns} rows={rows} onCellChange={onCellChange} />;
}
useReducer로 onCellChange + onCellsClear 통합 처리 tsx · ~45줄

실제 앱에서는 편집 / 삭제 / 붙여넣기 / 채우기 모두 동일한 reducer로 흐르도록 묶으면 undo 스택과 의미상 정합이 맞습니다.

import { useReducer } from 'react';
import {
  XlReact,
  type Row,
  type CellChange,
  type CellsClearPayload,
} from 'hyper-xl';

type Action =
  | { kind: 'set'; row: number; columnId: string; value: unknown }
  | { kind: 'clear'; ranges: CellsClearPayload['ranges'] };

function reducer(state: Row[], action: Action): Row[] {
  switch (action.kind) {
    case 'set':
      return state.map((r, i) =>
        i === action.row
          ? { ...r, data: { ...r.data, [action.columnId]: action.value } }
          : r,
      );
    case 'clear': {
      const next = state.map((r) => ({ ...r, data: { ...r.data } }));
      for (const range of action.ranges) {
        const r0 = Math.min(range.start.row, range.end.row);
        const r1 = Math.max(range.start.row, range.end.row);
        // 컬럼 ID 매핑은 columns 정의에서 가져옴 (생략)
        for (let r = r0; r <= r1; r++) {
          // next[r].data[columnId] = undefined;
        }
      }
      return next;
    }
  }
}

스타일 & 토큰

라이브러리는 CSS를 standalone 파일로만 배포합니다. JS 엔트리에서 CSS를 사이드이펙트로 import하지 않으므로 Next.js 등 “no global CSS from node_modules” 번들러와도 충돌하지 않습니다.

경로 필수 내용
hyper-xl/styles.css 필수 --xl-react-* 토큰 기본값 + 라이브러리 내부 BEM 클래스 규칙
hyper-xl/themes/light.css 선택 라이트 테마 토큰 오버라이드
CSS 토큰 오버라이드 예제 css

모든 시각 요소가 --xl-react-* CSS 변수로 구동되므로, 컨슈머는 번들을 건드리지 않고 개별 토큰만 덮어쓰면 됩니다.

/* 보호된 셀의 스트라이프 색상을 브랜드 색으로 변경 */
.xl-react-grid {
  --xl-react-readonly-stripe: rgba(255, 200, 0, 0.18);
  --xl-react-selection-border: #ff4081;
  --xl-react-selection-bg: rgba(255, 64, 129, 0.12);
}

XlReact 컴포넌트

XlReact가 라이브러리의 핵심 그리드 컴포넌트입니다. 완전한 컨트롤드 모드로 동작하므로 그리드는 로우 데이터를 절대 소유하지 않습니다. columnsrows를 넘기고, 필요한 기능에 대응하는 콜백을 와이어하면 됩니다. 그리드 외에도 툴바·다이얼로그 등 보조 컴포넌트(CellFormatToolbar, CellMergeToolbar, ConditionalFormatToolbar, FindReplaceDialog, ValidationDropdown, ExportButton, ImportDialog, PivotBuilder, PivotChart, SheetTabBar, PrintPreview, FormulaBar 등)가 root에서 함께 export 됩니다 — 필요한 것만 골라 쓰면 됩니다.

<XlReact
  columns={columns}
  rows={rows}
  rowHeight={28}
  columnWidth={120}
  onCellChange={(change) => applyEdit(change)}
  onCellsClear={(payload) => clearRanges(payload.ranges)}
  freezeFirstRow
  showSelectionStats
/>

전체 prop 목록은 XlReactProps 섹션에 있습니다.

컬럼 & 로우

Column

  • idstring

    안정적인 식별자. 정렬 / 필터 / 재정렬 페이로드가 이 id를 운반합니다.

  • accessor(row: Row) => T

    해당 컬럼의 셀 값을 반환합니다.

  • dataType'text' | 'number'

    기본 에디터의 입력 필터링과 유효성 스타일링을 결정합니다. 기본값 'text'. 'number'로 두면 편집 중 비숫자 키 입력이 거부됩니다.

  • requiredboolean

    accessor가 null / undefined / '' / NaN을 반환하면 invalid 스타일이 적용됩니다. 0 / false는 유효값입니다. 시각 큐일 뿐 commit을 막지 않습니다.

  • validate(value: T, row: Row) => boolean | string

    값 기반 검증. 비어있지 않은 문자열을 반환하면 invalid + 메시지, false는 메시지 없이 invalid.

  • cellRenderer / cellEditor(props) => ReactNode

    커스텀 셀 렌더러 / 에디터. 에디터는 onCommit / onCancel을 받습니다.

  • widthnumber

    초기 컬럼 너비(px). 사용자 드래그 리사이즈로 덮어쓰여집니다.

  • readOnlyboolean

    이 컬럼의 모든 셀을 보호 상태로 마킹. 그리드의 cellProtection prop과 union입니다.

  • validation{ listKey: string; strict?: boolean }

    컬럼을 validationLists의 명명된 목록에 연결해 드롭다운 선택을 활성화합니다. stricttrue면 목록에 없는 값을 invalid 스타일로 표시합니다. 자세한 내용은 데이터 검증 (드롭다운) 섹션을 참조하세요.

  • autoCompleteboolean

    §2.3: 편집 모드에서 같은 컬럼의 기존 값 중 입력값을 prefix로 갖는 첫 후보를 인라인 ghost-text로 제안합니다. Tab / (캐럿이 끝일 때)로 채택하고 Esc로 후보만 닫습니다. 대소문자 무시(prefix 매칭): 채택 시 원본 케이스를 사용합니다. 기본값 false.

Row

  • idstring | number

    안정적인 식별자. 삭제 / 재정렬 페이로드가 이 id를 사용합니다.

  • dataRecord<string, unknown>

    불투명한 로우 데이터. 그리드는 직접 읽지 않고 Column.accessor만 호출합니다.

  • heightnumber

    초기 행 높이(px).

커스텀 셀 렌더러: 진행률 막대 tsx · ~25줄
const progress: Column<number> = {
  id: 'progress',
  accessor: (r) => r.data.progress as number,
  dataType: 'number',
  cellRenderer: ({ value }) => (
    <div style={{ position: 'relative', height: '100%' }}>
      <div
        style={{
          position: 'absolute',
          inset: 0,
          width: `${Math.max(0, Math.min(100, value))}%`,
          background: 'rgba(11, 83, 148, 0.18)',
        }}
      />
      <span style={{ position: 'relative' }}>{value}%</span>
    </div>
  ),
};
커스텀 에디터: 셀렉트 박스 tsx · ~30줄
const statusCol: Column<'todo' | 'doing' | 'done'> = {
  id: 'status',
  accessor: (r) => r.data.status as 'todo' | 'doing' | 'done',
  cellEditor: ({ value, onCommit, onCancel }) => (
    <select
      autoFocus
      defaultValue={value}
      onBlur={(e) => onCommit(e.target.value as typeof value)}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onCancel();
      }}
    >
      <option value="todo">Todo</option>
      <option value="doing">Doing</option>
      <option value="done">Done</option>
    </select>
  ),
};

컨트롤드 데이터 모델

그리드는 로우 / 컬럼을 절대 소유하지 않습니다. 사용자 제스처를 감지하고 타입드 페이로드를 노출한 뒤, 컨슈머가 자신의 상태를 갱신할 것이라 신뢰합니다. 사용할 기능에 맞는 콜백만 와이어하면 됩니다.

제스처 콜백 페이로드
편집 커밋 onCellChange CellChange { coord, columnId, prevValue, nextValue }
선택 영역 삭제 onCellsClear CellsClearPayload { ranges }
선택 변경 onSelectionChange SelectionSnapshot { active, ranges }
편집 요청 (F2 / dblclick) onEditRequest CellCoord

셀 선택

FeatureHigh 단일 / 범위 / 다중 비연속 선택을 모두 지원하는 코어 선택 시스템.

기능

  • 클릭으로 활성 셀 지정. F2 / 더블클릭으로 편집 모드 진입.
  • Enter(↓) / Tab(→) / Shift+Enter(↑) / Shift+Tab(←) 이동. Esc는 편집 취소.
  • 드래그로 사각형 범위 선택. Shift+클릭 / Shift+방향키로 범위 확장.
  • Ctrl+A는 데이터 영역 → 시트 전체 토글. Shift+Ctrl+방향키는 데이터 끝까지 확장.
  • Ctrl+클릭 / Ctrl+드래그로 비연속 범위 추가.
  • 행 / 열 헤더 클릭으로 축 전체 선택.

API

  • onSelectionChange(snapshot: SelectionSnapshot) => void

    사용자가 보는 선택(활성 셀 또는 범위 리스트)이 바뀔 때마다 호출. 초기 마운트 시에는 호출되지 않습니다.

선택 상태를 외부로 동기화 tsx
import { useState } from 'react';
import { XlReact, type SelectionSnapshot } from 'hyper-xl';

export function Page() {
  const [sel, setSel] = useState<SelectionSnapshot | null>(null);

  return (
    <>
      <XlReact columns={cols} rows={rows} onSelectionChange={setSel} />
      <p>
        활성: ({sel?.active.row ?? '-'}, {sel?.active.col ?? '-'}) · 범위:{' '}
        {sel?.ranges.length ?? 0}
      </p>
    </>
  );
}
관련 타입 typescript
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
  active: CellCoord;
  ranges: ReadonlyArray<SelectionRange>;
}

편집 & 검증

FeatureHigh 한글 IME를 포함해 Excel과 동등한 입력 흐름.

편집 진입 모드

  • edit: F2 / 더블클릭. 초기 draft = 현재 값, 전체 선택.
  • overwrite: 인쇄 가능한 키. draft = 타이핑한 글자.
  • clear: Backspace. draft = 빈 문자열.

Enter / Tab / focus loss로 커밋 → onCellChange. Esc는 취소. 편집 모드에서 Alt+Enter는 현재 캐럿 위치에 \n을 삽입하고 편집을 유지합니다(§2.1). IME 조합 중에는 줄바꿈이 적용되지 않습니다. 셀 디스플레이에서 줄바꿈을 시각적으로 보존하려면 해당 셀에 align.wrap: true가 설정되어 있어야 합니다(pre-wrap). 컬럼이 autoComplete: true로 켜져 있으면 같은 컬럼 값 풀에서 prefix 매칭 후보가 ghost-text로 표시되고 Tab 또는 (캐럿이 끝일 때)로 채택, Esc로 후보만 닫을 수 있습니다(§2.3).

검증

  • Column.required: 빈 값에 invalid 스타일. commit은 막지 않음.
  • Column.validate(value, row): 값 기반 검증. true / false / 메시지 문자열.
  • Column.dataType: 'number': 기본 에디터가 비숫자 키 입력을 거부.

API

  • onCellChange(change: CellChange) => void

    편집을 활성화하려면 반드시 필요. 없으면 그리드는 암묵적으로 read-only.

  • onCellsClear(payload: CellsClearPayload) => void

    비어있지 않은 선택 영역에서 Delete 키 입력 시 호출.

  • onEditRequest(coord: CellCoord) => void

    F2 / dblclick 시 알림용. 실제 mutation 파이프라인과 독립.

  • readOnlyboolean

    onCellChange가 와이어되어 있어도 편집 진입을 막는 강제 스위치.

required + validate 조합 예제 tsx · ~25줄

required는 빈 값을, validate는 도메인 규칙을 검사합니다. 둘 다 invalid면 union으로 적용됩니다.

const qty: Column<number> = {
  id: 'qty',
  accessor: (r) => r.data.qty as number,
  dataType: 'number',
  required: true,
  validate: (value, row) => {
    if (value < 0) return '수량은 0 이상이어야 합니다';
    const capacity = row.data.capacity as number;
    if (value > capacity) return `용량(${capacity}) 초과`;
    return true;
  },
};
CellChange 처리 reducer tsx
const onCellChange = (change: CellChange) => {
  // change = { coord: { row, col }, columnId, prevValue, nextValue }
  setRows((prev) =>
    prev.map((r, i) =>
      i === change.coord.row
        ? { ...r, data: { ...r.data, [change.columnId]: change.nextValue } }
        : r,
    ),
  );
};

클립보드 (TSV)

FeatureHigh 엑셀과의 양방향 호환 복사 / 잘라내기 / 붙여넣기.

동작

  • Ctrl+C: 선택 영역을 TSV로 OS 클립보드에 기록 + marching ants 점선 표시.
  • Ctrl+X: 복사 후 onCellsClear로 소스 셀 초기화.
  • Ctrl+V: 클립보드 읽고 TSV 파싱 후 셀 단위로 onCellChange 발사. 단일 셀 소스는 대상 범위에 채움.
  • 우클릭 ▸ 선택하여 붙여넣기: onPasteSpecialRequest 알림만. 다이얼로그는 호스트가 렌더.

API

  • onCopy(payload: ClipboardCopyPayload) => void
  • onCut(payload: ClipboardCopyPayload) => void
  • onPasteRequest(payload: PasteRequestPayload) => void

    매 Ctrl+V마다 발사되는 알림. 자동 paste를 대체하지 않습니다 — readOnly가 아니고 onCellChange가 연결돼 있으면 자동 paste(onCellChange)가 그대로 함께 실행됩니다. 자동 paste를 끄고 직접 처리하려면 readOnly와 함께 사용하세요(아래 예제).

  • onPasteSpecialRequest(payload: PasteSpecialRequestPayload) => void
주의: typed 컬럼의 paste: 자동 paste의 onCellChange가 운반하는 nextValue는 항상 string(클립보드 텍스트)입니다. 숫자 / 날짜 컬럼은 reducer 내부에서 coerce하세요.
숫자 컬럼 paste 시 coerce tsx
const onCellChange = (change: CellChange) => {
  const col = columns.find((c) => c.id === change.columnId);
  let next = change.nextValue;
  if (col?.dataType === 'number' && typeof next === 'string') {
    const trimmed = next.trim();
    next = trimmed === '' ? null : Number(trimmed);
  }
  applyEdit({ ...change, nextValue: next });
};
onPasteRequest로 직접 paste 적용 (onCellChange 비활성) tsx
<XlReact
  columns={columns}
  rows={rows}
  readOnly /* 자동 paste 비활성 */
  onPasteRequest={async (payload) => {
    const text = await navigator.clipboard.readText();
    const tsv = text.split('\n').map((line) => line.split('\t'));
    applyTsvAt(payload.coord, tsv);
  }}
/>

채우기 핸들 & 단축키

FeatureMedium 엑셀 스타일 채우기 핸들 + Ctrl+D / Ctrl+R / Ctrl+Enter.

기능

  • 활성 셀의 채우기 핸들(우하단 작은 사각형)을 인접 셀로 드래그.
  • 단일 값 → 반복. 두 값 시드 → 선형 시퀀스 (예: 1,2 → 3,4,5).
  • 날짜 시퀀스는 step(일/월/연)으로 자동 감지.
  • 핸들 더블클릭 → 왼쪽 컬럼 데이터 끝까지 자동 채움.
  • Ctrl+D 위에서 아래로 채움.
  • Ctrl+R 왼쪽에서 오른쪽으로 채움.
  • Ctrl+Enter 선택 전체를 활성 값으로 채움.

모든 채우기 셀은 onCellChange로 흐릅니다. 보호된 셀은 자동으로 제외됩니다.

채우기를 외부 사이드 이펙트와 묶기 tsx

onCellChange는 fill / paste / typed-edit 모두에서 동일하게 호출됩니다. 구분이 필요하면 같은 reducer에 들어오는 burst를 묶어 처리할 수 있습니다.

let scheduled = false;
const queue: CellChange[] = [];

const onCellChange = (change: CellChange) => {
  queue.push(change);
  if (!scheduled) {
    scheduled = true;
    queueMicrotask(() => {
      flush(queue.splice(0));
      scheduled = false;
    });
  }
};

실행 취소 / 다시 실행

FeatureHigh 엔트리 수와 바이트로 제한되는 undo 스택.

동작

  • Ctrl+Z 실행 취소, Ctrl+Y / Ctrl+Shift+Z 다시 실행.
  • 셀 값 변경(편집, 붙여넣기, 채우기, 삭제, 잘라내기)을 cell-edits 커맨드로 기록. 행/열 삽입·삭제·재정렬·정렬 등 구조 변경은 현재 스택 범위 밖입니다(컨슈머의 행 순서 스냅샷이 필요하기 때문).
  • 역연산은 onCellChange로 재생 → reducer는 (coord, prev)가 clear의 역연산이도록 작성해야 함.
  • 기본값: 100 엔트리 / 8 MiB. 명세는 최소 50 엔트리 보장.

API

  • enableUndoboolean

    onCellChange가 와이어되면 기본 true. 외부에서 히스토리를 관리할 때 false로 옵트아웃.

  • undoMaxEntriesnumber
  • undoMaxBytesnumber
외부 BoundedUndoStack 사용 (enableUndo=false) tsx
import { BoundedUndoStack, type CellChange } from 'hyper-xl';

const undoStack = new BoundedUndoStack({ maxEntries: 200, maxBytes: 16 * 1024 * 1024 });

const onCellChange = (change: CellChange) => {
  undoStack.push({ kind: 'edit', change });
  applyEdit(change);
};

const onUndo = () => {
  const entry = undoStack.pop();
  if (entry?.kind === 'edit') {
    applyEdit({
      ...entry.change,
      prevValue: entry.change.nextValue,
      nextValue: entry.change.prevValue,
    });
  }
};

행 / 열 조작

FeatureHigh 리사이즈 / 삽입 / 삭제 / 재정렬 / 숨김 / 고정.

크기 조정

  • 헤더 경계 드래그로 너비 / 높이 조절.
  • 열 헤더 경계 더블클릭 → 내용맞춤(AutoFit). 행 헤더 경계 더블클릭 → 기본 높이로 리셋.
  • 다중 헤더 선택 시 일괄 적용.
  • 최소값은 minColumnWidth / minRowHeight로 제어.

삽입 / 삭제 / 재정렬

각 제스처가 타입드 페이로드를 노출합니다. 그리드는 rows / columns를 소유하지 않으므로 컨슈머가 배열을 직접 mutate합니다.

  • onRowsInsert(payload: RowsInsertPayload) => void

    { atIndex, position: 'above' | 'below', count }. prop 미설정 시 메뉴 항목 자체가 숨겨집니다.

  • onRowsDelete(payload: RowsDeletePayload) => void

    { rowIds, rowIndices }: 연속 또는 다중 선택.

  • onColumnsInsert(payload: ColumnsInsertPayload) => void
  • onColumnsDelete(payload: ColumnsDeletePayload) => void
  • onRowsReorder(payload: RowsReorderPayload) => void

    { rowIds, rowIndices, targetIndex }. targetIndex는 제거 의 목적지: splice(targetIndex, 0, ...moved)로 적용.

  • onColumnsReorder(payload: ColumnsReorderPayload) => void
  • 행 / 열 숨김내부 상태 (콜백 아님)

    숨김 / 다시 표시는 그리드 내부 visibility 상태로 처리됩니다 (틀 고정과 동일하게 컨슈머 콜백이 없음). 우클릭 메뉴의 "숨기기 / 숨김 취소" 또는 Ctrl+9·Ctrl+0(숨김), Ctrl+Shift+9·Ctrl+Shift+0(다시 표시)로 동작하며, 좌표 매핑은 그리드가 알아서 보정합니다. 관련 payload (RowsHidePayload 등)는 내부 전용이라 root export 되지 않습니다.

틀 고정

  • freezeFirstRow / freezeFirstColboolean

    freezeRowCount: 1 / freezeColCount: 1과 등가.

  • freezeRowCount / freezeColCountnumber

    앞쪽 N개 행 / 열 고정. freezeFirstRow / freezeFirstCol을 덮어씁니다.

onRowsInsert / onRowsDelete를 컨슈머 reducer에 매핑 tsx
const onRowsInsert = (p: RowsInsertPayload) => {
  setRows((prev) => {
    const idx = p.position === 'above' ? p.atIndex : p.atIndex + 1;
    const inserted = Array.from({ length: p.count }, (_, i) => ({
      id: crypto.randomUUID(),
      data: {},
    }));
    const next = prev.slice();
    next.splice(idx, 0, ...inserted);
    return next;
  });
};

const onRowsDelete = (p: RowsDeletePayload) => {
  const toDelete = new Set(p.rowIds);
  setRows((prev) => prev.filter((r) => !toDelete.has(r.id)));
};
헤더 드래그 재정렬 (rowsReorder.targetIndex 사용) tsx
const onRowsReorder = (p: RowsReorderPayload) => {
  setRows((prev) => {
    const moving = new Set(p.rowIndices);
    const remaining = prev.filter((_, i) => !moving.has(i));
    const moved = p.rowIndices.map((i) => prev[i]);
    remaining.splice(p.targetIndex, 0, ...moved);
    return remaining;
  });
};

행 계층 구조 (Grouping)

FeatureMedium PRD §6.4: 다단계 부모/자식 트리, 컨슈머가 보유하는 collapse 상태, 첫 데이터 컬럼에 ▶ / ▼ 디스클로저 위젯과 레벨별 들여쓰기 자동 적용.

데이터 모델

Row에 선택적 두 필드를 더해 계층을 표현합니다. 모두 옵션이라 기존 평면 데이터는 그대로 동작합니다.

  • Row.levelnumber (옵션)

    트리 깊이 (0 = 루트). 인접한 level 런으로 부모를 추론하므로 보통 parentId 없이 pre-order만 지키면 됩니다.

  • Row.parentIdRow['id'] | null (옵션)

    명시적 부모 id. 지정 시 추론을 덮어쓰고, 알 수 없는 id는 무시되어 레벨 추론으로 폴백합니다.

컨트롤드 패턴

그리드는 collapse 상태를 소유하지 않습니다. 컨슈머가 collapsedIds: Set<RowId>를 보유하고, 순수 헬퍼 computeRowOutline으로 가시 행 / 아웃라인 배열을 도출해 그리드에 건네는 구조: sortState / filterState와 동일한 제어형 prop 패턴입니다.

XlReact prop

  • rowOutlineReadonlyArray<RowOutlineCell | null | undefined>

    길이가 rows.length와 같아야 합니다. 각 항목은 { level, hasChildren, collapsed }이며, null / undefined 항목은 해당 행을 들여쓰기 / 위젯 대상에서 제외합니다.

  • onRowOutlineToggle(rowIndex: number, next: 'collapse' | 'expand') => void

    ▶ / ▼ 클릭 시 호출됩니다. useCallback으로 식별성을 고정하지 않으면 매 렌더마다 첫 컬럼 셀이 재렌더됩니다.

  • rowOutlineIndentPxnumber (기본 16)

    레벨당 좌측 들여쓰기 너비. 부모/리프 모두 디스클로저 슬롯을 동일 폭으로 예약해 정렬을 유지합니다.

순수 헬퍼

  • buildRowTree(rows)RowTree

    부모/자식/레벨 맵을 한 번에 산출. 중복 id는 첫 등장만 사용 (이후 무시). useMemorows identity에 캐싱 가능.

  • computeRowOutline(rows, collapsedIds, tree?){ visibleRows: Row[]; outline: RowOutline }

    O(N) 단일 패스로 가시 행과 아웃라인 메타데이터를 동시에 생성합니다. 결과를 그대로 <XlReact rows={visibleRows} rowOutline={outline} />로 전달하세요.

  • toggleRowCollapse(collapsedIds, rowId)Set<RowId>

    Set을 변경하지 않고 새 사본을 반환합니다 (id 추가 ↔ 제거). onRowOutlineToggle 핸들러용.

  • collapseAtLevels(rows, levels, tree?)Set<RowId>

    지정된 깊이의 자식을 가진 모든 행 id를 모읍니다. 초기 collapsedIds 시드(예: 전체를 팀 단위까지 접기)에 적합.

  • collectRowDescendantIds(rows, rowId, tree?)Set<RowId>

    지정한 행의 모든 후손 id Set. 부분 트리 일괄 접기 / 펼치기에 사용.

타입

  • RowOutlineCell{ level: number; hasChildren: boolean; collapsed: boolean }
  • RowOutlineReadonlyArray<RowOutlineCell | null>
  • RowTree{ childrenByParent; parentById; levelById; indexById }
  • RowIdRow['id']
부서 → 팀 → 직원 다단계 그루핑 (컨슈머 reducer) tsx
import { useCallback, useMemo, useState } from 'react';
import {
  XlReact,
  computeRowOutline,
  toggleRowCollapse,
  collapseAtLevels,
  type Row,
  type RowId,
} from 'hyper-xl';

const source: Row[] = [
  { id: 'sales',  data: { name: '영업부' },     level: 0 },
  { id: 'kr',     data: { name: '국내영업' },   level: 1 },
  { id: 'kim',    data: { name: '김영업' },     level: 2 },
  { id: 'lee',    data: { name: '이영업' },     level: 2 },
  { id: 'global', data: { name: '해외영업' },   level: 1 },
  { id: 'park',   data: { name: '박영업' },     level: 2 },
];

export function Org() {
  const [collapsed, setCollapsed] = useState<Set<RowId>>(
    () => collapseAtLevels(source, [1]), // 팀 단위까지 접고 시작.
  );
  const { visibleRows, outline } = useMemo(
    () => computeRowOutline(source, collapsed),
    [collapsed],
  );
  const onToggle = useCallback(
    (rowIndex: number) => {
      const row = visibleRows[rowIndex];
      if (row) setCollapsed((prev) => toggleRowCollapse(prev, row.id));
    },
    [visibleRows],
  );
  return (
    <XlReact
      columns={[{ id: 'name', accessor: (r) => r.data.name }]}
      rows={visibleRows}
      rowOutline={outline}
      onRowOutlineToggle={onToggle}
      rowOutlineIndentPx={18}
    />
  );
}

주의

  • 디스클로저 위젯은 첫 데이터 컬럼 (columnIndex 0)에만 렌더됩니다. 병합/고정 행과는 충돌 없이 공존합니다.
  • 위젯 클릭은 stopPropagation으로 셀 활성화 / 편집 진입을 차단합니다.
  • 가상화는 visibleRows 길이만 보므로, collapse 상태와 무관하게 큰 트리도 그대로 성능이 유지됩니다.
  • 소스 배열은 pre-order를 가정합니다 (부모 → 자식 순). 그렇지 않으면 parentId를 명시하세요.

정렬 & 필터

FeatureHigh 컨트롤드 다중 컬럼 정렬 + 컬럼별 값 필터.

정렬

  • 헤더 클릭이 단일 컬럼에서 ascdescnone 사이클.
  • Shift+클릭으로 다중 컬럼 정렬 확장: 결과 SortState는 정렬 키의 순서 배열.
  • 우클릭 ▸ 정렬 ▸ 오름차순 / 내림차순은 onSortAscending / onSortDescending.
  • 우클릭 ▸ 정렬 ▸ 사용자 지정은 onSortCustomRequest.
  • 그리드는 행을 재정렬하지 않습니다. 컨슈머가 callback에 응답하여 정렬.

필터

  • 각 컬럼 헤더의 깔때기 버튼 → 고유값 체크박스 드롭다운.
  • 상태는 sparse: 모든 옵션이 선택되면 해당 컬럼은 FilterState에서 제거.
  • filterPanelRows(pre-filter 소스)를 넘겨야 Excel-correct 고유값 리스트가 나옵니다.
  • 우클릭 ▸ 필터 ▸ 선택값으로 필터 / 필터 지우기는 각각 별도 콜백.

API

  • sortState / onSortStateChange SortState | (next: SortState) => void

    컨트롤드 정렬. onSortStateChange를 넘기면 클릭-투-정렬 헤더 화살표가 활성화됩니다. sortState는 현재 정렬 상태 표시와 다음 상태 계산에 쓰입니다(활성화 조건 아님).

  • filterState / onFilterStateChange FilterState | (next: FilterState) => void

    컨트롤드 필터. onFilterStateChange를 넘기면 헤더 필터 버튼이 활성화됩니다. filterState는 현재 필터 상태 표시와 다음 상태 계산에 쓰입니다(활성화 조건 아님).

  • filterPanelRowsReadonlyArray<Row>

    필터 드롭다운용 unfiltered 소스 (옵션).

컨슈머 측 정렬 적용 tsx
import { useMemo, useState } from 'react';
import { XlReact, type SortState } from 'hyper-xl';

const [sortState, setSortState] = useState<SortState>([]);

const sortedRows = useMemo(() => {
  if (sortState.length === 0) return rows;
  return [...rows].sort((a, b) => {
    for (const key of sortState) {
      const col = columns.find((c) => c.id === key.columnId);
      if (!col) continue;
      const av = col.accessor(a);
      const bv = col.accessor(b);
      if (av === bv) continue;
      const dir = key.direction === 'asc' ? 1 : -1;
      return (av > bv ? 1 : -1) * dir;
    }
    return 0;
  });
}, [rows, sortState]);

return (
  <XlReact
    columns={columns}
    rows={sortedRows}
    sortState={sortState}
    onSortStateChange={setSortState}
  />
);
FilterState로 행 좁히기 tsx
import { useMemo, useState } from 'react';
import { XlReact, valueToFilterKey, type FilterState } from 'hyper-xl';

const [filterState, setFilterState] = useState<FilterState>({});

const filteredRows = useMemo(() => {
  const filters = Object.entries(filterState);
  if (filters.length === 0) return rows;
  return rows.filter((r) =>
    filters.every(([columnId, { selectedValues }]) => {
      const col = columns.find((c) => c.id === columnId);
      if (!col) return true;
      return selectedValues.has(valueToFilterKey(col.accessor(r)));
    }),
  );
}, [rows, filterState]);

<XlReact
  columns={columns}
  rows={filteredRows}
  filterState={filterState}
  onFilterStateChange={setFilterState}
  filterPanelRows={rows}
/>
정렬 / 필터 관련 타입 typescript
type SortDirection = 'asc' | 'desc';
interface SortColumnEntry { columnId: string; direction: SortDirection; }
type SortState = ReadonlyArray<SortColumnEntry>;

interface ColumnValueFilter { selectedValues: ReadonlySet<string>; }
type FilterState = Readonly<Record<string, ColumnValueFilter>>;

const BLANK_FILTER_KEY = '__xl_react_blank__';

가상화 & 성능

FeatureHigh 두 축 가상화로 1M+ 셀에서도 60fps 유지.

특성

  • 행 / 열 가상화 항상 ON. 옵트인 불필요.
  • 셀 컴포넌트는 React.memo로 재사용.
  • 스크롤은 requestAnimationFrame으로 coalesce.
  • 대규모 paste / fill(10k+)은 processInChunks 헬퍼로 청크 분할.
  • Undo 스택은 엔트리 수 + 바이트로 제한 (실행 취소).

API

  • overscannumber

    뷰포트 밖에 미리 렌더하는 행 / 열 수.

  • rowHeightnumber
  • columnWidthnumber
processInChunks로 1만 행 비동기 적용 ts
import { processInChunks } from 'hyper-xl';

await processInChunks(
  largePasteCells,
  (cell) => applyEdit(cell),
  { chunkSize: 500 },
);

키보드 & 컨텍스트 메뉴

FeatureHigh Excel 동등의 키보드 맵과 우클릭 메뉴.

키보드 맵

동작
← ↑ → ↓한 칸 이동
Tab / Shift+Tab오른쪽 / 왼쪽
Enter / Shift+Enter아래 / 위
Home행의 첫 셀
Ctrl+HomeA1
End → 방향키데이터 끝 점프
Ctrl + 방향키데이터 영역 끝
Page Up / Down화면 단위 위/아래
Alt+Page Up / Down화면 단위 좌/우
F2활성 셀 편집
Esc편집 취소 (AutoComplete 후보가 보이면 1차로 후보만 닫음)
Alt+Enter편집 모드 셀 내부 줄바꿈 (§2.1)
Tab / 편집 모드 AutoComplete 후보 채택 (는 캐럿이 끝일 때만, §2.3)
Delete값 삭제
Ctrl+1onCellFormatRequest
Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z실행 취소 / 다시 실행 / 다시 실행
Ctrl+C / X / V복사 / 잘라내기 / 붙여넣기
Ctrl+Shift+C / V서식 복사 / 서식 붙여넣기: Format Painter (§7.3)
Ctrl+D / R / Enter채우기 (아래 / 오른쪽 / 선택)
Ctrl+F / Ctrl+H검색 / 바꾸기 다이얼로그 (§9)
Ctrl+G / F5셀로 이동 다이얼로그
Ctrl+Space / Shift+Space활성 열 / 활성 행 선택
Ctrl+9 / Ctrl+0선택 행 / 열 숨기기
Ctrl+Shift+9 / Ctrl+Shift+0선택 주변의 숨긴 행 / 열 다시 표시
Ctrl+; / Ctrl+Shift+;오늘 날짜 / 현재 시각 입력 (onCellChange 경로)

우클릭 컨텍스트 메뉴

메뉴는 우클릭 타깃(행 / 열 / 셀)에 따라 항목이 달라집니다. 각 항목은 그리드를 mutate하는 대신 타입드 콜백을 노출합니다. 콜백을 와이어하면 메뉴 항목이 활성화되고, 미설정 시 항목 자체가 숨겨집니다.

  • 잘라내기 / 복사 / 붙여넣기 / 선택하여 붙여넣기…
  • 행 / 열 위·아래·왼쪽·오른쪽 삽입
  • 행 / 열 삭제
  • 정렬 ▸ 오름차순 / 내림차순 / 사용자 지정…
  • 필터 ▸ 선택값으로 필터 / 필터 지우기
  • 틀 고정 (활성 셀 기준, 내부 상태)
  • 셀 서식… (onCellFormatRequest)
  • 메모 삽입… / 하이퍼링크… (onInsertNoteRequest / onInsertHyperlinkRequest)

검색 & 바꾸기

FeatureHigh 값 검색과 바꾸기: 그리드 내장 다이얼로그.

특성

  • 그리드 내장: 별도 와이어링 없이 동작합니다. 표에 포커스를 두고 단축키로 엽니다.
  • 옵션: 대소문자 구분 · 전체 일치(셀 내용 전체) · 정규식($1 백레퍼런스 지원).
  • 범위: 시트 전체 / 선택 영역. 2셀 이상 선택 시 범위 기본값이 선택 영역입니다.
  • 모두 찾기: 일치 셀 목록(행 우선)을 보여주고, 클릭하면 해당 셀로 이동·스크롤합니다.
  • 바꾸기 / 모두 바꾸기는 기존 onCellChange 경로로 처리됩니다. 실행 취소 1건으로 묶이고, 숫자 열은 편집과 동일하게 값이 변환되며, 보호된 셀(셀 보호)은 건너뛰고 onProtectedAction({ action: 'replace' })로 알립니다.
  • 검색 대상은 원본 값의 문자열입니다(숫자 형식이 적용된 표시 문자열이 아님): 바꾼 값이 onCellChange로 그대로 되돌아갈 수 있도록.

단축키

동작
Ctrl+F / Cmd+F검색 다이얼로그
Ctrl+H (Windows/Linux)바꾸기 다이얼로그
⌥⌘F (Cmd+Opt+F, macOS)바꾸기: macOS의 Cmd+H(창 숨기기)와 겹쳐 대체 단축키
Enter / Shift+Enter다음 / 이전 찾기
Esc닫기

비활성화

enableFindReplace={false}로 내장 다이얼로그를 끌 수 있습니다. 끄면 그리드가 Ctrl+F를 가로채지 않아 브라우저 기본 검색이 동작하며, 직접 검색 UI를 붙일 수 있도록 순수 헬퍼(findMatches / replaceInValue)와 FindReplaceDialog 컴포넌트를 export합니다.

API

  • enableFindReplaceboolean

    내장 검색 / 바꾸기 활성화. 기본값 true.

  • findMatches(args: FindMatchesArgs) => FindMatch[]

    행 우선 일치 목록. 잘못된 정규식은 InvalidRegexError.

  • replaceInValue(value, query, replacement, options) => string | null

    한 셀 값의 치환 결과(미일치 시 null).

순수 엔진으로 직접 검색 / 바꾸기 typescript
import { findMatches, replaceInValue } from 'hyper-xl';

const matches = findMatches({
  rows,
  columns,
  query: 'box',
  options: { matchCase: false, wholeCell: false, useRegex: false },
  scope: 'sheet',
});
// matches[0] === { coord, columnId, value }

const next = replaceInValue('Box 12', 'box', 'Crate', {
  matchCase: false,
  wholeCell: false,
  useRegex: false,
});
// next === 'Crate 12'

시트 배율

FeatureMedium 10% ~ 400% 배율, Ctrl+휠 + 우하단 위젯.

특성

  • 배율은 선형: 행 높이, 컬럼 너비, gutter, 헤더 lane, 폰트 크기가 모두 함께 스케일.
  • CSS transform이 아닌 사이즈 곱셈: 가상화 좌표와 폰트 렌더링이 픽셀 정렬 유지.
  • 우하단 위젯: / + / 슬라이더 / 퍼센티지 버튼(클릭 시 100% 리셋).
  • Ctrl + 휠은 커서 주변을 중심으로 줌인 / 아웃.
  • 컨트롤드(zoom + onZoomChange) / 언컨트롤드(defaultZoom) 모두 지원.

API

  • zoom / defaultZoomnumber

    1.0이 100%. 범위 0.1 ~ 4.0.

  • onZoomChange(zoom: number) => void

    컨트롤드 / 언컨트롤드 양쪽에서 모두 발사.

  • showZoomControlboolean

    우하단 위젯 표시 여부. 기본 true.

  • zoomMin / zoomMaxnumber
컨트롤드 zoom + 외부 토글 tsx
import { useState } from 'react';

const [zoom, setZoom] = useState(1);

return (
  <>
    <button onClick={() => setZoom(1)}>100%</button>
    <button onClick={() => setZoom(2)}>200%</button>
    <XlReact
      columns={columns}
      rows={rows}
      zoom={zoom}
      onZoomChange={setZoom}
      zoomMin={0.5}
      zoomMax={3}
    />
  </>
);

보기 모드 (분할 / 새 창 / 전체 화면)

FeatureMedium §13: 눈금선·머리글 토글(P1), 4분할 패널, 새 창 동기화, Fullscreen API.

특성

  • showGridlines · showHeaders 두 boolean prop은 그리드 루트에 modifier 클래스를 붙여 CSS로 즉시 반영됩니다.
  • showHeaders={false}는 컬럼 헤더 lane과 row gutter를 visibility: hidden으로 감춥니다. 가상화 좌표 / hit-test 산식이 그대로 유지되도록 레이아웃 공간은 보존됩니다.
  • SplitPaneView는 1 / 2(좌우·상하) / 4 패널 레이아웃을 CSS 그리드로 그리고, 짝지어진 패널 간 스크롤을 동기화합니다.
  • 스크롤 동기화는 "기대 좌표 마커" 방식: 같은 프레임에 두 쌍이 동시에 스크롤해도 서로의 동기화를 막지 않습니다 (피드백 루프 가드).
  • useFullscreen(ref)는 Fullscreen API와 vendor-prefix 폴백을 래핑합니다. 컴포넌트가 fullscreen 락을 쥔 채 언마운트되면 자동으로 exitFullscreen()이 호출됩니다.
  • useWorkbookBroadcast<T>(channelName)는 BroadcastChannel을 통한 동일 origin 윈도우 간 메시지 동기화입니다. 자기 자신은 메시지를 받지 않으며, 미지원 환경에서는 no-op으로 안전하게 감쇠합니다.
  • openSheetInNewWindow()은 현재 URL을 새 창으로 열어 같은 broadcast 채널의 두 번째 청취자를 띄울 때 사용하는 헬퍼입니다.
  • SplitPaneView@experimental: 향후 그리드의 명시적 scrollTo API로 DOM 클래스명 결합을 대체할 예정입니다.

API

  • showGridlinesboolean

    기본 true. false면 셀 우측·하단 테두리 색을 투명화해 캔버스 형태로 표시. 헤더·선택 영역·고정 분할선·사용자 정의 셀 테두리는 그대로 유지.

  • showHeadersboolean

    기본 true. false면 컬럼 헤더 lane과 row gutter가 시각적으로 사라집니다. 레이아웃 공간은 보존되며, 호스트가 전 영역에 데이터를 채우고 싶다면 외곽에 clip-path/overflow로 crop 처리.

  • SplitPaneView{ mode, renderPane, rowSplit?, colSplit?, ariaLabel?, className? }

    mode='none'|'horizontal'|'vertical'|'quad'. renderPane(paneId)는 각 패널(tl/tr/bl/br)별로 XlReact(또는 임의 컨텐츠)를 반환. 쌍 사이 스크롤이 자동으로 동기화됩니다.

  • useFullscreen(ref){ isFullscreen, isSupported, request, exit, toggle }

    ref가 가리키는 엘리먼트에 대해 Fullscreen API를 호출. fullscreenchange 이벤트를 자동 구독하므로 사용자가 ESC로 빠져나가도 동기화됩니다.

  • useWorkbookBroadcast<T>(channelName){ latest, broadcast, isSupported }

    동일 origin 다른 탭으로 임의 payload 송수신. 주의: 페이로드는 구조화 복제로 메인 스레드에서 직렬화되므로 100k 행 워크북을 매 키 입력마다 보내면 UI가 멈춥니다. 큰 워크북은 diff 또는 명령형 메시지로 송신하세요.

  • openSheetInNewWindow(options?)Window | null

    window.open 래퍼. 기본 target은 'xl-react-new-window'(재사용 가능), 기본 features는 폭/높이 1100×700. 팝업 차단 시 null.

2분할 + 새 창 동기화 tsx
import { useEffect } from 'react';
import {
  XlReact,
  SplitPaneView,
  useWorkbookBroadcast,
  openSheetInNewWindow,
  type Row,
} from 'hyper-xl';

function WorkbookView({ columns, rows, setRows }) {
  const sync = useWorkbookBroadcast<{ rows: Row[] }>('workbook/main');

  useEffect(() => { sync.broadcast({ rows }); }, [rows]);
  useEffect(() => {
    if (sync.latest) setRows(sync.latest.rows);
  }, [sync.latest]);

  return (
    <>
      <button onClick={() => openSheetInNewWindow()}>새 창 열기</button>
      <SplitPaneView
        mode="horizontal"
        renderPane={() => <XlReact columns={columns} rows={rows} />}
      />
    </>
  );
}
전체 화면 토글 tsx
import { useRef } from 'react';
import { XlReact, useFullscreen } from 'hyper-xl';

function FullscreenGrid({ columns, rows }) {
  const ref = useRef<HTMLDivElement>(null);
  const fs = useFullscreen(ref);
  return (
    <div ref={ref}>
      {fs.isSupported && (
        <button onClick={() => fs.toggle()}>
          {fs.isFullscreen ? '전체 화면 끄기' : '전체 화면'}
        </button>
      )}
      <XlReact columns={columns} rows={rows} />
    </div>
  );
}

인쇄 (미리보기 / 페이지네이션 / 머리글·바닥글)

FeatureMedium §12: 인쇄 미리보기 · 인쇄 영역 / 페이지 나누기 · 머리글·바닥글 플레이스홀더 · 행/열 반복 · 용지/방향/배율/여백.

특성

  • 4-레이어 분리. 순수 paginate() 엔진(DOM 무관), resolvePlaceholders() 머리글/바닥글 문법, usePrintController 상태 훅, PrintPreview 미리보기 모달. 각 레이어를 독립 사용 가능.
  • 그리디 페이지 패킹. 열을 좌→우로 채우다 폭을 넘으면 새 열 밴드, 각 열 밴드 안에서 행을 위→아래로 채웁니다. 페이지 순서는 Excel 기본인 "아래로, 그다음 오른쪽" (Down, then Over).
  • 플레이스홀더 문법(Excel 호환). &P 페이지 번호, &N 총 페이지, &D 날짜, &T 시간, &F 파일명, &A 시트명. &&는 리터럴 &. 알 수 없는 &X는 그대로 통과 (스타일 마커는 호스트가 해석).
  • 3-zone 머리글/바닥글. 좌 / 중앙 / 우 세 영역. 각 영역은 독립적으로 플레이스홀더를 해석합니다. 모두 비어 있으면 페이지 위/아래 밴드 자체가 사라져 본문 영역이 확장됩니다.
  • 인쇄 영역(printArea). SelectionRange로 지정하면 그 사각형 바깥은 페이지네이션에서 완전히 제외됩니다. null이면 전체 그리드.
  • 행/열 반복(repeat). 매 페이지 상단/좌측에 반복 출력되는 헤더 밴드. 본문에 포함되지 않도록 자동으로 인덱스에서 제외하여 중복 출력 방지.
  • 배율(scale). 0.1 ~ 4.0 클램프. 콘텐츠를 transform-scale로 축소하므로 종이 크기는 그대로지만 한 페이지에 더 많이 들어갑니다.
  • 페이지 나누기 미리보기 오버레이. PageBreakOverlaypaginate()rowPageBreaks/colPageBreaks를 받아 라이브 그리드 위에 점선 라인을 절대 위치로 그립니다 (포인터 이벤트 통과).
  • 실제 인쇄. startPrint()<body>xl-react-printing 클래스를 토글하고 window.print()를 호출. @media print 규칙이 미리보기 외 모든 요소를 숨기고 각 .xl-react-print-page마다 page-break-after를 적용. afterprint 이벤트 / 4초 타임아웃 중 먼저 오는 쪽에서 클래스가 자동 제거(이중 호출 방지 가드 포함).
  • 접근성. 모달은 role="dialog" + aria-label, 자동 포커스, ESC / 백드롭 클릭으로 닫힘. 페이지 카드는 클릭하면 "현재 페이지"가 모달 푸터에 갱신됩니다.

API

  • paginate(input)PaginationResult

    순수 함수. 행/열 개수 · 차원 · 옵션 · 머리글/바닥글 밴드 픽셀을 받아 페이지 배열, 페이지 경계 인덱스, 종이/사용 가능 영역 크기를 반환합니다. React에 의존하지 않으므로 PDF 익스포트 등 다른 렌더러에서 그대로 재사용 가능.

  • resolvePrintOptions(options?)ResolvedPrintOptions

    희소(sparse) 옵션 객체에 모듈 기본값을 채우고, scale을 [0.1, 4.0]로 클램프, 범위를 정규화. 미리보기와 컨트롤러 훅이 공유.

  • resolvePlaceholders(template, ctx)string

    단일 영역 텍스트의 &X 마커를 확장. ctx{ pageNumber, totalPages, date?, filename?, sheetName?, formatDate?, formatTime? }. 기본 날짜 포맷은 ISO-8601, 시간은 HH:MM; Intl.DateTimeFormat 등으로 자유롭게 교체 가능.

  • usePrintController({ initial? })PrintController

    { options, resolved, isOpen, update, setPrintArea, setRepeatRows, setRepeatCols, open, close, reset }. 옵션을 컨슈머 상태로 관리하는 얇은 래퍼. update는 항상 sparse 패치.

  • PrintPreview{ open, onClose, rows, columns, rowHeights?, colWidths?, defaultRowHeight?, defaultColWidth?, options, onOptionsChange?, formatValue?, onPrint?, labels?, rowLabel?, columnLabel?, headerBandPx?, footerBandPx? }

    미리보기 모달. 좌측 설정 사이드바(용지·방향·배율·여백·머리글/바닥글·반복 행/열·인쇄 영역·눈금선/머리글 인쇄 토글) + 우측 페이지 카드 스크롤러. formatValue 미지정 시 column.accessor(row)를 문자열로 변환해 렌더링: <XlReact>와 동일한 기본 동작.

  • PageBreakOverlay{ pagination, rowHeights?, colWidths?, defaultRowHeight, defaultColWidth, offsetTop?, offsetLeft?, className? }

    라이브 그리드 위에 점선 페이지 나누기 라인을 그리는 오버레이. position: relative 컨테이너 안에 pointer-events: none으로 띄워 선택/스크롤을 차단하지 않습니다.

  • startPrint(onAfter?)void

    미리보기 없이도 호출 가능한 진입점. body 클래스 토글 → window.print()afterprint 발화 또는 4초 안전 타이머: 둘 중 먼저 오는 쪽에서 클래스 제거 + onAfter 호출(이중 호출 방지).

  • PAPER_SIZES_MM · DEFAULT_MARGINS_MM · MM_TO_PX · DEFAULT_PRINT_OPTIONS · DEFAULT_PRINT_PREVIEW_LABELSconst

    파워 유저용 상수. 종이 프리셋, 기본 여백(19mm 균등), 96dpi 환산 상수, 옵션 기본값, 한국어 라벨 프리셋.

미리보기 + 컨트롤러 훅 + 직접 인쇄 tsx
import { useState } from 'react';
import {
  XlReact,
  PrintPreview,
  usePrintController,
  startPrint,
} from 'hyper-xl';

function Workbook({ columns, rows }) {
  const print = usePrintController({
    initial: {
      paperSize: 'A4',
      orientation: 'portrait',
      header: { center: '월간 출고 명세서' },
      footer: { right: '&D &T  &P / &N' },
      repeatRows: { start: 0, end: 0 },  // 1행(헤더)을 매 페이지 반복
      filename: 'shipments.xlsx',
      sheetName: 'Sheet1',
    },
  });

  return (
    <>
      <button onClick={print.open}>인쇄 미리보기</button>
      <button onClick={() => startPrint()}>바로 인쇄</button>
      <XlReact columns={columns} rows={rows} />
      <PrintPreview
        open={print.isOpen}
        onClose={print.close}
        rows={rows}
        columns={columns}
        options={print.options}
        onOptionsChange={print.update}
      />
    </>
  );
}
페이지 나누기 미리보기 오버레이 tsx
import { useMemo } from 'react';
import { XlReact, PageBreakOverlay, paginate } from 'hyper-xl';

function GridWithPageBreaks({ columns, rows, options }) {
  const pagination = useMemo(
    () => paginate({
      rowCount: rows.length,
      colCount: columns.length,
      defaultRowHeight: 24,
      defaultColWidth: 100,
      options,
    }),
    [rows.length, columns.length, options],
  );
  return (
    <div style={{ position: 'relative' }}>
      <XlReact columns={columns} rows={rows} />
      <PageBreakOverlay
        pagination={pagination}
        defaultRowHeight={24}
        defaultColWidth={100}
      />
    </div>
  );
}
머리글·바닥글 플레이스홀더 직접 해석 (PDF / 서버 렌더 재사용) tsx
import { resolvePlaceholders, paginate } from 'hyper-xl';

const pagination = paginate({
  rowCount: 500,
  colCount: 12,
  defaultRowHeight: 24,
  defaultColWidth: 100,
  options: { paperSize: 'A4', orientation: 'landscape' },
});

pagination.pages.forEach((page) => {
  const footer = resolvePlaceholders('&F · &P / &N', {
    pageNumber: page.pageIndex + 1,
    totalPages: pagination.pages.length,
    filename: 'export.xlsx',
  });
  // → "export.xlsx · 1 / 4"
});

셀 서식 (font / alignment / fill / border) + 편집 UI

FeatureMedium 컨슈머가 소유한 셀별 시각 서식을 그리드가 렌더링하고 toolbar가 편집.

특성

  • cellFormats는 맵 또는 함수로 주입합니다. 그리드는 서식 상태를 소유하지 않습니다.
  • CellFormatToolbar는 선택 영역과 쓰기 가능한 CellFormatsMap을 받아 새 map을 onCellFormatsChange로 돌려주는 라이브러리 제공 UI입니다.
  • 글꼴 family / size / bold / italic / underline / strikethrough / color를 인라인 스타일로 적용.
  • 가로·세로 정렬, 줄바꿈, 들여쓰기, 배경색, 상/하/좌/우 테두리를 셀별로 렌더링.
  • 가로 정렬은 'left' / 'center' / 'right' / 'justify'(양쪽맞춤) / 'distributed'(균등분할: 마지막 줄도 균등 정렬)을 지원합니다. 세로는 'top' / 'middle' / 'bottom' / 'distributed'(stretch).
  • 테두리는 Box, Top, Right, Bottom, Left가 선택 범위 외곽 사각형에만 적용되고, All만 범위 내부 모든 셀 경계까지 적용됩니다.
  • 맵 키는 현재 뷰의 0-based 좌표입니다. 행/열 삽입·삭제·재정렬을 직접 처리하는 앱은 같은 시점에 map도 shift/prune해야 합니다.
  • 함수 resolver는 id-keyed 상태를 O(1)로 렌더링할 때 유용하지만, 기본 toolbar는 resolver를 직접 갱신하지 않습니다.
  • 편집 중인 셀은 에디터 오버레이 뒤의 원본 값이 보이지 않도록 서식 스타일을 잠시 억제.
  • numberFormat 필드는 셀 값을 표시 문자열로 변환합니다(숫자 형식 참고). CellFormatToolbar의 숫자 형식 드롭다운과 소수 자릿수 증감 버튼으로 편집하며, cellRenderer가 없는 셀에 한해 렌더링 시점에 적용됩니다.
  • Format Painter: Ctrl+Shift+C로 활성 셀의 서식을 휘발성 인메모리 버퍼에 저장, Ctrl+Shift+V로 현재 선택 범위에 적용합니다(값 / 수식은 보존, §7.3). 버퍼가 비어 있으면 붙여넣기는 무동작이며, 서식 없는 셀에서 복사한 뒤 붙여넣으면 대상 셀의 서식이 지워집니다(Excel 동작). CellFormatToolbaronFormatPainterToggle + formatPainterArmed props를 와이어하면 동일 동작의 toolbar 버튼이 노출됩니다. cellFormats가 함수 resolver 형태이면 painter는 silent no-op이며, 브라우저 기본 단축키가 그대로 동작합니다.

표시와 편집

import { useState } from 'react';
import {
  CellFormatToolbar,
  XlReact,
  cellFormatKey,
  type CellFormatsMap,
  type SelectionSnapshot,
} from 'hyper-xl';

const initialFormats: CellFormatsMap = {
  [cellFormatKey(0, 0)]: {
    font: { bold: true, color: '#ffffff' },
    align: { horizontal: 'center', vertical: 'middle' },
    fill: { backgroundColor: '#1f2937' },
  },
  [cellFormatKey(1, 2)]: {
    align: { horizontal: 'right' },
    border: { bottom: { style: 'double', color: '#0f172a' } },
  },
};

function Sheet() {
  const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
  const [cellFormats, setCellFormats] = useState<CellFormatsMap>(initialFormats);

  return (
    <>
      <CellFormatToolbar
        selection={selection}
        cellFormats={cellFormats}
        onCellFormatsChange={setCellFormats}
      />
      <XlReact
        columns={columns}
        rows={rows}
        cellFormats={cellFormats}
        onSelectionChange={setSelection}
      />
    </>
  );
}

// 함수 형태: 보이는 셀마다 호출되므로 O(1)을 유지하세요.
// toolbar 편집 UI는 쓰기 가능한 CellFormatsMap과 함께 사용합니다.
<XlReact
  columns={columns}
  rows={rows}
  cellFormats={(rowIndex, columnIndex) =>
    rowIndex === 0 ? { font: { bold: true } } : undefined
  }
/>

API

  • cellFormatsCellFormatResolver | CellFormatsMap

    키는 0-based ${row}:${col}. 헬퍼 cellFormatKey를 사용하면 문자열 키 생성을 중앙화할 수 있습니다.

  • CellFormatToolbarcomponent

    selection, cellFormats, onCellFormatsChange를 받아 선택 범위에 글꼴·정렬·채우기·테두리 변경을 적용합니다. cellFormatsCellFormatsMap이어야 합니다.

  • applyCellFormatPatch(formats, ranges, patch) => CellFormatsMap

    toolbar와 같은 외부 UI에서 선택 영역에 nullable patch를 적용할 때 쓰는 순수 유틸리티입니다.

  • applyCellBorderPatch(formats, ranges, placement, side) => CellFormatsMap

    outline/top/right/bottom/left는 선택 범위 외곽선에만 적용합니다. all은 범위 내부 gridline까지 적용하되, 인접 셀이 같은 선을 중복으로 그리지 않도록 한쪽 셀이 edge를 소유합니다.

헤더 / 숫자 / 상태 / 합계 행 서식 + toolbar tsx
const initialFormats = useMemo(() => {
  const map: CellFormatsMap = {};
  columns.forEach((_, c) => {
    map[cellFormatKey(0, c)] = {
      font: { bold: true, color: '#fff' },
      align: { horizontal: 'center', vertical: 'middle' },
      fill: { backgroundColor: '#1f2937' },
    };
    map[cellFormatKey(totalRowIndex, c)] = {
      font: { bold: true },
      fill: { backgroundColor: '#fef9c3' },
      border: { top: { style: 'thick', color: '#ca8a04' } },
    };
  });
  map[cellFormatKey(2, 3)] = {
    align: { horizontal: 'center' },
    fill: { backgroundColor: '#fee2e2' },
    font: { color: '#991b1b', strikethrough: true },
  };
  return map;
}, [columns]);
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const [formats, setFormats] = useState<CellFormatsMap>(initialFormats);

<CellFormatToolbar
  selection={selection}
  cellFormats={formats}
  onCellFormatsChange={setFormats}
/>
<XlReact
  columns={columns}
  rows={rows}
  cellFormats={formats}
  onSelectionChange={setSelection}
/>

숫자 형식 (Number Format)

FeatureMedium 셀 값을 Excel 형식 코드에 따라 표시 문자열로 변환하는 순수 엔진.

특성

  • formatCellValue(value, format, locale?)는 UI에 의존하지 않는 순수 함수입니다. 그리드 없이 단독으로도 사용할 수 있습니다.
  • cellFormat.numberFormat이 지정되고 해당 컬럼에 cellRenderer가 없을 때만 렌더링 시점에 적용됩니다. cellRenderer가 항상 우선합니다.
  • 편집 중에는 항상 원본 값을 보여줍니다. 형식 변환은 표시 전용이며 저장되는 값은 바뀌지 않습니다.
  • CellFormatToolbar에 숫자 형식 드롭다운(일반·숫자·통화·회계·백분율·지수·분수·날짜·시간·텍스트)과 소수 자릿수 늘림/줄임 버튼이 내장되어 선택 영역에 형식 코드를 적용합니다. 목록은 numberFormats prop으로 교체할 수 있습니다.
  • 일반(General) / 정수 / 소수 / 천단위 / 통화(₩·$) / 회계 / 백분율 / 과학적 표기 / 분수 / 날짜·시간 형식을 지원합니다.
  • 4-섹션 사용자 지정 코드 양수;음수;0;텍스트, 강제 0 채움(00.0), 단위 접미사(0"톤"), 색상 토큰([Red]), 자릿수 자리표시자 0 / # / ?를 해석합니다.
  • 결정성 계약: 그룹 구분자(,)와 소수점(.)은 로캘과 무관하게 ASCII로 고정됩니다. 로캘은 Intl.DateTimeFormat(UTC) 기반의 월·요일 이름에만 영향을 줍니다.
  • 날짜는 Date 객체, Excel 일련번호(1899-12-30 기준), ISO 문자열을 모두 받아들입니다.
  • 숫자로 해석되지 않는 값은 텍스트 섹션(@)으로 그대로 통과시킵니다.

표시와 사용

import {
  XlReact,
  cellFormatKey,
  formatCellValue,
  NUMBER_FORMAT_PRESETS,
  increaseDecimals,
  decreaseDecimals,
  type CellFormatsMap,
} from 'hyper-xl';

// 1) 순수 함수로 단독 사용
formatCellValue(1500000, '₩ #,##0');        // "₩ 1,500,000"
formatCellValue(0.3625, '0.0%');            // "36.3%"
formatCellValue('2026-05-22', 'YYYY년 MM월 DD일'); // "2026년 05월 22일"
formatCellValue(-98000, '#,##0;[Red](#,##0)');     // "(98,000)" (음수 섹션)

// 2) 그리드 셀에 적용: cellFormats에 numberFormat 지정
const cellFormats: CellFormatsMap = {
  [cellFormatKey(1, 1)]: { numberFormat: NUMBER_FORMAT_PRESETS.CURRENCY_KRW },
  [cellFormatKey(1, 2)]: { numberFormat: '0.0%' },
};

<XlReact columns={columns} rows={rows} cellFormats={cellFormats} />

// 3) 소수 자릿수 증감 헬퍼 (형식 코드를 받아 형식 코드를 반환)
increaseDecimals('#,##0');    // "#,##0.0"
decreaseDecimals('#,##0.00'); // "#,##0.0"

API

  • formatCellValue(value, format?, locale?) => string

    값을 형식 코드에 따라 표시 문자열로 변환하는 순수 함수입니다. format이 없으면 일반(General) 형식을, locale 기본값은 'en-US'입니다.

  • NUMBER_FORMAT_PRESETSRecord<NumberFormatPreset, string>

    자주 쓰는 형식 코드 상수 모음입니다. GENERAL, INTEGER, NUMBER, THOUSANDS, CURRENCY_KRW, CURRENCY_USD, ACCOUNTING_KRW, PERCENT, SCIENTIFIC, FRACTION, DATE_ISO, DATE_KO, TIME_HM, DATETIME, TEXT 등.

  • increaseDecimals / decreaseDecimals(format) => string

    형식 코드의 소수 자릿수를 하나 늘리거나 줄여 새 형식 코드를 반환합니다. 엑셀의 “소수 자릿수 늘림/줄임” 버튼에 대응하는 API 레벨 헬퍼입니다.

  • adjustFormatDecimals(format, delta) => string

    delta만큼 소수 자릿수를 가감하는 하위 헬퍼입니다. increaseDecimals / decreaseDecimals가 이 함수를 감쌉니다.

  • CellFormatToolbar · numberFormats{ label, value }[]

    툴바 숫자 형식 드롭다운의 프리셋 목록을 교체합니다. value는 형식 코드이며, 빈 문자열('')은 일반(General)으로 매핑되어 셀의 numberFormat을 지웁니다. 생략하면 기본 한국어 프리셋 목록을 사용합니다.

셀 스타일 / 테마 프리셋 (Cell Styles)

FeatureLow 이름이 붙은 CellFormat 번들(내장 프리셋 + 사용자 스타일)을 선택 영역에 합성하는 순수 모델 + 갤러리 툴바. (명세서 §7.7)

특성

  • 셀 스타일은 이름이 붙은 CellFormat 번들입니다. 적용하면 그리드가 이미 그리는 cellFormats 항목으로 평탄화되므로 새 그리드 상태나 렌더 경로가 없습니다(셀 서식·조건부 서식과 같은 컨슈머 컨트롤드 모델).
  • 내장 프리셋 15종: 제목·머리글 / 좋음·나쁨·보통 / 입력·출력·계산 / 경고·메모 / 소계·합계 / 강조 1~3(테마 색). Excel 기본 테마 색을 따릅니다. BUILTIN_CELL_STYLES(맵) / BUILTIN_CELL_STYLE_LIST(순서 보존 배열)로 노출됩니다.
  • createCellStyleRegistry(custom?)는 내장 프리셋 위에 사용자 스타일을 얹습니다(같은 id는 사용자 우선). 레지스트리는 불변(frozen): defineCellStyle / removeCellStyle은 새 레지스트리를 반환해 React 상태에 그대로 들어갑니다(저장 / 재사용).
  • applyCellStyle의 적용 방식: 'replace'(기본: 셀 서식을 스타일로 교체, Excel 동작) · 'merge'(facet 단위로 덮어쓰기: 추가/덮어쓰기만, 제거는 안 함) · undefined·빈 포맷(표준으로 지우기). replace는 셀마다 깊은 복제본을 써서, 스타일 정의를 나중에 바꿔도 이미 칠한 셀이 변하지 않습니다.
  • 적용은 스타일의 포맷을 복사하므로 그리드는 셀↔스타일 링크를 보관하지 않습니다. 따라서 Excel과 달리 스타일 정의를 수정해도 이미 적용된 셀은 자동 갱신되지 않습니다. 변경을 전파하려면 다시 적용하세요.
  • buildTableStyleFormats(P3): 머리글 행 · 줄무늬 본문 · 합계 행을 한 번에 적용해 표 스타일을 만듭니다.
  • CellStyleToolbarselection + cellFormats를 받는 컨트롤드 갤러리 드롭다운(스와치 미리보기). 같은 드롭다운을 CellFormatToolbarcellStyleRegistry prop을 넘겨 통합할 수 있습니다(병합·조건부 서식과 동일한 fold-in 패턴).

사용법

import { useState } from 'react';
import {
  XlReact,
  CellFormatToolbar,
  createCellStyleRegistry,
  applyNamedCellStyle,
  type CellFormatsMap,
  type SelectionSnapshot,
} from 'hyper-xl';

// 내장 프리셋 + 저장된 사용자 스타일("브랜드").
const registry = createCellStyleRegistry([
  {
    id: 'brand',
    label: '브랜드',
    category: 'custom',
    format: {
      fill: { backgroundColor: '#1f3864' },
      font: { bold: true, color: '#ffd966' },
      align: { horizontal: 'center' },
    },
  },
]);

function Sheet() {
  const [formats, setFormats] = useState<CellFormatsMap>({});
  const [selection, setSelection] = useState<SelectionSnapshot | null>(null);

  return (
    <>
      {/* "셀 스타일 ▾" 갤러리를 서식 툴바에 통합: 클릭하면 cellFormats를 갱신. */}
      <CellFormatToolbar
        selection={selection}
        cellFormats={formats}
        onCellFormatsChange={setFormats}
        cellStyleRegistry={registry}
      />
      <XlReact
        columns={columns}
        rows={rows}
        cellFormats={formats}
        onSelectionChange={setSelection}
      />
    </>
  );
}

// 또는 툴바 없이 순수 헬퍼로 직접 적용:
// const next = applyNamedCellStyle(formats, selection.ranges, registry, 'total');

API

  • createCellStyleRegistry(custom?: NamedCellStyle[]) => CellStyleRegistry

    내장 프리셋에 사용자 스타일을 얹은 불변(frozen) 레지스트리를 만듭니다. 같은 id는 사용자 스타일이 덮어씁니다. BUILTIN_CELL_STYLE_REGISTRY는 프리셋만 담은 기성 레지스트리입니다.

  • NamedCellStyle{ id; label?; category?; builtin?; format }

    이름이 붙은 CellFormat 번들. id가 레지스트리 키이고 format이 적용할 서식입니다. category는 갤러리 섹션 묶음용.

  • defineCellStyle / removeCellStyle(registry, …) => CellStyleRegistry

    스타일을 추가·교체하거나 제거한 레지스트리를 반환합니다(입력은 불변). 알 수 없는 id 제거는 같은 참조를 그대로 돌려주는 no-op입니다.

  • resolveCellStyle / getCellStyle / listCellStyles조회 헬퍼

    각각 id → CellFormat, id → NamedCellStyle, 레지스트리 → 삽입 순서 배열(카테고리 / builtin 필터 지원)을 돌려줍니다.

  • applyCellStyle(formats, ranges, format?, options?) => CellFormatsMap

    선택 영역에 스타일 CellFormat을 합성합니다. options.mode'replace'(기본) / 'merge'. formatundefined(또는 빈 포맷)이면 영역의 서식을 지웁니다(표준). 입력 맵은 절대 변경하지 않습니다.

  • applyNamedCellStyle(formats, ranges, registry, id, options?) => CellFormatsMap

    레지스트리에서 id를 풀어 적용하는 단축 헬퍼. 알 수 없는 id는 입력 맵을 그대로(같은 참조) 돌려주는 no-op이라 잘못된 id가 서식을 지우지 않습니다.

  • buildTableStyleFormats(formats, range, options) => CellFormatsMap

    머리글 · 줄무늬 본문(band / bandAlt) · 합계 행을 한 번에 적용해 표 스타일을 만드는 P3 헬퍼.

  • CellStyleToolbarcomponent

    엑셀식 “셀 스타일” 갤러리 드롭다운. selection · cellFormats · onCellFormatsChange를 받는 컨트롤드 컴포넌트로, registry · applyMode · labels로 커스터마이즈합니다. 같은 갤러리를 CellFormatToolbarcellStyleRegistry를 넘겨 통합할 수도 있습니다.

조건부 서식 (Conditional Formatting)

FeatureLow 규칙 목록을 셀별 서식·장식으로 환원하는 순수 평가기 + 데이터 막대 / 아이콘 렌더.

특성

  • evaluateConditionalFormats(rules, rows, columns, options?)는 그리드 상태에 의존하지 않는 순수 함수입니다. 각 컬럼의 accessor가 돌려주는 원본 값으로 비교하므로 숫자 표시 변환(number format)과 독립적이고 단위 테스트가 쉽습니다.
  • 반환값은 { formats, decorations }입니다. formats"row:col" 키의 CellFormatsMap으로 cellFormats prop에 그대로 연결됩니다.
  • 지원 규칙: 값 비교(이상/이하/사이), 상위·하위 N(개수 또는 %), 평균 초과·미만, 중복·고유, 텍스트(포함/시작/끝), 날짜(오늘/어제/지난 7일/이번·지난 주/이번·지난 달), 색조(Color Scale), 데이터 막대, 아이콘 세트.
  • 색조는 빨강→노랑→초록 그라데이션을 fill.backgroundColor로 환원합니다(기본값은 Excel의 min / 50번째 백분위 / max 3색).
  • 데이터 막대와 아이콘 세트는 CellFormat으로 표현할 수 없어 decorations로 분리됩니다. makeConditionalCellRenderer가 만든 cellRenderer로 셀에 그려지므로 그리드 코어는 수정되지 않습니다.
  • 규칙은 배열 순서대로 우선순위가 적용됩니다(인덱스 0이 가장 높음). 같은 셀에 여러 규칙이 걸리면 CSS 속성별로 앞선 규칙이 이깁니다. stopIfTrue가 매칭되면 그 셀에는 하위 규칙이 적용되지 않습니다.
  • 상위·하위 N, 평균, 색조 등 범위 기반 규칙은 대상 컬럼 전체 셀을 하나의 풀로 계산합니다(Excel의 "적용 범위"와 동일).
  • 날짜 규칙은 options.now를 주입해 결정적으로 평가할 수 있습니다(테스트·SSR). 주 시작 요일은 options.weekStartsOn(0=일, 1=월).
  • makeConditionalCellRenderercellRenderer에 인덱스가 없으므로 row.id / column.id로 셀을 역참조합니다. 평가기와 렌더러에는 같은 rows / columns 배열을 넘기세요(순서·id 동일). 반환된 렌더러와 그것으로 만든 컬럼은 useMemo로 안정화하세요. 렌더러 정체성이 바뀌면 컬럼 객체가 새로 생겨 셀별 memo가 깨지고 모든 장식 셀이 다시 렌더됩니다.
  • 숫자 형식과의 조합: 셀에 cellRenderer가 걸리면 그리드의 numberFormat 표시 경로가 우회됩니다. 따라서 데이터 막대 / 아이콘 옆의 값에도 숫자 형식을 유지하려면 makeConditionalCellRenderer에 그리드와 같은 cellFormatsoptions.cellFormats로 넘기세요. 그러면 값이 formatCellValue로 변환되어 Cell.tsx와 동일하게 표시됩니다(예: 1234567 대신 ₩1,234,567). 우선순위는 options.baseRenderernumberFormat → 원본 순입니다.
  • 평가기는 범위 통계(백분위·상위 N 등)를 위해 대상 컬럼의 전체 행을 스캔합니다(가시 영역만이 아님). 대용량 그리드에서는 useMemo로 적극 캐시하고, 필요하면 규칙 적용 범위를 미리 좁히세요.

사용법

import { useMemo } from 'react';
import {
  XlReact,
  cellFormatKey,
  evaluateConditionalFormats,
  makeConditionalCellRenderer,
  type ConditionalRule,
} from 'hyper-xl';

const rows = [
  { id: 1, data: { item: '컨테이너 A', stock: 120, rate: 92, trend: 12 } },
  { id: 2, data: { item: '컨테이너 B', stock: 45, rate: 58, trend: -4 } },
  { id: 3, data: { item: '컨테이너 C', stock: 200, rate: 76, trend: 3 } },
];

// 평가기와 그리드는 같은 컬럼 배열(같은 id·순서)을 공유해야 합니다.
const columns = [
  { id: 'item', width: 150, accessor: (r) => r.data.item },
  { id: 'stock', width: 150, accessor: (r) => r.data.stock, dataType: 'number' },
  { id: 'rate', width: 110, accessor: (r) => r.data.rate, dataType: 'number' },
  { id: 'trend', width: 110, accessor: (r) => r.data.trend, dataType: 'number' },
];

const rules: ConditionalRule[] = [
  // 색조: 처리율을 빨강→노랑→초록으로.
  { type: 'colorScale', columns: ['rate'] },
  // 값 비교: 처리율 60 미만은 굵은 빨강 글씨로(색조 채우기와 함께).
  { type: 'cellValue', columns: ['rate'], operator: 'lessThan', value: 60,
    format: { font: { bold: true, color: '#b91c1c' } } },
  // 데이터 막대: 재고량을 셀 안 막대로.
  { type: 'dataBar', columns: ['stock'], color: '#3b82f6' },
  // 아이콘 세트: 추세를 ▼ ● ▲ 세 구간으로.
  { type: 'iconSet', columns: ['trend'], iconSet: 'triangles' },
];

function Sheet() {
  // 순수 평가기: 규칙 + 행 + 컬럼 → { formats, decorations }.
  const result = useMemo(() => evaluateConditionalFormats(rules, rows, columns), []);

  // 평가기 형식 + 컨슈머의 숫자 형식(재고 컬럼에 천단위 + 단위 접미사).
  // 서로 다른 셀을 건드리므로 맵 스프레드로 안전하게 합쳐집니다.
  const cellFormats = useMemo(() => ({
    ...result.formats,
    ...Object.fromEntries(
      rows.map((_, r) => [cellFormatKey(r, 1), { numberFormat: '#,##0"개"' }]),
    ),
  }), [result]);

  // 데이터 막대 / 아이콘은 CellFormat으로 표현 불가 → cellRenderer로 렌더.
  // cellFormats를 함께 넘기면 막대 옆 값도 셀의 numberFormat을 따릅니다.
  const render = useMemo(
    () => makeConditionalCellRenderer(result, rows, columns, { cellFormats }),
    [result, cellFormats],
  );
  const decorated = useMemo(
    () =>
      columns.map((c) =>
        c.id === 'stock' || c.id === 'trend' ? { ...c, cellRenderer: render } : c,
      ),
    [render],
  );

  // 합쳐진 cellFormats를 그리드와 데코레이션 렌더러가 공유합니다.
  return <XlReact columns={decorated} rows={rows} cellFormats={cellFormats} />;
}

API

  • evaluateConditionalFormats(rules, rows, columns, options?) => { formats, decorations }

    규칙 목록을 셀별 CellFormat 맵(formats)과 데이터 막대 / 아이콘 장식 맵(decorations)으로 환원하는 순수 함수입니다. 둘 다 "row:col" 키의 sparse 맵입니다.

  • ConditionalRuleunion

    cellValue · topBottom · average · duplicate / unique · text · date · colorScale · dataBar · iconSet의 판별 유니온. 모든 규칙은 columns?(대상 컬럼 id, 생략 시 전체)와 stopIfTrue?를 공유합니다.

  • EvaluateOptions{ now?: Date; weekStartsOn?: 0 | 1 }

    날짜 규칙의 기준 시각과 주 시작 요일. now를 주입하면 결정적으로 평가됩니다.

  • makeConditionalCellRenderer(result, rows, columns, options?) => cellRenderer

    평가기의 데이터 막대 / 아이콘 장식을 셀에 입히는 cellRenderer를 만듭니다. 장식이 없는 셀은 값 표시로 통과하므로 모든 컬럼에 적용해도 안전합니다. options.baseRenderer로 기존 렌더러를 감싸거나, options.cellFormats(그리드와 동일)를 넘겨 값에 셀의 numberFormat을 적용할 수 있습니다.

  • ConditionalCellRendererOptions{ baseRenderer?; cellFormats? }

    makeConditionalCellRenderer의 옵션. 값 표시 우선순위는 baseRenderercellFormatsnumberFormat → 원본 값 순이며, baseRenderer가 있으면 cellFormats는 무시됩니다.

  • ConditionalDataBar / ConditionalIconcomponent

    장식을 직접 렌더링할 때 쓰는 표현형 컴포넌트. 커스텀 cellRenderer에서 조합할 수 있습니다.

  • resolveConditionalDecoration(decorations, rowIndex, columnIndex) => ConditionalDecoration | undefined

    인덱스로 장식을 조회하는 헬퍼(resolveCellFormat과 동일한 패턴).

  • ConditionalFormatToolbarcomponent

    엑셀식 “조건부 서식” 드롭다운. CellMergeToolbar와 같은 컨트롤드 컴포넌트로, selection · columns · rules · onRulesChange를 받아 선택 영역의 컬럼을 대상으로 규칙을 추가/삭제합니다. 메뉴: 셀 강조(보다 큼·작음·사이, 값 입력) · 상위/하위 · 평균 초과·미만 · 중복·고유 · 데이터 막대·색조·아이콘 세트 · 규칙 지우기(선택 영역 / 전체). 새 규칙은 배열 앞에 추가되어 가장 높은 우선순위를 가집니다. 같은 드롭다운을 CellFormatToolbarconditionalRules / onConditionalRulesChange / columns를 넘겨 서식 툴바에 통합할 수도 있습니다(병합 컨트롤과 동일한 패턴).

  • 규칙 빌더 헬퍼build* / clear* / selectionColumnIds

    툴바를 직접 만들 때 쓰는 순수 헬퍼: selectionColumnIds, buildDataBarRule · buildColorScaleRule · buildIconSetRule · buildCellValueRule · buildTopBottomRule · buildAverageRule · buildDuplicateRule, appendRule, clearRulesForColumns · clearAllRules, DEFAULT_HIGHLIGHT_FORMAT.

커스텀 셀 렌더러 (Custom Cell Renderer)

FeatureHigh 셀 안에 임의의 React 요소를 그리는 표시 렌더러 + 편집 렌더러. 컬럼·셀 두 레벨에서 지정.

특성

  • 표시(Display)와 편집(Edit) 분리: cellRenderer는 화면 표시 전용, cellEditor는 F2 · 더블클릭 · 타이핑으로 진입하는 편집 전용입니다. 둘 다 선택적이며, 없으면 기본 텍스트 표시 / 기본 입력 에디터로 폴백합니다(기존 동작 무변경).
  • 두 레벨 지정: 컬럼 단위(Column.cellRenderer · Column.cellEditor)와 셀 단위(cellRenderers prop). 셀 단위가 컬럼 단위를 오버라이드합니다.
  • cellRendererscellFormats · cellAnnotations와 동일한 리졸버 패턴입니다: "row:col" 키의 sparse 맵, 또는 (rowIndex, columnIndex) => CellRenderer | undefined 함수.
  • 표시 렌더러 props: { value, row, column, rowIndex, columnIndex, isEditing }. 단, 표시 렌더러는 편집 중에는 호출되지 않으므로(편집 셀은 비워지고 에디터 오버레이가 대신 그려짐) isEditing은 항상 false입니다 — isEditing: true는 편집 렌더러에서만 전달됩니다. 편집 렌더러는 여기에 { onCommit, onCancel, mode, initialDraft }가 더해집니다.
  • 편집기는 컴포넌트로 마운트됩니다(자체 fiber): useState 등 훅으로 드래프트 상태를 자유롭게 관리할 수 있습니다. 반면 표시 렌더러는 순수 함수로 호출되므로 최상위에서 훅을 쓰지 마세요(상태가 필요하면 자식 컴포넌트를 렌더). 타이핑으로 진입하면 mode='overwrite' · initialDraft에 그 키가 담겨 옵니다. (그리드는 에디터 영역의 user-select를 복원하므로 입력·텍스트 선택이 정상 동작합니다.)
  • onCommit(next, nav?)타입을 보존합니다: 입력한 값(숫자 · 객체 등)이 강제 변환 없이 onCellChange로 흐릅니다. nav('enter' | 'tab' | 'shift-tab' | 'shift-enter' | 'none')로 커밋 후 활성 셀 이동을 내장 에디터처럼 제어합니다. next가 현재 값과 같으면 커밋이 일어나지 않습니다.
  • 가상 스크롤 호환: 셀은 React.memo로 비교되며 cellRenderer 정체성이 같으면 재렌더를 건너뜁니다. 렌더러를 모듈 스코프나 useMemo로 안정화하세요. 매 렌더마다 새 함수를 넘기면 모든 셀이 다시 그려집니다. 리졸버 함수형도 같은 참조를 돌려주는 패턴을 권장합니다.
  • 외부 클릭 시 취소: 그리드는 커스텀 에디터의 드래프트를 알 수 없으므로, 다른 셀을 클릭하면 편집을 취소합니다(내장 입력은 blur에 저장). 엑셀처럼 클릭아웃에도 저장하려면 에디터의 onBlur에서 직접 onCommit을 호출하세요. 그 커밋이 취소보다 먼저 처리됩니다.
  • 읽기 전용 컬럼(readOnly)은 cellEditor가 있어도 편집기가 열리지 않습니다. validation 목록이 걸린 컬럼은 목록 피커가 우선합니다. 병합 영역은 앵커 셀에서만 렌더 · 편집됩니다.

사용법

import { useState } from 'react';
import {
  XlReact,
  type CellRendererProps,
  type CellEditorProps,
  type CellRenderers,
  type Column,
} from 'hyper-xl';

const rows = [
  { id: 1, data: { task: '양하', progress: 65, status: '진행중' } },
  { id: 2, data: { task: '출항', progress: 100, status: '완료' } },
  { id: 3, data: { task: '대기', progress: 15, status: '지연' } },
];

// 표시 렌더러: 값/행/열/인덱스/편집중 여부를 props로 받습니다.
// 진척도를 색이 있는 막대로: 100%=초록, 30% 미만=빨강, 그 외 파랑.
function ProgressBar({ value }: CellRendererProps) {
  const pct = Math.max(0, Math.min(100, Number(value) || 0));
  const color = pct >= 100 ? '#16a34a' : pct < 30 ? '#dc2626' : '#2563eb';
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <div style={{ flex: 1, height: 6, background: '#e5e7eb', borderRadius: 3 }}>
        <div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 3 }} />
      </div>
      <span style={{ color: '#9ca3af', fontSize: 12 }}>{pct}%</span>
    </div>
  );
}

// 상태 배지: 상태 문자열을 색이 있는 칩으로.
const BADGE: Record<string, string> = {
  진행중: '#2563eb',
  완료: '#16a34a',
  지연: '#dc2626',
};
function StatusBadge({ value }: CellRendererProps) {
  const c = BADGE[String(value)] ?? '#6b7280';
  return (
    <span style={{ color: c, background: `${c}1a`, padding: '2px 8px', borderRadius: 10, fontSize: 12 }}>
      {String(value)}
    </span>
  );
}

// 편집 렌더러: 표시(막대)와 분리된 숫자 입력 에디터(0~100).
// 에디터는 컴포넌트로 마운트되므로 useState 같은 훅을 자유롭게 씁니다.
// onCommit(next, nav?)는 타입 그대로 커밋, onCancel은 취소.
function ProgressEditor({ value, mode, initialDraft, onCommit, onCancel }: CellEditorProps) {
  const seed = mode === 'overwrite' && initialDraft ? initialDraft : String(Number(value) || 0);
  const [draft, setDraft] = useState(seed);
  const commit = (nav?: 'enter') =>
    onCommit(Math.max(0, Math.min(100, Math.round(Number(draft) || 0))), nav);
  return (
    <div
      style={{ display: 'flex', alignItems: 'center', gap: 6 }}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) commit();
      }}
    >
      <input
        type="number"
        min={0}
        max={100}
        value={draft}
        autoFocus
        onFocus={mode === 'edit' ? (e) => e.target.select() : undefined}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') commit('enter');
          if (e.key === 'Escape') onCancel();
        }}
        style={{ width: 64 }}
      />
      <span>%</span>
    </div>
  );
}

const columns: Column[] = [
  { id: 'task', width: 120, accessor: (r) => r.data.task },
  {
    id: 'progress',
    width: 220,
    accessor: (r) => r.data.progress,
    // 컬럼 단위 렌더러: 이 컬럼의 모든 셀에 적용.
    cellRenderer: (p) => <ProgressBar {...p} />,
    cellEditor: (p) => <ProgressEditor {...p} />,
  },
  {
    id: 'status',
    width: 120,
    accessor: (r) => r.data.status,
    cellRenderer: (p) => <StatusBadge {...p} />,
  },
];

// 셀 단위 오버라이드: "row:col" 맵 또는 (rowIndex, columnIndex) => renderer 함수.
const cellRenderers: CellRenderers = {
  '2:0': ({ value }) => <strong style={{ color: '#dc2626' }}>{String(value)}</strong>,
};

function Sheet() {
  return (
    <XlReact
      columns={columns}
      rows={rows}
      cellRenderers={cellRenderers}
      onCellChange={(c) => console.log(c.coord, c.nextValue)}
    />
  );
}

API

  • cellRenderersCellRenderers

    셀 단위 표시 렌더러. "row:col" 키의 맵 또는 (rowIndex, columnIndex) => CellRenderer | undefined 함수입니다. 같은 셀에 컬럼 렌더러와 셀 렌더러가 모두 있으면 셀 렌더러가 우선합니다.

  • Column.cellRenderer / Column.cellEditorCellRenderer<T> / CellEditor<T>

    컬럼 단위 표시 · 편집 렌더러. cellEditor는 F2 · 더블클릭 · 타이핑으로 진입하는 편집 UI를 그립니다.

  • CellRendererProps<T>{ value; row; column; rowIndex; columnIndex; isEditing }

    표시 렌더러가 받는 props. row · column은 원본 객체를 참조로 전달하고, isEditing은 그 셀이 편집 중인지 알려줍니다.

  • CellEditorProps<T>extends CellRendererProps<T> { onCommit; onCancel; mode?; initialDraft? }

    편집 렌더러가 받는 props. onCommit(next, nav?)은 타입을 보존하며 커밋(같은 값이면 무시), onCancel()은 편집 취소입니다. mode('edit' | 'overwrite' | 'clear')와 initialDraft는 진입 방식(F2 vs 타이핑)을 구분합니다.

  • CellRenderer<T> / CellEditor<T>(props) => ReactNode

    각각 CellRendererProps · CellEditorProps를 받아 노드를 반환하는 함수 타입.

  • CellRenderersCellRenderersMap | CellRendererResolver

    cellRenderers prop의 타입. CellRenderersMapRecord<"row:col", CellRenderer>, CellRendererResolver(rowIndex, columnIndex) => CellRenderer | undefined.

  • CellEditCommitNav'enter' | 'shift-enter' | 'tab' | 'shift-tab' | 'none'

    onCommit의 두 번째 인자. 커밋 후 활성 셀 이동 방향을 내장 에디터와 동일하게 지정합니다(기본 'none').

  • cellRendererKey / resolveCellRenderer(rowIndex, columnIndex) => string · (cellRenderers, rowIndex, columnIndex) => CellRenderer | undefined

    키 빌더와 리졸버 헬퍼(cellFormatKey · resolveCellFormat과 동일한 패턴). 맵 · 함수 두 형태를 모두 처리합니다.

셀 병합 (Merge / Unmerge / Merge & Center)

FeatureHigh 컨슈머가 소유한 병합 영역을 그리드가 하나의 앵커 셀로 렌더링하고 toolbar가 편집.

특성

  • merges는 직사각형 범위(SelectionRange)의 배열입니다. 그리드는 병합 상태를 소유하지 않고 렌더링만 합니다.
  • 각 병합 영역은 좌상단 앵커 셀 하나로 그려지고 전체 영역을 가로·세로로 span합니다. 가려진 셀은 렌더링되지 않습니다.
  • 병합 영역 내부를 클릭하면 영역 전체가 선택되고 active 셀은 앵커로 고정됩니다.
  • 방향키 이동은 병합 경계를 건너뛰어, 커서가 앵커에 갇히지 않고 영역 바깥으로 빠져나갑니다.
  • 앵커 행이 화면 밖으로 스크롤되어도 가상화 윈도와 겹치는 한 span은 계속 렌더링됩니다.
  • 병합 / 병합 해제 / 병합하고 가운데 컨트롤은 CellFormatToolbarmerges / onMergesChange를 넘기면 서식 툴바에 통합됩니다. 별도로 쓰고 싶다면 CellMergeToolbar도 그대로 제공됩니다.
  • Merge & Center는 병합과 동시에 앵커에 가로 가운데 정렬을 적용합니다. CellFormatToolbar에 넘긴 cellFormats / onCellFormatsChange를 그대로 사용합니다.
  • 그리드는 기본적으로 가려진 셀의 값을 보존합니다. 엑셀처럼 좌상단만 남기고 나머지를 비우려면 onMergeClearCovered를 옵트인해 받은 범위를 컨슈머가 직접 지웁니다(coveredCellRanges 헬퍼로도 계산 가능). 값만 비우며 가려진 셀의 cellFormats는 그대로 남습니다.
  • 병합 한 번은 콜백 여러 개를 차례로 호출합니다(onMergesChange → 가운데 정렬이면 onCellFormatsChangeonMergeClearCovered). 실행 취소를 쓰는 앱은 이들을 하나의 트랜잭션으로 묶어 한 번에 되돌리도록 하세요.
  • 병합 좌표는 현재 뷰의 0-based입니다. 행/열 삽입·삭제·재정렬을 직접 처리하는 앱은 같은 시점에 merges 배열도 shift/prune해야 합니다.

표시와 편집

import { useState } from 'react';
import {
  CellFormatToolbar,
  XlReact,
  type CellFormatsMap,
  type SelectionRange,
  type SelectionSnapshot,
} from 'hyper-xl';

const columns = [
  { id: 'name', width: 160, accessor: (r) => r.data.name },
  { id: 'q1', width: 90, accessor: (r) => r.data.q1 },
  { id: 'q2', width: 90, accessor: (r) => r.data.q2 },
  { id: 'q3', width: 90, accessor: (r) => r.data.q3 },
];

function Sheet() {
  const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
  const [rows, setRows] = useState([
    { id: 0, data: { name: '2026 분기 매출', q1: '', q2: '', q3: '' } },
    { id: 1, data: { name: '컨테이너 A', q1: 1500, q2: 1800, q3: 1650 } },
  ]);
  const [merges, setMerges] = useState<SelectionRange[]>([
    // 첫 행 전체를 가로지르는 제목 배너.
    { start: { row: 0, col: 0 }, end: { row: 0, col: 3 } },
  ]);
  const [cellFormats, setCellFormats] = useState<CellFormatsMap>({
    '0:0': { align: { horizontal: 'center' }, font: { bold: true } },
  });

  // 엑셀처럼 병합하면 좌상단 값만 남기고 가려진 셀을 비웁니다(옵트인).
  // 라이브러리는 기본적으로 데이터를 보존하므로 컨슈머가 직접 지웁니다.
  // (값만 비웁니다. 가려진 셀의 cellFormats는 그대로 남습니다.)
  const clearCovered = (ranges: SelectionRange[]) => {
    setRows((prev) =>
      prev.map((row, r) => {
        const hit = ranges.some(
          (g) => r >= g.start.row && r <= g.end.row,
        );
        if (!hit) return row;
        const data = { ...row.data };
        for (const g of ranges) {
          if (r < g.start.row || r > g.end.row) continue;
          for (let c = g.start.col; c <= g.end.col; c++) {
            const id = columns[c]?.id;
            if (id) data[id] = null;
          }
        }
        return { ...row, data };
      }),
    );
  };

  return (
    <>
      {/* merges / onMergesChange를 넘기면 병합 컨트롤이 서식 툴바에 통합됩니다. */}
      <CellFormatToolbar
        selection={selection}
        cellFormats={cellFormats}
        onCellFormatsChange={setCellFormats}
        merges={merges}
        onMergesChange={setMerges}
        onMergeClearCovered={clearCovered}
      />
      <XlReact
        columns={columns}
        rows={rows}
        merges={merges}
        cellFormats={cellFormats}
        onSelectionChange={setSelection}
      />
    </>
  );
}

API

  • mergesReadonlyArray<SelectionRange>

    컨슈머가 소유한 병합 영역 배열. 각 범위는 좌상단 앵커 셀 하나로 렌더링되고 나머지 셀은 가려집니다. 생략하면 병합 레이어가 렌더링되지 않습니다.

  • CellMergeToolbarcomponent (merges, onMergesChange, onMergeClearCovered)

    merges / onMergesChange를 넘기면 병합 / 병합 해제 / 병합하고 가운데 컨트롤이 서식 툴바에 통합됩니다. onMergeClearCovered는 병합으로 가려지는 셀의 범위를 돌려주므로, 받은 범위를 비우면 엑셀처럼 좌상단 값만 남길 수 있습니다. 병합만 따로 쓰려면 동일한 props의 CellMergeToolbar를 사용합니다.

  • coveredCellRanges(range) => SelectionRange[]

    병합 영역에서 좌상단 앵커를 뺀, 가려지는 셀들을 최대 2개의 직사각형으로 반환하는 순수 헬퍼입니다. 커스텀 병합 UI에서 직접 클리어 범위를 계산할 때 사용합니다.

  • mergeSelection(merges, ranges) => SelectionRange[]

    선택 범위를 병합 영역으로 추가하고 겹치는 기존 병합을 흡수하는 순수 유틸리티입니다.

  • unmergeSelection(merges, ranges) => SelectionRange[]

    선택 범위와 교차하는 병합 영역을 제거합니다.

  • normalizeMerges(merges) => SelectionRange[]

    범위를 정규화하고 중복·단일 셀·겹치는 영역을 정리합니다(먼저 들어온 것 우선).

셀 주석 (read-only 툴팁)

FeatureMedium 개발 단에서 주입하는 셀별 툴팁.

특성

  • 주석이 있는 셀은 우상단에 작은 삼각형 인디케이터 표시.
  • 호버 시 표준 툴팁: 진입/이탈 딜레이, Esc로 닫기.
  • read-only 데이터: 그리드는 주석 편집 UI를 제공하지 않음.
  • 병합 / 분할 / 행·열 삭제 시 컨슈머가 자체 소스에서 해당 엔트리를 제거.

두 가지 형태

// 함수 형태
<XlReact
  cellAnnotations={(rowIndex, columnIndex) =>
    rowIndex === 0 ? '헤더 메모' : undefined
  }
/>

// 맵 형태 (`${row}:${col}` 키)
<XlReact cellAnnotations={{ '0:1': '호버해보세요', '3:4': '중요' }} />

함수는 보이는 셀마다 매 렌더 호출됩니다. O(1)을 유지하세요. 무거운 소스는 useMemo로 맵 형태로 materialize.

API

  • cellAnnotationsCellAnnotationResolver | CellAnnotationsMap
  • annotationShowDelayMsnumber

    툴팁 표시 딜레이 (기본 500).

  • annotationHideDelayMsnumber

    포인터 이탈 후 숨김 딜레이 (기본 100).

컬럼 메타데이터로부터 주석 맵 만들기 tsx
import { useMemo } from 'react';
import { XlReact, cellAnnotationKey, type CellAnnotationsMap } from 'hyper-xl';

const annotations: CellAnnotationsMap = useMemo(() => {
  const map: Record<string, string> = {};
  rows.forEach((row, rIdx) => {
    columns.forEach((col, cIdx) => {
      const note = row.data[`${col.id}__note`] as string | undefined;
      if (note) map[cellAnnotationKey(rIdx, cIdx)] = note;
    });
  });
  return map;
}, [rows, columns]);

<XlReact columns={columns} rows={rows} cellAnnotations={annotations} />;
헬퍼 함수: cellAnnotationKey / resolveCellAnnotation ts
import { cellAnnotationKey, resolveCellAnnotation } from 'hyper-xl';

cellAnnotationKey(0, 1);                  // '0:1'
resolveCellAnnotation(map, 0, 1);         // 'Hover me' | undefined

셀 보호 (read-only)

FeatureHigh 모든 편집 surface에 걸쳐 셀 단위 read-only를 일관되게 강제.

커버리지

보호는 위치 기반(행 / 열 인덱스)이며 Column.readOnly와 union입니다. 보호된 셀은 다음을 모두 차단합니다:

  • edit: F2, 더블클릭, 타이핑 덮어쓰기, Backspace 초기화
  • clear: 비어있지 않은 선택에 Delete
  • paste: Ctrl+V, native paste, 우클릭 붙여넣기
  • fill: 채우기 핸들, Ctrl+D, Ctrl+R, Ctrl+Enter
  • cut: Ctrl+X의 clear-half
  • move: Shift+드래그 이동에서 소스 또는 타깃이 보호된 셀을 포함
  • rowDelete / columnDelete: 보호 셀을 포함하는 행 / 열 삭제

컨슈머가 onCellChange로 다시 보내는 수식 재계산은 차단되지 않습니다: 보호는 사용자 의도만 가로챕니다. 다중 셀 제스처(paste / fill)는 비보호 부분에만 적용되고, onProtectedAction 콜백이 스킵된 셀을 보고합니다.

API

  • cellProtection(rowIndex, columnIndex) => boolean

    사용자 mutation을 거부할 셀은 true를 반환.

  • onProtectedAction(info: ProtectedActionInfo) => void

    { action, coords }: 최소 1개 셀이 스킵된 경우 발사.

보호된 셀은 미세한 줄무늬 배경으로 표시됩니다. 색상은 --xl-react-readonly-stripe로 오버라이드.

헤더 행 보호 + 토스트 메시지 tsx
<XlReact
  columns={columns}
  rows={rows}
  cellProtection={(rowIndex) => rowIndex === 0}
  onProtectedAction={(info) => {
    toast(`이 셀은 보호되어 있습니다 (${info.action} · ${info.coords.length}개)`);
  }}
/>
ProtectedAction 타입 typescript
type CellProtectionPredicate = (rowIndex: number, columnIndex: number) => boolean;
type ProtectedAction =
  | 'edit' | 'clear' | 'paste' | 'fill' | 'cut' | 'move'
  | 'replace' | 'rowDelete' | 'columnDelete';
interface ProtectedActionInfo {
  action: ProtectedAction;
  coords: ReadonlyArray<CellCoord>;
}

선택 영역 집계

FeatureMedium 좌하단 상태 표시줄의 실시간 SUM / AVG / COUNT.

동작

  • 선택이 2개 이상 셀을 덮을 때만 렌더링.
  • SUM / AVG는 비숫자 셀을 무시.
  • AVG 분모는 빈 셀 제외.
  • COUNT는 Excel COUNTA와 동일 (비어있지 않은 셀).
  • MIN / MAX는 미리 계산되어 노출: 커스텀 readout에 활용.

API

  • showSelectionStatsboolean
  • selectionStatsLocalestring | string[]

    BCP-47 로케일 (예: 'ko-KR').

computeAggregates로 커스텀 상태바 만들기 tsx
import { useMemo, useState } from 'react';
import {
  XlReact,
  computeAggregates,
  type SelectionSnapshot,
} from 'hyper-xl';

const [selection, setSelection] = useState<SelectionSnapshot | null>(null);

const aggregates = useMemo(
  () => computeAggregates(selection?.ranges ?? [], rows, columns),
  [selection, rows, columns],
);

<XlReact
  columns={columns}
  rows={rows}
  showSelectionStats={false}
  onSelectionChange={setSelection}
/>
<footer>
  SUM={aggregates.sum?.toLocaleString('ko-KR')} ·
  AVG={aggregates.avg?.toFixed(2)} ·
  COUNT={aggregates.count}
</footer>

데이터 검증 (드롭다운)

FeatureHigh 명명된 목록에 컬럼을 연결해 셀에서 드롭다운으로 값을 선택.

특성

  • validationLists로 명명된 목록을 정의하고 Column.validation.listKey로 컬럼에 연결합니다. 목록 데이터는 컨슈머 소유이며 그리드는 읽기만 합니다.
  • 항목은 문자열('활성') 또는 객체({ value, label }) 모두 지원: 객체는 드롭다운에 label을 보여주지만 셀에는 value(예: 코드)를 저장합니다.
  • 활성 목록 셀에 ▾ 캐럿이 표시되고, 항목이 8개를 넘으면 검색창이 자동으로 나타납니다 (label · value 부분 일치, 대소문자 무시).
  • 값을 선택해도 셀 선택은 유지됩니다(엑셀과 동일: 자동 이동 없음). commit은 onCellChange로 흐르며 값이 바뀐 경우에만 실행 취소 스택에 기록됩니다.
  • validation.stricttrue면 목록에 없는 비어있지 않은 값은 invalid 스타일로 표시됩니다. 빈 값은 required의 몫입니다.
  • 보호된 셀(Column.readOnly / cellProtection)에서는 캐럿이 숨겨지고, 열기를 시도하면 onProtectedAction이 발사됩니다.

드롭다운 열기

  • 활성 목록 셀의 ▾ 캐럿 클릭
  • Alt +
  • F2 또는 더블클릭: 목록 컬럼이면 텍스트 에디터 대신 드롭다운이 열립니다.
  • 열린 뒤: / 이동(끝에서 멈춤) · Enter 선택 · Esc 또는 바깥 클릭으로 닫기.

API

  • validationListsRecord<string, ValidationList>

    listKey → 목록. 목록은 문자열 또는 { value, label } 항목의 배열.

  • Column.validation{ listKey: string; strict?: boolean }

    컬럼을 목록에 연결. strict는 목록 외 값을 invalid로 표시.

상태 / 출발항 컬럼을 드롭다운으로 tsx
const columns = [
  { id: 'name', accessor: (r) => r.data.name },
  {
    id: 'status',
    accessor: (r) => r.data.status,
    validation: { listKey: 'statusList', strict: true },
  },
  {
    id: 'port',
    accessor: (r) => r.data.port,
    validation: { listKey: 'portList' }, // 객체 목록
  },
];

<XlReact
  columns={columns}
  rows={rows}
  onCellChange={applyChange}
  validationLists={{
    statusList: ['활성', '검토', '보류', '단종'],
    // 드롭다운엔 label, 셀엔 value(코드)를 저장
    portList: [
      { value: 'KRPUS', label: '부산항 (KRPUS)' },
      { value: 'JPTYO', label: '도쿄항 (JPTYO)' },
    ],
  }}
/>
목록 헬퍼로 검증 / 필터 직접 처리 typescript
import {
  resolveColumnList,
  filterOptions,
  isValueInList,
} from 'hyper-xl';

// 컬럼 + validationLists → ResolvedValidationOption[] | null
// (목록 셀이 아니면 null)
const options = resolveColumnList(column, validationLists);

// 검색창과 동일한 부분 일치 필터 (label · value, 대소문자 무시)
const matches = filterOptions(options ?? [], 'kr');

// strict 검증과 동일 규칙 (빈 값은 항상 통과)
const ok = isValueInList('KRPUS', options ?? []);

수식 엔진 (Formula Engine: 사칙연산 + 셀 참조)

FeatureMedium =A1+B1·=A1*B1/2 등 셀 참조와 사칙연산을 평가하고, 의존 셀이 바뀌면 자동 재계산합니다.

특성

  • 순수 함수 토크나이저 → 파서 → 평가기 + FormulaSheet 헬퍼. 그리드는 표시 전용이며, 컨슈머가 onCellChange로 시트에 raw 입력을 전달하면 시트가 의존 그래프를 따라 결과를 갱신합니다.
  • 지원 문법: 정수 / 소수, 단항 부호(-A1), 사칙연산(+ − * /), 괄호, A1 형식의 상대/절대 셀 참조(다중 문자 열 포함: AA1, AB10, $A$1, $A1, A$1).
  • 자동 채우기(드래그·Ctrl+D·Ctrl+R·Ctrl+Enter)는 셀 참조의 상대 부분을 이동량만큼 자동으로 이동시키고, $가 붙은 절대 부분은 그대로 유지합니다. Excel과 동일한 동작입니다. 라이브러리는 shiftFormulaRefs를 통해 동일한 변환을 외부에서도 호출할 수 있게 노출합니다.
  • 오류 코드: #DIV/0!(0으로 나눔), #CIRCULAR!(순환 참조), #REF!(잘못된 참조), #VALUE!(비숫자 피연산자), #NAME?(미지원 식별자 또는 함수: P2에서 확장 예정).
  • 순환 감지는 Kahn 토폴로지 정렬로 처리됩니다. 사이클에 속한 모든 셀이 #CIRCULAR!로 표시되며, 사이클을 끊으면 정상 값으로 회복됩니다. 재귀가 없어 수천 단계의 의존 체인도 호출 스택을 폭주시키지 않습니다.
  • 빈 셀은 산술에서 0으로, 숫자 문자열은 자동 변환됩니다("5" → 5). 라벨 문자열은 #VALUE!로 전파됩니다.
  • 범위(A1:B5)와 시트 함수(SUM 등)는 본 단계의 범위 밖이며, 입력 시 #NAME?로 거부됩니다.

API

  • FormulaSheetclass

    setRaw(coord, raw)로 셀에 raw 값을 저장하고, getRaw(coord) / getDisplay(coord)로 raw·표시 값을 읽습니다. setRaw는 변경 영향이 미친 좌표 배열을 반환합니다.

  • parseFormula(input: string) => FormulaAst | FormulaParseError

    선두 = 유무와 무관하게 동작. $A$1 같은 절대 참조는 지원합니다. 함수 호출, 범위 등 미지원 입력은 FormulaParseError('#NAME?' / '#REF!')로 거부합니다.

  • evaluateAst(ast, resolveRef) => number | FormulaErrorCode

    의존 셀 값을 resolveRef(coord)로 가져와 평가. 오류 코드는 전이적으로 전파됩니다.

  • extractRefs(ast) => { row, col }[]

    AST가 참조하는 셀 좌표를 나열: 의존 그래프 구축용.

  • a1ToCoord / coordToA1A1 ↔ {row,col} 변환

    'B3'{ row: 2, col: 1 }. 다중 문자 열과 $ 절대 마커를 모두 허용합니다(a1ToCoord는 마커를 무시하고 좌표만 반환). coordToA1(row, col, { rowAbsolute, colAbsolute })$를 붙여 출력할 수 있습니다.

  • parseA1(ref: string) => ParsedCellRef | null

    A1 참조를 { row, col, rowAbsolute, colAbsolute }로 분해: $ 마커를 보존해야 할 때 사용합니다.

  • shiftFormulaRefs(formula, deltaRow, deltaCol) => string

    수식 문자열의 상대 셀 참조를 (deltaRow, deltaCol)만큼 이동하고 $가 붙은 절대 참조는 그대로 둡니다. 자동 채우기 시 라이브러리가 내부적으로 사용하며, 외부에서도 동일한 변환을 호출할 수 있습니다.

  • isFormulaError(value) => value is FormulaErrorCode

    5개의 Excel 오류 리터럴(FORMULA_ERROR_CODES) 가드.

FormulaSheetonCellChange에 연결 tsx
import { useMemo, useRef, useState } from 'react';
import { FormulaSheet, XlReact } from 'hyper-xl';

// 컨슈머 소유. 시트는 raw + 평가 결과 + 의존 그래프를 보관합니다.
const sheetRef = useRef<FormulaSheet | null>(null);
if (!sheetRef.current) {
  const s = new FormulaSheet();
  s.setRaw({ row: 1, col: 1 }, 12);          // B2
  s.setRaw({ row: 1, col: 2 }, 1500);        // C2
  s.setRaw({ row: 1, col: 3 }, '=B2*C2');    // D2 → 18000
  sheetRef.current = s;
}

const [rows, setRows] = useState(buildRowsFromSheet(sheetRef.current));

const columns = useMemo(() => (
  // 셀 표시는 시트의 계산값으로, 편집은 raw 텍스트로: 더블클릭하면 =B2*C2 가 보입니다.
  cols.map((c, i) => ({
    ...c,
    cellRenderer: ({ rowIndex, columnIndex }) =>
      String(sheetRef.current?.getDisplay({ row: rowIndex, col: columnIndex }) ?? ''),
  }))
), []);

return (
  <XlReact
    columns={columns}
    rows={rows}
    onCellChange={(change) => {
      sheetRef.current?.setRaw(change.coord, change.nextValue as string | number | null);
      setRows(buildRowsFromSheet(sheetRef.current!));
    }}
  />
);
순수 함수만 단독 사용: UI 없이 평가기를 호출 typescript
import { parseFormula, evaluateAst } from 'hyper-xl';

const ast = parseFormula('=A1+B1*2');
if ('type' in ast && ast.type === 'error') {
  // ast.code === '#NAME?' 등
} else {
  const values = new Map([['0:0', 5], ['0:1', 7]]);
  const result = evaluateAst(ast, ({ row, col }) =>
    values.get(`${row}:${col}`) ?? null,
  );
  // result === 19
}
$ 절대 참조 + 자동 채우기 시 상대 참조 이동 typescript
import { shiftFormulaRefs } from 'hyper-xl';

// =B3*C3*(1+$B$2) 를 한 칸 아래로 채우면 절대 참조는 고정,
// 상대 참조는 행 +1 만큼 이동합니다.
shiftFormulaRefs('=B3*C3*(1+$B$2)', 1, 0);
// → '=B4*C4*(1+$B$2)'

shiftFormulaRefs('=$A$1+B1', 5, 2);
// → '=$A$1+D6'

// 자동 채우기 엔진(projectFill / fillDownWithinSelection 등)이 내부적으로
// 같은 함수를 사용합니다. 컨슈머가 별도로 호출할 필요는 없지만,
// 임의의 변환이 필요할 때 활용할 수 있습니다.

수식 입력줄 (Formula Bar)

FeatureMedium 엑셀의 수식 입력줄과 동일한 외형 · 동작의 독립 컴포넌트. 이름 상자 · fx · 입력칸 · ✓/✗ 버튼을 한 줄로 묶어 그리드 바로 위에 도킹할 수 있습니다.

특성

  • 컨트롤드 컴포넌트입니다. activeRef(A1 문자열) · value(raw 수식 또는 리터럴)을 외부에서 주입하고, onCommit(next)으로 사용자가 확정한 값을 받아 시트 모델(FormulaSheet.setRaw 등)에 반영합니다.
  • 내부 draft 문자열은 컴포넌트 안에 보관되므로 키 입력마다 부모가 리렌더되지 않습니다. value prop이 바뀌면(외부 paste·undo 등) 편집 중이 아닐 때에만 draft를 덮어씁니다.
  • Enter / ✓ / 포커스 이탈 → commit, Esc / ✗ → cancel. 엑셀과 동일하게 포커스 이탈도 confirm으로 처리합니다.
  • 한·중·일 IME 조합 가드: compositionstartcompositionend 사이의 Enter는 commit으로 처리되지 않으며, nativeEvent.isComposing · keyCode === 229를 함께 검사합니다.
  • 이름 상자(name box)는 onNavigate를 연결할 때만 편집 가능해지고, 유효한 A1 참조('C5', '$B$2' 등)에 한해 (row, col, ref)를 전달합니다. 잘못된 입력은 activeRef로 스냅백.
  • readOnly는 수식 입력칸과 이름 상자를 모두 잠가 편집 진입과 commit을 막되 컴포넌트는 그대로 렌더(포커스된 read-only input의 Enter도 commit하지 않음), disabled는 그레이드아웃 + 상호작용 차단.
  • v1 한계: 그리드 내 CellEditor의 in-progress draft와 양방향 실시간 동기화는 지원하지 않습니다. 셀 편집을 commit한 뒤에만 바의 값이 갱신됩니다. (CellEditor draft store 공유는 후속 작업.)

Props

  • activeRefstring | null

    활성 셀의 A1 참조. coordToA1(active.row, active.col)SelectionSnapshot에서 파생합니다. null이면 이름 상자가 비고, 입력칸도 빈 상태로 렌더.

  • valuestring | number | null

    활성 셀의 raw 입력값: 수식이면 '=A1*B1', 리터럴이면 숫자 또는 문자열. FormulaSheet.getRaw(active) 또는 행 데이터 접근자에서 파생합니다.

  • onCommit(value: string | null) => void

    Enter · ✓ · 포커스 이탈 시점에 호출. 빈 문자열은 null로 전달되므로 FormulaSheet.setRaw에 그대로 흘려보낼 수 있습니다.

  • onCancel() => void

    Esc · ✗ 시점. draft는 value로 복원됩니다.

  • onNavigate({ row, col, ref }) => void

    이름 상자에 A1 참조를 입력하고 Enter했을 때 호출됩니다. 미설정 시 이름 상자는 표시 전용(readOnly).

  • readOnly · disabledboolean

    readOnly는 commit 차단, disabled는 시각적 비활성 + 상호작용 차단. 시트 보호(protected) 상태와 연동하기 좋습니다.

  • labelsFormulaBarLabels

    nameBox · formulaInput · commit · cancel · fxIcon · emptyPlaceholder. 기본값은 한국어('이름 상자' · '수식 입력줄' · '입력' · '취소' · 'fx').

SelectionSnapshot + FormulaSheet에 연결 tsx
import { useRef, useState } from 'react';
import {
  FormulaBar,
  FormulaSheet,
  XlReact,
  coordToA1,
  type SelectionSnapshot,
} from 'hyper-xl';

// 컨슈머가 보관하는 시트 인스턴스 (참조 안정성 위해 ref).
const sheetRef = useRef<FormulaSheet | null>(null);
if (!sheetRef.current) sheetRef.current = new FormulaSheet();

const [selection, setSelection] = useState<SelectionSnapshot | null>(null);

const active = selection?.active ?? null;
const activeRef = active ? coordToA1(active.row, active.col) : null;
const value = active ? sheetRef.current.getRaw(active) : null;

return (
  <>
    <FormulaBar
      activeRef={activeRef}
      value={value}
      onCommit={(next) => {
        if (!active) return;
        sheetRef.current!.setRaw(active, next);
        // 시트의 표시값이 갱신되었으므로 그리드 행 데이터도 재구성합니다.
        setRows(buildRowsFromSheet(sheetRef.current!));
      }}
    />
    <XlReact
      columns={columns}
      rows={rows}
      onSelectionChange={setSelection}
      onCellChange={(c) => {
        sheetRef.current!.setRaw(c.coord, c.nextValue as string | number | null);
        setRows(buildRowsFromSheet(sheetRef.current!));
      }}
    />
  </>
);
이름 상자에 네비게이션 연결 tsx
// 컨슈머가 활성 셀을 제어할 수 있는 경우(예: 자체 selection 모델),
// onNavigate를 받아 자신의 reducer로 점프시킵니다.
<FormulaBar
  activeRef={activeRef}
  value={value}
  onCommit={handleCommit}
  onNavigate={({ row, col }) => selectionDispatch({ type: 'moveTo', row, col })}
/>

가져오기 / 내보내기 (Excel · CSV · TSV)

FeatureHigh 그리드 스냅샷을 .xlsx 또는 RFC 4180 CSV / TSV로 직렬화하고, 같은 형식을 다시 그리드로 흡수합니다. ExcelJS는 await import('exceljs')로 지연 로드되므로 메인 번들은 의존성 없이 유지됩니다.

특성

  • 스냅샷 기반 API: 헬퍼는 컨슈머가 조립한 GridSnapshot(rows·columns· 선택적 cellFormats·merges)만 받습니다. 그리드 컴포넌트 내부 상태를 들여다보지 않으므로 임의의 시점에 임의의 형태로 내보낼 수 있습니다.
  • 지연 로딩: exceljspeerDependencies(optional)에 위치하고 번들러 설정에서도 외부화됩니다. CSV / TSV만 사용하면 ExcelJS는 아예 설치할 필요가 없습니다.
  • 대칭 라운드트립: 내보내기 기본값은 includeHeader: false(컨슈머의 row 0이 이미 캡션 행을 담는 일반 패턴), 가져오기 기본값은 dropHeaderRow: false로 헤더 행도 데이터에 포함시켜 시각적으로 동일한 그리드가 복원됩니다. headerRow는 컬럼 id 추출에만 사용됩니다.
  • 서식 보존: 글꼴(굵게·기울임·밑줄·색), 채우기(배경색), 정렬(수평·수직), 테두리(4면 + 대각선), 숫자 형식, 컬럼 너비, 병합 셀이 양방향 매핑됩니다 (cellFormatToExcelStyle / cellFormatFromExcelStyle). 헤더는 자동으로 굵게 + 어두운 배경 + 1행 freeze가 적용됩니다(headerStyle / freezeHeader로 끌 수 있음).
  • 수식 라운드트립: 셀 값이 =…로 시작하는 문자열이면 export 시 실제 Excel formula 셀로 기록되고 (Excel이 파일을 열 때 재계산), import 시 같은 문자열로 그대로 복원됩니다. FormulaSheet와 결합하면 표시값까지 라이브로 평가됩니다.
  • 다중 시트 export: exportMultiSheetXlsx는 여러 스냅샷을 한 워크북에 직렬화합니다. sheetName을 생략하면 Sheet1·Sheet2…가 자동 부여되고, 중복은 _2·_3 suffix로 해소됩니다.
  • 파일 크기 가드: importFromXlsxmaxFileSizeBytes(기본 DEFAULT_MAX_IMPORT_SIZE_BYTES = 50 MB)를 초과하는 입력을 파싱 전에 ImportFileTooLargeError로 거부합니다. 0으로 설정하면 해제.
  • 검증 보고: ImportResult.warningsempty-sheet·sheet-not-found· header-missing·duplicate-header· cell-error(#REF! / #VALUE! 등)· merge-skipped 같은 종류별 메시지가 누적됩니다. <ImportDialog>는 종류별로 묶어 카운트와 샘플 메시지를 표시합니다.
  • 컬럼 매핑 UI: 다이얼로그의 "컬럼 매핑" 테이블에서 원본 헤더별로 타겟 Column.id를 편집할 수 있습니다. 결과 객체는 확인 시점에 새 id로 리네이밍되어 컨슈머로 전달됩니다. 프로그래밍 방식으로는 ImportOptions.columnMapping (또는 CsvOptions.columnMapping) 옵션에 직접 매핑을 전달할 수 있습니다.
  • CSV / TSV: RFC 4180 따옴표 / 이스케이프 + BOM 스트립 + 구분자 자동 감지(, · \t · ; · |) + 선행 0 보존("012"는 숫자로 변환되지 않음). ExcelJS 없이 동작합니다.

API

  • exportToXlsx (snapshot: GridSnapshot, options?: ExportOptions) => Promise<Blob>

    단일 시트 .xlsx Blob 반환. 다운로드는 triggerBlobDownload로 트리거합니다.

  • exportMultiSheetXlsx (entries: MultiSheetEntry[]) => Promise<Blob>

    여러 스냅샷을 시트 단위로 묶어 한 워크북에 작성. 각 엔트리는 snapshotExportOptions(시트별 sheetName·range·columnIds 등)를 함께 받습니다.

  • importFromXlsx (source: File | Blob | ArrayBuffer | Uint8Array, options?: ImportOptions) => Promise<ImportResult>

    ExcelJS 워크북을 ImportResult로 변환. 크기가 options.maxFileSizeBytes(기본 50 MB)를 넘으면 ImportFileTooLargeError를 던집니다.

  • exportToCsv / importFromCsv (snapshot, CsvOptions?) => string · (text, CsvOptions?) => ImportResult

    ExcelJS 없이 동작하는 RFC 4180 직렬화기. delimiter · newline · bom · includeHeader · columnMapping을 지원합니다.

  • buildWorkbookFromSnapshot / parseWorkbookToSnapshot 'hyper-xl/exceljs' 서브패스

    ExcelJS Workbook 인스턴스를 직접 다루는 저수준 경로: 지연 로딩을 직접 관리하거나, 여러 라이브러리와 결합해 워크북을 조립할 때 사용합니다. 루트 엔트리의 타입 선언에서 ExcelJS 타입이 새어 나오지 않도록 별도 서브패스로 분리되어 있으므로 import { buildWorkbookFromSnapshot } from 'hyper-xl/exceljs' 형태로 import 하세요.

  • cellFormatToExcelStyle / cellFormatFromExcelStyle 'hyper-xl/exceljs' 서브패스 (CellFormat ↔ ExcelJS.Style)

    양방향 변환. 글꼴·채우기·정렬·테두리·숫자 형식을 커버합니다. 위의 두 헬퍼와 같은 서브패스에 있으며 ExcelJS 설치를 요구합니다.

  • defaultExportFilename (prefix?, now?) => string

    <prefix>_YYYYMMDD_HHmm.xlsx(로컬 시간): 기본 prefix는 xl-react.

  • triggerBlobDownload (blob: Blob, filename: string) => void

    브라우저에서만 동작하는 다운로드 트리거. 비브라우저 환경에서는 no-op입니다.

  • ExportButton React 컴포넌트

    rows·columns·선택적 cellFormats·merges를 받아 클릭 시 .xlsx를 다운로드합니다. busy 상태를 자동 관리하고 onError로 실패를 알립니다.

  • ImportDialog React 컴포넌트

    파일 선택 / 드래그·드롭 → 시트·헤더 행 선택 + 컬럼 매핑 편집 + 앞 10행 미리보기 + 검증 보고 그룹 → 확인 시 onImport(result, file) 호출. 라벨은 DEFAULT_IMPORT_DIALOG_LABELS를 통째로 또는 키별로 덮어쓸 수 있습니다.

ExportButton + ImportDialog: 데모와 동일한 패턴 tsx
import { useState } from 'react';
import {
  ExportButton,
  ImportDialog,
  defaultExportFilename,
  type ImportResult,
} from 'hyper-xl';

function Page() {
  const [columns, setColumns] = useState(initialColumns);
  const [rows, setRows] = useState(initialRows);
  const [cellFormats, setCellFormats] = useState({});
  const [open, setOpen] = useState(false);

  const onImport = (result: ImportResult /*, file */) => {
    // 새 데이터로 그리드를 통째로 교체. 매핑 / 워닝은 result 안에.
    setColumns(result.columns);
    setRows(result.rows);
    setCellFormats(result.cellFormats);
  };

  return (
    <>
      <ExportButton
        rows={rows}
        columns={columns}
        cellFormats={cellFormats}
        filename={() => defaultExportFilename('my-report')}
      />
      <button onClick={() => setOpen(true)}>가져오기…</button>
      <ImportDialog open={open} onClose={() => setOpen(false)} onImport={onImport} />
    </>
  );
}
프로그래밍 방식: 함수만 사용 typescript
import {
  exportToXlsx,
  importFromXlsx,
  exportToCsv,
  importFromCsv,
  triggerBlobDownload,
  defaultExportFilename,
} from 'hyper-xl';

// xlsx: 메모리에 Blob 생성 후 브라우저에 다운로드 트리거
const blob = await exportToXlsx(
  { rows, columns, cellFormats, merges },
  { sheetName: '주문', includeHeader: false },
);
triggerBlobDownload(blob, defaultExportFilename('order'));

// xlsx: File을 받아 파싱 (50MB 가드 자동 적용)
const result = await importFromXlsx(file, {
  sheetName: '주문',
  headerRow: 1,
  columnMapping: { '이름': 'name', '수량': 'qty' },
});

// CSV: ExcelJS 없이 순수 함수
// exportToCsv는 기본적으로 헤더를 쓰지 않고(includeHeader 기본 false),
// importFromCsv는 기본적으로 첫 행을 헤더로 봅니다(includeHeader 기본 true).
// 라운드트립을 맞추려면 양쪽 includeHeader를 명시하세요.
const csv = exportToCsv({ rows, columns }, { delimiter: ',', bom: true, includeHeader: true });
const csvResult = importFromCsv(csv, { includeHeader: true });
다중 시트 + 수식 라운드트립 typescript
import { exportMultiSheetXlsx } from 'hyper-xl';

// 행마다 `=B{r}*C{r}` 같은 수식 문자열을 넣으면 export 시 실제 Excel
// formula 셀로 기록됩니다. 가져오기로 다시 읽어도 `=…` 문자열이 그대로
// 복원되므로 FormulaSheet와 결합해 라이브 평가가 가능합니다.
const order = [
  { id: 0, data: { name: '이름', qty: '수량', price: '단가', total: '합계' } },
  { id: 1, data: { name: 'A', qty: 12, price: 1500, total: '=B2*C2' } },
  { id: 2, data: { name: 'B', qty: 8,  price: 2300, total: '=B3*C3' } },
];

const blob = await exportMultiSheetXlsx([
  { snapshot: { rows: order, columns }, sheetName: '주문' },
  { snapshot: { rows: inventory, columns }, sheetName: '재고' },
]);

피벗 테이블

FeatureHigh PRD §10A: Row / Column / Value / Filter 네 영역의 드래그&드롭 빌더, 9가지 집계, 값 표시 형식 총 13가지(P1 6가지: 일반 / 총합계 % / 열 % / 행 % / 부모 행 % / 부모 열 % + §10A.4 P2 계산 모드 7가지: 차이 / % 차이 / 누계 / % 누계 / 오름차순·내림차순 순위 / 인덱스), 행·열 칩에서의 그룹화(날짜: 연/분기/월/주/일 · 숫자: 임의 구간), 총합계 행·열, 단일 피벗 새로 고침, 피벗 차트(막대·선·원형)까지 포함된 MVP. 결과 표는 라이브러리 표준 <XlReact> 그리드로 렌더되어 셀 선택 · 키보드 네비 · 클립보드 · 줌 · Freeze 등 그리드의 모든 UX를 그대로 사용합니다.

구성 요소

  • PivotBuilder: 4영역 드래그&드롭 UI + 결과 그리드를 한 컴포넌트로 묶은 완성형. 데모 페이지의 "피벗 테이블" 탭이 그대로 이걸 씁니다.
  • buildPivot(rows, config): 순수 함수. PivotResult를 반환합니다. 자체 UI를 만들거나 결과만 다른 그리드/차트에 흘려보낼 때 사용.
  • pivotResultToGrid(result, labels?): PivotResult<XlReact>에 바로 넘길 수 있는 { columns, rows, merges, freezeRowCount, freezeColCount } 스냅샷으로 변환합니다. 헤더의 colSpan / rowSpan 은 그리드의 merges로 매핑되며, 값 셀의 숫자는 Intl.NumberFormat(최대 소수 4자리)으로 자동 포맷됩니다.
  • pivotAggregate(kind, values): 9가지 집계의 단일 디스패처(sum · count · countA · average · max · min · stdDev · variance · product). 비숫자(NaN, ±Infinity, 객체, boolean)는 버킷을 오염시키지 않고 조용히 무시되며, variance/stdDev는 Welford 알고리즘으로 표본 분산(n − 1) 형태로 계산합니다.

PivotConfig 모양

  • rowsPivotField[]

    행 축 그룹화 키. 순서가 곧 트리의 깊이: 첫 번째가 최상위 그룹.

  • columnsPivotField[]

    열 축 그룹화 키. rows와 대칭.

  • valuesPivotValueField[]

    집계 대상. 각 항목은 { key, label?, agg }이며 같은 key를 여러 번 추가해 서로 다른 집계(예: 합계 · 평균)를 동시에 산출할 수 있습니다.

  • filtersPivotFilterField[]

    그룹핑 이전에 적용되는 화이트리스트 필터. selectedValues를 비우면 해당 필드는 무필터로 통과.

  • showGrandTotalRowboolean (기본 true)

    rows.length === 0이면 자동으로 비활성: 행이 1개뿐인 축에서 총합계 행을 또 그리면 본문이 그대로 복제돼 보이기 때문입니다. columns도 동일.

  • showGrandTotalColumnboolean (기본 true)

총합계는 셀의 합이 아님

총합계 행 / 열은 본문 셀의 합으로 계산되지 않습니다. 항상 원본 행 전체에서 aggregate를 다시 돌립니다. average· min·max·variance·stdDev· product는 분배 법칙이 성립하지 않으므로, 셀을 합치면 잘못된 값이 나옵니다. 셀이 비어 있는 자리(matrix[r][c] === null)에서도 총합계가 올바르게 계산되는 이유가 이것입니다.

헤들리스 사용: buildPivot으로 결과만 받기 tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const shipments = [
  { id: 1, data: { region: '부산', product: '컨테이너', qty: 10 } },
  { id: 2, data: { region: '부산', product: '벌크',     qty:  5 } },
  { id: 3, data: { region: '인천', product: '컨테이너', qty: 30 } },
  { id: 4, data: { region: '인천', product: '벌크',     qty:  7 } },
];

const config: PivotConfig = {
  rows:    [{ key: 'region' }],
  columns: [{ key: 'product' }],
  values:  [{ key: 'qty', agg: 'sum' }],
  filters: [],
};

const result = buildPivot(shipments, config);

// result.matrix         → [[10, 5], [30, 7]]
// result.rowPaths       → [['부산'], ['인천']]
// result.columnPaths    → [['컨테이너'], ['벌크']]
// result.grandTotalRow  → [40, 12]    // 컨테이너 / 벌크 총합
// result.grandTotalColumn → [15, 37]  // 부산 / 인천 총합
// result.grandTotal     → [52]        // 전체 총합: 원본에서 다시 계산
PivotBuilder: 드래그&드롭 UI를 그대로 사용 tsx
import { PivotBuilder, type PivotAvailableField, type Row } from 'hyper-xl';

const rows: Row[] = /* ... 원본 행들 ... */;

const fields: PivotAvailableField[] = [
  { key: 'region',  label: '양하항',  type: 'text' },
  { key: 'product', label: '품종',    type: 'text' },
  { key: 'team',    label: '부서',    type: 'text' },
  { key: 'qty',     label: '수량',    type: 'number' },
  { key: 'weight',  label: '선적량',  type: 'number' },
];

export function PivotDemo() {
  return (
    <PivotBuilder
      rows={rows}
      availableFields={fields}
      // 선택 사항: onConfigChange로 외부 저장소에 레이아웃을 영속화할 수 있습니다.
      onConfigChange={(config) => console.log(config)}
    />
  );
}
pivotResultToGrid로 직접 <XlReact> 렌더 tsx

자체 사이드바를 만들고 결과만 표준 그리드로 띄우고 싶을 때. PivotBuilder가 내부에서 사용하는 어댑터를 그대로 쓰면 됩니다.

import { XlReact, buildPivot, pivotResultToGrid } from 'hyper-xl';

const result   = buildPivot(rows, config);
const snapshot = pivotResultToGrid(result, {
  grandTotalRow:    '총합계',
  grandTotalColumn: '총합계',
});

return (
  <XlReact
    columns={snapshot.columns}
    rows={snapshot.rows}
    merges={snapshot.merges}
    freezeRowCount={snapshot.freezeRowCount}
    freezeColCount={snapshot.freezeColCount}
    readOnly
  />
);

값 표시 형식 (§10A.4 P1)

각 Value 칩에는 집계 함수 옆에 표시 형식 드롭다운이 붙어 있어 동일한 집계를 6가지 모드로 다시 표현할 수 있습니다. 모든 % 모드는 분자를 모드별 분모로 나누고, 분모가 null 또는 0일 때는 셀이 비게(null) 됩니다. 그리드는 % 모드 셀을 Intl.NumberFormat({ style: 'percent', maximumFractionDigits: 2 })로 그립니다.

  • normal기본값

    원본 집계 그대로.

  • percentOfTotal총합계 %

    셀 / 전체 총합. 그리드의 총합계 셀은 100% 가 됩니다.

  • percentOfColumn열 %

    셀 / 해당 열 합계. 열별 총합계 행은 100%.

  • percentOfRow행 %

    셀 / 해당 행 합계. 행별 총합계 열은 100%.

  • percentOfParentRow부모 행 %

    다중 계층 행 피벗에서 셀 / 상위 그룹 합계. 최상위 행에서는 부모가 전체 데이터셋과 같아 결과적으로 열별 합계 대비 비율과 동일합니다.

  • percentOfParentColumn부모 열 %

    열 축의 대칭. 다중 계층 열에서 의미를 가집니다.

엔진은 원본 집계를 matrix / grandTotal*에 그대로 유지하면서, 표시용 변환을 displayMatrix / displayGrandTotal*에 별도로 채워 줍니다. 헤들리스 사용자는 원본을, 그리드는 display*를 읽어 두 관점이 같은 PivotResult에서 동시에 공존합니다.

값 표시 형식 사용 예시 tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows:    [{ key: 'region' }],
  columns: [{ key: 'product' }],
  values: [
    // 같은 필드를 raw + % 두 가지 표현으로 동시에 띄울 수 있습니다.
    { key: 'qty', agg: 'sum', valueDisplay: 'normal' },
    { key: 'qty', agg: 'sum', valueDisplay: 'percentOfTotal', label: 'qty 비중' },
  ],
  filters: [],
};

const result = buildPivot(rows, config);
// result.displayMatrix[0]  → [30, 30/87, 5, 5/87]   // v=0 raw, v=1 비율
// result.displayGrandTotal → [87, 1]                // % 모드의 총합은 100%

그룹화: 날짜 단위 · 숫자 구간 (§10A.5 P1)

행 / 열 영역의 칩에는 아이콘이 있어 우클릭(또는 클릭)으로 그룹화 팝오버를 엽니다. 팝오버는 필드 타입에 따라 다른 UI를 보여 줍니다:

  • date 필드PivotDateGroupUnit

    연 / 분기 / 월 / 주 / 일 라디오 그룹. 선택한 단위의 달력 경계로 값을 내림(year → 1월 1일, quarter → 분기 시작 월의 1일, week → 해당 ISO 주의 월요일, day → 자정)합니다. 라벨은 2026 · 2026-Q1 · 2026-05 · 2026-W22 · 2026-05-27 형식.

  • number 필드PivotNumberBin

    size 폭 + 선택적 origin 시작점. 값은 [origin + k·size, origin + (k+1)·size) 의 반-열린 구간으로 떨어집니다. 라벨은 0~100, 100~200, …

그룹화가 활성화된 칩에는 라벨 옆에 [분기] 또는 [10] 같은 작은 배지가 표시됩니다. 강제 해석할 수 없는 값(빈 셀, ISO 형식이 아닌 문자열 등)은 그룹화되지 않고 기존 (공백) 또는 원래 값 버킷으로 떨어져: 그룹과 비-그룹 행이 같은 결과에 공존할 수 있습니다.

엔진은 내부 readField 단계에서 값을 버킷으로 정규화(bucketize, 비공개 구현)하므로 모든 후속 경로(헤더 트리 · 부모 버킷 · % 모드 분모)는 변경 없이 동작합니다. 즉, 분기로 묶은 행 × 분기로 묶은 열 위에서도 §10A.4의 percentOfParentRow 같은 표시 형식이 그대로 의미를 가집니다.

그룹화 사용 예시 tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows: [
    // shipDate 필드의 raw 값(Date 또는 ISO 문자열)을 분기 단위로 묶는다.
    { key: 'shipDate', grouping: { dateUnit: 'quarter' } },
  ],
  columns: [{ key: 'region' }],
  values: [{ key: 'qty', agg: 'sum' }],
  filters: [],
};

// result.rowHeaders[0]  → [{ label: '2026-Q1', ... }, { label: '2026-Q2', ... }, ...]
// result.matrix         → 분기 × region 의 qty 합계

// 숫자 필드: 100 단위 bin
const binConfig: PivotConfig = {
  rows: [{ key: 'weight', grouping: { numberBin: { size: 100 } } }],
  columns: [],
  values: [{ key: 'qty', agg: 'count' }],
  filters: [],
};
// result.rowHeaders[0]  → [{ label: '0~100' }, { label: '100~200' }, ...]

정렬 · 필터 (§10A.6 P1)

행 / 열 영역의 칩에는 아이콘이 있어 클릭하면 정렬·필터 팝오버가 열립니다. 팝오버는 세 개의 섹션을 가집니다:

  • 정렬 (per-field)PivotFieldSort

    라벨 오름/내림차순(label-asc / label-desc), 값 기준 오름/내림차순(value-asc / value-desc: 값 필드 인덱스 선택), 그리고 manual 사용자 지정 순서. 사용자 지정 모드는 현재 표시되고 있는 그룹 키들을 ▲ / ▼ 버튼으로 위·아래로 이동시켜 적용합니다. null 집계는 정렬 방향과 무관하게 항상 마지막에 정렬됩니다(Excel 동작).

  • 라벨 필터 (per-field)PivotLabelFilter

    네 가지 변종: include (체크박스로 명시적 허용 키 선택), text (포함 / 접두 / 접미 / 일치 / 불일치), number (gt / lt / gte / lte / equals / notEquals / between), date (before / after / on / notOn / between). 라벨 필터는 그룹 트리의 노드 단위로 적용되어, 자식이 모두 잘려 나간 부모 노드도 함께 제거됩니다.

  • 값 필터 (axis-level)PivotValueFilter

    rowValueFilter / columnValueFilter 로 행 또는 열 축 전체에 적용됩니다. topNtop / bottom 방향과 n, aboveAverage / belowAverage (축 평균 기준), threshold (수치 비교) 네 가지가 있습니다. Top-N은 경계값에서의 동률을 모두 유지합니다(Excel 동작).

정렬·필터가 적용된 칩에는 라벨 옆에 작은 배지(↑ / ↓ / ⇅ / ⚑)가 나타납니다. 라벨 필터와 값 필터에 의해 잘려 나간 행/열 리프는 헤더에서 빠지고, 총합계와 % 표시 모드의 분모도 보이는 데이터만 합산하도록 재계산됩니다. 즉, "평균 이상 + 총합계 %" 조합에서 필터된 행은 분모에서도 제외됩니다.

정렬 · 필터 사용 예시 tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows: [
    {
      key: 'region',
      // 값 합계가 큰 순으로 정렬
      sort: { mode: 'value-desc', valueFieldIndex: 0 },
      // '부산'을 포함하는 라벨만
      labelFilter: { kind: 'text', op: 'contains', pattern: '부산' },
    },
  ],
  columns: [{ key: 'product' }],
  values: [{ key: 'qty', agg: 'sum' }],
  filters: [],
  // 행 축 전체에 평균 이상 필터
  rowValueFilter: { kind: 'aboveAverage', valueFieldIndex: 0 },
};

// rowHeaders[0]      → 평균을 넘긴 지역들이 sum(qty) 내림차순으로 정렬되어 표시
// grandTotal / displayMatrix → 살아남은 행의 데이터로만 재계산

레이아웃 옵션 (§10A.7 P1)

PivotConfig.layout 으로 부분합 위치 · 보고서 형식 · 빈 셀 표시값을 Excel 호환으로 제어합니다. PivotBuilder 패널의 오른쪽 위에 세 개의 드롭다운으로 노출됩니다.

  • subtotalPosition'top' | 'bottom' | 'none'

    행 축에 2단계 이상이 있을 때 각 비-리프 그룹에 대해 부분합 행을 자동 삽입합니다. top 은 그룹 시작 위에, bottom 은 마지막 리프 뒤에 놓이며, none 이면 시각적으로 숨깁니다. 엔진은 PivotResult.rowSubtotals 로 항상 계산해 두기 때문에 차트/내보내기 컨슈머는 위치 설정과 무관하게 부분합을 읽을 수 있습니다.

  • reportLayout'compact' | 'outline' | 'tabular'

    tabular (디폴트): 각 리프 행이 모든 부모 라벨을 반복 표시합니다. outline: 각 행 필드가 자기 열을 가지지만, 리프 행에는 가장 깊은 라벨만 표시하고 부모 라벨은 별도 그룹 헤더 행에 둡니다. compact: 모든 행 레벨을 한 컬럼에 들여쓰기로 표시(컬럼이 좁아 긴 라벨도 들어가도록 행 헤더 너비가 늘어납니다).

  • emptyCellDisplaystring

    값이 null 인 셀(매칭 데이터 없음)을 어떻게 보일지. 빌더의 기본 옵션은 ''(빈 칸), '0', '-'; 코드로 config.layout.emptyCellDisplay 에 임의 문자열을 넣어도 됩니다. 행 헤더 컬럼은 영향을 받지 않고 항상 빈 칸으로 유지됩니다.

§10A.4 의 % 표시 모드와도 자연스럽게 결합됩니다. percentOfParentRow 는 부분합 셀에서 "한 단계 위 부모 그룹의 합계"를 분모로 사용하므로, 중첩 부분합도 100% 가 아닌 부모 대비 비율로 표시됩니다(예: 부산-컨테이너 부분합 = 30 / 35 ≈ 85.7%).

구현 노트: VirtualGrid 의 머지 레이어는 본문(body) 팬에만 그려지기 때문에, 행 헤더 고정 영역에서 머지된 셀의 앵커는 화면에 나타나지 않습니다. pivotResultToGrid 는 부분합/총합계 라벨을 머지로 잡지 않고 해당 컬럼에 그대로 두며, 나머지 행 헤더 컬럼은 비워 둡니다. Excel 의 컴팩트/개요 부분합 행과 동일한 모양입니다.

레이아웃 옵션 사용 예시 tsx
import { buildPivot, pivotResultToGrid, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows: [{ key: 'region' }, { key: 'product' }],
  columns: [{ key: 'month' }],
  values: [{ key: 'qty', agg: 'sum' }],
  filters: [],
  layout: {
    subtotalPosition: 'bottom',
    reportLayout: 'outline',
    emptyCellDisplay: '-',
  },
};

const result   = buildPivot(rows, config);
const snapshot = pivotResultToGrid(result, undefined, config.layout);
// result.rowSubtotals: depth/path/leafFrom-leafTo + displayValues 포함
// snapshot.rows:       outline 모드 → 그룹 헤더 행이 비-리프 그룹마다 자동 삽입

모두 새로 고침 (§10A.8 P1)

한 화면에 여러 PivotBuilder 가 같은 원본 배열을 바라볼 때 한 번의 클릭으로 모두 다시 계산하고 싶을 수 있습니다. 각 빌더의 "새로 고침" 버튼은 자기 자신만 다시 계산하므로, 다중 피벗 환경에서는 PivotRefreshScope 로 트리를 감싸고 usePivotRefreshAll() 훅으로 일괄 트리거를 만듭니다.

  • PivotRefreshScope{ children: ReactNode }

    자식 트리에 단조 증가하는 nonce 와 refreshAll 콜백을 React 컨텍스트로 공급합니다. 같은 스코프 안의 모든 PivotBuilder 가 이 nonce 를 useMemo 의존성으로 함께 읽기 때문에, 한 번의 bump 으로 모두 다시 buildPivot 을 호출합니다. 컨텍스트 바깥의 피벗은 기존처럼 자기 버튼만 반응합니다.

  • usePivotRefreshAll()() => void

    자식 컴포넌트(보통 페이지 상단의 "모두 새로 고침" 버튼)가 호출하는 트리거. PivotRefreshScope 바깥에서 쓰면 안전한 no-op 을 반환하므로, 단일 피벗 경로/스코프 없는 경로에서도 동일한 컴포넌트를 그대로 마운트할 수 있습니다.

구현 노트: 스코프 nonce 는 빌더의 로컬 새로고침 nonce 와 병행 으로 동작합니다. 한쪽이 bump 되면 그쪽만 반응할 수도 있고, 둘 다 동시에 변하면 한 번만 재계산합니다. 데이터 식별이 바뀌지 않는 in-place mutation 까지도 강제 재계산되도록 설계했기 때문에, 소스 배열을 직접 수정한 뒤 호출해도 화면이 갱신됩니다.

두 개의 PivotBuilder 를 한 스코프로 묶기 tsx
import { PivotBuilder, PivotRefreshScope, usePivotRefreshAll } from 'hyper-xl';

function PivotPage({ rows }: { rows: Row[] }) {
  return (
    <PivotRefreshScope>
      <RefreshAllButton />
      <PivotBuilder rows={rows} availableFields={fields} initialConfig={byRegion} />
      <PivotBuilder rows={rows} availableFields={fields} initialConfig={byProduct} />
    </PivotRefreshScope>
  );
}

function RefreshAllButton() {
  const refreshAll = usePivotRefreshAll(); // 스코프 밖이면 no-op
  return <button onClick={refreshAll}>모두 새로 고침</button>;
}

세부 정보 (Show Details) (§10A.9 P1)

값 셀을 더블클릭하면 그 셀이 집계한 원본 행 들을 모달로 보여줍니다. Excel 의 "Show Details" 와 동일한 동작입니다. 셀이 본문(body) · 부분합 · 총합계 행 · 총합계 열 · 좌상단 그랜드토탈 중 어디에 있든 동일한 API 로 원본 행을 역추적할 수 있습니다.

  • onShowDetails?(details, rows) => void

    값 셀 더블클릭을 가로채 본인 UI (별도 시트/탭/대화상자) 로 라우팅합니다. 콜백이 등록되어 있으면 기본 모달은 열리지 않으며, detailsPivotDrillDownDetails, rows 는 원본 배열에서 추출한 ReadonlyArray<Row> 입니다.

  • disableShowDetails?boolean

    내장 모달을 완전히 끄는 스위치. onShowDetails 가 있을 때는 의미가 없고, 더블클릭 자체를 무시하고 싶을 때 둘 다 지정하거나 이것만 켜면 됩니다.

  • PivotDetailsModalPivotDetailsModalProps

    내장 모달을 빌더 외부에서 직접 렌더링하고 싶은 헤드리스 컨슈머용 컴포넌트. buildPivot + pivotResultToGrid + pivotDrillDownAt + resolvePivotDrillDown 만 조합해서 자체 UI 를 만들고 다이얼로그만 라이브러리 것을 재사용하는 경로를 지원합니다.

  • pivotDrillDownAt(snapshot, row, col)PivotDrillDownTarget | null

    그리드 좌표 → 드릴다운 타깃. 헤더 · 라벨 · 코너 셀이면 null. 반환된 타깃은 kind 으로 'body' | 'subtotal' | 'grandTotalRow' | 'grandTotalColumn' | 'grandTotal' 중 하나를 구분합니다.

  • resolvePivotDrillDown(target, result)PivotDrillDownDetails

    타깃 × PivotResult → 원본 행 인덱스 + 행/열 경로 + 값 필드 디스크립터. 순수 함수이며, 필요하면 pivotDrillDownRows(target, result, rows) 로 인덱스 대신 실제 Row 객체까지 한 번에 얻을 수 있습니다.

엔진은 더블클릭 역추적에 필요한 최소 데이터를 PivotResult 에 직접 노출합니다. rowLeafSourceIndices[r] · columnLeafSourceIndices[c] · effectiveSourceIndices. 행 · 열 리프별 원본 인덱스는 각 축의 필터(필터 영역 + 라벨 필터 + 값 필터) 까지 적용된 결과이고, effectiveSourceIndices 는 두 축 필터의 교집합: 즉 "양쪽 모두 보이는" 행들의 인덱스입니다. 차트/내보내기 등 그리드를 거치지 않는 컨슈머도 같은 인덱스로 원본 추적이 가능합니다.

접근성: 모달은 role="dialog" + aria-modal="true" 로 노출하고, 열릴 때 활성 엘리먼트를 기억했다가 닫힐 때 포커스를 복원합니다. Esc · 배경 클릭 · 닫기 버튼 세 가지 모두 닫기 트리거이며, Esc 는 다이얼로그 안에서만 가로채 바깥 그리드의 선택 해제 단축키를 깨뜨리지 않습니다.

본문 셀 더블클릭 → onShowDetails 로 원본 행 추출 tsx
import { PivotBuilder, type PivotDrillDownDetails, type Row } from 'hyper-xl';

function ShipmentPivot({ rows }: { rows: Row[] }) {
  const [drill, setDrill] = useState<{ details: PivotDrillDownDetails; rows: ReadonlyArray<Row> } | null>(null);

  return (
    <>
      <PivotBuilder
        rows={rows}
        availableFields={fields}
        initialConfig={config}
        // 더블클릭한 셀의 (행 경로, 열 경로, 값 필드) + 그 셀이 집계한
        // 원본 행 배열을 함께 받습니다. 별도 시트로 라우팅하거나,
        // PivotDetailsModal 을 직접 마운트해도 됩니다.
        onShowDetails={(details, sourceRows) => setDrill({ details, rows: sourceRows })}
      />
      {drill ? (
        <PivotDetailsModal
          details={drill.details}
          sourceRows={drill.rows}
          availableFields={fields}
          onClose={() => setDrill(null)}
        />
      ) : null}
    </>
  );
}

프리셋 피벗 (§10A.11 P1)

배선 시스템에서 자주 만들 것으로 예상되는 피벗 구성을 즉시 적용할 수 있도록 PivotConfig 프리셋(P1 5종 + §10A.11 P2 3종 = 현재 8종)과 해당 프리셋을 검증할 수 있는 가짜 배선 데이터셋(fake wiring manifest)을 함께 제공합니다. PivotBuilderinitialConfig 또는 buildPivot 에 그대로 주입하면 됩니다.

  • WIRING_PIVOT_PRESETSReadonlyArray<WiringPivotPreset>

    프리셋을 순서대로 노출하는 frozen 배열. 첫 세 항목은 "배선신청 합계" 패밀리(양하항별 / 품종별 / 부서별), 넷째 항목은 행=양하항 × 열=월(shipDate 의 §10A.5 월 그룹화) 기준 선적량 누계, 다섯째 항목은 부서×월 기준 선적량을 percentOfRow (§10A.4) 로 표시하는 "계획 대비 실적 비율 (%)" 입니다. 이후 §10A.11 P2 에서 항차·면허·생산 진척 기반 3종이 추가되어 현재 배열은 총 8종을 담습니다.

  • WIRING_PIVOT_PRESET_BY_IDRecord<WiringPivotPresetId, WiringPivotPreset>

    버튼 id · URL 슬러그 등으로 이미 프리셋이 지목된 상황에서 개별 항목을 바로 찾아 쓸 수 있는 맵 형태입니다. 항목은 WIRING_PIVOT_PRESETS 의 동일 인스턴스를 가리킵니다.

  • buildWiringShipmentDataset(opts?: { count?: number; seed?: number }) => Row[]

    결정론적 Mulberry32 PRNG 로 만든 가짜 배선 매니페스트. 기본 60행 · 시드 0xc0ffee · 2026-01-01 ~ 2026-05-31 범위로 shipDate 를 분산시켜 §10A.5 날짜 그룹화(연/분기/월/주/일) 를 검증하기에 충분합니다. 같은 시드는 항상 같은 행을 돌려주므로 스냅샷 / 스토리북 테스트에서 안전하게 재사용할 수 있습니다.

  • WIRING_PRESET_FIELDSReadonlyArray<PivotAvailableField>

    위 데이터셋과 1:1 로 맞춰 둔 필드 디스크립터 목록(총 11개). 양하항 · 품종 · 부서 · 월 · 선적일 · 수량 · 선적량(t) · 계획(t) · 항차 · 면허 상태 · 생산 진척(%) 순서로 노출됩니다. PivotBuilderavailableFields 에 그대로 넘기면 프리셋과 데이터셋이 손대지 않고 결합됩니다.

PivotBuilderuncontrolled 컴포넌트라 initialConfig 가 마운트 시 1회만 적용됩니다. 사용자가 칩을 눌러 프리셋을 바꿀 때는 React key 를 프리셋 id 로 지정해 빌더를 다시 마운트하는 것이 권장 패턴입니다. 데모의 PivotPresetShowcase 가 이 방식을 사용합니다.

Sub-path 빌드: 프리셋만 필요한 컨슈머는 hyper-xl/pivot/presets 진입점으로 임포트할 수 있습니다. 엔진(buildPivot) · 빌더(PivotBuilder) · 드릴다운(PivotDetailsModal) 코드는 포함되지 않으므로, 번들러가 사용하지 않는 6KB 안팎의 작은 청크만 가져갑니다. 메인 hyper-xl 진입점은 동일한 심볼을 그대로 re-export 하므로 기존 코드는 변경 없이 호환됩니다.

컨슈머가 직접 프리셋 뱅크를 정의하려면 라이브러리가 노출한 제네릭 PivotPreset<Id extends string> 을 사용하세요. 자체 id 리터럴 유니온을 매개변수로 넘기면 switch 를 완전 분기로 좁힐 수 있고, 캐스팅 없이도 WIRING_PIVOT_PRESETS 와 동일한 모양으로 배열을 짤 수 있습니다 (예: PivotPreset<'order-summary' | 'sla-status'>[]).

"계획 대비 실적 비율(누계비%)": 진짜 의미의 "런닝 토털 %" 는 §10A.4 P2 에서 도입될 runningTotal / differenceFrom 디스플레이로 표현될 예정입니다. P1 프리셋은 현재 라이브러리가 지원하는 다섯 가지 % 모드 중 percentOfRow 를 사용해, "각 월이 그 부서의 행 누계에서 차지하는 비율" 로 근사 표시합니다. 계획(t) 측정값은 원본 톤수 그대로 두어 분모 확인이 가능합니다.

프리셋 즉시 적용 + 데이터셋 tsx
import { PivotBuilder } from 'hyper-xl';
// 프리셋 전용 sub-path: 엔진/빌더/드릴다운을 끌어오지 않습니다.
import {
  WIRING_PIVOT_PRESETS,
  WIRING_PRESET_FIELDS,
  buildWiringShipmentDataset,
  type WiringPivotPreset,
} from 'hyper-xl/pivot/presets';
import { useMemo, useState } from 'react';

export function WiringPresetShowcase() {
  // 결정론적 가짜 매니페스트: 같은 시드는 항상 같은 행을 돌려줍니다.
  const rows = useMemo(() => buildWiringShipmentDataset(), []);
  const [presetId, setPresetId] = useState<WiringPivotPreset['id']>(
    WIRING_PIVOT_PRESETS[0]!.id,
  );
  const preset =
    WIRING_PIVOT_PRESETS.find((p) => p.id === presetId) ?? WIRING_PIVOT_PRESETS[0]!;

  return (
    <>
      <div role="group" aria-label="피벗 프리셋">
        {WIRING_PIVOT_PRESETS.map((p) => (
          <button
            key={p.id}
            type="button"
            aria-pressed={p.id === presetId}
            title={p.description}
            onClick={() => setPresetId(p.id)}
          >
            {p.label}
          </button>
        ))}
      </div>
      {/* PivotBuilder 는 uncontrolled: key 로 강제 리마운트해 initialConfig 를 재적용 */}
      <PivotBuilder
        key={preset.id}
        rows={rows}
        availableFields={WIRING_PRESET_FIELDS}
        initialConfig={preset.config}
      />
    </>
  );
}

다중 집계 (§10A.3 P2)

값(Value) 영역에는 같은 필드를 여러 번 추가해 각각 다른 집계로 동시에 표시할 수 있습니다. 예: sum of qty + average of qty. 엔진은 인덱스 기반 dispatch 로 칩별 설정을 추적하므로 같은 필드가 두 번 들어와도 충돌하지 않습니다. 빌더 UI 에서는 각 Value 칩에 ⎘ 복제 버튼이 붙어 있어 한 번 클릭으로 동일한 칩을 다음 슬롯에 복사해 다른 agg 또는 valueDisplay 로 변경할 수 있습니다.

값 표시 형식: 계산 모드 (§10A.4 P2)

P1 의 5가지 % 모드에 더해 Excel 의 "Show Values As" 계산 패밀리 7가지가 추가되었습니다(전체 PivotValueDisplay 는 총 13가지). 이 중 index(셀 × 총합) / (행합 × 열합) 의 양축 대칭 공식이라 축 개념이 없고, 나머지 6가지가 축(axis) 개념을 가져 PivotValueField.valueDisplayAxis ('column' 기본 / 'row') 로 어느 방향을 따라 변환할지 결정합니다. 축이 의미를 갖는 모드(내부 집합 PIVOT_VALUE_DISPLAY_AXIS_AWARE — 패키지로 export 되지 않는 구현 디테일)는 빌더 UI 에서 표시 형식 옆에 계산 방향 드롭다운이 자동 표시됩니다.

  • differenceFromPrevious차이

    해당 축의 첫 셀은 null, 이후 셀은 현재 − 이전. null 셀이 끼면 그 자리에서 체인이 끊겨 다시 null.

  • percentDifferenceFromPrevious% 차이

    (현재 − 이전) / 이전. 이전 값이 0 이거나 null 이면 null. 빌더는 % 포맷터로 그립니다.

  • runningTotal누계

    축을 따라 누적 합. null 셀은 0 으로 취급되어 누계가 계속 진행됩니다(Excel 동작).

  • percentOfRunningTotal누계 %

    각 셀까지의 누계 / 축 전체 합. 마지막 셀은 100%.

  • rankAscending · rankDescending순위

    RANK.EQ 시맨틱: 동률은 같은(낮은) 순위를 공유하고 null 셀은 순위 카운팅에서 빠집니다.

  • index인덱스

    (cell × grandTotal) / (rowTotal × columnTotal): Excel 의 인덱스 공식. 행/열 부분 합이 0 이면 null.

구현 노트: 계산 모드는 % 모드와 동일하게 matrix 는 원본 집계를 유지하고 displayMatrix 만 변환합니다. 둘 다 활성이 아닐 때는 displayMatrixmatrix 를 그대로 가리키므로 오버헤드가 없습니다.

텍스트 수동 그룹화 + 접기 (§10A.5 P2 / §10A.9 P2)

행/열 영역의 칩에 아이콘이 추가되어 수동 그룹 팝오버를 엽니다. 한 그룹은 { label, values: [...] } 로 정의되며, 같은 그룹 안의 값들은 그룹 라벨 하나로 합쳐집니다. 매칭은 엔진 내부의 canonical equality(groupKeyOf, 비공개 구현) 기준이라 '1'1 같은 정수형 표현도 동일하게 묶입니다. 매칭되지 않는 값은 원래 라벨로 그대로 표시됩니다.

행 축에 2단계 이상이 있을 때는 PivotGridSnapshot.collapsibleRowGroups 가 노출되어 각 비-리프 그룹의 {depth, pathKey, leafFrom, leafTo} 를 알려 줍니다. 사이드바의 그룹 접기/펼치기 패널에서 토글하면 해당 그룹의 모든 리프 행이 숨겨지고 부분합 행이 강제 표시됩니다 (subtotalPosition: 'none' 이어도 강제 노출). "모두 접기" / "모두 펼치기" / "드릴 업"(가장 안쪽 행 필드 제거) 액션도 함께 제공됩니다. 접힘 상태는 root export 된 pivotResultToGrid(result, labels?, layout?, adapterOptions?) 의 네 번째 인자 adapterOptions.collapsedRowGroupKeys 로 전달합니다. 참고: 옵션 타입 PivotGridAdapterOptions 와 키 빌더 pivotRowGroupKey(path) 는 공개 패키지 API(root export)에는 노출되지 않습니다 — collapsedRowGroupKeys(ReadonlySet<string>)에는 PivotGridSnapshot.collapsibleRowGroups[].pathKey 값을 그대로 담으면 됩니다.

슬라이서 · 타임라인 (§10A.6 P2)

여러 피벗을 동시에 필터링하는 시각적 컨트롤이 추가되었습니다. PivotSlicerScope 로 감싼 서브트리 안의 모든 PivotBuilder 는 같은 슬라이서 상태를 구독하여 한 번의 클릭으로 모두 함께 갱신됩니다. Excel 의 "Report Connections" 사고 모델을 컨텍스트로 자동화한 셈입니다.

  • <PivotSlicerScope>Provider

    선택 상태를 보유하는 컨텍스트 공급자. 같은 scope 안의 모든 PivotBuilderbuildPivot 호출 시 슬라이서가 발행한 row predicate 를 추가 필터로 받아갑니다. 기존 PivotRefreshScope 와 직교적이라서 둘 다 감싸는 것도 가능합니다.

  • <PivotSlicer field="..." rows={...} />React.FC

    카테고리 칩 패널. field 의 고유 값들을 토글 칩으로 나열하고, 사용자가 선택한 부분 집합만 통과시킵니다. 기본은 "전부 선택"(어떤 필터도 적용되지 않음). "모두 해제" 는 빈 집합을 발행해 피벗을 의도적으로 비우고, "필터 해제" 는 슬라이서를 scope 에서 제거해 다시 전체 통과로 돌립니다. id 미지정 시 field 가 식별자로 쓰입니다.

  • <PivotTimeline field="..." rows={...} />React.FC

    날짜 슬라이서 (연/분기/월 zoom). 원본 행에서 파싱 가능한 날짜의 min/max 사이를 선택된 단위로 버킷팅해서 보여줍니다. 단일 클릭은 한 버킷, Shift-클릭은 anchor 와의 범위 선택으로 반-개구간 [startMs, endMs) 를 발행합니다. 같은 단일 버킷을 다시 클릭하면 범위가 해제됩니다. initialUnit 기본값은 'month'.

  • usePivotSlicerRowPredicate()Hook

    scope 안의 모든 활성 슬라이서가 AND-결합된 row predicate 를 반환합니다. scope 밖이거나 활성 슬라이서가 없으면 null 을 돌려 주어 의존 useMemo 가 무의미하게 churn 되지 않도록 합니다. PivotBuilder 는 내부적으로 이 훅을 호출해 buildPivot(rows, config, { extraRowFilter }) 의 옵션 필드로 전달합니다.

  • buildPivot: options.extraRowFilter(row, index) => boolean

    엔진에 추가된 옵션. config.filters 와 AND-결합되며 drill-down 의 effectiveSourceIndices 는 원본 인덱스 기준을 그대로 유지합니다 (행을 미리 솎아내지 않으므로 "원본 row 로의 매핑" 이 깨지지 않습니다).

슬라이서가 가진 field 가 row 의 data 에 없으면 해당 row 는 통과합니다. 한 scope 에 데이터 shape 이 다른 피벗을 섞어 놓아도 슬라이서가 의도치 않게 비-관련 피벗을 비우지 않도록 한 안전 장치입니다. 엄격한 매칭이 필요하면 scope 을 분리하세요.

레이아웃 폴리시: 머리글 반복 · 줄무늬 · 스타일 프리셋 (§10A.7 P2)

  • layout.repeatItemLabelsboolean

    개요(outline) / 압축(compact) 형식에서 비어 있던 부모 라벨 셀을 모든 리프 행에 채워 넣어, 정렬 / 필터링된 결과를 인쇄나 CSV 로 내보냈을 때도 행 헤더가 끊기지 않도록 합니다.

  • layout.bandedRows · layout.bandedColumnsboolean

    짝수 / 홀수 본문 셀에 교대 배경색을 입혀 표 가독성을 높입니다. cellFormats 로 그리드에 전달되므로 그리드의 기본 렌더링 흐름과 통합됩니다.

  • layout.stylePreset'none' | 'light' | 'medium' | 'dark'

    Excel 의 "Light / Medium / Dark" 패밀리를 모사한 내장 팔레트. 헤더 / 부분합 / 총합계 / 줄무늬에 각각 다른 색을 입혀 줍니다. none 이면 cellFormatsnull 로 유지되어 그리드 기본 룩을 사용합니다. 컨슈머가 내려 보내는 cellFormats 가 같은 키에서 우선합니다.

자동 새로 고침 (§10A.8 P2)

PivotBuilder 에 두 개의 새 prop 이 추가되었습니다:

  • autoRefreshIntervalMsnumber | null

    setInterval 기반 주기적 재계산. 이는 해당 빌더만 재계산하는 per-pivot 타이머입니다 — PivotRefreshScope 안에 있어도 자신의 로컬 nonce 만 올리고 scope 전체로 fan-out 하지 않습니다(같은 cadence 의 타이머가 N개 피벗에 각각 깔리는 중복을 피하기 위함). scope 의 모든 피벗을 하나의 cadence 로 함께 갱신하려면 usePivotAutoRefresh(intervalMs, opts) 훅을 scope 레벨에서 (예: 공용 툴바에서) 한 번 호출하세요 — 이 훅은 scope nonce 를 올려 모든 피벗이 같이 재계산됩니다. 0 / null / 생략 시 타이머 비활성.

  • refreshOnMountboolean

    Excel 의 "Refresh data when opening the file" 와 동일하게 마운트 시점에 한 번 refresh 를 발사합니다. 기본 false.

외부 · 동적 데이터 원본 (§10A.1 P2)

PivotBuilderrows prop 은 그대로 ReadonlyArray<Row> 를 받지만, 행이 다른 그리드 · DB 쿼리 · 외부 피드 같은 변동 가능한 출처에서 흘러들 때는 RowSource 어댑터 와 짝을 이루는 hook 으로 연결하면 별도 ‘새로 고침’ 클릭 없이 자동으로 재계산됩니다. 네 가지 팩토리와 세 가지 hook 이 노출됩니다.

  • staticRowSource(rows)(rows: ReadonlyArray<Row>) => RowSource

    기존 배열을 RowSource 모양으로 감싸기만 하는 무변경 어댑터. subscribe 는 no-op. 정적/동적 원본을 한 combinedRowSource 안에 함께 넣고 싶을 때 사용합니다.

  • dynamicRowSource(initial?)(initial?: ReadonlyArray<Row>) => DynamicRowSource

    가변 메모리 원본. add(row | rows[]), remove(predicate), setRow(predicate, updater), replace(rows), clear() 가 노출되고, 돌연변이가 일어날 때마다 새 배열 reference 를 발행한 뒤 구독자에게 알립니다. 행 추가가 0건이거나 predicate 가 매칭되지 않은 경우 알림을 보내지 않으므로 불필요한 재계산이 발생하지 않습니다.

  • asyncRowSource(fetcher, options?)(fetcher: () => Promise<Rows> | Rows, options?) => AsyncRowSource

    DB 쿼리/HTTP 페치 등의 비동기 원본. 마운트 직후 자동 fetch (또는 manual: true) , refetch() 수동 트리거, setIntervalMs(ms) 동적 폴링 cadence, 레이스 토큰으로 stale 응답 차단, onError 에러 싱크. dispose() 호출 시 타이머와 모든 구독자가 정리됩니다.

  • combinedRowSource(...sources)(...sources: ReadonlyArray<RowSource>) => CombinedRowSource

    여러 RowSourcerows입력 순서대로 이어 붙인 가상 원본. 입력 중 하나가 변경되면 결합 결과도 다시 발행됩니다. “다른 그리드 기반 피벗” 시나리오: 그리드 A 와 그리드 B 가 각자 dynamicRowSource 를 들고 있어도 단일 피벗이 둘의 합집합을 집계: 의 표준 해법입니다.

  • useRowSource(source | rows)(source: RowSource | ReadonlyArray<Row>) => ReadonlyArray<Row>

    React 훅. useSyncExternalStore 위에 구현되어 concurrent rendering 에서도 tearing 없이 최신 스냅샷을 반환합니다. 원시 배열을 그대로 넘기면 그 배열을 반환하므로 "배열 혹은 source" 어느 쪽이든 받는 컴포넌트를 쉽게 만들 수 있습니다. 반환값을 그대로 <PivotBuilder rows={…}> 에 넘기면 끝.

  • useDynamicRowSource(initial?)(initial?: ReadonlyArray<Row>) => DynamicRowSource

    마운트 1회만 시드 행으로 dynamicRowSource 를 생성해 useMemo 캐시에 보관하는 편의 훅. initial 은 첫 렌더에서만 읽히며, 이후 배열은 source.replace(rows) 으로 교체합니다.

  • useAsyncRowSource(fetcher, options?)(fetcher, options?) => AsyncRowSource

    마운트 1회만 asyncRowSource 를 생성하고, 호스트 컴포넌트 언마운트 시 자동으로 dispose() 를 호출해 폴링 타이머와 구독자 풀을 정리합니다. fetcher 는 ref 로 추적하여 매 렌더마다 인라인 클로저를 넘겨도 안전합니다. 폴링 cadence 는 source.setIntervalMs(ms) 로 런타임 변경할 수 있습니다.

기존 <PivotBuilder rows={array}> API 는 변경되지 않습니다. RowSource 는 opt-in 추가 표면이고, 어댑터를 도입하지 않은 데모/소비자는 영향이 없습니다. 데모의 “외부 · 동적 데이터 원본” 섹션에서 +행 추가 / 마지막 행 제거 / 모두 비우기 / 초기값 복원 버튼이 같은 패턴을 라이브로 보여줍니다.

import { useMemo } from 'react';
import { PivotBuilder, dynamicRowSource, useRowSource, type Row } from 'hyper-xl';

function ExternalPivot({ seed }: { seed: Row[] }) {
  const source = useMemo(() => dynamicRowSource(seed), []);
  const rows = useRowSource(source);
  return (
    <>
      <button onClick={() => source.add(makeRow())}>+ 행 추가</button>
      <PivotBuilder rows={rows} availableFields={FIELDS} />
    </>
  );
}

프리셋 추가 (§10A.11 P2)

기존 P1 5개에 더해 3개의 P2 프리셋이 추가되었습니다. 항차별 적재 현황 (행: 항차, 열: 양하항, 값: 선적량+수량), 부서별 면허 상태 (행: 부서 × 면허, 열: 월 그룹화된 shipDate, 값: count) , 부서별 생산 진척 (행: 부서, 열: 월, 값: 진척률 평균 + 수량 합계). 데이터셋에는 voyage / license / productionPct 필드가 추가되어 라이선스 상태별로 진척률이 상관되도록 시뮬레이션됩니다.

피벗 차트 (§10A.10 P2)

PivotChart 는 피벗 결과를 막대 / 선 / 원형 차트로 렌더합니다. 외부 차트 라이브러리 없이 순수 SVG 로 그리며, PivotSlicerScope · PivotRefreshScope 의 컨텍스트를 그대로 구독해서 같은 scope 안의 PivotBuilder 들과 동일한 슬라이서 / "모두 새로 고침" 신호로 동시에 갱신됩니다. 한 차트당 하나의 값 필드를 시각화하며, 카테고리 축은 'row'(기본) 또는 'column' 중 하나를 선택할 수 있습니다.

  • rowsReadonlyArray<Row>

    원본 행. PivotBuilder 와 동일한 shape 을 받으며, 내부적으로 buildPivot(rows, config) 을 호출합니다.

  • configPivotConfig

    피벗 스펙. 차트는 엔진의 총합계 슬라이스를 읽어 시각화하기 때문에, 컨슈머가 showGrandTotal* 토글을 꺼도 차트 내부에선 강제로 켜진 사본을 사용합니다 (형제 PivotBuilder 의 토글은 그대로 유지). 참조 안정성 필요: JSX 안에 인라인으로 객체 리터럴을 넘기지 말고 useState / useMemo / 모듈 상수로 보관하세요.

  • kind'bar' | 'line' | 'pie'

    차트 종류. 막대 / 선 / 원형 중 하나. 한 컴포넌트가 셋 모두를 지원하므로 같은 config 로 세 차트를 동시에 띄우면 직관적인 비교 뷰가 만들어집니다.

  • valueFieldIndexnumber (기본 0)

    config.values 안의 인덱스. 범위 밖이면 0 으로 클램프됩니다.

  • categoryAxis'row' | 'column' (기본 'row')

    카테고리를 만들 축. 'row' 는 row leaf 1개당 1개 포인트, 'column' 은 column leaf 1개당 1개 포인트. 선택한 축에 필드가 없으면 빈 상태를 표시합니다.

  • width · heightnumber (기본 520 · 280)

    SVG viewBox 크기. CSS 의 width: 100% 와 함께 비율 보존(xMidYMid meet) 으로 스케일됩니다.

  • title · labels.emptystring

    선택. 헤더 텍스트와 빈 상태 메시지를 한국어로 커스터마이즈할 때 사용합니다. title 미지정 시 값 필드의 라벨이 기본 제목이 됩니다.

  • pivotResultToChartSeries(result, options?)함수

    자체 차트 라이브러리(D3, recharts 등) 를 쓰고 싶을 때 사용하는 헤들리스 헬퍼. PivotResult 에서 { label, value }[] 리스트를 추출합니다. 비-분배적 집계 (average / min / max / variance / stdDev / product) 에서도 정확한 leaf 값을 얻기 위해 엔진의 grand-total 슬라이스를 읽으며, 셀을 더하지 않습니다.

막대 / 선 차트는 null 값을 갭으로 표시합니다 (선은 끊기고, 막대는 그리지 않음). 원형 차트는 null 과 음수 / 0 슬라이스를 자동으로 생략합니다. Excel 도 "음수 슬라이스" 에 의미를 부여하지 않습니다. 음수가 섞인 데이터에 원형을 쓰면 잘려 보이므로, 그런 경우엔 막대 차트를 권장합니다.

피벗 + 슬라이서 + 차트: 한 scope 에서 모두 묶기 tsx
import {
  PivotBuilder,
  PivotChart,
  PivotRefreshScope,
  PivotSlicer,
  PivotSlicerScope,
  type PivotConfig,
  type Row,
} from 'hyper-xl';

const chartConfig: PivotConfig = {
  rows:    [{ key: 'region' }],
  columns: [],
  values:  [{ key: 'qty', agg: 'sum', label: '수량 합계' }],
  filters: [],
};

export function Dashboard({ rows }: { rows: Row[] }) {
  return (
    <PivotRefreshScope>
      <PivotSlicerScope>
        <PivotSlicer field="region" title="양하항" rows={rows} />
        {/* 세 차트가 동일 scope 안에 있어 슬라이서·새로 고침을 공유 */}
        <PivotChart rows={rows} config={chartConfig} kind="bar"  title="막대" />
        <PivotChart rows={rows} config={chartConfig} kind="line" title="선" />
        <PivotChart rows={rows} config={chartConfig} kind="pie"  title="원형" />
        <PivotBuilder rows={rows} availableFields={fields} initialConfig={chartConfig} />
      </PivotSlicerScope>
    </PivotRefreshScope>
  );
}
헤들리스: 직접 매핑해서 D3/recharts 로 그리기 tsx
import { buildPivot, pivotResultToChartSeries } from 'hyper-xl';

const result = buildPivot(rows, config);
const series = pivotResultToChartSeries(result, {
  valueFieldIndex: 0,
  categoryAxis:    'row',
});
// series → [{ label: '부산', value: 30 }, { label: '인천', value: 70 }, ...]
// 이 배열을 그대로 recharts/visx/D3 에 넘겨 자체 스타일링된 차트로 그릴 수 있습니다.

드래그&드롭 한계 (MVP)

  • 데스크톱 HTML5 DnD만 지원합니다. 모바일 터치 / 키보드 재정렬 UI는 다음 PR.
  • 드롭 표시선(insertion indicator)을 그리지 않습니다. 칩은 항상 영역의 끝에 들어가며, 중간으로 이동하려면 일단 다른 영역으로 옮긴 뒤 다시 드롭하세요.
  • 영역 변경의 ARIA live-region 공지는 아직 없습니다.

시트 / 탭 (Workbook)

FeatureHigh PRD §14: 여러 시트를 가진 워크북 모델. 시트 추가 / 삭제 / 이름 변경 / 탭 색상 / 순서 이동 / 복제, 숨김·표시, 보호(읽기 전용) 메타데이터를 한 곳에서 관리합니다. <XlReact>는 항상 단일 시트의 행 / 서식만 그리므로, 시트별 페이로드(rows / cellFormats / merges)는 컨슈머가 Record<SheetId, …>로 직접 보관합니다. 라이브러리는 "어떤 시트들이 있고 지금 어느 시트가 활성인가"라는 메타만 다룹니다.

구성 요소

  • createWorkbook(options?): 초기 시트 목록과 activeSheetId를 갖는 Workbook 객체를 만듭니다. 모든 시드 시트가 hidden: true이면 첫 시트가 자동으로 표시 상태로 승격됩니다.
  • workbookReducer(workbook, action): 순수 리듀서. 'add' | 'delete' | 'rename' | 'recolor' | 'reorder' | 'duplicate' | 'setHidden' | 'setProtected' | 'activate'의 아홉 가지 액션을 처리하며, 무결성 위반(중복 이름·중복 id·마지막 시트 삭제·마지막 표시 시트 숨김)은 동일 참조 반환으로 거절합니다.
  • useWorkbook(options?): workbookReduceruseReducer로 감싸 안정적인 콜백(addSheet, deleteSheet, renameSheet, …)과 파생 값(activeSheet, visibleSheets, hiddenSheets)을 한 번에 제공합니다.
  • <SheetTabBar />: 그리드 하단에 두는 탭 스트립. "+", 컨텍스트 메뉴(이름 변경 · 복제 · 색상 · 숨김 · 보호 · 삭제), 숨김 시트 드롭다운, 인라인 이름 편집(Enter 확정 · Esc 취소), HTML5 드래그 재정렬을 포함합니다. 시각 속성은 --xl-react-sheet-tab-* 토큰으로 모두 노출되며, UI 문구는 SheetTabBarLabels로 교체 가능합니다. DEFAULT_SHEET_TAB_BAR_LABELS(영어)와 KOREAN_SHEET_TAB_BAR_LABELS(한국어) 두 프리셋을 번들에 포함합니다.
  • workbookToMultiSheetEntries(workbook, getSnapshot, options?): 워크북을 exportMultiSheetXlsx가 받는 MultiSheetEntry[]로 변환합니다. 시트별 { rows, columns, cellFormats?, merges? } 스냅샷을 돌려주는 리졸버를 컨슈머가 넘겨주면, 숨김 시트는 기본적으로 건너뛰면서(includeHidden: true로 강제 가능) 한 번에 .xlsx로 내보냅니다.

시트 메타만 라이브러리 소유

Sheet{ id, name, color?, hidden?, protected? } 다섯 필드뿐입니다. 행 데이터·셀 서식·undo 스택·열 정의 등은 모두 컨슈머가 시트 id를 키로 한 자체 Map에 보관하고, activeSheetId가 바뀔 때마다 그에 해당하는 페이로드를 <XlReact>rows·cellFormats로 흘려보내면 됩니다. 라이브러리가 페이로드를 들고 있지 않으므로 외부 데이터 소스(서버 페이지 네이션·동적 쿼리·CRDT)와도 자유롭게 합쳐 쓸 수 있습니다. protected도 메타 플래그일 뿐이고 실제 쓰기 차단은 readOnly={activeSheet.protected}처럼 컨슈머가 그리드에 전달해 적용합니다.

유지하는 불변식

  • 최소 한 개의 시트. 'delete'는 워크북에 마지막으로 남은 시트는 지우지 않습니다.
  • 최소 한 개의 표시 시트. 숨겨진 시트들이 남아 있어도 마지막으로 표시 중인 시트를 지우거나('delete') 숨기는 ('setHidden') 액션은 거절합니다. 이 상태에 빠지면 사용자가 어떤 시트도 클릭할 수 없게 되기 때문입니다.
  • activeSheetId는 항상 표시 시트. 활성 시트가 숨겨지거나 삭제되면 그 자리에서 다음 표시 시트로 자동 이동합니다. 'activate'는 숨김 시트에는 적용되지 않습니다.
  • 중복 이름 / 중복 id 거절. 공백·중복 이름 변경은 no-op이며, 동일 참조 반환으로 컨슈머가 무시 여부를 감지할 수 있습니다.
  • 복제는 표시·편집 가능 상태로. 원본이 숨김이거나 보호 상태여도 복제본은 항상 표시되고 편집 가능한 상태로 생성됩니다. "보호된 시트의 편집 가능한 복사본"이 복제의 기본 의도이기 때문입니다.
최소 와이어링: 시트별 행을 외부 Map으로 분리해서 보관 tsx
import { useEffect, useRef, useState } from 'react';
import {
  XlReact,
  SheetTabBar,
  useWorkbook,
  KOREAN_SHEET_TAB_BAR_LABELS,
  type Row,
  type SheetId,
} from 'hyper-xl';

function Workbook() {
  const workbook = useWorkbook({
    initialSheets: [{ id: 'sheet-1', name: 'Sheet1' }],
  });

  // 시트별 페이로드는 라이브러리 밖. 키는 sheet.id 그대로.
  const [data, setData] = useState<Record<SheetId, { rows: Row[] }>>(() => ({
    'sheet-1': { rows: [{ id: 'r1', data: {} }] },
  }));

  // 시트 추가/삭제에 맞춰 페이로드 Map을 동기화.
  const prevRef = useRef(workbook.workbook);
  useEffect(() => {
    const prev = prevRef.current;
    const next = workbook.workbook;
    const added = next.sheets.filter((s) => !prev.sheets.some((p) => p.id === s.id));
    const removed = prev.sheets.filter((p) => !next.sheets.some((s) => s.id === p.id));
    if (added.length || removed.length) {
      setData((m) => {
        const out = { ...m };
        for (const s of added) out[s.id] = { rows: [] };  // 빈 시트로 추가
        for (const s of removed) delete out[s.id];
        return out;
      });
    }
    prevRef.current = next;
  }, [workbook.workbook]);

  const active = data[workbook.activeSheet.id] ?? { rows: [] };

  return (
    <>
      <XlReact
        columns={columns}
        rows={active.rows}
        onCellChange={(change) =>
          setData((m) => {
            const sheetId = workbook.activeSheet.id;
            const rows = m[sheetId].rows.map((row, i) =>
              i === change.coord.row
                ? { ...row, data: { ...row.data, [change.columnId]: change.nextValue } }
                : row,
            );
            return { ...m, [sheetId]: { rows } };
          })
        }
        readOnly={!!workbook.activeSheet.protected}
      />
      <SheetTabBar controller={workbook} labels={KOREAN_SHEET_TAB_BAR_LABELS} />
    </>
  );
}
여러 시트를 한 번에 .xlsx로 내보내기 tsx
import {
  workbookToMultiSheetEntries,
  exportMultiSheetXlsx,
  triggerBlobDownload,
} from 'hyper-xl';

async function exportWorkbook() {
  // getSnapshot 콜백은 sheetId가 아니라 Sheet 객체를 받습니다.
  const entries = workbookToMultiSheetEntries(
    workbook.workbook,
    (sheet) => ({
      rows: data[sheet.id].rows,
      columns,
      cellFormats: formats[sheet.id],
      merges: merges[sheet.id],
    }),
    // includeHidden 기본값은 false: 숨김 시트는 자동으로 빠집니다.
    { defaults: { dateFormat: 'yyyy-mm-dd' } },
  );
  // exportMultiSheetXlsx는 Blob을 반환 — 파일 저장은 triggerBlobDownload로.
  const blob = await exportMultiSheetXlsx(entries);
  triggerBlobDownload(blob, 'workbook.xlsx');
}

드래그 재정렬 / 숨김 시트와의 좌표 일관성

<SheetTabBar />의 드롭 처리기는 사용자가 보는 표시 시트 순서를 기준으로 재정렬한 뒤, 그 결과를 전체 sheets 배열의 절대 인덱스로 변환해 'reorder'를 디스패치합니다. 그래서 숨김 시트가 표시 시트 사이사이에 끼어 있어도 시각적 드래그 결과와 내부 순서가 어긋나지 않습니다. 'reorder'를 직접 디스패치할 때는 toIndex전체 배열 인덱스라는 점에 유의하세요.

API 표면

  • Workbook{ sheets: ReadonlyArray<Sheet>; activeSheetId: SheetId }

    순수 직렬화 가능한 모양: 클래스 인스턴스나 클로저가 없어 그대로 저장 / 복원 가능.

  • Sheet{ id: SheetId; name: string; color?: string | null; hidden?: boolean; protected?: boolean }

    hidden / protected는 켜진 상태만 키로 두는 것을 권장: 외부 동등 비교를 깨끗하게 유지하려 false는 저장하지 않습니다. colornull로 "색 없음"을 표현할 수 있습니다.

  • WorkbookActiondiscriminated union

    { type: 'add' | 'delete' | … }의 9가지 변형. 무결성 위반 시 리듀서는 동일 참조를 반환합니다.

  • SheetTabBarControllersubset of WorkbookController

    탭 바가 실제로 필요로 하는 콜백만 골라낸 좁은 인터페이스. 자체 디스패처를 쓰는 컨슈머는 이 모양만 만족하면 컴포넌트를 그대로 끼울 수 있습니다.

API: XlReactProps

기능별로 정리한 전체 prop 표. 자세한 맥락은 각 기능 섹션을 참조하세요.

데이터

  • columnsAnyColumn[]
  • rowsRow[]
  • rowHeightnumber
  • columnWidthnumber
  • overscannumber
  • classNamestring
  • readOnlyboolean
  • minColumnWidth / minRowHeightnumber

선택 & 편집

  • onSelectionChange(snapshot: SelectionSnapshot) => void
  • onEditRequest(coord: CellCoord) => void
  • onCellChange(change: CellChange) => void
  • onCellsClear(payload: CellsClearPayload) => void

틀 고정

  • freezeFirstRow / freezeFirstColboolean
  • freezeRowCount / freezeColCountnumber

행 / 열 조작

  • onRowsInsert / onRowsDelete
  • onColumnsInsert / onColumnsDelete
  • onRowsReorder / onColumnsReorder

클립보드

  • onCopy / onCut
  • onPasteRequest / onPasteSpecialRequest

정렬 & 필터

  • sortState / onSortStateChange
  • filterState / onFilterStateChange
  • filterPanelRows
  • onSortAscending / onSortDescending / onSortCustomRequest
  • onFilterByValueRequest / onClearFilterRequest

컨텍스트 메뉴 의도

  • onCellFormatRequest
  • onInsertNoteRequest / onInsertHyperlinkRequest

실행 취소

  • enableUndo / undoMaxEntries / undoMaxBytes

배율

  • zoom / defaultZoom / onZoomChange
  • showZoomControl / zoomMin / zoomMax

보기 모드

  • showGridlinesboolean
  • showHeadersboolean

서식

  • cellFormatsCellFormatResolver | CellFormatsMap
  • onCellFormatsChange(next: CellFormatsMap) => void
  • borderDrawToolBorderDrawTool | null
  • borderDrawSideCellBorderSide
  • mergesReadonlyArray<SelectionRange>

커스텀 렌더러

  • cellRenderersCellRenderers (맵 | 리졸버)

주석

  • cellAnnotations
  • annotationShowDelayMs / annotationHideDelayMs

집계

  • showSelectionStats / selectionStatsLocale

보호

  • cellProtection / onProtectedAction

검색 & 바꾸기

  • enableFindReplaceboolean (기본 true)

데이터 검증

  • validationListsRecord<string, ValidationList>

행 계층 구조

  • rowOutlineReadonlyArray<RowOutlineCell | null | undefined>
  • onRowOutlineToggle(rowIndex: number, next: 'collapse' | 'expand') => void
  • rowOutlineIndentPxnumber (기본 16)

API: 타입 정의

'hyper-xl'에서 re-export. 다수는 src/types/에 정의되지만, 일부(selection · editing · contextMenu · sortFilter · protection 계열)는 src/XlReact/… 각 모듈에서 src/types/index.ts가 다시 re-export 합니다.

코어 타입 typescript
type Column<T = unknown>;
type AnyColumn = Column<any>;
type ColumnValidationResult = boolean | string;

interface Row {
  id: string | number;
  data: Record<string, unknown>;
  height?: number;
  level?: number;                          // §6.4 행 계층 깊이
  parentId?: string | number | null;       // §6.4 명시적 부모 참조
}

interface CellRendererProps<T> {
  value: T;
  row: Row;
  column: Column<T>;
  rowIndex: number;
  columnIndex: number;
  isEditing: boolean;
}

type CellEditCommitNav =
  | 'enter' | 'shift-enter' | 'tab' | 'shift-tab' | 'none';

interface CellEditorProps<T> extends CellRendererProps<T> {
  onCommit: (next: T, nav?: CellEditCommitNav) => void;
  onCancel: () => void;
  mode?: 'edit' | 'overwrite' | 'clear';
  initialDraft?: string;
}

type CellRenderer<T> = (props: CellRendererProps<T>) => ReactNode;
type CellEditor<T> = (props: CellEditorProps<T>) => ReactNode;

// cellRenderers prop: 맵 또는 리졸버
type CellRenderersMap = Record<string, CellRenderer>;
type CellRendererResolver =
  (rowIndex: number, columnIndex: number) => CellRenderer | undefined;
type CellRenderers = CellRenderersMap | CellRendererResolver;
선택 & 편집 타입 typescript
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
  active: CellCoord;
  ranges: ReadonlyArray<SelectionRange>;
}

interface CellChange {
  coord: CellCoord;
  columnId: string;
  prevValue: unknown;
  nextValue: unknown;
}

interface CellsClearPayload {
  ranges: ReadonlyArray<SelectionRange>;
}
데이터 검증 타입 typescript
interface ColumnValidation {
  listKey: string;
  strict?: boolean;
}

interface ValidationListOption { value: string; label?: string; }
type ValidationListItem = string | ValidationListOption;
type ValidationList = ReadonlyArray<ValidationListItem>;
type ValidationLists = Readonly<Record<string, ValidationList>>;

// 정규화된 옵션: value/label이 항상 존재 (헬퍼 반환 형태)
interface ResolvedValidationOption { value: string; label: string; }
컨텍스트 메뉴 페이로드 typescript · ~30줄
interface RowsInsertPayload { atIndex: number; position: 'above' | 'below'; count: number; }
interface RowsDeletePayload { rowIds: ReadonlyArray<Row['id']>; rowIndices: ReadonlyArray<number>; }
interface ColumnsInsertPayload { atIndex: number; position: 'left' | 'right'; count: number; }
interface ColumnsDeletePayload { columnIds: ReadonlyArray<string>; columnIndices: ReadonlyArray<number>; }
interface RowsReorderPayload {
  rowIds: ReadonlyArray<Row['id']>;
  rowIndices: ReadonlyArray<number>;
  targetIndex: number;
}
interface ColumnsReorderPayload {
  columnIds: ReadonlyArray<string>;
  columnIndices: ReadonlyArray<number>;
  targetIndex: number;
}

interface ClipboardCopyPayload {
  ranges: ReadonlyArray<{ start: CellCoord; end: CellCoord }>;
  text: string;
}
interface PasteRequestPayload { coord: CellCoord; ranges: ReadonlyArray<SelectionRange>; }
interface PasteSpecialRequestPayload { coord: CellCoord; }

interface SortColumnPayload { columnId: string; columnIndex: number; }
interface FilterByValuePayload { coord: CellCoord; value: unknown; }
interface CellFormatRequestPayload { coord: CellCoord; }
interface CellAddressActionPayload { coord: CellCoord; }
정렬 & 필터 타입 typescript
type SortDirection = 'asc' | 'desc';
interface SortColumnEntry { columnId: string; direction: SortDirection; }
type SortState = ReadonlyArray<SortColumnEntry>;

interface ColumnValueFilter { selectedValues: ReadonlySet<string>; }
type FilterState = Readonly<Record<string, ColumnValueFilter>>;

const BLANK_FILTER_KEY: string;
function valueToFilterKey(value: unknown): string;
셀 서식 타입 typescript
type CellHorizontalAlign = 'left' | 'center' | 'right' | 'justify' | 'distributed';
type CellVerticalAlign = 'top' | 'middle' | 'bottom' | 'distributed';
type CellBorderLineStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'thick';

interface CellFont {
  family?: string;
  size?: number;
  bold?: boolean;
  italic?: boolean;
  underline?: boolean | 'single' | 'double';
  strikethrough?: boolean;
  color?: string;
}
interface CellAlign {
  horizontal?: CellHorizontalAlign;
  vertical?: CellVerticalAlign;
  wrap?: boolean;
  indent?: number;
}
interface CellFill { backgroundColor?: string; }
interface CellBorderSide { style?: CellBorderLineStyle; color?: string; width?: number; }
interface CellBorder {
  top?: CellBorderSide;
  right?: CellBorderSide;
  bottom?: CellBorderSide;
  left?: CellBorderSide;
  diagonalDown?: CellBorderSide;  // ╲ 셀 내부 대각선 (SVG 오버레이)
  diagonalUp?: CellBorderSide;    // ╱ 셀 내부 대각선
}
interface CellFormat {
  font?: CellFont;
  align?: CellAlign;
  fill?: CellFill;
  border?: CellBorder;
  numberFormat?: string;
}

type CellFormatsMap = Readonly<Partial<Record<string, CellFormat>>>;
type CellFormatResolver = (rowIndex: number, columnIndex: number)
  => CellFormat | null | undefined;
type CellFormats = CellFormatResolver | CellFormatsMap;

type NullablePatch<T> = { [K in keyof T]?: T[K] | null };
type CellFontPatch = NullablePatch<CellFont>;
type CellAlignPatch = NullablePatch<CellAlign>;
type CellFillPatch = NullablePatch<CellFill>;
type CellBorderSidePatch = NullablePatch<CellBorderSide>;
interface CellBorderPatch {
  top?: CellBorderSidePatch | null;
  right?: CellBorderSidePatch | null;
  bottom?: CellBorderSidePatch | null;
  left?: CellBorderSidePatch | null;
  diagonalDown?: CellBorderSidePatch | null;
  diagonalUp?: CellBorderSidePatch | null;
}
type CellFormatPatch = {
  font?: CellFontPatch | null;
  align?: CellAlignPatch | null;
  fill?: CellFillPatch | null;
  border?: CellBorderPatch | null;
  numberFormat?: string | null;
};
type CellBorderPlacement = 'outline' | 'all' | 'top' | 'right' | 'bottom' | 'left' | 'none'
  | 'diagonal-down' | 'diagonal-up';
type CellFormatPatchResolver = (
  current: CellFormat | undefined,
  coord: CellCoord,
) => CellFormatPatch | null | undefined;

interface CellFormatToolbarProps {
  selection: SelectionSnapshot | null;
  cellFormats?: CellFormatsMap;
  onCellFormatsChange: (next: CellFormatsMap) => void;
  disabled?: boolean;
  className?: string;
  // 글꼴 / 숫자 형식 옵션 오버라이드
  fontFamilies?: readonly CellFormatToolbarFontOption[];
  fontSizes?: readonly number[];
  numberFormats?: readonly CellFormatToolbarNumberFormatOption[];
  // 병합 통합 (CellMergeToolbar 기능을 흡수)
  merges?: ReadonlyArray<SelectionRange>;
  onMergesChange?: (next: SelectionRange[]) => void;
  onMergeClearCovered?: (ranges: SelectionRange[]) => void;
  mergeLabels?: CellMergeToolbarLabels;
  // 테두리 그리기 도구
  activeBorderTool?: BorderDrawTool | null;
  onBorderDrawToolChange?: (tool: BorderDrawTool | null, side: CellBorderSide) => void;
  // 조건부 서식 통합
  conditionalRules?: readonly ConditionalRule[];
  onConditionalRulesChange?: (next: ConditionalRule[]) => void;
  columns?: readonly AnyColumn[];
  conditionalFormatLabels?: Partial<ConditionalFormatToolbarLabels>;
  // 셀 스타일 프리셋 통합
  cellStyleRegistry?: CellStyleRegistry;
  cellStyleApplyMode?: CellStyleApplyMode;
  cellStyleLabels?: Partial<CellStyleToolbarLabels>;
  // 서식 복사(Format Painter)
  formatPainterArmed?: boolean;
  onFormatPainterToggle?: () => void;
  formatPainterLabel?: string;
}

function cellFormatKey(rowIndex: number, columnIndex: number): string;
function resolveCellFormat(
  formats: CellFormats | undefined,
  rowIndex: number,
  columnIndex: number,
): CellFormat | undefined;
function applyCellFormatPatch(
  formats: CellFormatsMap | undefined,
  ranges: ReadonlyArray<SelectionRange>,
  patch: CellFormatPatch | CellFormatPatchResolver,
): CellFormatsMap;
function applyCellBorderPatch(
  formats: CellFormatsMap | undefined,
  ranges: ReadonlyArray<SelectionRange>,
  placement: CellBorderPlacement,
  side: CellBorderSidePatch,
): CellFormatsMap;
주석 타입 typescript
type CellAnnotationsMap = Readonly<Record<string, string>>;
type CellAnnotationResolver = (rowIndex: number, columnIndex: number)
  => string | null | undefined;
type CellAnnotations = CellAnnotationResolver | CellAnnotationsMap;

function cellAnnotationKey(rowIndex: number, columnIndex: number): string;
function resolveCellAnnotation(
  annotations: CellAnnotations | undefined,
  rowIndex: number,
  columnIndex: number,
): string | undefined;
보호 타입 typescript
type CellProtectionPredicate = (rowIndex: number, columnIndex: number) => boolean;
type ProtectedAction =
  | 'edit' | 'clear' | 'paste' | 'fill' | 'cut' | 'move'
  | 'replace' | 'rowDelete' | 'columnDelete';
interface ProtectedActionInfo {
  action: ProtectedAction;
  coords: ReadonlyArray<CellCoord>;
}

API: 유틸리티

'hyper-xl'에서 export 하는 주요 유틸리티(대표 항목). root는 이 목록 외에도 서식·조건부 서식·셀 스타일·병합·검색·피벗·시트·인쇄·수식 관련 헬퍼와 컴포넌트를 다수 export 합니다 — 전체 목록은 각 기능 섹션과 src/index.ts를 참고하세요.

  • computeAggregates(ranges, rows, columns) => SelectionAggregates

    상태바와 동일한 SUM / AVG / COUNT / MIN / MAX 계산.

  • processInChunks(items, perItem, options?) => Promise<void>

    paste / fill가 사용하는 비동기 청크 반복 헬퍼. 콜백 perItem(item, index)은 두 번째 인자이며, 옵션은 { chunkSize?, signal? }.

  • BoundedUndoStackclass (options: BoundedUndoStackOptions)

    엔트리 수 + 바이트 한도가 있는 standalone undo 스토어.

  • defaultColumnLabel(index: number) => string

    A1 스타일 컬럼 라벨: 0 → 'A', 26 → 'AA'.

  • defaultRowLabel(index: number) => string

    1-based 행 라벨: 0 → '1'.

  • cellAnnotationKey(row, col) => string
  • resolveCellAnnotation(annotations, row, col) => string | undefined
  • cellFormatKey(row, col) => string
  • resolveCellFormat(formats, row, col) => CellFormat | undefined
  • applyCellFormatPatch(formats, ranges, patch) => CellFormatsMap
  • applyCellBorderPatch(formats, ranges, placement, side) => CellFormatsMap
  • valueToFilterKey(value: unknown) => string
  • findMatches(args: FindMatchesArgs) => FindMatch[]

    검색 / 바꾸기 다이얼로그가 쓰는 행 우선 일치 엔진.

  • replaceInValue(value, query, replacement, options) => string | null

    한 셀 값의 치환 결과(미일치 시 null).

  • compileMatcher(query, options) => CompiledMatcher
  • cellValueToString(value: unknown) => string
  • resolveColumnList(column, validationLists) => ResolvedValidationOption[] | null

    컬럼의 검증 목록을 정규화. 목록 셀이 아니면 null.

  • filterOptions(options, query) => ResolvedValidationOption[]

    검색창과 동일한 label · value 부분 일치(대소문자 무시).

  • isValueInList(value, options) => boolean

    strict 검증과 동일 규칙. 빈 값은 항상 true.

  • normalizeList(list) => ResolvedValidationOption[]
  • normalizeOption(item) => ResolvedValidationOption
  • FormulaSheetclass

    raw + 표시 값 + 의존 그래프를 관리하는 시트 헬퍼. 수식 엔진 참고.

  • parseFormula(input) => FormulaAst | FormulaParseError

    사칙연산 + 셀 참조 문법의 파서.

  • evaluateAst(ast, resolveRef) => number | FormulaErrorCode

    파싱된 AST를 셀 값 리졸버로 평가. 오류 전파 포함.

  • extractRefs(ast) => { row, col }[]
  • a1ToCoord / coordToA1A1 ↔ {row,col} 변환 ($ 마커 허용)
  • parseA1(ref) => ParsedCellRef | null

    $ 마커를 포함한 절대/상대 정보를 분해해서 반환.

  • shiftFormulaRefs(formula, deltaRow, deltaCol) => string

    수식의 상대 참조를 이동하고 $ 절대 참조는 고정: 자동 채우기 엔진이 내부적으로 사용.

  • isFormulaError(value) => boolean

    FORMULA_ERROR_CODES 중 하나인지 확인.

  • SplitPaneViewcomponent @experimental

    분할 패널 wrapper: 1 / 2 / 4 패널 + 짝지어진 패널 간 스크롤 동기화.

  • useFullscreen(ref) => { isFullscreen, isSupported, request, exit, toggle }

    Fullscreen API + vendor-prefix 폴백. 호스트 unmount 시 fullscreen lock 자동 해제.

  • useWorkbookBroadcast<T>(channelName) => { latest, broadcast, isSupported }

    동일 origin BroadcastChannel 래퍼: 새 창 동기화에 사용.

  • openSheetInNewWindow(options?) => Window | null

    window.open 래퍼. 기본 target은 재사용 가능한 명명된 윈도우.

기능 변경 이력

현재 리비전까지 머지된 기능 (최신순).

기능 유형 우선순위 완료일
인쇄 (Print: 미리보기 모달 · A4/A3/A5/Letter/Legal/Tabloid 용지 + 가로/세로 · 여백 / 배율(10~400%) · 머리글·바닥글 3-zone + &P/&N/&D/&T/&F/&A 플레이스홀더 · 인쇄 영역 · 행/열 반복 · 페이지 나누기 미리보기 오버레이 · @media print 페이지 브레이크 · 순수 paginate() 엔진(PDF 재사용 가능))FeatureMedium2026-05-29
보기 모드 (View Modes: 눈금선 / 머리글 표시·숨김 · 1 / 2 / 4 분할 패널 + 스크롤 동기화 · 새 창 BroadcastChannel 동기화 · Fullscreen API)FeatureMedium2026-05-28
시트 / 탭 (Workbook: 다중 시트 모델 · 추가 / 삭제 / 이름 변경 / 탭 색상 / 드래그 재정렬 / 복제 · 숨김 · 보호(읽기 전용) · 시트별 페이로드는 컨슈머 소유 · 다중 시트 .xlsx 내보내기 브리지 · 영어 / 한국어 라벨 프리셋)FeatureHigh2026-05-28
피벗 테이블 (Pivot Table: 4영역 드래그&드롭 빌더 · 9가지 집계 · 13가지 값 표시 형식(P1 % 6 + P2 계산 모드 7) · 행/열 그룹화(날짜 단위 · 숫자 구간) · 정렬·라벨 필터·축 값 필터(Top N / 평균 이상·이하 / 임계값) · 부분합 위치 · 보고서 형식(컴팩트/개요/표) · 빈 셀 표시값 · 총합계 · 다중 피벗 일괄 새로 고침 · 세부 정보 (Show Details: 값 셀 더블클릭으로 원본 행 추출) · 프리셋 피벗 (배선신청 합계 / 월별 누계 / 계획 대비 실적 %) · 피벗 차트 (막대 / 선 / 원형) · 외부 · 동적 데이터 원본 (RowSource 어댑터: 다른 그리드 / DB 쿼리 / 행 추가 시 자동 재계산) · XlReact 그리드로 결과 렌더)FeatureHigh2026-05-28
행 계층 구조 (Row Hierarchy / Grouping: 다단계 트리 + 접기/펼치기)FeatureMedium2026-05-27
가져오기 / 내보내기 (Excel · CSV · TSV: 서식 · 수식 · 다중 시트 · 컬럼 매핑 · 검증 보고)FeatureHigh2026-05-27
수식 엔진 (Formula Engine: 사칙연산 + 셀 참조 + $ 절대 참조 + 자동 채우기 시 상대 이동)FeatureMedium2026-05-27
커스텀 셀 렌더러 (Custom Cell Renderer)FeatureHigh2026-05-23
데이터 검증 목록 (드롭다운)FeatureHigh2026-05-23
검색 / 바꾸기 (Find & Replace)FeatureHigh2026-05-23
조건부 서식 (Conditional Formatting)FeatureLow2026-05-23
숫자 형식 엔진 (Number Format)FeatureHigh2026-05-23
셀 병합 (Merge / Unmerge / Merge & Center)FeatureHigh2026-05-22
셀 서식 + 편집 toolbarFeatureMedium2026-05-22
셀 보호 (read-only)FeatureHigh2026-05-13
선택 영역 집계 (SUM / AVG / COUNT)FeatureMedium2026-05-13
셀 주석 / 툴팁FeatureMedium2026-05-13
시트 배율FeatureMedium2026-05-13
정렬 & 필터FeatureHigh2026-05-13
실행 취소 / 다시 실행FeatureHigh2026-05-13
채우기 핸들 & 단축키FeatureMedium2026-05-13
클립보드 (TSV)FeatureHigh2026-05-13
키보드 네비게이션 & 컨텍스트 메뉴FeatureHigh2026-05-13
행 & 열 조작FeatureHigh2026-05-12
버그 수정: 컬럼 헤더 레이아웃 회귀BugHigh2026-05-11
가상화 & 성능FeatureHigh2026-05-11
셀 편집 & 검증FeatureHigh2026-05-11
셀 선택 시스템FeatureHigh2026-05-11
React 라이브러리 프로젝트 초기 셋업FeatureHigh2026-05-11