Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
420 lines
15 KiB
JavaScript
420 lines
15 KiB
JavaScript
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
|
||
/**
|
||
* Nova NovaSelect – Select2-style dropdown
|
||
*
|
||
* Options format: [{ value, label, icon?, disabled?, group? }]
|
||
*
|
||
* @prop {Array} options - list of option objects
|
||
* @prop {*} value - selected value (or array of values in multi mode)
|
||
* @prop {function} onChange - called with new value (or array in multi mode)
|
||
* @prop {boolean} multi - allow multiple selections
|
||
* @prop {string} placeholder - placeholder text
|
||
* @prop {boolean} searchable - filter options by typing (default true)
|
||
* @prop {boolean} clearable - show clear button when a value is selected
|
||
* @prop {string} label - label above the trigger
|
||
* @prop {string} error - validation error
|
||
* @prop {string} hint - helper text
|
||
* @prop {boolean} required - asterisk on label
|
||
* @prop {boolean} disabled
|
||
* @prop {function} renderOption - custom render fn: (option) => ReactNode
|
||
*/
|
||
export default function NovaSelect({
|
||
options = [],
|
||
value,
|
||
onChange,
|
||
multi = false,
|
||
placeholder = 'Select…',
|
||
searchable = true,
|
||
clearable = false,
|
||
label,
|
||
error,
|
||
hint,
|
||
required = false,
|
||
disabled = false,
|
||
renderOption,
|
||
id,
|
||
className = '',
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [search, setSearch] = useState('')
|
||
const [highlighted, setHigh] = useState(-1)
|
||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 300, openUp: false })
|
||
|
||
const triggerRef = useRef(null)
|
||
const searchRef = useRef(null)
|
||
const listRef = useRef(null)
|
||
const inputId = id ?? (label ? `nova-select-${label.toLowerCase().replace(/\s+/g, '-')}` : 'nova-select')
|
||
|
||
// Normalize value to array internally
|
||
const selected = useMemo(() => {
|
||
if (multi) return Array.isArray(value) ? value : (value != null ? [value] : [])
|
||
return value != null ? [value] : []
|
||
}, [value, multi])
|
||
|
||
const selectedSet = useMemo(() => new Set(selected.map(String)), [selected])
|
||
|
||
// Filtered + grouped options
|
||
const filtered = useMemo(() => {
|
||
const q = search.toLowerCase()
|
||
return options.filter((o) => !q || o.label.toLowerCase().includes(q))
|
||
}, [options, search])
|
||
|
||
// Compute dropdown position from trigger bounding rect
|
||
const measurePosition = useCallback(() => {
|
||
if (!triggerRef.current) return
|
||
const rect = triggerRef.current.getBoundingClientRect()
|
||
const spaceBelow = window.innerHeight - rect.bottom
|
||
const spaceAbove = rect.top
|
||
const dropH = Math.min(280, filtered.length * 38 + 52) // approx
|
||
const openUp = spaceBelow < dropH + 8 && spaceAbove > spaceBelow
|
||
|
||
// Clamp horizontal position so the dropdown doesn't render off-screen or far away
|
||
const padding = 8
|
||
const leftClamped = Math.max(padding, Math.min(rect.left, window.innerWidth - rect.width - padding))
|
||
|
||
setDropPos({
|
||
top: openUp ? rect.top - dropH - 4 : rect.bottom + 4,
|
||
left: leftClamped,
|
||
width: rect.width,
|
||
openUp,
|
||
})
|
||
}, [filtered.length])
|
||
|
||
const openDropdown = useCallback(() => {
|
||
if (disabled) return
|
||
measurePosition()
|
||
setOpen(true)
|
||
setHigh(-1)
|
||
}, [disabled, measurePosition])
|
||
|
||
const closeDropdown = useCallback(() => {
|
||
setOpen(false)
|
||
setSearch('')
|
||
setHigh(-1)
|
||
}, [])
|
||
|
||
// Focus search when opened
|
||
useLayoutEffect(() => {
|
||
if (open && searchable) {
|
||
setTimeout(() => searchRef.current?.focus(), 0)
|
||
}
|
||
}, [open, searchable])
|
||
|
||
// Close dropdown when scrolling outside it (prevents portal drifting from trigger)
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const onScroll = (e) => {
|
||
const dropdown = document.getElementById(`nova-select-dropdown-${inputId}`)
|
||
if (dropdown && dropdown.contains(e.target)) return // scrolling inside list — keep open
|
||
closeDropdown()
|
||
}
|
||
const onResize = () => closeDropdown()
|
||
window.addEventListener('scroll', onScroll, true)
|
||
window.addEventListener('resize', onResize)
|
||
return () => {
|
||
window.removeEventListener('scroll', onScroll, true)
|
||
window.removeEventListener('resize', onResize)
|
||
}
|
||
}, [open, closeDropdown, inputId])
|
||
|
||
// Click outside
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const handler = (e) => {
|
||
if (
|
||
!triggerRef.current?.contains(e.target) &&
|
||
!document.getElementById(`nova-select-dropdown-${inputId}`)?.contains(e.target)
|
||
) {
|
||
closeDropdown()
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [open, closeDropdown, inputId])
|
||
|
||
// Scroll highlighted item into view
|
||
useEffect(() => {
|
||
if (highlighted < 0 || !listRef.current) return
|
||
const item = listRef.current.querySelectorAll('[data-option]')[highlighted]
|
||
item?.scrollIntoView({ block: 'nearest' })
|
||
}, [highlighted])
|
||
|
||
const selectOption = useCallback((opt) => {
|
||
if (opt.disabled) return
|
||
if (multi) {
|
||
const exists = selectedSet.has(String(opt.value))
|
||
onChange(exists ? selected.filter((v) => String(v) !== String(opt.value)) : [...selected, opt.value])
|
||
setSearch('')
|
||
searchRef.current?.focus()
|
||
} else {
|
||
onChange(opt.value)
|
||
closeDropdown()
|
||
triggerRef.current?.focus()
|
||
}
|
||
}, [multi, selected, selectedSet, onChange, closeDropdown])
|
||
|
||
const clearValue = useCallback((e) => {
|
||
e.stopPropagation()
|
||
onChange(multi ? [] : null)
|
||
}, [multi, onChange])
|
||
|
||
const removeTag = useCallback((val, e) => {
|
||
e.stopPropagation()
|
||
onChange(selected.filter((v) => String(v) !== String(val)))
|
||
}, [selected, onChange])
|
||
|
||
// Keyboard handler on search/trigger
|
||
const handleKeyDown = useCallback((e) => {
|
||
if (!open) {
|
||
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
|
||
e.preventDefault()
|
||
openDropdown()
|
||
}
|
||
return
|
||
}
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault()
|
||
setHigh((h) => Math.min(h + 1, filtered.length - 1))
|
||
break
|
||
case 'ArrowUp':
|
||
e.preventDefault()
|
||
setHigh((h) => Math.max(h - 1, 0))
|
||
break
|
||
case 'Enter':
|
||
e.preventDefault()
|
||
if (highlighted >= 0 && filtered[highlighted]) selectOption(filtered[highlighted])
|
||
break
|
||
case 'Escape':
|
||
e.preventDefault()
|
||
closeDropdown()
|
||
triggerRef.current?.focus()
|
||
break
|
||
case 'Backspace':
|
||
if (multi && !search && selected.length > 0) {
|
||
onChange(selected.slice(0, -1))
|
||
}
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
|
||
|
||
// Build display label(s)
|
||
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
|
||
|
||
const hasValue = selected.length > 0
|
||
|
||
// Trigger appearance
|
||
const triggerClass = [
|
||
'relative w-full flex items-center min-h-[42px] rounded-xl border px-3 py-1.5 gap-2 cursor-pointer',
|
||
'bg-white/[0.06] text-sm text-white transition-all duration-150',
|
||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
|
||
error
|
||
? 'border-red-500/60 focus-visible:ring-red-500/40'
|
||
: open
|
||
? 'border-accent/50 ring-2 ring-accent/40'
|
||
: 'border-white/12 hover:border-white/22',
|
||
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
|
||
className,
|
||
].join(' ')
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
{label && (
|
||
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
|
||
{label}{required && <span className="text-red-400 ml-1">*</span>}
|
||
</label>
|
||
)}
|
||
|
||
{/* Trigger button */}
|
||
<div
|
||
ref={triggerRef}
|
||
id={inputId}
|
||
role="combobox"
|
||
aria-expanded={open}
|
||
aria-haspopup="listbox"
|
||
aria-label={label ?? placeholder}
|
||
tabIndex={disabled ? -1 : 0}
|
||
className={triggerClass}
|
||
onClick={open ? closeDropdown : openDropdown}
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
{/* Tags (multi) or selected label (single) */}
|
||
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
|
||
{multi && selected.map((v) => (
|
||
<span
|
||
key={v}
|
||
className="inline-flex items-center gap-1 h-6 px-2 rounded-md bg-accent/20 text-accent text-xs font-medium"
|
||
>
|
||
{labelMap[String(v)] ?? v}
|
||
<button
|
||
type="button"
|
||
tabIndex={-1}
|
||
onClick={(e) => removeTag(v, e)}
|
||
className="hover:text-white transition-colors"
|
||
aria-label={`Remove ${labelMap[String(v)] ?? v}`}
|
||
>
|
||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||
<path d="M1 1l6 6M7 1L1 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
))}
|
||
|
||
{!multi && hasValue && (
|
||
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
|
||
)}
|
||
|
||
{!hasValue && (
|
||
<span className="text-slate-500 truncate">{placeholder}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right icons */}
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{clearable && hasValue && (
|
||
<button
|
||
type="button"
|
||
tabIndex={-1}
|
||
onClick={clearValue}
|
||
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:text-white transition-colors"
|
||
aria-label="Clear"
|
||
>
|
||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
className={`text-slate-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||
>
|
||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
||
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
|
||
|
||
{/* Dropdown portal */}
|
||
{open && createPortal(
|
||
<div
|
||
id={`nova-select-dropdown-${inputId}`}
|
||
role="listbox"
|
||
aria-multiselectable={multi}
|
||
className="fixed z-[500] flex flex-col rounded-xl border border-white/12 bg-nova-900/80 backdrop-blur-xl shadow-2xl shadow-black/50 overflow-hidden"
|
||
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width, maxHeight: 280 }}
|
||
>
|
||
{/* Search */}
|
||
{searchable && (
|
||
<div className="px-2 pt-2 pb-1 border-b border-white/8">
|
||
<div className="relative">
|
||
<svg
|
||
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none"
|
||
aria-hidden="true"
|
||
>
|
||
<circle cx="5" cy="5" r="3.5" stroke="currentColor" strokeWidth="1.5" />
|
||
<path d="M8 8l2.5 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||
</svg>
|
||
<input
|
||
ref={searchRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Search…"
|
||
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Options */}
|
||
<div ref={listRef} className="overflow-y-auto flex-1 nova-scrollbar" style={{ maxHeight: 220 }}>
|
||
{filtered.length === 0 ? (
|
||
<p className="px-3 py-6 text-center text-xs text-slate-500">No options found</p>
|
||
) : (
|
||
filtered.map((opt, idx) => {
|
||
const isSelected = selectedSet.has(String(opt.value))
|
||
const isHighlighted = idx === highlighted
|
||
const prevGroup = idx > 0 ? filtered[idx - 1].group : undefined
|
||
const showGroupHeader = opt.group != null && opt.group !== prevGroup
|
||
|
||
return (
|
||
<React.Fragment key={String(opt.value)}>
|
||
{showGroupHeader && (
|
||
<div className={`px-3 pt-2 pb-0.5 text-[10px] font-semibold text-slate-500 uppercase tracking-widest select-none${idx > 0 ? ' border-t border-white/5' : ''}`}>
|
||
{opt.group}
|
||
</div>
|
||
)}
|
||
<div
|
||
data-option
|
||
role="option"
|
||
aria-selected={isSelected}
|
||
aria-disabled={opt.disabled}
|
||
onClick={() => selectOption(opt)}
|
||
onMouseEnter={() => setHigh(idx)}
|
||
className={[
|
||
'flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer transition-colors duration-75',
|
||
opt.disabled ? 'opacity-40 cursor-not-allowed' : '',
|
||
isHighlighted ? 'bg-white/[0.13]' : 'hover:bg-white/[0.07]',
|
||
isSelected ? 'text-accent' : 'text-white/85',
|
||
].join(' ')}
|
||
>
|
||
{/* Checkmark in multi mode */}
|
||
{multi && (
|
||
<span className={[
|
||
'w-4 h-4 shrink-0 rounded border flex items-center justify-center transition-colors',
|
||
isSelected ? 'bg-accent border-accent' : 'border-white/25 bg-white/5',
|
||
].join(' ')}>
|
||
{isSelected && (
|
||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" aria-hidden="true">
|
||
<path d="M1.5 4.5l2 2 4-4" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
)}
|
||
</span>
|
||
)}
|
||
|
||
{opt.icon && <span className="shrink-0">{opt.icon}</span>}
|
||
|
||
{renderOption ? renderOption(opt) : (
|
||
<span className="flex-1 truncate">{opt.label}</span>
|
||
)}
|
||
|
||
{/* Tick in single mode */}
|
||
{!multi && isSelected && (
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-accent shrink-0" aria-hidden="true">
|
||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
)}
|
||
</div>
|
||
</React.Fragment>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
)
|
||
}
|