Skip to content

Commit 8ddafec

Browse files
committed
feat: v0.13.0 — cell format, conditional rules, autocomplete, column hide/show UI
- format: ColumnDefinition.format (string pattern or function) for display-only formatting - Number patterns: #,##0 / #,##0.00 / 0.00% / $#,##0.00 - Date patterns: yyyy-MM-dd / HH:mm / etc. - Raw value preserved for editing and clipboard - conditionalRules: per-column rules applying background/color/fontWeight/fontStyle - Multiple rules merged in order - autocomplete: boolean | 'strict' for text columns - Dropdown from existing column values, Arrow/Enter navigation - strict mode: rejects values not in existing list - Column hide/show UI - hideColumn(key) / showColumn(key) / getHiddenColumns() API - Header right-click context menu: Hide column / Show hidden columns - Hidden column indicator button at column boundary - column-visibility-change + header-context-menu events - New types: CellStyle, ConditionalRule - 246 tests (was 213, +33)
1 parent edfc600 commit 8ddafec

11 files changed

Lines changed: 858 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.13.0] - 2026-05-19
11+
12+
### Added
13+
- **셀 표시 형식 (`format`)**: `ColumnDefinition.format`에 형식 문자열(`'#,##0.00'`, `'0.00%'`, `'$#,##0'`, `'yyyy-MM-dd'` 등) 또는 커스텀 함수 지정. 편집/클립보드에는 raw value 유지
14+
- **조건부 서식 (`conditionalRules`)**: `ColumnDefinition.conditionalRules``{ when, style }` 규칙 배열 지정. 조건 true 시 background/color/fontWeight/fontStyle 인라인 스타일 적용. 다중 규칙 병합
15+
- **자동완성 편집기 (`autocomplete`)**: `ColumnDefinition.autocomplete: true | 'strict'`. 텍스트 편집 시 기존 컬럼 값 기반 드롭다운 제안. Arrow Down/Up으로 탐색, Enter로 선택. `'strict'` 모드는 목록 외 값 거부
16+
- **컬럼 숨기기/표시 UI**: 헤더 우클릭 → 내장 컨텍스트 메뉴 (Hide column / Show 숨긴 열). 숨겨진 열 인접 인디케이터 버튼. `hideColumn(key)` / `showColumn(key)` / `getHiddenColumns()` API
17+
18+
### Changed
19+
- `ColumnDefinition``format`, `conditionalRules`, `autocomplete` 필드 추가
20+
- 새 공개 타입: `CellStyle`, `ConditionalRule`
21+
- 새 이벤트: `header-context-menu`, `column-visibility-change`
22+
23+
---
24+
1025
## [0.12.0] - 2026-05-19
1126

1227
### Added

ROADMAP.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Roadmap
22

33
> Last updated: 2026-05-19
4-
> Current version: **v0.12.0** (213 tests, 93.6 KB / 20.0 KB gzip + react wrapper)
4+
> Current version: **v0.13.0** (246 tests)
55
66
flex-table 개발 로드맵. 완료된 기능과 향후 계획을 추적한다.
77

@@ -141,6 +141,19 @@ inline whitespace gap. 최종 해결: 모든 prefix/pinned 셀을 `position: abs
141141

142142
---
143143

144+
## Phase 13: 서식 & 표시 — v0.13.0 ✅ COMPLETE
145+
146+
> **완료**: 셀 포맷, 조건부 서식, 자동완성 편집기, 컬럼 숨기기/표시 UI.
147+
148+
| ID | Task | Status | Description |
149+
|----|------|--------|-------------|
150+
| FM-01 | 숫자/날짜 표시 형식 || `format` 속성: 문자열 패턴 또는 함수 |
151+
| FM-02 | 조건부 서식 || `conditionalRules` 배열 |
152+
| FM-03 | 자동완성 편집기 || `autocomplete: true | 'strict'` |
153+
| FM-04 | 컬럼 숨기기/표시 UI || 헤더 우클릭 메뉴 + 인디케이터 |
154+
155+
---
156+
144157
## Phase 10: Ecosystem & Production — v0.10.0 ✅ COMPLETE
145158

146159
> **완료**: React wrapper, MorphDB Studio 전환 준비 완료.
@@ -192,6 +205,8 @@ inline whitespace gap. 최종 해결: 모든 prefix/pinned 셀을 `position: abs
192205
| **v0.9.0**| 179 | 74.67 KB | 16.62 KB | 100K+ 스크롤 최적화, pinned right 컬럼 |
193206
| **v0.10.0**| 179 | 74.62 KB | 16.60 KB | React wrapper (`@iyulab/flex-table/react`) |
194207
| **v0.11.0**| 189 | TBD | TBD | 마우스 드래그 선택, Fill Down/Right |
208+
| **v0.12.0**| 213 | TBD | TBD | 비연속 선택, 컬럼 드래그, select 편집기, 찾기/바꾸기, 행 드래그, Fill Handle |
209+
| **v0.13.0** | 246 | TBD | TBD | 셀 포맷, 조건부 서식, 자동완성, 컬럼 숨기기/표시 UI |
195210

