Cell virtual-list options
type: "virtual-list" renders multiple items in a cell; it is unavailable in headers. List selection, expansion, and search are temporary and may reset when table virtualization recreates the cell.
Display and selection
Section titled “Display and selection”| props | Default | Behavior |
|---|---|---|
| itemHeight | 28px | Item height, minimum 1 |
| limit | 5 | Initial preview count, minimum 1 |
| height | itemHeight × 5 | Bounded expanded scroll height; number or CSS length |
| more | false | true renders the overflow … as an expand button |
| searchable | false | Shows search when true and this row alone is selected |
More first selects the row exclusively, then expands the list. With more false, selecting the row exclusively also virtualizes an overflowing list. Item clicks select the row; Ctrl/Cmd toggles it and Shift selects a visible row range. Enter/Space follow the same rules. The search value differs from the value in item-click events.
Try it
Section titled “Try it”Expand More and search. The visible list changes while the 12 source items remain unchanged.
import { useMemo, useRef, useState } from 'react';import { CominsTable, type CominsTableColumn, type CominsTableRef, type CominsVirtualListItem } from 'comins-table';import 'comins-table/styles.css';
type Row = { id: string; name: string; role: string; active: boolean; ready: boolean; progress: number; items: CominsVirtualListItem[] };const initialRows: Row[] = ['alpha', 'beta'].map((id, index) => ({ id, name: index ? 'Beta' : 'Alpha', role: 'reader', active: false, ready: false, progress: index ? 75 : 40, items: Array.from({ length: 12 }, (_, i) => ({ value: `${id}-${i + 1}`, label: `Task ${i + 1}`, searchText: `task-${i + 1}` })),}));
export default function TableComponentsDemo({ lang = 'ko', mode = 'inputs' }: { lang?: string; mode?: 'inputs' | 'list' }) { const ko = lang === 'ko'; const table = useRef<CominsTableRef<Row>>(null); const [rows, setRows] = useState(initialRows); const [events, setEvents] = useState<string[]>([]); const [clicks, setClicks] = useState({ cell: 0, row: 0 }); const record = (message: string) => setEvents(previous => [...previous.slice(-11), message]); const update = (id: string, patch: Partial<Row>) => setRows(previous => previous.map(row => row.id === id ? { ...row, ...patch } : row)); const columns = useMemo<CominsTableColumn<Row>[]>(() => { const identity: CominsTableColumn<Row> = { field: 'id', label: 'ID', width: 130, header: { components: [{ type: 'menu', id: 'actions', direction: 'right', props: { children: '⋯', 'aria-label': ko ? '헤더 메뉴' : 'Header menu' }, items: [{ type: 'label', label: ko ? '동작' : 'Actions' }, { value: 'inspect', label: ko ? '상태 확인' : 'Inspect state' }, { type: 'divider' }, { value: 'disabled', label: ko ? '비활성 항목' : 'Disabled item', disabled: true }], onOpenChange: ({ open }) => record(`menu:${open}`), onSelect: ({ value }) => record(`menu-select:${value}`), }] }, }; if (mode === 'list') return [identity, { field: 'items', label: ko ? '작업 목록' : 'Tasks', width: 420, cell: { components: [{ type: 'virtual-list', id: 'tasks', items: ({ row }) => row.data.items, props: { limit: 3, itemHeight: 28, height: 140, more: true, searchable: true, 'aria-label': ko ? '작업 목록' : 'Tasks' }, searchFilter: ({ item, value }) => (item.searchText ?? '').includes(value.trim().toLowerCase()), onClickItem: ({ row, itemIndex, value }) => record(`item:${row.id}:${itemIndex}:${value}`), }] }, }]; return [identity, { field: 'name', label: ko ? '이름' : 'Name', width: 180, cell: { components: [{ type: 'input', id: 'name', props: ({ value, row }) => ({ value: String(value), 'aria-label': `${row.id} name` }), onChange: ({ row, value }) => { update(String(row.id), { name: value }); record(`commit:${row.id}:${value}`); }, onValueChange: ({ row, value }) => record(`value:${row.id}:${value}`), }] } }, { field: 'role', label: ko ? '역할' : 'Role', width: 140, cell: { components: [{ type: 'select', id: 'role', options: [{ value: 'reader', label: ko ? '읽기' : 'Reader' }, { value: 'editor', label: ko ? '편집' : 'Editor' }], props: ({ value, row }) => ({ value: String(value), 'aria-label': `${row.id} role` }), onValueChange: ({ row, value }) => { update(String(row.id), { role: value }); record(`role:${value}`); }, }] } }, { field: 'active', label: ko ? '활성' : 'Active', width: 90, cell: { components: [{ type: 'checkbox', id: 'active', props: ({ value, row }) => ({ checked: Boolean(value), 'aria-label': `${row.id} active` }), onCheckedChange: ({ row, checked }) => { update(String(row.id), { active: checked }); record(`active:${checked}`); }, }] } }, { field: 'role', id: 'role-radio', label: ko ? '역할 라디오' : 'Role radio', width: 190, cell: { components: [{ type: 'radio', id: 'role-radio', options: [{ value: 'reader', label: ko ? '읽기' : 'Reader' }, { value: 'editor', label: ko ? '편집' : 'Editor' }], props: ({ value, row }) => ({ value: String(value), 'aria-label': `${row.id} role radio` }), onValueChange: ({ row, value }) => { update(String(row.id), { role: value }); record(`radio:${value}`); }, }] } }, { field: 'ready', label: ko ? '준비' : 'Ready', width: 90, cell: { components: [{ type: 'toggle', id: 'ready', props: ({ value, row }) => ({ checked: Boolean(value), children: value ? 'ON' : 'OFF', 'aria-label': `${row.id} ready` }), onCheckedChange: ({ row, checked }) => { update(String(row.id), { ready: checked }); record(`ready:${checked}`); }, }] } }, { field: 'progress', label: ko ? '진행률' : 'Progress', width: 120, cell: { components: [{ type: 'progress', id: 'progress', props: ({ value }) => ({ value: Number(value), max: 100 }) }] } }, { field: 'id', id: 'action', label: ko ? '동작' : 'Action', width: 110, cell: { components: [{ type: 'button', id: 'inspect', props: ({ row }) => ({ children: ko ? '확인' : 'Inspect', 'aria-label': `${row.id} inspect` }), onClick: ({ row }) => record(`button:${row.id}`), }] } }, ]; }, [ko, mode]); return <div className="demo" data-demo={`table-components-${mode}`}> <div className="demo-toolbar"> <button onClick={() => table.current?.setSelectedRows([0])}>{ko ? '첫 행 선택' : 'Select first row'}</button> <button onClick={() => { setRows(initialRows); setEvents([]); setClicks({ cell: 0, row: 0 }); table.current?.setSelectedRows([]); }}>{ko ? '초기화' : 'Reset'}</button> </div> <div className="table-viewport" style={mode === 'list' ? { height: 380 } : undefined}> <CominsTable ref={table} columns={columns} data={rows} getRowId={row => row.id} onChangeData={setRows} getRowHeight={mode === 'list' ? () => 'auto' : undefined} onClickCell={() => setClicks(value => ({ ...value, cell: value.cell + 1 }))} onClickRow={() => setClicks(value => ({ ...value, row: value.row + 1 }))} /> </div> <p className="demo-status" data-component-clicks>{ko ? '일반 클릭' : 'Ordinary clicks'}: cell {clicks.cell} / row {clicks.row}</p> <details open><summary>{ko ? '이벤트 순서' : 'Event order'}</summary><pre data-component-events>{JSON.stringify(events, null, 2)}</pre></details> <details><summary>{ko ? '부모 데이터' : 'Parent data'}</summary><pre data-component-data>{JSON.stringify(rows.map(({ items, ...row }) => ({ ...row, itemCount: items.length })), null, 2)}</pre></details> </div>;}CominsVirtualListComponentConfig
Section titled “CominsVirtualListComponentConfig”type: "virtual-list";Required virtual-list discriminator.
items: CominsVirtualListItems<TPayload>;Required item array or cell-payload resolver. Keep value unique within the list.
props?: CominsTableComponentProps<TPayload, React.HTMLAttributes<HTMLDivElement> & { height?: number | string; itemHeight?: number; limit?: number; more?: boolean; searchable?: boolean; }>;Div HTML attributes plus height/itemHeight/limit/more/searchable above. Accepts an object or resolver.
searchFilter
Section titled “searchFilter”searchFilter?: (payload: CominsVirtualListSearchFilterPayload) => boolean;Default search performs case-insensitive substring matching on label text. A custom function receives item, itemIndex, and search text value, returning boolean.
onClickItem
Section titled “onClickItem”onClickItem?: (payload: TPayload & { event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>; item: CominsVirtualListItem; itemIndex: number; value: CominsComponentPrimitiveValue; }) => void;Emits item/itemIndex/selected item.value plus cell payload. event is MouseEvent or KeyboardEvent.
onContextMenuItem
Section titled “onContextMenuItem”onContextMenuItem?: (payload: TPayload & { event: React.MouseEvent<HTMLButtonElement>; item: CominsVirtualListItem; itemIndex: number; value: CominsComponentPrimitiveValue; }) => void;Item context-menu event. Call event.preventDefault() when showing a custom menu.
CominsVirtualListItem
Section titled “CominsVirtualListItem”value: CominsComponentPrimitiveValue;Required string/number/boolean item identity.
label: React.ReactNode;Required display content and default search target. Complex ReactNodes may not yield searchable text.
data?: TItem;Optional application data attached to the item. Does not automatically edit table data.
disabled
Section titled “disabled”disabled?: boolean;Default false. Prevents item activation.
searchText
Section titled “searchText”searchText?: string;Present in the published type, but 0.2.0 default search does not read it. Use item.searchText explicitly in searchFilter if needed.
CominsVirtualListSearchFilterPayload
Section titled “CominsVirtualListSearchFilterPayload”item: CominsVirtualListItem;Original item being tested.
itemIndex
Section titled “itemIndex”itemIndex: number;Index in the original items array before filtering.
value: string;Current search-input string, distinct from item.value.
CominsVirtualListItems
Section titled “CominsVirtualListItems”export type CominsVirtualListItems<TPayload> = Array<CominsVirtualListItem> | ((payload: TPayload) => Array<CominsVirtualListItem>);Array or item resolver receiving the cell payload.