import { createPortal } from 'react-dom'; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; import { getSikshyaApi, getWpApi, SIKSHYA_ENDPOINTS } from '../../api'; import { FieldHint } from './FieldHint'; import { useDebouncedValue } from '../../hooks/useDebouncedValue'; import { __, sprintf } from '../../lib/i18n'; type CourseRow = { id: number; title: string; status?: string }; function normalizeCourses(raw: { courses?: Array<{ id: number; title: string; status?: string }> } | undefined): CourseRow[] { const list = Array.isArray(raw?.courses) ? raw!.courses!.map((x) => ({ id: x.id, title: (x.title && String(x.title).trim()) || sprintf(__('Course #%d', 'sikshya'), x.id), status: x.status, })) : []; list.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })); return list; } /** * Searchable course picker: dropdown with search, backed by {@link SIKSHYA_ENDPOINTS.pro.coursesSearch}. * Use `fieldLayout="toolbar"` for list filters (hint slot + stretch); `compact` for forms/modals. */ export function CourseFilterSelect(props: { enabled: boolean; value: number; onChange: (courseId: number) => void; disabled?: boolean; allLabel?: string; /** When false, omit the first “clear” row (e.g. modal must pick a real course). */ allowClear?: boolean; /** Toolbar = hint row + flex stretch; compact = control only (e.g. inside modals). */ fieldLayout?: 'toolbar' | 'compact'; /** Fixed dropdown z-index (raise above dialogs, e.g. 11050). */ dropdownZIndex?: number; hint?: string; label?: string; /** Use `sr-only` on list toolbars so the trigger lines up with search/sort controls. */ labelVisibility?: 'visible' | 'sr-only'; className?: string; }) { const { enabled, value, onChange, disabled, allLabel = __('All courses', 'sikshya'), allowClear = true, fieldLayout = 'toolbar', dropdownZIndex = 10000, hint, label = __('Course filter', 'sikshya'), labelVisibility = 'visible', className, } = props; const triggerId = useId(); const listboxId = useId(); const hintDescId = useId(); const wrapRef = useRef(null); const panelRef = useRef(null); const searchInputRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); /** Only debounce while the panel is open so a stale term is not used right after reopening. */ const debouncedSearch = useDebouncedValue(open ? query : '', 280); const [results, setResults] = useState([]); const [listLoading, setListLoading] = useState(false); const [labelById, setLabelById] = useState>({}); const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); const mergeLabels = useCallback((rows: CourseRow[]) => { setLabelById((prev) => { const next = { ...prev }; for (const r of rows) next[r.id] = r.title; return next; }); }, []); useEffect(() => { if (!open) setQuery(''); }, [open]); const resolvedTitle = value > 0 ? labelById[value] : undefined; useEffect(() => { if (!enabled || value <= 0 || resolvedTitle) { return; } let cancelled = false; void (async () => { try { const data = await getWpApi().get>( `/sik_course?context=edit&per_page=1&include=${encodeURIComponent(String(value))}&_fields=id,title` ); if (cancelled || !Array.isArray(data) || !data[0]) { if (!cancelled) { setLabelById((prev) => prev[value] ? prev : { ...prev, [value]: sprintf(__('Course #%d', 'sikshya'), value) } ); } return; } const t = data[0].title?.rendered ? String(data[0].title.rendered).replace(/<[^>]*>/g, '').trim() : ''; if (!cancelled) { const title = t || sprintf(__('Course #%d', 'sikshya'), value); setLabelById((prev) => ({ ...prev, [value]: title })); } } catch { if (!cancelled) { setLabelById((prev) => prev[value] ? prev : { ...prev, [value]: sprintf(__('Course #%d', 'sikshya'), value) } ); } } })(); return () => { cancelled = true; }; }, [enabled, value, resolvedTitle]); useEffect(() => { if (!open || !enabled) return; let cancelled = false; setListLoading(true); void (async () => { try { const r = await getSikshyaApi().get<{ courses?: Array<{ id: number; title: string; status?: string }> }>( SIKSHYA_ENDPOINTS.pro.coursesSearch({ search: debouncedSearch.trim(), per_page: 50 }) ); if (cancelled) return; const list = normalizeCourses(r); setResults(list); mergeLabels(list); } catch { if (!cancelled) setResults([]); } finally { if (!cancelled) setListLoading(false); } })(); return () => { cancelled = true; }; }, [open, enabled, debouncedSearch, mergeLabels]); useLayoutEffect(() => { if (!open || !wrapRef.current) { setPos(null); return; } const update = () => { const el = wrapRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const pad = 8; const width = Math.max(rect.width, 280); let left = rect.left; if (left + width > window.innerWidth - pad) { left = Math.max(pad, window.innerWidth - pad - width); } setPos({ top: rect.bottom + 4, left, width }); }; update(); const id = window.requestAnimationFrame(update); window.addEventListener('resize', update); window.addEventListener('scroll', update, true); return () => { window.cancelAnimationFrame(id); window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true); }; }, [open, results.length]); useEffect(() => { if (!open) return; const t = window.setTimeout(() => searchInputRef.current?.focus(), 0); return () => window.clearTimeout(t); }, [open]); useEffect(() => { if (!open) return; const onPointer = (e: MouseEvent | TouchEvent) => { const n = e.target; if (!(n instanceof Node)) return; if (wrapRef.current?.contains(n) || panelRef.current?.contains(n)) return; setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onPointer); document.addEventListener('touchstart', onPointer, { passive: true }); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onPointer); document.removeEventListener('touchstart', onPointer); document.removeEventListener('keydown', onKey); }; }, [open]); useEffect(() => { if (!enabled) setOpen(false); }, [enabled]); const displayLabel = value <= 0 ? allLabel : labelById[value] || sprintf(__('Course #%d', 'sikshya'), value); const labelSrOnly = labelVisibility === 'sr-only'; const hintForA11y = hint && labelSrOnly ? `${hint}` : undefined; const pick = (id: number, title?: string) => { onChange(id); if (id > 0 && title) { setLabelById((prev) => ({ ...prev, [id]: title })); } setOpen(false); }; const panel = open && pos && typeof document !== 'undefined' ? (
setQuery(e.target.value)} placeholder={__('Search courses…', 'sikshya')} aria-controls={listboxId} autoComplete="off" className="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" />
    {allowClear ? (
  • ) : null} {listLoading ? (
  • {__('Searching…', 'sikshya')}
  • ) : results.length === 0 ? (
  • {query.trim() ? __('No courses match your search.', 'sikshya') : __('No courses found.', 'sikshya')}
  • ) : ( results.map((r) => (
  • )) )}
) : null; const shellClass = fieldLayout === 'toolbar' ? `flex h-full min-h-0 flex-col text-sm text-slate-700 dark:text-slate-300 ${className ?? ''}` : `flex flex-col text-sm text-slate-700 dark:text-slate-300 ${className ?? ''}`; return (
{label} {hintForA11y ? ( {hintForA11y} ) : null}
{fieldLayout === 'toolbar' ? ( <>
{hint && !labelSrOnly ? {hint} : null} ) : hint && !labelSrOnly ? (

{hint}

) : null} {typeof document !== 'undefined' && panel ? createPortal(panel, document.body) : null}
); }