196211
---
197212

demo/index.html

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ <h1>flex-table</h1>
7272
<span id="undo-status"></span>
7373
</div>
7474

75-
<h2 style="margin-top: 32px;">v0.12 New Features</h2>
75+
<h2 style="margin-top: 32px;">v0.13 New Features</h2>
7676
<p class="info">
77-
Ctrl+Click (multi-select) &middot; Column drag reorder &middot; Select editor &middot;
78-
Find/Replace (Ctrl+F/H) &middot; Row drag reorder &middot; Fill Handle
77+
Format (number/date) &middot; Conditional Rules &middot; Autocomplete editor &middot;
78+
Column Hide/Show (right-click header)
7979
</p>
8080
<flex-table id="new-features-table" row-height="32" show-row-numbers editable></flex-table>
8181

@@ -239,23 +239,32 @@ <h2 style="margin-top: 32px;">Horizontal Virtual Scroll Test</h2>
239239
table.exportToFile('csv', 'flex-table-export.csv');
240240
});
241241

242-
// --- New Features Table ---
242+
// --- New Features Table (v0.13) ---
243243
const newFeaturesTable = document.getElementById('new-features-table');
244244
newFeaturesTable.columns = [
245-
{ key: 'name', header: 'Name', type: 'text', width: 150 },
245+
{ key: 'name', header: 'Name', type: 'text', width: 150, autocomplete: true },
246+
{ key: 'dept', header: 'Dept', type: 'text', width: 120, autocomplete: 'strict' },
247+
{ key: 'score', header: 'Score', type: 'number', width: 100,
248+
format: '#,##0.00',
249+
conditionalRules: [
250+
{ when: (v) => v < 50, style: { background: '#fdd', color: '#c0392b', fontWeight: 'bold' } },
251+
{ when: (v) => v >= 80, style: { background: '#d4edda', color: '#155724' } },
252+
]
253+
},
254+
{ key: 'revenue', header: 'Revenue', type: 'number', width: 130, format: '$#,##0.00' },
255+
{ key: 'rate', header: 'Rate', type: 'number', width: 80, format: '0.0%' },
256+
{ key: 'date', header: 'Date', type: 'date', width: 120, format: 'yyyy-MM-dd' },
246257
{ key: 'status', header: 'Status', type: 'select', options: ['Active', 'Inactive', 'Pending'], width: 120 },
247-
{ key: 'score', header: 'Score', type: 'number', width: 100 },
248-
{ key: 'region', header: 'Region', type: 'select', options: [
249-
{ label: 'North America', value: 'NA' },
250-
{ label: 'Europe', value: 'EU' },
251-
{ label: 'Asia Pacific', value: 'APAC' },
252-
], width: 140 },
253258
];
254-
newFeaturesTable.data = Array.from({ length: 20 }, (_, i) => ({
255-
name: `Item ${i + 1}`,
259+
const depts = ['Engineering', 'Sales', 'Marketing', 'HR', 'Finance'];
260+
newFeaturesTable.data = Array.from({ length: 30 }, (_, i) => ({
261+
name: `User ${i + 1}`,
262+
dept: depts[i % depts.length],
263+
score: Math.round(Math.random() * 100),
264+
revenue: Math.random() * 10000,
265+
rate: Math.random(),
266+
date: new Date(2026, i % 12, (i % 28) + 1),
256267
status: ['Active', 'Inactive', 'Pending'][i % 3],
257-
score: (i + 1) * 10,
258-
region: ['NA', 'EU', 'APAC'][i % 3],
259268
}));
260269

261270
// --- 100-Column Wide Table ---

src/flex-table.test.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,4 +2156,274 @@ describe('FlexTable', () => {
21562156
expect(el.data[2]['name']).toBe('foo');
21572157
});
21582158
});
2159+
2160+
describe('column format', () => {
2161+
it('applies string format pattern to cell display', async () => {
2162+
const el = createElement();
2163+
el.columns = [{ key: 'price', header: 'Price', type: 'number', format: '$#,##0.00' }];
2164+
el.data = [{ price: 1234.56 }];
2165+
await el.updateComplete;
2166+
2167+
const cell = el.shadowRoot!.querySelector('.ft-cell');
2168+
expect(cell!.textContent?.trim()).toMatch(/^\$[\d,]+\.\d{2}$/);
2169+
});
2170+
2171+
it('applies function format to cell display', async () => {
2172+
const el = createElement();
2173+
el.columns = [{ key: 'val', header: 'Val', format: (v) => `[${v}]` }];
2174+
el.data = [{ val: 42 }];
2175+
await el.updateComplete;
2176+
2177+
const cell = el.shadowRoot!.querySelector('.ft-cell');
2178+
expect(cell!.textContent?.trim()).toBe('[42]');
2179+
});
2180+
2181+
it('editor shows raw value, not formatted value', async () => {
2182+
const el = createElement();
2183+
el.columns = [{ key: 'price', header: 'Price', type: 'number', format: '$#,##0.00' }];
2184+
el.data = [{ price: 42 }];
2185+
await el.updateComplete;
2186+
2187+
(el as any)._editingCell = { row: 0, col: 0 };
2188+
await el.updateComplete;
2189+
2190+
const input = el.shadowRoot!.querySelector<HTMLInputElement>('.ft-editor');
2191+
expect(input).toBeTruthy();
2192+
expect(input!.value).toBe('42');
2193+
});
2194+
2195+
it('clipboard copy uses raw value, not formatted', async () => {
2196+
const el = createElement();
2197+
el.columns = [{ key: 'val', header: 'Val', format: (v) => `formatted:${v}` }];
2198+
el.data = [{ val: 'hello' }];
2199+
await el.updateComplete;
2200+
2201+
const { copyToClipboard } = await import('./clipboard/clipboard.js');
2202+
const text = copyToClipboard(el.data, el.columns, { startRow: 0, endRow: 0, startCol: 0, endCol: 0 });
2203+
expect(text).toBe('hello');
2204+
});
2205+
});
2206+
2207+
describe('autocomplete editor', () => {
2208+
function makeAcEl(autocomplete: boolean | 'strict' = true) {
2209+
const el = createElement();
2210+
el.columns = [{ key: 'tag', header: 'Tag', autocomplete }];
2211+
el.data = [{ tag: 'apple' }, { tag: 'apricot' }, { tag: 'banana' }];
2212+
return el;
2213+
}
2214+
2215+
it('shows dropdown with matching candidates when typing', async () => {
2216+
const el = makeAcEl();
2217+
await el.updateComplete;
2218+
2219+
(el as any)._editingCell = { row: 0, col: 0 };
2220+
(el as any)._autocompleteState = { candidates: ['apple', 'apricot'], activeIndex: -1 };
2221+
await el.updateComplete;
2222+
2223+
const dropdown = el.shadowRoot!.querySelector('.ft-autocomplete-dropdown');
2224+
expect(dropdown).toBeTruthy();
2225+
const items = el.shadowRoot!.querySelectorAll('.ft-autocomplete-item');
2226+
expect(items.length).toBe(2);
2227+
});
2228+
2229+
it('no dropdown when no candidates', async () => {
2230+
const el = makeAcEl();
2231+
await el.updateComplete;
2232+
2233+
(el as any)._editingCell = { row: 0, col: 0 };
2234+
(el as any)._autocompleteState = null;
2235+
await el.updateComplete;
2236+
2237+
const dropdown = el.shadowRoot!.querySelector('.ft-autocomplete-dropdown');
2238+
expect(dropdown).toBeNull();
2239+
});
2240+
2241+
it('_getAutocompleteCandidates returns unique matching values', async () => {
2242+
const el = makeAcEl();
2243+
await el.updateComplete;
2244+
2245+
const candidates = (el as any)._getAutocompleteCandidates(el.columns[0], 'ap');
2246+
expect(candidates).toContain('apple');
2247+
expect(candidates).toContain('apricot');
2248+
expect(candidates).not.toContain('banana');
2249+
});
2250+
2251+
it('_getAutocompleteCandidates with empty text returns all unique values', async () => {
2252+
const el = makeAcEl();
2253+
await el.updateComplete;
2254+
2255+
const candidates = (el as any)._getAutocompleteCandidates(el.columns[0], '');
2256+
expect(candidates).toHaveLength(3);
2257+
});
2258+
2259+
it('ArrowDown increases activeIndex', async () => {
2260+
const el = makeAcEl();
2261+
await el.updateComplete;
2262+
2263+
(el as any)._editingCell = { row: 0, col: 0 };
2264+
(el as any)._autocompleteState = { candidates: ['apple', 'apricot'], activeIndex: -1 };
2265+
await el.updateComplete;
2266+
2267+
const input = el.shadowRoot!.querySelector<HTMLInputElement>('.ft-editor');
2268+
input!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
2269+
expect((el as any)._autocompleteState.activeIndex).toBe(0);
2270+
});
2271+
2272+
it('strict mode rejects value not in list', async () => {
2273+
const el = makeAcEl('strict');
2274+
await el.updateComplete;
2275+
2276+
(el as any)._editingCell = { row: 0, col: 0 };
2277+
(el as any)._editing.start({ row: 0, col: 0 }, 'apple');
2278+
(el as any)._applyEdit('mango');
2279+
await el.updateComplete;
2280+
2281+
// Should not update the value
2282+
expect(el.data[0]['tag']).toBe('apple');
2283+
});
2284+
2285+
it('strict mode accepts value in list', async () => {
2286+
const el = makeAcEl('strict');
2287+
await el.updateComplete;
2288+
2289+
(el as any)._editingCell = { row: 0, col: 0 };
2290+
(el as any)._editing.start({ row: 0, col: 0 }, 'apple');
2291+
(el as any)._applyEdit('banana');
2292+
await el.updateComplete;
2293+
2294+
expect(el.data[0]['tag']).toBe('banana');
2295+
});
2296+
});
2297+
2298+
describe('column hide/show UI', () => {
2299+
function makeCols() {
2300+
const el = createElement();
2301+
el.columns = [
2302+
{ key: 'a', header: 'A' },
2303+
{ key: 'b', header: 'B' },
2304+
{ key: 'c', header: 'C' },
2305+
];
2306+
el.data = [{ a: 1, b: 2, c: 3 }];
2307+
return el;
2308+
}
2309+
2310+
it('hideColumn hides a column and fires column-visibility-change', async () => {
2311+
const el = makeCols();
2312+
await el.updateComplete;
2313+
2314+
const events: CustomEvent[] = [];
2315+
el.addEventListener('column-visibility-change', e => events.push(e as CustomEvent));
2316+
2317+
el.hideColumn('b');
2318+
await el.updateComplete;
2319+
2320+
expect(el.columns.find(c => c.key === 'b')!.hidden).toBe(true);
2321+
expect(events).toHaveLength(1);
2322+
expect(events[0].detail.key).toBe('b');
2323+
expect(events[0].detail.hidden).toBe(true);
2324+
});
2325+
2326+
it('showColumn shows a hidden column and fires column-visibility-change', async () => {
2327+
const el = makeCols();
2328+
await el.updateComplete;
2329+
el.hideColumn('b');
2330+
await el.updateComplete;
2331+
2332+
const events: CustomEvent[] = [];
2333+
el.addEventListener('column-visibility-change', e => events.push(e as CustomEvent));
2334+
2335+
el.showColumn('b');
2336+
await el.updateComplete;
2337+
2338+
expect(el.columns.find(c => c.key === 'b')!.hidden).toBe(false);
2339+
expect(events[0].detail.hidden).toBe(false);
2340+
});
2341+
2342+
it('getHiddenColumns returns only hidden columns', async () => {
2343+
const el = makeCols();
2344+
await el.updateComplete;
2345+
el.hideColumn('b');
2346+
await el.updateComplete;
2347+
2348+
const hidden = el.getHiddenColumns();
2349+
expect(hidden).toHaveLength(1);
2350+
expect(hidden[0].key).toBe('b');
2351+
});
2352+
2353+
it('header-context-menu event fires on header right-click', async () => {
2354+
const el = makeCols();
2355+
await el.updateComplete;
2356+
2357+
const events: CustomEvent[] = [];
2358+
el.addEventListener('header-context-menu', e => events.push(e as CustomEvent));
2359+
2360+
(el as any)._onHeaderContextMenu(
2361+
{ preventDefault: () => {}, clientX: 100, clientY: 50 } as MouseEvent,
2362+
el.columns[0]
2363+
);
2364+
2365+
expect(events).toHaveLength(1);
2366+
expect(events[0].detail.key).toBe('a');
2367+
});
2368+
2369+
it('_renderHeaderContextMenu shows hide-column item', async () => {
2370+
const el = makeCols();
2371+
await el.updateComplete;
2372+
2373+
(el as any)._headerMenu = { key: 'a', x: 100, y: 50, hiddenNeighbors: [] };
2374+
await el.updateComplete;
2375+
2376+
const menu = el.shadowRoot!.querySelector('.ft-header-menu');
2377+
expect(menu).toBeTruthy();
2378+
expect(menu!.textContent).toContain('Hide');
2379+
});
2380+
2381+
it('clicking hide in menu hides the column', async () => {
2382+
const el = makeCols();
2383+
await el.updateComplete;
2384+
2385+
(el as any)._headerMenu = { key: 'b', x: 100, y: 50, hiddenNeighbors: [] };
2386+
await el.updateComplete;
2387+
2388+
const items = el.shadowRoot!.querySelectorAll<HTMLElement>('.ft-header-menu-item');
2389+
items[0].click();
2390+
await el.updateComplete;
2391+
2392+
expect(el.columns.find(c => c.key === 'b')!.hidden).toBe(true);
2393+
expect((el as any)._headerMenu).toBeNull();
2394+
});
2395+
});
2396+
2397+
describe('conditional formatting', () => {
2398+
it('applies background style when condition is true', async () => {
2399+
const el = createElement();
2400+
el.columns = [{
2401+
key: 'score', header: 'Score', type: 'number',
2402+
conditionalRules: [{ when: (v) => (v as number) < 60, style: { background: '#fdd', color: 'red' } }]
2403+
}];
2404+
el.data = [{ score: 40 }, { score: 80 }];
2405+
await el.updateComplete;
2406+
2407+
const cells = el.shadowRoot!.querySelectorAll<HTMLElement>('.ft-cell');
2408+
expect(cells[0].style.background).toBe('rgb(255, 221, 221)');
2409+
expect(cells[1].style.background).toBe('');
2410+
});
2411+
2412+
it('merges multiple matching rules', async () => {
2413+
const el = createElement();
2414+
el.columns = [{
2415+
key: 'v', header: 'V',
2416+
conditionalRules: [
2417+
{ when: (v) => (v as number) > 0, style: { background: 'blue' } },
2418+
{ when: (v) => (v as number) > 0, style: { color: 'white' } },
2419+
]
2420+
}];
2421+
el.data = [{ v: 1 }];
2422+
await el.updateComplete;
2423+
2424+
const cell = el.shadowRoot!.querySelector<HTMLElement>('.ft-cell');
2425+
expect(cell!.style.background).toBe('blue');
2426+
expect(cell!.style.color).toBe('white');
2427+
});
2428+
});
21592429
});

0 commit comments

Comments
 (0)