This commit is contained in:
+192
-22
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -29,13 +30,45 @@ export function ComboBox({
|
||||
defaultValue = "",
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
data: { value: string; label: string }[];
|
||||
data: { value: string; label: React.ReactNode; context?: string[] }[];
|
||||
placeholder?: string;
|
||||
trigger?: React.ReactNode;
|
||||
onValueSelect?: (val: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState(defaultValue);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const list = React.useMemo(
|
||||
() =>
|
||||
data.map((ent) => ({
|
||||
context: (
|
||||
ent.context?.join("") ??
|
||||
"" + (typeof ent.label === "string" ? ent.label : "") + ent.value
|
||||
).toLowerCase(),
|
||||
comp: (
|
||||
<CommandItem
|
||||
key={ent.value}
|
||||
value={ent.value}
|
||||
onSelect={(currentValue) => {
|
||||
if (onValueSelect) onValueSelect(currentValue);
|
||||
else setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{ent.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === ent.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
),
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue) setValue(defaultValue);
|
||||
@@ -61,30 +94,18 @@ export function ComboBox({
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-100 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={placeholder} className="h-9" />
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
onValueChange={setQuery}
|
||||
placeholder={placeholder}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>None found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{data.map((ent) => (
|
||||
<CommandItem
|
||||
key={ent.value}
|
||||
value={ent.value}
|
||||
onSelect={(currentValue) => {
|
||||
if (onValueSelect) onValueSelect(currentValue);
|
||||
else setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{ent.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === ent.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
{list
|
||||
.filter((e) => e.context.includes(queryLower))
|
||||
.map((e) => e.comp)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -94,3 +115,152 @@ export function ComboBox({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VirtualizedComboBox({
|
||||
data,
|
||||
placeholder = "Search...",
|
||||
id,
|
||||
name,
|
||||
className,
|
||||
trigger,
|
||||
onValueSelect,
|
||||
defaultValue = "",
|
||||
itemHeight = 36,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
data: { value: string; label: React.ReactNode; context?: string[] }[];
|
||||
placeholder?: string;
|
||||
trigger?: React.ReactNode;
|
||||
onValueSelect?: (val: string) => void;
|
||||
itemHeight?: number;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState(defaultValue);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
if (!q) return data;
|
||||
return data.filter((ent) => {
|
||||
const ctx = (
|
||||
ent.context?.join("") ??
|
||||
"" + (typeof ent.label === "string" ? ent.label : "") + ent.value
|
||||
).toLowerCase();
|
||||
return ctx.includes(q);
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const [scrollEl, setScrollEl] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setTimeout(() => {
|
||||
setScrollEl(listRef.current);
|
||||
}, 100);
|
||||
}, [open, listRef]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollEl,
|
||||
estimateSize: () => itemHeight,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue) setValue(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
const visibleItems = scrollEl ? virtualizer.getVirtualItems() : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger || (
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-50 justify-between", className)}
|
||||
{...props}
|
||||
>
|
||||
<div>
|
||||
{value
|
||||
? data.find((ent) => ent.value === value)?.label
|
||||
: placeholder}
|
||||
</div>
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-100 p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
onValueChange={setQuery}
|
||||
placeholder={placeholder}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{ maxHeight: 300, overflowY: "auto" }}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm">None found.</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((virtualItem) => {
|
||||
const item = filtered[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<CommandItem
|
||||
value={item.value}
|
||||
onSelect={(currentValue) => {
|
||||
if (onValueSelect) onValueSelect(currentValue);
|
||||
else
|
||||
setValue(
|
||||
currentValue === value ? "" : currentValue,
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === item.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<input id={id} name={name} type="hidden" value={value} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import type { ErrorInfo } from "next/dist/client/components/error-boundary";
|
||||
import { ModalBase } from "./modal";
|
||||
import { Button } from "./ui/button";
|
||||
import { DialogClose, DialogFooter } from "./ui/dialog";
|
||||
|
||||
export function ErrorModal({
|
||||
error,
|
||||
reset,
|
||||
}: Omit<ErrorInfo, "unstable_retry">) {
|
||||
return (
|
||||
<ModalBase title="Modal Error">
|
||||
An error occured while spawning modal:
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{error.stack?.split("\n").slice(0, 2).join("\n")}
|
||||
</pre>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={reset}>
|
||||
Retry
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ModalBase>
|
||||
);
|
||||
}
|
||||
+15
-6
@@ -9,6 +9,7 @@ import type {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DialogClose } from "./ui/dialog";
|
||||
import { Label } from "./ui/label";
|
||||
import { Divide } from "lucide-react";
|
||||
|
||||
const AUTOSAVE_TTL_MS = 10 * 60 * 1000;
|
||||
const STORAGE_PREFIX = "form:autosave:";
|
||||
@@ -28,7 +29,7 @@ type FormContextValue = {
|
||||
|
||||
const FormContext = React.createContext<FormContextValue | null>(null);
|
||||
|
||||
function useFormContext() {
|
||||
export function useFormContext() {
|
||||
const ctx = React.useContext(FormContext);
|
||||
if (!ctx) throw new Error("FormInput/FormAction must be inside FormProvider");
|
||||
return ctx;
|
||||
@@ -153,9 +154,13 @@ export function FormProvider<T extends TypedFormDataShape>({
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = safeParseAutosave(localStorage.getItem(storageKey(id)));
|
||||
if (!stored) return;
|
||||
if (!stored) {
|
||||
setStorageLock(null);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - stored.savedAt > AUTOSAVE_TTL_MS) {
|
||||
localStorage.removeItem(storageKey(id));
|
||||
setStorageLock(null);
|
||||
return;
|
||||
}
|
||||
setValues(stored.values || {});
|
||||
@@ -282,8 +287,8 @@ export function FormInput({
|
||||
className,
|
||||
}: FormInputProps) {
|
||||
const { values, setValue, updateValues } = useFormContext();
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
const value = (values[name] ?? childProps.defaultValue) || "";
|
||||
const childProps = (children.props ?? {}) as Record<string, unknown>;
|
||||
const value = values[name] ?? childProps?.defaultValue ?? "";
|
||||
const nextProps: Record<string, unknown> = { name, defaultValue: undefined };
|
||||
const applyValue = React.useCallback(
|
||||
(nextValue: unknown) => {
|
||||
@@ -336,6 +341,10 @@ export function FormInput({
|
||||
nextProps.id = `forminput-${name}`;
|
||||
}
|
||||
|
||||
if (children.$$typeof === Symbol.for("react.lazy")) {
|
||||
children = React.use((children as any)._payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
@@ -346,10 +355,10 @@ export function FormInput({
|
||||
<span className="font-normal opacity-70">{subLabel}</span>
|
||||
)}
|
||||
</Label>
|
||||
{React.cloneElement(children, nextProps)}
|
||||
{React.cloneElement(children || Divide, nextProps)}
|
||||
</div>
|
||||
) : (
|
||||
React.cloneElement(children, nextProps)
|
||||
React.cloneElement(children || Divide, nextProps)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user