feat: social graph features
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Combobox — a shadcn-style single-select dropdown with optional search.
|
||||
*
|
||||
* Trigger button shows the selected label (or a placeholder); clicking it
|
||||
* opens a panel with an optional search box and a filtered, keyboard-
|
||||
* navigable list. Selecting an option calls `onChange(value)` and closes.
|
||||
*
|
||||
* Notes:
|
||||
* • All state changes happen in event handlers (click / keydown / input)
|
||||
* — never synchronously inside an effect — so opening can't trigger
|
||||
* React's "cascading renders" warning. The only effect just wires the
|
||||
* outside-pointerdown listener.
|
||||
* • `value === ""` is a valid selectable value (e.g. a "Not specified"
|
||||
* option), so the field can represent "cleared".
|
||||
* • The panel is absolutely positioned within a relative wrapper, so the
|
||||
* parent must not clip overflow (forms/cards here don't).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ComboboxOption = { value: string; label: string };
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select…",
|
||||
searchPlaceholder = "Search…",
|
||||
emptyText = "No results.",
|
||||
searchable = true,
|
||||
disabled = false,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
searchable?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value) ?? null;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!searchable || q === "") return options;
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [options, query, searchable]);
|
||||
|
||||
const active =
|
||||
filtered.length === 0 ? -1 : Math.min(activeIndex, filtered.length - 1);
|
||||
|
||||
// Close on outside pointerdown. (Listener only — no setState in body.)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointer(e: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointer);
|
||||
return () => document.removeEventListener("pointerdown", onPointer);
|
||||
}, [open]);
|
||||
|
||||
function openMenu() {
|
||||
setQuery("");
|
||||
const i = options.findIndex((o) => o.value === value);
|
||||
setActiveIndex(i < 0 ? 0 : i);
|
||||
setOpen(true);
|
||||
if (searchable) {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
if (open) setOpen(false);
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
function select(v: string) {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (active >= 0) select(filtered[active].value);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"w-full h-10 flex items-center justify-between gap-2 rounded-md border border-border bg-background px-3 text-sm text-left",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className={cn("truncate", !selected && "text-muted-foreground")}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-border bg-card shadow-lg animate-in fade-in-0 zoom-in-95">
|
||||
{searchable ? (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full h-9 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ul role="listbox" className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((o, i) => (
|
||||
<li
|
||||
key={o.value || "__empty__"}
|
||||
role="option"
|
||||
aria-selected={o.value === value}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
onClick={() => select(o.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-3 py-2 text-sm cursor-pointer",
|
||||
i === active ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{o.label}</span>
|
||||
{o.value === value ? (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DatePicker — a calendar popover for picking a single date, no external
|
||||
* deps. Trigger button shows the formatted date (or a placeholder);
|
||||
* clicking opens a month grid with prev/next navigation. Value in/out is
|
||||
* an ISO "YYYY-MM-DD" string ("" = no date). `max`/`min` (also ISO) bound
|
||||
* the selectable range — out-of-range days render disabled.
|
||||
*
|
||||
* The panel is portaled to <body> with fixed positioning and collision
|
||||
* handling: it opens below the trigger, flips above when there isn't room,
|
||||
* and clamps to the viewport horizontally. Position is measured in event
|
||||
* handlers (open click + scroll/resize) — never synchronously inside an
|
||||
* effect — so opening can't trigger React's cascading-render warning.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
const PANEL_W = 288; // matches w-72
|
||||
const PANEL_H = 332; // approximate; used only for the flip decision
|
||||
const GAP = 6;
|
||||
const MARGIN = 8;
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
function toISO(y: number, m0: number, d: number) {
|
||||
return `${y}-${pad2(m0 + 1)}-${pad2(d)}`;
|
||||
}
|
||||
/** Parse "YYYY-MM-DD" → {y, m0 (0-based), d}, or null. */
|
||||
function parseISO(s: string): { y: number; m0: number; d: number } | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
||||
if (!m) return null;
|
||||
return { y: Number(m[1]), m0: Number(m[2]) - 1, d: Number(m[3]) };
|
||||
}
|
||||
function prettyDate(s: string): string {
|
||||
const p = parseISO(s);
|
||||
if (!p) return "";
|
||||
return new Date(p.y, p.m0, p.d).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
min,
|
||||
id,
|
||||
ariaLabel,
|
||||
placeholder = "Select a date",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
max?: string;
|
||||
min?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Either `top` (opens below, anchored to the trigger's bottom) or
|
||||
// `bottom` (opens above, anchored just over the trigger's top) is set —
|
||||
// anchoring the relevant edge keeps the panel flush to the trigger
|
||||
// regardless of its actual height. `maxHeight` clamps it to the
|
||||
// available space (scrolls if it ever can't fit).
|
||||
const [pos, setPos] = useState<{
|
||||
left: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const today = new Date();
|
||||
const todayISO = toISO(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const initial = parseISO(value) ?? {
|
||||
y: today.getFullYear(),
|
||||
m0: today.getMonth(),
|
||||
d: today.getDate(),
|
||||
};
|
||||
const [view, setView] = useState<{ y: number; m0: number }>({
|
||||
y: initial.y,
|
||||
m0: initial.m0,
|
||||
});
|
||||
|
||||
// Measure the trigger and place the panel flush to it — below by
|
||||
// default, flipped above only when below lacks room and above has more.
|
||||
// Called from event handlers only (open click + scroll/resize).
|
||||
function recompute() {
|
||||
const btn = triggerRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = rect.left;
|
||||
if (left + PANEL_W > vw - MARGIN) left = vw - MARGIN - PANEL_W;
|
||||
if (left < MARGIN) left = MARGIN;
|
||||
|
||||
const spaceBelow = vh - rect.bottom - GAP - MARGIN;
|
||||
const spaceAbove = rect.top - GAP - MARGIN;
|
||||
|
||||
if (spaceBelow >= PANEL_H || spaceBelow >= spaceAbove) {
|
||||
// Below: top edge anchored just under the trigger.
|
||||
setPos({ left, top: rect.bottom + GAP, maxHeight: Math.max(180, spaceBelow) });
|
||||
} else {
|
||||
// Above: bottom edge anchored just over the trigger (grows upward),
|
||||
// so it stays adjacent no matter how tall the calendar is.
|
||||
setPos({ left, bottom: vh - rect.top + GAP, maxHeight: Math.max(180, spaceAbove) });
|
||||
}
|
||||
}
|
||||
|
||||
// Outside-click + Escape + reposition on scroll/resize, while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointer(e: PointerEvent) {
|
||||
if (triggerRef.current?.contains(e.target as Node)) return;
|
||||
if (panelRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", recompute);
|
||||
window.addEventListener("scroll", recompute, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", recompute);
|
||||
window.removeEventListener("scroll", recompute, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function openMenu() {
|
||||
const p = parseISO(value);
|
||||
if (p) setView({ y: p.y, m0: p.m0 });
|
||||
recompute();
|
||||
setOpen(true);
|
||||
}
|
||||
function toggle() {
|
||||
if (open) setOpen(false);
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
setView((v) => {
|
||||
const d = new Date(v.y, v.m0 + delta, 1);
|
||||
return { y: d.getFullYear(), m0: d.getMonth() };
|
||||
});
|
||||
}
|
||||
|
||||
function pick(day: number) {
|
||||
onChange(toISO(view.y, view.m0, day));
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const monthLabel = new Date(view.y, view.m0, 1).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const startWeekday = new Date(view.y, view.m0, 1).getDay();
|
||||
const daysInMonth = new Date(view.y, view.m0 + 1, 0).getDate();
|
||||
const cells: (number | null)[] = [
|
||||
...Array(startWeekday).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
function disabled(day: number) {
|
||||
const iso = toISO(view.y, view.m0, day);
|
||||
if (max && iso > max) return true;
|
||||
if (min && iso < min) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"w-full h-10 flex items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-left",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className={cn("truncate flex-1", !value && "text-muted-foreground")}>
|
||||
{value ? prettyDate(value) : placeholder}
|
||||
</span>
|
||||
{value ? (
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Clear date"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open && pos && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pos.top,
|
||||
bottom: pos.bottom,
|
||||
left: pos.left,
|
||||
width: PANEL_W,
|
||||
maxHeight: pos.maxHeight,
|
||||
overflowY: "auto",
|
||||
zIndex: 70,
|
||||
}}
|
||||
className="rounded-md border border-border bg-card p-3 shadow-lg animate-in fade-in-0 zoom-in-95"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous month"
|
||||
onClick={() => shiftMonth(-1)}
|
||||
className="size-8 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium">{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next month"
|
||||
onClick={() => shiftMonth(1)}
|
||||
className="size-8 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-1">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
className="h-7 flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`b${i}`} className="h-9" />;
|
||||
const iso = toISO(view.y, view.m0, day);
|
||||
const isSelected = iso === value;
|
||||
const isToday = iso === todayISO;
|
||||
const isDisabled = disabled(day);
|
||||
return (
|
||||
<button
|
||||
key={iso}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => pick(day)}
|
||||
className={cn(
|
||||
"h-9 rounded-md text-sm inline-flex items-center justify-center transition-colors",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground font-medium"
|
||||
: "hover:bg-muted",
|
||||
!isSelected && isToday ? "ring-1 ring-primary/40" : "",
|
||||
isDisabled
|
||||
? "opacity-30 cursor-not-allowed hover:bg-transparent"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user