mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
* feat: add jazz globa group cons * chore: remove topic selector atom * chore: use jazz from constant * chore: remove delete model and add new topic selector * chore: use jazz group id form constant in search component * chore: use jazz group id form constant in public home route * fix: topic selector in link * fix: topic section in detail topic * chore: update la editor * chore: content header tweak class * chore: add btn variant to topic selector * refactor: tweak border for link header * chore: page header * fix: page detail route
180 lines
5.2 KiB
TypeScript
180 lines
5.2 KiB
TypeScript
import React, { useMemo, useCallback, useRef, forwardRef } from "react"
|
|
import { atom, useAtom } from "jotai"
|
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
|
import { Button, buttonVariants } from "@/components/ui/button"
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
|
import { cn } from "@/lib/utils"
|
|
import { LaIcon } from "@/components/custom/la-icon"
|
|
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
|
|
import { useCoState } from "@/lib/providers/jazz-provider"
|
|
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
|
import { ListOfTopics, Topic } from "@/lib/schema"
|
|
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
|
import { VariantProps } from "class-variance-authority"
|
|
|
|
interface TopicSelectorProps extends VariantProps<typeof buttonVariants> {
|
|
showSearch?: boolean
|
|
defaultLabel?: string
|
|
searchPlaceholder?: string
|
|
value?: string | null
|
|
onChange?: (value: string) => void
|
|
onTopicChange?: (value: Topic) => void
|
|
className?: string
|
|
renderSelectedText?: (value?: string | null) => React.ReactNode
|
|
side?: "bottom" | "top" | "right" | "left"
|
|
align?: "center" | "end" | "start"
|
|
}
|
|
|
|
export const topicSelectorAtom = atom(false)
|
|
|
|
export const TopicSelector = forwardRef<HTMLButtonElement, TopicSelectorProps>(
|
|
(
|
|
{
|
|
showSearch = true,
|
|
defaultLabel = "Select topic",
|
|
searchPlaceholder = "Search topic...",
|
|
value,
|
|
onChange,
|
|
onTopicChange,
|
|
className,
|
|
renderSelectedText,
|
|
side = "bottom",
|
|
align = "end",
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useAtom(topicSelectorAtom)
|
|
const group = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, { root: { topics: [] } })
|
|
|
|
const handleSelect = useCallback(
|
|
(selectedTopicName: string, topic: Topic) => {
|
|
onChange?.(selectedTopicName)
|
|
onTopicChange?.(topic)
|
|
setIsTopicSelectorOpen(false)
|
|
},
|
|
[onChange, setIsTopicSelectorOpen, onTopicChange]
|
|
)
|
|
|
|
const displaySelectedText = useMemo(() => {
|
|
if (renderSelectedText) {
|
|
return renderSelectedText(value)
|
|
}
|
|
return <span className="truncate">{value || defaultLabel}</span>
|
|
}, [value, defaultLabel, renderSelectedText])
|
|
|
|
return (
|
|
<Popover open={isTopicSelectorOpen} onOpenChange={setIsTopicSelectorOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
ref={ref}
|
|
size="sm"
|
|
type="button"
|
|
role="combobox"
|
|
variant="secondary"
|
|
className={cn("gap-x-2 text-sm", className)}
|
|
{...props}
|
|
>
|
|
{displaySelectedText}
|
|
<LaIcon name="ChevronDown" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-52 rounded-lg p-0"
|
|
side={side}
|
|
align={align}
|
|
onCloseAutoFocus={e => e.preventDefault()}
|
|
>
|
|
{group?.root.topics && (
|
|
<TopicSelectorContent
|
|
showSearch={showSearch}
|
|
searchPlaceholder={searchPlaceholder}
|
|
value={value}
|
|
onSelect={handleSelect}
|
|
topics={group.root.topics}
|
|
/>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
)
|
|
|
|
TopicSelector.displayName = "TopicSelector"
|
|
|
|
interface TopicSelectorContentProps extends Omit<TopicSelectorProps, "onChange" | "onTopicChange"> {
|
|
onSelect: (value: string, topic: Topic) => void
|
|
topics: ListOfTopics
|
|
}
|
|
|
|
const TopicSelectorContent: React.FC<TopicSelectorContentProps> = React.memo(
|
|
({ showSearch, searchPlaceholder, value, onSelect, topics }) => {
|
|
const [search, setSearch] = React.useState("")
|
|
const filteredTopics = useMemo(
|
|
() => topics.filter(topic => topic?.prettyName.toLowerCase().includes(search.toLowerCase())),
|
|
[topics, search]
|
|
)
|
|
|
|
const parentRef = useRef<HTMLDivElement>(null)
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: filteredTopics.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => 35,
|
|
overscan: 5
|
|
})
|
|
|
|
return (
|
|
<Command>
|
|
{showSearch && (
|
|
<CommandInput placeholder={searchPlaceholder} className="h-9" value={search} onValueChange={setSearch} />
|
|
)}
|
|
<CommandList>
|
|
<div ref={parentRef} style={{ height: "200px", overflow: "auto" }}>
|
|
<div
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
width: "100%",
|
|
position: "relative"
|
|
}}
|
|
>
|
|
<CommandGroup>
|
|
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
|
const topic = filteredTopics[virtualRow.index]
|
|
return (
|
|
topic && (
|
|
<CommandItem
|
|
key={virtualRow.key}
|
|
value={topic.name}
|
|
onSelect={value => onSelect(value, topic)}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
height: `${virtualRow.size}px`,
|
|
transform: `translateY(${virtualRow.start}px)`
|
|
}}
|
|
>
|
|
<span>{topic.prettyName}</span>
|
|
<LaIcon
|
|
name="Check"
|
|
className={cn("absolute right-3", topic.name === value ? "text-primary" : "text-transparent")}
|
|
/>
|
|
</CommandItem>
|
|
)
|
|
)
|
|
})}
|
|
</CommandGroup>
|
|
</div>
|
|
</div>
|
|
</CommandList>
|
|
</Command>
|
|
)
|
|
}
|
|
)
|
|
|
|
TopicSelectorContent.displayName = "TopicSelectorContent"
|
|
|
|
export default TopicSelector
|