import { NgTemplateOutlet } from "@angular/common";
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
contentChild,
effect,
inject,
input,
model,
signal,
TemplateRef,
untracked,
viewChild,
ViewEncapsulation,
} from "@angular/core";
import type { SharedDataBase } from "../../core/shared-data/sd-shared-data.provider";
import { SdSelect, type SelectModeValue } from "../../controls/select/sd-select";
import { SdSelectButton } from "../../controls/select/sd-select-button";
import { SdSelectItem } from "../../controls/select/sd-select-item";
import { SdTextfield } from "../../controls/input/sd-textfield";
import {
SdItemOfTemplate,
type SdItemOfTemplateContext,
} from "../../core/template/sd-item-of-template";
import {
SdModalProvider,
type SdModalContentDef,
type SdModalInfo,
} from "../../core/modal/sd-modal.provider";
import type {
SdSelectModal,
SdSelectModalInfo,
} from "../../controls/button/sd-modal-select-button";
import { NgIcon } from "@ng-icons/core";
import { tablerEdit, tablerSearch } from "@ng-icons/tabler-icons";
import { matchesSearchText } from "./matchesSearchText";
@Component({
selector: "sd-shared-data-select",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
SdSelect,
SdTextfield,
SdSelectItem,
SdItemOfTemplate,
NgTemplateOutlet,
SdSelectButton,
NgIcon,
],
template: `
@if (modal()) {
}
@if (editModal()) {
}
@if (
(!required() && selectMode() === "single") ||
(useUndefined() && selectMode() === "multi")
) {
@if (undefinedTplRef()) {
} @else {
미지정
}
}
@if (
getItemSelectable(item, index, depth) &&
(isDropdownOpen() || selectedKeys().includes(item.__valueKey))
) {
}
`,
})
export class SdSharedDataSelect<
TItem extends SharedDataBase,
TMode extends keyof SelectModeValue,
TModal extends SdSelectModal,
> {
private readonly _sdModal = inject(SdModalProvider);
private readonly _selectCtrl = viewChild(SdSelect);
value = model[TMode]>();
items = input.required();
disabled = input(false, { transform: booleanAttribute });
required = input(false, { transform: booleanAttribute });
useUndefined = input(false, { transform: booleanAttribute });
inset = input(false, { transform: booleanAttribute });
inline = input(false, { transform: booleanAttribute });
size = input<"sm" | "lg">();
selectMode = input("single" as TMode);
filterFn = input<(item: TItem, index: number, ...params: any[]) => boolean>();
filterFnParams = input();
modal = input>();
editModal = input>>();
selectClass = input();
multiSelectionDisplayDirection = input<"vertical">();
getIsHiddenFn = input<(item: TItem, index: number) => boolean>(
(item) => item.__isHidden,
);
getSearchTextFn = input<(item: TItem, index: number) => string>(
(item) => item.__searchText,
);
displayOrderKeyProp = input();
itemTplRef = contentChild>>(
SdItemOfTemplate,
{ read: TemplateRef },
);
undefinedTplRef = contentChild>("undefinedTpl", {
read: TemplateRef,
});
trackByFn = (item: TItem): TItem["__valueKey"] => item.__valueKey;
searchText = signal(undefined);
isDropdownOpen = computed(() => this._selectCtrl()?.dropdownOpen() ?? false);
hasParentKey = computed(() =>
this.items().some((item) => item.__parentKey != null),
);
itemByParentKeyMap = computed(() => {
if (!this.hasParentKey()) return undefined;
const map = new Map();
for (const item of this.items()) {
const parentKey = item.__parentKey as TItem["__valueKey"] | undefined;
const existing = map.get(parentKey);
if (existing != null) {
existing.push(item);
} else {
map.set(parentKey, [item]);
}
}
return map;
});
rootDisplayItems = computed(() => {
let result = this.items().filter((item, index) => {
if (this.filterFn() != null) {
if (!this.filterFn()!(item, index, ...(this.filterFnParams() ?? []))) {
return false;
}
}
if (this.hasParentKey()) {
return item.__parentKey == null;
}
return true;
});
const orderProp = this.displayOrderKeyProp();
if (orderProp != null) {
result = result.orderBy((item) => (item as any)[orderProp]);
}
return result;
});
private readonly _searchTextMatchCache = computed(() => {
const cache = new Map();
const searchText = this.searchText();
const getSearchTextFn = this.getSearchTextFn();
const hasParent = this.hasParentKey();
const parentMap = this.itemByParentKeyMap();
const items = this.items();
const check = (item: TItem, index: number): boolean => {
const key = item.__valueKey;
const cached = cache.get(key);
if (cached != null) return cached;
const itemText = getSearchTextFn(item, index);
if (matchesSearchText(itemText, searchText)) {
cache.set(key, true);
return true;
}
if (hasParent && parentMap != null) {
const children = parentMap.get(key) ?? [];
for (let i = 0; i < children.length; i++) {
if (check(children[i], i)) {
cache.set(key, true);
return true;
}
}
}
cache.set(key, false);
return false;
};
for (let i = 0; i < items.length; i++) {
check(items[i], i);
}
return cache;
});
selectedKeys = computed((): any[] => {
const val = this.value();
if (val == null) return [];
if (Array.isArray(val)) return val;
return [val];
});
constructor() {
// 드롭다운 닫힘 시 검색어 초기화 (open → closed 전환 시에만)
let prevOpen = false;
effect(() => {
const ctrl = this._selectCtrl();
const currentOpen = ctrl != null ? ctrl.dropdownOpen() : false;
if (prevOpen && !currentOpen) {
untracked(() => this.searchText.set(undefined));
}
prevOpen = currentOpen;
});
}
getItemSelectable(item: TItem, _index: number, depth: number): boolean {
if (!this.hasParentKey()) return true;
// 트리 구조에서 depth가 0이면서 __parentKey가 있는 항목은 선택 불가
return depth !== 0 || item.__parentKey == null;
}
getItemVisible(item: TItem, index: number): boolean {
if (
this.isIncludeSearchText(item, index) &&
!this.getIsHiddenFn()(item, index)
) {
return true;
}
// 현재 선택된 항목은 항상 표시
const val = this.value();
if (val === item.__valueKey) return true;
if (Array.isArray(val) && val.includes(item.__valueKey)) return true;
return false;
}
isIncludeSearchText(item: TItem, _index: number): boolean {
return this._searchTextMatchCache().get(item.__valueKey) ?? false;
}
private readonly _sortedChildrenMap = computed(() => {
const parentMap = this.itemByParentKeyMap();
if (parentMap == null) return undefined;
const orderProp = this.displayOrderKeyProp();
if (orderProp == null) return parentMap;
const sorted = new Map();
for (const [key, children] of parentMap) {
sorted.set(
key,
children.orderBy((item) => (item as any)[orderProp]),
);
}
return sorted;
});
getChildren = (item: SharedDataBase): TItem[] => {
return (
this._sortedChildrenMap()?.get(item.__valueKey) ??
[]
);
};
async onModalButtonClick(event: MouseEvent): Promise {
event.preventDefault();
event.stopPropagation();
const modalInfo = this.modal();
if (modalInfo == null) return;
const result = await this._sdModal.showAsync({
...modalInfo,
inputs: {
selectMode: this.selectMode(),
selectedKeys: this.selectedKeys(),
...modalInfo.inputs,
} as any,
});
if (result != null) {
const newValue =
this.selectMode() === "multi"
? result.selectedKeys
: result.selectedKeys[0];
this.value.set(newValue);
}
}
async onEditModalButtonClick(event: MouseEvent): Promise {
event.preventDefault();
event.stopPropagation();
const modalInfo = this.editModal();
if (modalInfo == null) return;
await this._sdModal.showAsync(modalInfo);
}
protected readonly tablerSearch = tablerSearch;
protected readonly tablerEdit = tablerEdit;
}