import { computed, ref, type ComputedRef, type Ref } from 'vue' import { normalizePidSearchValue } from '../../composables/useIsPid' import { useFdsI18n } from '../../plugin/useFdsI18n' import type { FdsSearchSelectProProps } from './types' type SearchSelectItem = Record export const useSearchSelectProItems = ({ props, searchFields, searchTerm, bypassSearchFilter, showSelectedOnly, selectedItems, isMultiple, isPid, }: { props: FdsSearchSelectProProps searchFields: ComputedRef searchTerm: Ref bypassSearchFilter: Ref showSelectedOnly: Ref selectedItems: Ref isMultiple: ComputedRef isPid: Ref }) => { const { locale: activeLocale, t } = useFdsI18n() const matchingItems = ref([]) const displayedItems = ref([]) const visibleItemsLimit = ref(props.maxItems && props.maxItems > 0 ? props.maxItems : null) const resolvedLocale = computed<'en' | 'sv'>(() => (activeLocale.value.toLowerCase().startsWith('en') ? 'en' : 'sv')) const toStableValue = (value: unknown): unknown => { if (Array.isArray(value)) { return value.map((item) => toStableValue(item)) } if (value && typeof value === 'object') { const sortedObject: Record = {} Object.keys(value as Record) .sort() .forEach((key) => { sortedObject[key] = toStableValue((value as Record)[key]) }) return sortedObject } return value } const getItemIdentifier = (item: SearchSelectItem): string => { const { itemKey } = props if (itemKey && item[itemKey] !== undefined) { return String(item[itemKey]) } return JSON.stringify(toStableValue(item)) } const isDividerItem = (item: SearchSelectItem): boolean => { const dividerField = props.dividerField ?? 'isDivider' const dividerValue = item[dividerField] return dividerValue === true || item.type === 'divider' } const isItemSelected = (item: SearchSelectItem): boolean => { const itemId = getItemIdentifier(item) return selectedItems.value.some((selected) => getItemIdentifier(selected) === itemId) } const isHierarchyPaddingEnabled = computed( () => isMultiple.value && props.hierarchical && !searchTerm.value.length && !showSelectedOnly.value, ) const hasDividerMode = computed( () => !!props.autoDividerBy || (props.items ?? []).some((item) => { const dividerField = props.dividerField ?? 'isDivider' const dividerValue = item[dividerField] return dividerValue === true || item.type === 'divider' }), ) const isDividerPaddingEnabled = computed( () => hasDividerMode.value && !props.hierarchical && !searchTerm.value.length && !showSelectedOnly.value, ) const optionPaddingStyle = (item: SearchSelectItem) => { if (isDividerItem(item)) return null if (isHierarchyPaddingEnabled.value) { const levelField = props.levelField ?? 'level' const level = Number(item[levelField] ?? 0) const safeLevel = Number.isFinite(level) && level > 0 ? level : 0 return { paddingLeft: `${12 + safeLevel * 12}px`, } } if (isDividerPaddingEnabled.value) { return { paddingLeft: '24px', } } return null } const getItemSortValue = (item: SearchSelectItem): string => { const primaryField = searchFields.value[0] if (primaryField && item[primaryField] !== undefined && item[primaryField] !== null) { return String(item[primaryField]) } if (item.label !== undefined && item.label !== null) { return String(item.label) } if (item.name !== undefined && item.name !== null) { return String(item.name) } return '' } const reorderExplicitDividerGroups = (items: SearchSelectItem[], locale: 'en' | 'sv'): SearchSelectItem[] => { const dividerLabelField = searchFields.value[0] ?? 'label' const unspecifiedGroupKey = '__UNSPECIFIED__' const defaultUnspecifiedLabel = t('FdsSearchSelectPro.unspecified') const unspecifiedLabel = String(props.unspecifiedLabel ?? '').trim() || defaultUnspecifiedLabel const isUnspecifiedDividerLabel = (raw: string): boolean => { const trimmed = raw.trim() if (!trimmed) return true const lower = trimmed.toLowerCase() if (lower === unspecifiedGroupKey.toLowerCase()) return true const candidates = new Set( [unspecifiedLabel, defaultUnspecifiedLabel] .map((s) => s.trim().toLowerCase()) .filter(Boolean), ) return candidates.has(lower) } const groups: Array<{ divider: SearchSelectItem | null children: SearchSelectItem[] label: string unspecified: boolean }> = [] let current = { divider: null as SearchSelectItem | null, children: [] as SearchSelectItem[], label: '', unspecified: false, } const flush = () => { if (current.divider || current.children.length) groups.push(current) } items.forEach((item) => { if (isDividerItem(item)) { flush() const raw = String(item[dividerLabelField] ?? item.label ?? item.name ?? '').trim() const isUnspecified = isUnspecifiedDividerLabel(raw) current = { divider: item, children: [], label: isUnspecified ? unspecifiedLabel : raw, unspecified: isUnspecified, } } else { current.children.push(item) } }) flush() const [prefix, ...rest] = groups rest.sort((a, b) => { if (a.unspecified && !b.unspecified) return 1 if (b.unspecified && !a.unspecified) return -1 return a.label.localeCompare(b.label, locale) }) const ordered = prefix ? [prefix, ...rest] : rest return ordered.flatMap((g) => (g.divider ? [g.divider, ...g.children] : [...g.children])) } const sourceItems = computed(() => { const baseItems = props.items ?? [] const groupField = props.autoDividerBy if (!groupField) { const dividerField = props.dividerField ?? 'isDivider' const hasExplicitDividers = baseItems.some((item) => item[dividerField] === true || item.type === 'divider') if (!hasExplicitDividers) return baseItems const locale = resolvedLocale.value return reorderExplicitDividerGroups(baseItems, locale) } const dividerField = props.dividerField ?? 'isDivider' const levelField = props.levelField ?? 'level' const locale = resolvedLocale.value const defaultUnspecifiedLabel = t('FdsSearchSelectPro.unspecified') const unspecifiedLabel = String(props.unspecifiedLabel ?? '').trim() || defaultUnspecifiedLabel const grouped = new Map() const groupLabels = new Map() const unspecifiedGroupKey = '__UNSPECIFIED__' baseItems.forEach((item) => { const groupValue = String(item[groupField] ?? '').trim() const groupKey = groupValue || unspecifiedGroupKey const groupLabel = groupValue || unspecifiedLabel if (!grouped.has(groupKey)) { grouped.set(groupKey, []) groupLabels.set(groupKey, groupLabel) } grouped.get(groupKey)?.push(item) }) const sortedGroups = [...grouped.keys()].sort((a, b) => { if (a === unspecifiedGroupKey) return 1 if (b === unspecifiedGroupKey) return -1 return (groupLabels.get(a) ?? a).localeCompare(groupLabels.get(b) ?? b, locale) }) const flattened: SearchSelectItem[] = [] const dividerLabelField = searchFields.value[0] ?? 'label' sortedGroups.forEach((groupKey) => { const groupItems = grouped.get(groupKey) ?? [] const groupLabel = groupLabels.get(groupKey) ?? groupKey const dividerItem: SearchSelectItem = { type: 'divider', [dividerField]: true, [levelField]: 0, [groupField]: groupLabel, [dividerLabelField]: groupLabel, label: groupLabel, } flattened.push(dividerItem) const sortedItems = [...groupItems].sort((a, b) => getItemSortValue(a).localeCompare(getItemSortValue(b), locale)) flattened.push(...sortedItems) }) return flattened }) const sortResponse = (response: SearchSelectItem[]) => { if (!response.length) return [] const firstItem = response[0] ?? {} const allKeys = [...searchFields.value, ...Object.keys(firstItem).filter((k) => !searchFields.value.includes(k))] return response.map((item) => { const sorted: Record = {} allKeys.forEach((key) => { sorted[key] = item[key] || `${key}: den här uppgiften saknas.` }) return sorted }) } const matchesSearchTerm = (item: SearchSelectItem): boolean => { if (bypassSearchFilter.value) return true if (!searchTerm.value) return true const searchValue = isPid.value ? normalizePidSearchValue(searchTerm.value) : searchTerm.value const searchLower = searchValue.toLowerCase() const pidRegex = /^\d{8}[a-zA-Z0-9]{4}$/ if (pidRegex.test(searchValue)) return true return searchFields.value.some((key) => { const value = item[key] if (!value) return false const stringValue = String(value) const valueLower = stringValue.toLowerCase() const unmaskedValue = isPid.value ? normalizePidSearchValue(stringValue) : stringValue.replace(/\D/g, '') return ( valueLower.includes(searchLower) || (unmaskedValue.length > 0 && unmaskedValue.toLowerCase().includes(searchLower)) ) }) } const matchesDividerSearchTerm = (item: SearchSelectItem): boolean => { if (bypassSearchFilter.value) return false if (!isDividerItem(item) || !searchTerm.value) return false const searchValue = isPid.value ? normalizePidSearchValue(searchTerm.value) : searchTerm.value const searchLower = searchValue.toLowerCase() const dividerSearchFields = [...searchFields.value, 'label', 'name'] return dividerSearchFields.some((field) => { const value = item[field] if (!value) return false return String(value).toLowerCase().includes(searchLower) }) } const filterWithDividerGroups = (sourceData: SearchSelectItem[]): SearchSelectItem[] => { if (bypassSearchFilter.value || !searchTerm.value) return sourceData const matched: SearchSelectItem[] = [] let currentDivider: SearchSelectItem | null = null let currentChildren: SearchSelectItem[] = [] const flushCurrentGroup = () => { if (!currentDivider) { matched.push(...currentChildren.filter((item) => matchesSearchTerm(item))) } else { const dividerMatched = matchesDividerSearchTerm(currentDivider) const matchedChildren = currentChildren.filter((item) => matchesSearchTerm(item)) if (!dividerMatched && matchedChildren.length === 0) { return } if (dividerMatched || props.showOutOfBoundsDivider !== false) { matched.push(currentDivider) } if (dividerMatched) { matched.push(...currentChildren) } else { matched.push(...matchedChildren) } } } sourceData.forEach((item) => { if (isDividerItem(item)) { flushCurrentGroup() currentDivider = item currentChildren = [] return } currentChildren.push(item) }) flushCurrentGroup() return matched } const filterAndPaginate = (onTotal: (count: number) => void) => { if (!sourceItems.value.length) { matchingItems.value = [] displayedItems.value = [] onTotal(0) return } let sourceData = sourceItems.value if (props.preserveOrder && sourceData.length) { sourceData = sortResponse(sourceData) } const matchedArray = hasDividerMode.value ? filterWithDividerGroups(sourceData) : sourceData.filter((item) => matchesSearchTerm(item)) matchingItems.value = matchedArray const shouldLimitItems = props.maxItems !== undefined && props.maxItems > 0 const effectiveLimit = shouldLimitItems ? (visibleItemsLimit.value ?? props.maxItems) : null if (isMultiple.value && showSelectedOnly.value) { const selectedOnlyItems = matchedArray.filter((item) => isItemSelected(item)) displayedItems.value = effectiveLimit !== null ? selectedOnlyItems.slice(0, effectiveLimit) : selectedOnlyItems } else { displayedItems.value = effectiveLimit !== null ? matchedArray.slice(0, effectiveLimit) : matchedArray } const totalMatchingCount = matchingItems.value.filter( (item) => props.dividerSelectable || !isDividerItem(item), ).length onTotal(totalMatchingCount) } const resetVisibleItemsLimit = () => { visibleItemsLimit.value = props.maxItems && props.maxItems > 0 ? props.maxItems : null } const syncSelectedItemsWithItems = () => { const currentItems = props.items ?? [] const currentIds = new Set(currentItems.map((item) => getItemIdentifier(item))) selectedItems.value = selectedItems.value.filter((item) => currentIds.has(getItemIdentifier(item))) } const hasInternalMoreItems = computed(() => matchingItems.value.length > displayedItems.value.length) const hasMoreServerPages = computed(() => { if (props.page === undefined || props.totalPages === undefined) return false return props.page < props.totalPages }) const shouldShowLoadMore = computed(() => { if (isMultiple.value && showSelectedOnly.value) return false if (hasInternalMoreItems.value) return true if (hasMoreServerPages.value) return true if (props.maxItems && props.maxItems > 0) return false return props.showLoadMore }) const handleInternalLoadMore = (): boolean => { if (props.maxItems && props.maxItems > 0 && hasInternalMoreItems.value) { visibleItemsLimit.value = (visibleItemsLimit.value ?? props.maxItems) + props.maxItems return true } return false } return { displayedItems, filterAndPaginate, getItemIdentifier, isDividerItem, isItemSelected, optionPaddingStyle, sourceItems, resetVisibleItemsLimit, shouldShowLoadMore, handleInternalLoadMore, syncSelectedItemsWithItems, } }