co-authored by
Gunshin
Unverified
parent
45f6d06cc9
commit
8a96ea8573
+39
-11
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
@@ -11,18 +12,45 @@ export default function Image({
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextImage>) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const [rect, setRect] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current?.parentElement) {
|
||||
const { top: pTop, left: pLeft } =
|
||||
ref.current.parentElement.getBoundingClientRect();
|
||||
const { top, left, width, height } = ref.current.getBoundingClientRect();
|
||||
setRect({ top: top - pTop, left: left - pLeft, width, height });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"transition-opacity duration-700 opacity-0",
|
||||
isLoaded && "opacity-100",
|
||||
className,
|
||||
<>
|
||||
<NextImage
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"transition-opacity duration-700 opacity-0",
|
||||
isLoaded && "opacity-100",
|
||||
className,
|
||||
)}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
{...props}
|
||||
/>
|
||||
{isLoaded || (
|
||||
<div
|
||||
className="absolute backdrop-blur-md bg-[#2228] p-1 overflow-hidden rounded-sm animate-in fade-in"
|
||||
style={rect}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client"
|
||||
|
||||
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
type MultiSelectContextType = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
selectedValues: Set<string>
|
||||
toggleValue: (value: string) => void
|
||||
items: Map<string, ReactNode>
|
||||
onItemAdded: (value: string, label: ReactNode) => void
|
||||
}
|
||||
const MultiSelectContext = createContext<MultiSelectContextType | null>(null)
|
||||
|
||||
export function MultiSelect({
|
||||
children,
|
||||
values,
|
||||
defaultValues,
|
||||
onValuesChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
values?: string[]
|
||||
defaultValues?: string[]
|
||||
onValuesChange?: (values: string[]) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [internalValues, setInternalValues] = useState(
|
||||
new Set<string>(values ?? defaultValues),
|
||||
)
|
||||
const selectedValues = values ? new Set(values) : internalValues
|
||||
const [items, setItems] = useState<Map<string, ReactNode>>(new Map())
|
||||
|
||||
function toggleValue(value: string) {
|
||||
const getNewSet = (prev: Set<string>) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(value)) {
|
||||
newSet.delete(value)
|
||||
} else {
|
||||
newSet.add(value)
|
||||
}
|
||||
return newSet
|
||||
}
|
||||
setInternalValues(getNewSet)
|
||||
onValuesChange?.([...getNewSet(selectedValues)])
|
||||
}
|
||||
|
||||
const onItemAdded = useCallback((value: string, label: ReactNode) => {
|
||||
setItems(prev => {
|
||||
if (prev.get(value) === label) return prev
|
||||
return new Map(prev).set(value, label)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MultiSelectContext
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
selectedValues,
|
||||
toggleValue,
|
||||
items,
|
||||
onItemAdded,
|
||||
}}
|
||||
>
|
||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||
{children}
|
||||
</Popover>
|
||||
</MultiSelectContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiSelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
} & ComponentPropsWithoutRef<typeof Button>) {
|
||||
const { open } = useMultiSelectContext()
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
{...props}
|
||||
variant={props.variant ?? "outline"}
|
||||
role={props.role ?? "combobox"}
|
||||
aria-expanded={props["aria-expanded"] ?? open}
|
||||
className={cn(
|
||||
"flex h-auto min-h-9 w-fit items-center justify-between gap-2 overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiSelectValue({
|
||||
placeholder,
|
||||
clickToRemove = true,
|
||||
className,
|
||||
overflowBehavior = "wrap-when-open",
|
||||
...props
|
||||
}: {
|
||||
placeholder?: string
|
||||
clickToRemove?: boolean
|
||||
overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff"
|
||||
} & Omit<ComponentPropsWithoutRef<"div">, "children">) {
|
||||
const { selectedValues, toggleValue, items, open } = useMultiSelectContext()
|
||||
const [overflowAmount, setOverflowAmount] = useState(0)
|
||||
const valueRef = useRef<HTMLDivElement>(null)
|
||||
const overflowRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const shouldWrap =
|
||||
overflowBehavior === "wrap" ||
|
||||
(overflowBehavior === "wrap-when-open" && open)
|
||||
|
||||
const checkOverflow = useCallback(() => {
|
||||
if (valueRef.current == null) return
|
||||
|
||||
const containerElement = valueRef.current
|
||||
const overflowElement = overflowRef.current
|
||||
const items = containerElement.querySelectorAll<HTMLElement>(
|
||||
"[data-selected-item]",
|
||||
)
|
||||
|
||||
if (overflowElement != null) overflowElement.style.display = "none"
|
||||
items.forEach(child => child.style.removeProperty("display"))
|
||||
let amount = 0
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const child = items[i]!
|
||||
if (containerElement.scrollWidth <= containerElement.clientWidth) {
|
||||
break
|
||||
}
|
||||
amount = items.length - i
|
||||
child.style.display = "none"
|
||||
overflowElement?.style.removeProperty("display")
|
||||
}
|
||||
setOverflowAmount(amount)
|
||||
}, [])
|
||||
|
||||
const handleResize = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
valueRef.current = node
|
||||
|
||||
const mutationObserver = new MutationObserver(checkOverflow)
|
||||
const observer = new ResizeObserver(debounce(checkOverflow, 100))
|
||||
|
||||
mutationObserver.observe(node, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
})
|
||||
observer.observe(node)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
valueRef.current = null
|
||||
}
|
||||
},
|
||||
[checkOverflow],
|
||||
)
|
||||
|
||||
if (selectedValues.size === 0 && placeholder) {
|
||||
return (
|
||||
<span className="min-w-0 overflow-hidden font-normal text-muted-foreground">
|
||||
{placeholder}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={handleResize}
|
||||
className={cn(
|
||||
"flex w-full gap-1.5 overflow-hidden",
|
||||
shouldWrap && "h-full flex-wrap",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{[...selectedValues]
|
||||
.filter(value => items.has(value))
|
||||
.map(value => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-selected-item
|
||||
className="group flex items-center gap-1"
|
||||
key={value}
|
||||
onClick={
|
||||
clickToRemove
|
||||
? e => {
|
||||
e.stopPropagation()
|
||||
toggleValue(value)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{items.get(value)}
|
||||
{clickToRemove && (
|
||||
<XIcon className="size-2 text-muted-foreground group-hover:text-destructive" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge
|
||||
style={{
|
||||
display: overflowAmount > 0 && !shouldWrap ? "block" : "none",
|
||||
}}
|
||||
variant="outline"
|
||||
ref={overflowRef}
|
||||
>
|
||||
+{overflowAmount}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiSelectContent({
|
||||
search = true,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
search?: boolean | { placeholder?: string; emptyMessage?: string }
|
||||
children: ReactNode
|
||||
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
|
||||
const canSearch = typeof search === "object" ? true : search
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "none" }}>
|
||||
<Command>
|
||||
<CommandList>{children}</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
<PopoverContent className="min-w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command {...props}>
|
||||
{canSearch ? (
|
||||
<CommandInput
|
||||
placeholder={
|
||||
typeof search === "object" ? search.placeholder : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button autoFocus className="sr-only" />
|
||||
)}
|
||||
<CommandList>
|
||||
{canSearch && (
|
||||
<CommandEmpty>
|
||||
{typeof search === "object" ? search.emptyMessage : undefined}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{children}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiSelectItem({
|
||||
value,
|
||||
children,
|
||||
badgeLabel,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
badgeLabel?: ReactNode
|
||||
value: string
|
||||
} & Omit<ComponentPropsWithoutRef<typeof CommandItem>, "value">) {
|
||||
const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext()
|
||||
const isSelected = selectedValues.has(value)
|
||||
|
||||
useEffect(() => {
|
||||
onItemAdded(value, badgeLabel ?? children)
|
||||
}, [value, children, onItemAdded, badgeLabel])
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
{...props}
|
||||
onSelect={() => {
|
||||
toggleValue(value)
|
||||
onSelect?.(value)
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn("mr-2 size-4", isSelected ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{children}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiSelectGroup(
|
||||
props: ComponentPropsWithoutRef<typeof CommandGroup>,
|
||||
) {
|
||||
return <CommandGroup {...props} />
|
||||
}
|
||||
|
||||
export function MultiSelectSeparator(
|
||||
props: ComponentPropsWithoutRef<typeof CommandSeparator>,
|
||||
) {
|
||||
return <CommandSeparator {...props} />
|
||||
}
|
||||
|
||||
function useMultiSelectContext() {
|
||||
const context = useContext(MultiSelectContext)
|
||||
if (context == null) {
|
||||
throw new Error(
|
||||
"useMultiSelectContext must be used within a MultiSelectContext",
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function debounce<T extends (...args: never[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
return function (this: unknown, ...args: Parameters<T>) {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func.apply(this, args), wait)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user