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;
| 1 ? layout.headerFeatureRowSpan() : null
"
[attr.data-c]="_fc"
[style.left.px]="fixing.fixedLeftMap().get(_fc)"
(sdResize)="onFixedCellResize($event, _fc)"
>
@if (selection.hasSelectable() && selectMode() === "multi") {
}
|
@if (expanding.hasExpandable()) {
1 ? layout.headerFeatureRowSpan() : null
"
[attr.data-c]="-1"
[style.left.px]="fixing.fixedLeftMap().get(-1)"
(sdResize)="onFixedCellResize($event, -1)"
>
|
}
}
@for (cell of row; track $index) {
@if (!cell.isLastRow) {
1 ? cell.colspan : null"
[attr.rowspan]="cell.rowspan > 1 ? cell.rowspan : null"
[attr.data-c]="cell.colIndex"
[attr.title]="cell.text"
[style.left.px]="
cell.fixed ? fixing.fixedLeftMap().get(cell.colIndex) : null
"
>
|
} @else {
1 ? cell.colspan : null"
[attr.rowspan]="cell.rowspan > 1 ? cell.rowspan : null"
[attr.data-c]="cell.colIndex"
[attr.title]="cell.colDef?.tooltip ?? cell.text"
[attr.aria-sort]="getAriaSortValue(cell)"
[class.help]="cell.colDef?.tooltip"
[style]="getHeaderCellStyle(cell)"
(sdResize)="onFixedCellResize($event, cell.colIndex)"
(click)="onHeaderClick($event, cell)"
>
@if (cell.colDef && !cell.colDef.disableResizing) {
}
|
}
}
}
@if (layout.hasSummary()) {
@let _sfc = expanding.hasExpandable() ? -2 : -1;
|
@if (expanding.hasExpandable()) {
|
}
@for (colDef of layout.columnDefs(); track colDef.key; let c = $index) {
@if (getColumnSummaryTpl(colDef.key); as tpl) {
}
|
}
}
@for (
item of displayItems();
track trackByFn()(item, $index) ?? item;
let rowIdx = $index
) {
@let _fc = expanding.hasExpandable() ? -2 : -1;
|
@if (selectMode() === "multi") {
@let _selectable = selection.getSelectable(item);
} @else if (selectMode() === "single") {
@let _selectable = selection.getSelectable(item);
@if (_selectable === true && selection.getCanChangeFn(item)) {
}
}
|
@if (expanding.hasExpandable()) {
@let itemDef = getItemDef(item);
@if (itemDef.depth > 0) {
}
@if (itemDef.hasChildren) {
}
|
}
@for (colDef of layout.columnDefs(); track colDef.key; let colIdx = $index) {
@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";