import { booleanAttribute, ChangeDetectionStrategy, Component, computed, contentChildren, inject, input, model, output, signal, ViewEncapsulation, } from "@angular/core"; import { NgTemplateOutlet } from "@angular/common"; import { SdSheetColumn } from "./sd-sheet-column"; import { SdCheckbox } from "../../controls/checkbox/sd-checkbox"; import { NgIcon } from "@ng-icons/core"; import { tablerArrowRight, tablerArrowsSort, tablerCaretRight, tablerSettings, tablerSortAscending, tablerSortDescending, } from "@ng-icons/tabler-icons"; import { useSheetLayoutEngine } from "./useSheetLayoutEngine"; import { useSheetColumnFixing } from "./useSheetColumnFixing"; import { useSelectionManager } from "../../core/selection/useSelectionManager"; import { type SortingDef, useSortingManager } from "../../core/selection/useSortingManager"; import { SdPagination } from "../../controls/pagination/sd-pagination"; import { SdAnchor } from "../../controls/button/sd-anchor"; import { SdButton } from "../../controls/button/sd-button"; import type { SdSheetCellKeydownEventParam, SdSheetConfig, SdSheetHeaderDef, SdSheetItemKeydownEventParam, } from "./types"; import type { SdResizeEvent } from "../../core/events/sd-resize"; import { SdResizeDirective } from "../../core/events/sd-resize"; import { injectSdSystemConfigResource } from "../../core/config/injectSdSystemConfigResource"; import { injectSheetDomAccessor } from "./injectSheetDomAccessor"; import { useSheetCellAgent } from "./useSheetCellAgent"; import { injectSheetColumnResizing } from "./injectSheetColumnResizing"; import { useSheetDisplayPipeline } from "./useSheetDisplayPipeline"; import { useSheetCellStyling } from "./useSheetCellStyling"; import { useSheetFocusIndicator } from "./useSheetFocusIndicator"; import { injectSheetSelectRowIndicator } from "./injectSheetSelectRowIndicator"; import { SdModalProvider } from "../../core/modal/sd-modal.provider"; import { SdSheetConfigModal } from "./sd-sheet-config.modal"; import { SdEvents } from "../../core/events/sd-events"; @Component({ selector: "sd-sheet", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, standalone: true, imports: [ NgTemplateOutlet, SdCheckbox, NgIcon, SdPagination, SdAnchor, SdButton, SdResizeDirective, ], hostDirectives: [ { directive: SdEvents, outputs: ["keydown.capture", "focus.capture", "blur.capture"] }, ], host: { "class": "flex-column fill", "[attr.data-sd-inset]": "inset()", "[attr.data-sd-focus-mode]": "focusMode()", "(keydown.capture)": "onKeydownCapture($event)", "(focus.capture)": "onFocusCapture($event)", "(dblclick)": "onDblClick($event)", "(blur.capture)": "onBlurCapture($event)", }, template: ` @if ((key() || effectivePageCount() > 1) && !hideConfigBar()) {
@if (key()) { } @if (effectivePageCount() > 1) { }
}
@for (row of layout.headerDefTable(); track $index; let rowIdx = $index) { @if (rowIdx === 0) { @let _fc = expanding.hasExpandable() ? -2 : -1; @if (expanding.hasExpandable()) { } } @for (cell of row; track $index) { @if (!cell.isLastRow) { } @else { } } } @if (layout.hasSummary()) { @let _sfc = expanding.hasExpandable() ? -2 : -1; @if (expanding.hasExpandable()) { } @for (colDef of layout.columnDefs(); track colDef.key; let c = $index) { } } @for ( item of displayItems(); track trackByFn()(item, $index) ?? item; let rowIdx = $index ) { @let _fc = expanding.hasExpandable() ? -2 : -1; @if (expanding.hasExpandable()) { } @for (colDef of layout.columnDefs(); track colDef.key; let colIdx = $index) { } }
@if (selection.hasSelectable() && selectMode() === "multi") { }
{{ cell.text }}
@if (cell.colDef && getColumnHeaderTpl(cell.colDef.key); as headerTpl) {
} @else {
{{ cell.text }}
} @if (cell.colDef && !cell.colDef.disableSorting) { @let _sortDef = getSortDef(cell.colDef.key);
@if (_sortDef?.desc === false) { } @else if (_sortDef?.desc === true) { } @else { } @if (_sortDef?.indexText) { {{ _sortDef?.indexText }} }
}
@if (cell.colDef && !cell.colDef.disableResizing) {
}
@if (getColumnSummaryTpl(colDef.key); as tpl) { }
@if (selectMode() === "multi") { @let _selectable = selection.getSelectable(item); } @else if (selectMode() === "single") { @let _selectable = selection.getSelectable(item); @if (_selectable === true && selection.getCanChangeFn(item)) { } } @let itemDef = getItemDef(item); @if (itemDef.depth > 0) {
} @if (itemDef.hasChildren) { }
@if (getColumnCellTpl(colDef.key); as tpl) { }
`, styles: [ /* language=SCSS */ ` @use "../../../scss/commons/mixins"; $z-index-fixed: 2; $z-index-head: 3; $z-index-head-fixed: 4; $z-index-select-row-indicator: 5; $z-index-focus-row-indicator: 6; $z-index-resize-indicator: 7; $border-color: var(--theme-gray-lighter); $border-color-dark: var(--theme-gray-lighter); $border-color-darker: var(--theme-gray-light); $border-radius: var(--border-radius-default); sd-sheet { border: 1px solid $border-color-dark; border-radius: $border-radius; > ._tool { background: var(--control-color); border-top-left-radius: $border-radius; border-top-right-radius: $border-radius; border-bottom: 1px solid $border-color-dark; } > ._sheet-container { position: relative; background: var(--sheet-bg); border-bottom-left-radius: $border-radius; border-bottom-right-radius: $border-radius; overflow: auto; > table { border-spacing: 0; table-layout: fixed; margin-right: 2px; margin-bottom: 2px; border-bottom-right-radius: $border-radius; > * > tr > *:last-child { border-right: 1px solid $border-color-dark; } > * > tr:last-child > * { border-bottom: 1px solid $border-color-dark; } > *:last-child > tr:last-child > td:last-child { border-bottom-right-radius: $border-radius; overflow: hidden; } > * > tr > * { border-right: 1px solid $border-color; border-bottom: 1px solid $border-color; white-space: nowrap; overflow: hidden; padding: 0; position: relative; &._fixed:has(+ :not(._fixed)) + :not(._fixed):not([data-c="0"]) { border-left: 1px solid $border-color; } &._feature-cell { background: var(--theme-gray-lightest); min-width: calc(var(--font-size-default) + 2px + var(--sheet-ph) * 2); padding: var(--sheet-pv) var(--sheet-ph); text-align: left; > ng-icon { cursor: pointer; color: var(--text-trans-lightest); } } &._fixed { position: sticky; left: 0; &:has(+ :not(._fixed)) { border-right: 1px solid $border-color-dark; } } } > thead { position: sticky; top: 0; z-index: $z-index-head; > tr > th { position: relative; background: var(--theme-gray-lightest); vertical-align: middle; &._fixed { z-index: $z-index-head-fixed; } &._last-depth { border-bottom: 1px solid $border-color-dark; } &._feature-cell { border-bottom: 1px solid $border-color-dark; } &._sort { cursor: pointer; &:hover { text-decoration: underline; } } > ._headerContent { > ._sort-icon { padding: var(--gap-xs) var(--gap-xs) var(--gap-xs) 0; background-color: var(--theme-gray-lightest); } } > ._resizer { position: absolute; top: 0; right: 0; bottom: 0; width: 2px; cursor: ew-resize; } } &:has(> tr._summary-row) { > tr > th._last-depth { border-bottom: 1px solid $border-color; } > tr._summary-row > th { background: var(--theme-warning-lightest); text-align: left; border-bottom: 1px solid $border-color-dark; } } } > tbody > tr > td { background: var(--control-color); vertical-align: top; &._fixed { z-index: $z-index-fixed; } > ._depth-indicator { display: inline-block; margin-top: 0.4em; width: 0.5em; height: 0.5em; border-left: 1px solid var(--text-trans-default); border-bottom: 1px solid var(--text-trans-default); vertical-align: top; } } } > ._focus-row-indicator { display: none; position: absolute; pointer-events: none; background: rgba(158, 158, 158, 0.1); z-index: $z-index-focus-row-indicator; > ._focus-cell-indicator { position: absolute; border: 2px solid var(--theme-primary-default); border-radius: $border-radius; } } > ._resize-indicator { display: none; position: absolute; pointer-events: none; top: 0; height: 100%; border: 1px dotted $border-color-darker; z-index: $z-index-resize-indicator; } > ._select-row-indicator-container { display: none; position: absolute; pointer-events: none; top: 0; left: 0; width: 100%; height: 100%; z-index: $z-index-select-row-indicator; > ._select-row-indicator { display: block; left: 0; position: absolute; pointer-events: none; background: var(--theme-primary-default); opacity: 0.1; } } } ._p-sheet { padding: var(--sheet-pv) var(--sheet-ph); } &[data-sd-focus-mode="row"] { > ._sheet-container > ._focus-row-indicator > ._focus-cell-indicator { display: none !important; } } &[data-sd-inset="true"] { border: none; border-radius: 0; } } `, ], }) export class SdSheet { // Inputs key = input(); items = input([]); trackByFn = input<(item: TItem, index: number) => unknown>((item) => item); selectMode = input<"single" | "multi">(); autoSelect = input<"click" | "focus">(); getItemSelectableFn = input<(item: TItem) => boolean | string>(); getChildrenFn = input<(item: TItem, index: number) => TItem[] | undefined>(); useAutoSort = input(false, { transform: booleanAttribute }); visiblePageCount = input(10); totalPageCount = input(0); itemsPerPage = input(0); focusMode = input<"row" | "cell">("cell"); inset = input(false, { transform: booleanAttribute }); contentStyle = input(); getItemCellClassFn = input<(item: TItem, colKey: string) => string>(); getItemCellStyleFn = input<(item: TItem, colKey: string) => string | undefined>(); hideConfigBar = input(false, { transform: booleanAttribute }); // Outputs itemKeydown = output>(); cellKeydown = output>(); // Models selectedKeys = model([]); expandedItems = model([]); sorts = model([]); // Re-exported from useSortingManager currentPage = model(0); // Content query columnControls = contentChildren(SdSheetColumn); columnControlsInput = input([]); private readonly _effectiveColumnControls = computed(() => [ ...this.columnControls(), ...this.columnControlsInput(), ]); // Injected providers private readonly _sdModal = inject(SdModalProvider); // DOM accessor & cell agent domAccessor = injectSheetDomAccessor(); cellAgent = useSheetCellAgent({ domAccessor: this.domAccessor }); // Config resource private readonly _configResource = injectSdSystemConfigResource({ key: this.key, }); // Resizing composable private readonly _resizing = injectSheetColumnResizing({ domAccessor: this.domAccessor, configResource: this._configResource, }); _isResizing = this._resizing.isResizing; _resizeIndicatorLeft = this._resizing.indicatorLeft; onResizerMousedown = this._resizing.onMousedown; onResizerDblClick = this._resizing.onDblClick; // Layout engine layout = useSheetLayoutEngine({ columnControls: this._effectiveColumnControls, config: computed(() => this._configResource.value()), }); // Sorting manager sorting = useSortingManager({ sorts: this.sorts, }); // Display pipeline (sort → page → expand → display) private readonly _pipeline = useSheetDisplayPipeline({ items: this.items, useAutoSort: this.useAutoSort, sortItems: (items) => this.sorting.sort(items), itemsPerPage: this.itemsPerPage, currentPage: this.currentPage, totalPageCount: this.totalPageCount, expandedItems: this.expandedItems, getChildrenFn: this.getChildrenFn, }); effectivePageCount = this._pipeline.effectivePageCount; expanding = this._pipeline.expanding; displayItems = this._pipeline.displayItems; // Column fixing (DOM 측정 기반 통합 fixedLeftMap) private readonly _fixedCellWidths = signal(new Map()); fixing = useSheetColumnFixing({ columnDefs: this.layout.columnDefs, cellWidths: this._fixedCellWidths, hasExpandable: this.expanding.hasExpandable, }); // Cell styling composable private readonly _styling = useSheetCellStyling({ columnDefs: this.layout.columnDefs, fixedLeftMap: this.fixing.fixedLeftMap, getItemCellStyleFn: this.getItemCellStyleFn, getItemCellClassFn: this.getItemCellClassFn, getChildrenFn: this.getChildrenFn, expandingDef: (item) => this.expanding.def(item), isCellEditMode: (addr) => this.cellAgent.isCellEditMode(addr), }); getHeaderCellStyle = this._styling.getHeaderCellStyle; getCellStyle = this._styling.getCellStyle; getFixedCellStyle = this._styling.getFixedCellStyle; getCellStyleWithIndent = this._styling.getCellStyleWithIndent; getDataCellClass = this._styling.getDataCellClass; // Focus indicator private readonly _focusIndicator = useSheetFocusIndicator({ domAccessor: this.domAccessor, }); // Select row indicator private readonly _selectRowIndicator = injectSheetSelectRowIndicator({ domAccessor: this.domAccessor, selectedKeys: this.selectedKeys, displayItems: this.displayItems, trackByFn: this.trackByFn, }); // Selection manager selection = useSelectionManager({ displayItems: this.displayItems, selectedKeys: this.selectedKeys, selectMode: this.selectMode, getItemSelectableFn: this.getItemSelectableFn, trackByFn: this.trackByFn, }); // Icons icons = { tablerSettings, tablerCaretRight, tablerArrowsSort, tablerSortAscending, tablerSortDescending, tablerArrowRight, }; private readonly _columnControlMap = computed(() => { const map = new Map(); for (const col of this._effectiveColumnControls()) { map.set(col.key(), col); } return map; }); getColumnHeaderTpl(key: string) { const col = this._columnControlMap().get(key); return col?.headerTplRef() ?? null; } getColumnCellTpl(key: string) { const col = this._columnControlMap().get(key); return col?.cellTplRef() ?? null; } getColumnSummaryTpl(key: string) { const col = this._columnControlMap().get(key); return col?.summaryTplRef() ?? null; } getSelectableTooltip(item: TItem): string | undefined { const result = this.selection.getSelectable(item); if (typeof result === "string") return result; return undefined; } onRowClick(item: TItem): void { if (this.autoSelect() === "click") { this.selection.select(item); } } onCellClick(event: Event, item: TItem): void { if (this.autoSelect() === "click") { this.selection.select(item); } } onCellFocus(item: TItem): void { if (this.autoSelect() === "focus") { this.selection.select(item); } } onHeaderClick(event: MouseEvent, cell: SdSheetHeaderDef): void { if (event.timeStamp - this._resizing.lastResizeEndTimeStamp() < 50) return; if (cell.colDef == null) return; if (cell.colDef.disableSorting) return; this.sorting.toggle(cell.colDef.key, event.shiftKey); } getSortDef(key: string) { return this.sorting.defMap().get(key) ?? null; } getItemDef(item: TItem) { return this.expanding.def(item); } // PERF-005: Set-based lookup for O(1) isExpanded check private readonly _expandedSet = computed(() => new Set(this.expandedItems())); isExpanded(item: TItem): boolean { return this._expandedSet().has(item); } getAriaExpanded(item: TItem): string | null { if (this.getChildrenFn() == null) return null; const def = this.getItemDef(item); if (!def.hasChildren) return null; return this.isExpanded(item) ? "true" : "false"; } onExpandClick(event: Event, item: TItem): void { event.stopPropagation(); this.expanding.toggle(item); } getAriaSortValue(cell: SdSheetHeaderDef): string | null { if (cell.colDef == null) return null; const sortDef = this.sorting.defMap().get(cell.colDef.key); if (sortDef == null) return null; return sortDef.desc ? "descending" : "ascending"; } async onKeydownCapture(event: KeyboardEvent): Promise { await this.cellAgent.handleKeydownCapture(event); } onDblClick(event: MouseEvent): void { this.cellAgent.handleCellDoubleClick(event); } onFocusCapture(event: FocusEvent): void { this._autoScrollOnFocus(event); this._focusIndicator.redraw(); } onBlurCapture(event: FocusEvent): void { this.cellAgent.handleBlurCapture(event); this._focusIndicator.redraw(); } onContainerScroll(): void { this._focusIndicator.redraw(); } onTableResize(event: SdResizeEvent): void { if (!event.widthChanged && !event.heightChanged) return; this._focusIndicator.redraw(); this._selectRowIndicator.redraw(); } onFixedCellResize(event: SdResizeEvent, key: number): void { if (!event.widthChanged) return; const width = event.target.offsetWidth; this._fixedCellWidths.update((m) => { const newMap = new Map(m); newMap.set(key, width); return newMap; }); } onSelectorMouseDown(event: MouseEvent, r: number): void { if (!event.shiftKey) return; event.preventDefault(); event.stopPropagation(); // SdCheckbox host의 (click) 자체 토글이 microtask로 적용한 범위 selection을 되돌리는 것을 방지 const checkboxEl = event.currentTarget; if (checkboxEl instanceof HTMLElement) { checkboxEl.addEventListener( "click", (e) => { e.preventDefault(); e.stopImmediatePropagation(); }, { once: true, capture: true }, ); } const focusedEl = document.activeElement; if (!(focusedEl instanceof HTMLElement)) return; const focusedTrEl = focusedEl.tagName.toLowerCase() === "tr" ? focusedEl : focusedEl.closest("tr"); if (!(focusedTrEl instanceof HTMLTableRowElement)) return; const frAttr = focusedTrEl.getAttribute("data-r"); if (frAttr == null) return; const fr = parseInt(frAttr, 10); if (Number.isNaN(fr)) return; const items = this.displayItems(); const isSelect = this.selection.isSelected(items[fr]); queueMicrotask(() => { for (let i = Math.min(fr, r); i <= Math.max(fr, r); i++) { if (isSelect) { this.selection.select(items[i]); } else { this.selection.deselect(items[i]); } } }); const row = this.domAccessor.getRow(r); row?.querySelector("[tabindex]")?.focus(); } private _autoScrollOnFocus(event: FocusEvent): void { if (!(event.target instanceof HTMLElement)) return; const tdEl = event.target.tagName.toLowerCase() === "td" ? event.target : event.target.closest("td"); if (!(tdEl instanceof HTMLTableCellElement)) return; if (tdEl.classList.contains("_fixed")) return; const containerEl = this.domAccessor.getContainer(); const theadEl = this.domAccessor.getTHead(); const fixedHeaders = this.domAccessor.getLastDepthFixedHeaders(); containerEl.scrollIntoViewIfNeeded( { top: tdEl.offsetTop, left: tdEl.offsetLeft }, { top: theadEl.offsetHeight, left: fixedHeaders.reduce((sum, el) => sum + el.offsetWidth, 0), }, ); } onItemKeydown(event: KeyboardEvent, item: TItem): void { this.itemKeydown.emit({ item, event }); } onCellKeydown(event: KeyboardEvent, item: TItem, colKey: string): void { this.cellKeydown.emit({ item, key: colKey, event }); } async onConfigButtonClick(): Promise { const result = await this._sdModal.showAsync({ title: "시트 설정", type: SdSheetConfigModal, inputs: { sheetKey: this.key()!, controls: this._effectiveColumnControls(), config: this._configResource.value(), }, }); if (result != null) { this._configResource.set(result); } } } // Re-export SortingDef from useSortingManager for convenience export type { SortingDef } from "../../core/selection/useSortingManager";