253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
/**
|
||
* Компонент облака топиков для выбора родительских тем
|
||
* @module TopicPillsCloud
|
||
*/
|
||
|
||
import { createSignal, createMemo, For, Show } from 'solid-js'
|
||
import styles from '../styles/Form.module.css'
|
||
|
||
/**
|
||
* Интерфейс для топика
|
||
*/
|
||
export interface TopicPill {
|
||
id: string
|
||
title: string
|
||
slug: string
|
||
community: string
|
||
parent_ids?: string[]
|
||
depth?: number
|
||
}
|
||
|
||
/**
|
||
* Пропсы компонента TopicPillsCloud
|
||
*/
|
||
interface TopicPillsCloudProps {
|
||
/** Доступные топики для выбора */
|
||
topics: TopicPill[]
|
||
/** Выбранные топики */
|
||
selectedTopics: string[]
|
||
/** Колбек при изменении выбора */
|
||
onSelectionChange: (selectedIds: string[]) => void
|
||
/** Фильтр по сообществу */
|
||
communityFilter?: string
|
||
/** Исключить топики (например, текущий редактируемый) */
|
||
excludeTopics?: string[]
|
||
/** Максимальное количество выбранных топиков */
|
||
maxSelection?: number
|
||
/** Заголовок компонента */
|
||
title?: string
|
||
/** Показать поиск */
|
||
showSearch?: boolean
|
||
/** Плейсхолдер для поиска */
|
||
searchPlaceholder?: string
|
||
/** Класс для стилизации */
|
||
class?: string
|
||
/** Скрыть выбранные элементы в заголовке (показывать только в основном списке) */
|
||
hideSelectedInHeader?: boolean
|
||
}
|
||
|
||
/**
|
||
* Компонент облака топиков для выбора
|
||
*/
|
||
const TopicPillsCloud = (props: TopicPillsCloudProps) => {
|
||
const [searchQuery, setSearchQuery] = createSignal('')
|
||
|
||
/**
|
||
* Фильтрованные и отсортированные топики
|
||
*/
|
||
const filteredTopics = createMemo(() => {
|
||
let topics = props.topics
|
||
|
||
// Исключаем запрещенные топики
|
||
if (props.excludeTopics?.length) {
|
||
topics = topics.filter(topic => !props.excludeTopics!.includes(topic.id))
|
||
}
|
||
|
||
// Фильтруем по поисковому запросу
|
||
const query = searchQuery().toLowerCase().trim()
|
||
if (query) {
|
||
topics = topics.filter(topic =>
|
||
topic.title.toLowerCase().includes(query) ||
|
||
topic.slug.toLowerCase().includes(query)
|
||
)
|
||
}
|
||
|
||
// Умная сортировка: выбранные → релевантные → остальные
|
||
return topics.sort((a, b) => {
|
||
const aSelected = props.selectedTopics.includes(a.id)
|
||
const bSelected = props.selectedTopics.includes(b.id)
|
||
|
||
// Сначала выбранные топики
|
||
if (aSelected && !bSelected) return -1
|
||
if (!aSelected && bSelected) return 1
|
||
|
||
// Для не выбранных: приоритет топикам из того же сообщества
|
||
if (props.communityFilter) {
|
||
const aSameCommunity = a.community === props.communityFilter
|
||
const bSameCommunity = b.community === props.communityFilter
|
||
|
||
if (aSameCommunity && !bSameCommunity) return -1
|
||
if (!aSameCommunity && bSameCommunity) return 1
|
||
}
|
||
|
||
// Потом по алфавиту
|
||
return a.title.localeCompare(b.title, 'ru')
|
||
})
|
||
})
|
||
|
||
/**
|
||
* Обработчик клика по топику
|
||
*/
|
||
const handleTopicClick = (topicId: string) => {
|
||
const currentSelection = [...props.selectedTopics]
|
||
const index = currentSelection.indexOf(topicId)
|
||
|
||
if (index >= 0) {
|
||
// Убираем из выбора
|
||
currentSelection.splice(index, 1)
|
||
} else {
|
||
// Добавляем в выбор (если не превышен лимит)
|
||
if (!props.maxSelection || currentSelection.length < props.maxSelection) {
|
||
currentSelection.push(topicId)
|
||
}
|
||
}
|
||
|
||
props.onSelectionChange(currentSelection)
|
||
}
|
||
|
||
/**
|
||
* Проверяет, выбран ли топик
|
||
*/
|
||
const isSelected = (topicId: string) => props.selectedTopics.includes(topicId)
|
||
|
||
/**
|
||
* Проверяет, можно ли выбрать еще топики
|
||
*/
|
||
const canSelectMore = () => {
|
||
return !props.maxSelection || props.selectedTopics.length < props.maxSelection
|
||
}
|
||
|
||
/**
|
||
* Получает уровень вложенности топика
|
||
*/
|
||
const getTopicDepth = (topic: TopicPill): number => {
|
||
if (topic.depth !== undefined) return topic.depth
|
||
return topic.parent_ids?.length || 0
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Получить выбранные топики как объекты
|
||
*/
|
||
const selectedTopicObjects = createMemo(() => {
|
||
return props.topics.filter(topic => props.selectedTopics.includes(topic.id))
|
||
})
|
||
|
||
return (
|
||
<div class={`${styles.topicPillsCloud} ${props.class || ''}`}>
|
||
{/* Поиск в самом верху */}
|
||
<Show when={props.showSearch}>
|
||
<div class={styles.pillsSearchContainer}>
|
||
<input
|
||
type="text"
|
||
class={`${styles.input} ${styles.pillsSearchInput}`}
|
||
placeholder={props.searchPlaceholder || 'Поиск...'}
|
||
value={searchQuery()}
|
||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||
/>
|
||
<Show when={searchQuery().trim()}>
|
||
<button
|
||
type="button"
|
||
class={styles.clearSearchBtn}
|
||
onClick={() => setSearchQuery('')}
|
||
title="Очистить поиск"
|
||
>
|
||
×
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Заголовок и выбранные топики */}
|
||
<Show when={props.title || (props.selectedTopics.length > 0 && !props.hideSelectedInHeader)}>
|
||
<div class={styles.pillsCloudHeader}>
|
||
<div class={styles.headerSection}>
|
||
<Show when={props.title}>
|
||
<h4 class={styles.pillsCloudTitle}>{props.title}</h4>
|
||
</Show>
|
||
<Show when={props.selectedTopics.length > 0 && !props.hideSelectedInHeader}>
|
||
<div class={styles.selectedTopicsDisplay}>
|
||
<span class={styles.selectedLabel}>Выбрано ({props.selectedTopics.length}):</span>
|
||
<div class={styles.selectedTopicsContainer}>
|
||
<For each={selectedTopicObjects()}>
|
||
{(topic) => (
|
||
<button
|
||
type="button"
|
||
class={`${styles.topicPill} ${styles.pillSelected} ${styles.pillCompact}`}
|
||
onClick={() => handleTopicClick(topic.id)}
|
||
title={`Убрать ${topic.title}`}
|
||
>
|
||
<span class={styles.pillTitle}>{topic.title}</span>
|
||
<span class={styles.pillRemoveIcon}>×</span>
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
<div class={styles.pillsContainer}>
|
||
<For each={filteredTopics()}>
|
||
{(topic) => {
|
||
const selected = isSelected(topic.id)
|
||
const disabled = !selected && !canSelectMore()
|
||
const depth = getTopicDepth(topic)
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
class={`${styles.topicPill} ${
|
||
selected ? styles.pillSelected : ''
|
||
} ${disabled ? styles.pillDisabled : ''} ${
|
||
depth > 0 ? styles.pillNested : ''
|
||
}`}
|
||
onClick={() => !disabled && handleTopicClick(topic.id)}
|
||
disabled={disabled}
|
||
title={`${topic.title} (${topic.slug})`}
|
||
data-depth={depth}
|
||
>
|
||
<Show when={depth > 0}>
|
||
<span class={styles.pillDepthIndicator}>
|
||
{' '.repeat(depth)}└
|
||
</span>
|
||
</Show>
|
||
<span class={styles.pillTitle}>{topic.title}</span>
|
||
<Show when={selected}>
|
||
<span class={styles.pillRemoveIcon}>×</span>
|
||
</Show>
|
||
</button>
|
||
)
|
||
}}
|
||
</For>
|
||
</div>
|
||
|
||
<Show when={filteredTopics().length === 0}>
|
||
<div class={styles.emptyState}>
|
||
<Show
|
||
when={searchQuery().trim()}
|
||
fallback={<span>Нет доступных топиков</span>}
|
||
>
|
||
<span>Ничего не найдено по запросу "{searchQuery()}"</span>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default TopicPillsCloud
|