TreeView.tsx343 lines · main
1'use client'
2
3import { cva, VariantProps } from 'class-variance-authority'
4import { ChevronRight, FolderClosed, FolderOpen, Loader2 } from 'lucide-react'
5import {
6 ComponentPropsWithoutRef,
7 FocusEvent,
8 forwardRef,
9 ReactNode,
10 useEffect,
11 useRef,
12 useState,
13} from 'react'
14import TreeViewPrimitive, { flattenTree, type INodeRendererProps } from 'react-accessible-treeview'
15
16import { cn } from '../../lib/utils'
17import { Input } from '../shadcn/ui/input'
18
19const TreeView = TreeViewPrimitive
20
21const CHEVRON_ICON_SIZE = 14
22const ENTITY_ICON_SIZE = 16
23
24export type TreeViewItemVariantProps = VariantProps<typeof TreeViewItemVariant>
25export const TreeViewItemVariant = cva(
26 // [Joshen Temp]: aria-selected:text-foreground not working as aria-selected property not rendered in DOM,
27 // [Joshen Temp]: aria-selected:!bg-selection not working as aria-selected property not rendered in DOM
28 'group relative transition-colors h-[28px] flex items-center gap-3 text-sm cursor-pointer select-none text-foreground-light hover:bg-control aria-expanded:bg-transparent data-[state=open]:bg-transparent', // data-[state=open]:bg-control bg state for context menu open
29 {
30 variants: {
31 isSelected: {
32 true: 'text-foreground !bg-selection', // bg state for context menu open
33 false: '',
34 },
35 isOpened: {
36 true: 'bg-control',
37 false: '',
38 },
39 isPreview: {
40 true: 'bg-control text-foreground',
41 false: '',
42 },
43 },
44 }
45)
46
47const TreeViewItem = forwardRef<
48 HTMLDivElement,
49 ComponentPropsWithoutRef<'div'> & {
50 /** The level of the item in the tree */
51 level: number
52 /** Specifies if the item is expanded */
53 isExpanded?: boolean
54 /** Specifies if the item is opened somewhere */
55 isOpened?: boolean
56 /** Specifies if the item is a preview */
57 isPreview?: boolean
58 /** Specifies if the item is a branch */
59 isBranch?: boolean
60 /** The padding for each level of the item */
61 levelPadding?: number
62 /** Specifies if the item is selected */
63 isSelected?: boolean
64 /** The horizontal padding of the item */
65 xPadding?: number
66 /** name of entity */
67 name: string | ReactNode
68 /** Optional description of entity */
69 description?: string
70 /** String name to use for title attribute when name is a ReactNode */
71 nameForTitle?: string
72 /** icon of entity */
73 icon?: ReactNode
74 /** Specifies if the item is being edited, shows an input */
75 isEditing?: boolean
76 /** Callback for when the item is edited */
77 onEditSubmit?: (value: string) => void
78 /** For asynchronous loading */
79 isLoading?: boolean
80 /** Callback for double-click */
81 onDoubleClick?: (e: React.MouseEvent) => void
82 /** Actions to render on the right end of the item */
83 actions?: ReactNode
84 }
85>(
86 (
87 {
88 level = 1,
89 levelPadding = 38,
90 isExpanded = false,
91 isOpened = false,
92 isBranch = false,
93 isSelected = false,
94 isPreview = false,
95 isLoading = false,
96 xPadding = 16,
97 name = '',
98 description,
99 nameForTitle,
100 icon,
101 isEditing = false,
102 onEditSubmit,
103 onDoubleClick,
104 actions,
105 ...props
106 },
107 ref
108 ) => {
109 const nameString = nameForTitle ?? (typeof name === 'string' ? name : '')
110 const [localValueState, setLocalValueState] = useState(nameString)
111 const inputRef = useRef<HTMLInputElement>(null)
112 const timeRef = useRef<number>(0)
113
114 useEffect(() => {
115 if (isEditing) {
116 // [Ivan] This component is supposed to focus on its input when it's rendered. The focus doesn't work every time because
117 // the initial render is triggered by a dropdown menu which at the end of the closing animation, steals focus from the input
118 // and triggers the blur event (which closes the input). The issue is reported at https://github.com/radix-ui/primitives/issues/3106.
119
120 // [Joshen] This is to prevent accidental onBlur callbacks by checking that the onBlur event is being triggered
121 // within 400ms of the input field being in an edit state. 400ms is just an arbitary value which I think
122 // represents an "accidental" on blur
123 timeRef.current = Number(new Date())
124
125 // The focus will be attempted after a slight delay to ensure that the dropdown closing animations are complete.
126 setTimeout(() => {
127 const input = inputRef.current
128 if (input) {
129 // If the input is not the active element, focus it
130 if (document.activeElement !== input) {
131 input.focus()
132 }
133
134 // Need a slight delay to ensure focus is established. When editing starts, select text up to the last dot
135 // When editing starts, select text up to the last dot
136 setTimeout(() => {
137 const fileName = input.value
138 const lastDotIndex = fileName.lastIndexOf('.')
139 const startPos = 0
140 const endPos = lastDotIndex > 0 ? lastDotIndex : fileName.length
141
142 try {
143 input.setSelectionRange(startPos, endPos)
144 } catch (e) {
145 console.error('Could not set selection range', e)
146 }
147 }, 50)
148 }
149 }, 200)
150 } else {
151 setLocalValueState(nameString)
152 }
153 }, [isEditing, nameString])
154
155 useEffect(() => {
156 if (!isLoading) {
157 setLocalValueState(nameString)
158 }
159 }, [isLoading, nameString])
160
161 const handleBlur = (e: FocusEvent<HTMLInputElement, Element>) => {
162 const timestamp = Number(new Date())
163 const timeDiff = timestamp - timeRef.current
164
165 if (timeDiff < 400) {
166 e.preventDefault()
167 inputRef.current?.focus()
168 } else {
169 onEditSubmit?.(localValueState)
170 }
171 }
172
173 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
174 e.preventDefault()
175 onEditSubmit?.(localValueState)
176 }
177
178 // [Joshen] These properties were causing console errors as they were getting passed as props to the parent div
179 const {
180 isDisabled,
181 isHalfSelected,
182 handleSelect,
183 handleExpand,
184 treeState,
185 dispatch,
186 ...divProps
187 } = props as any
188
189 const trimmedDescription = description?.trim()
190 const titleText = trimmedDescription ? `${nameString}\n${trimmedDescription}` : nameString
191
192 return (
193 <div
194 ref={ref}
195 title={titleText}
196 {...divProps}
197 aria-selected={isSelected}
198 aria-expanded={!isEditing && isExpanded}
199 onDoubleClick={onDoubleClick}
200 className={cn(
201 TreeViewItemVariant({ isSelected, isOpened, isPreview }),
202 !!actions && 'pr-2',
203 !isEditing && !!actions && 'justify-between',
204 props.className
205 )}
206 style={{
207 paddingLeft: xPadding + ((level - 1) * levelPadding) / 2,
208 ...props.style,
209 }}
210 data-treeview-is-branch={isBranch}
211 data-treeview-level={level}
212 >
213 {Array.from({ length: level - 1 }).map((_, i) => (
214 <div
215 key={i}
216 style={{
217 left: xPadding + (i * levelPadding) / 2 + CHEVRON_ICON_SIZE / 2,
218 }}
219 className={'absolute h-full w-px bg-border-strong'}
220 ></div>
221 ))}
222
223 {isSelected && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
224
225 <div className="flex items-center gap-x-3 truncate">
226 {isBranch ? (
227 <>
228 {isLoading ? (
229 <Loader2 className={cn('text-foreground-muted animate-spin')} size={14} />
230 ) : (
231 <ChevronRight
232 className={cn(
233 'text-foreground-muted',
234 'group-aria-selected:text-foreground-light',
235 'group-aria-expanded:text-foreground-light',
236 'transition-transform duration-200',
237 'group-aria-expanded:rotate-90'
238 )}
239 size={CHEVRON_ICON_SIZE}
240 strokeWidth={1.5}
241 />
242 )}
243 <TreeViewFolderIcon
244 className={cn(
245 'transition-colors',
246 ' text-foreground-muted',
247 'group-aria-selected:text-foreground-light',
248 'group-aria-expanded:text-foreground-light'
249 )}
250 isOpen={isExpanded}
251 size={ENTITY_ICON_SIZE}
252 strokeWidth={1.5}
253 />
254 </>
255 ) : (
256 icon || (
257 <SQL_ICON
258 className={cn(
259 'transition-colors',
260 'fill-foreground-muted',
261 'group-aria-selected:fill-foreground',
262 'w-5 h-5 shrink-0'
263 )}
264 size={ENTITY_ICON_SIZE}
265 strokeWidth={1.5}
266 />
267 )
268 )}
269 <span className={cn(isEditing && 'hidden', 'truncate text-sm')}>{name}</span>
270 </div>
271
272 {!isEditing && actions}
273
274 <form onSubmit={handleSubmit} className={cn(!isEditing && 'hidden')}>
275 <Input
276 autoFocus
277 ref={inputRef}
278 onChange={(e) => {
279 setLocalValueState(e.target.value)
280 }}
281 onBlur={handleBlur}
282 onKeyDownCapture={(e) => {
283 // stop keyboard down bubbling up to TreeView.root
284 // on enter key, send onEditSubmit callback
285 if (e.key === 'Enter') {
286 inputRef.current?.blur()
287 } else if (e.key === 'Escape') {
288 setLocalValueState(nameString)
289 onEditSubmit?.(nameString)
290 } else {
291 e.stopPropagation()
292 }
293 }}
294 className="block w-full text-sm px-2 py-1 h-7"
295 value={localValueState}
296 />
297 </form>
298 </div>
299 )
300 }
301)
302
303const SQL_ICON = forwardRef<SVGSVGElement, LucideSVGProps>((props, _ref) => (
304 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
305 <g clipPath="url(#clip0_1018_49117)">
306 <path d="M20.8457 14.4531V15.6348H17.8916V14.4531H20.8457ZM18.3311 8.52539V15.6348H16.9004V8.52539H18.3311Z" />
307 <path d="M13.6865 14.5508L15.3857 16.084L14.4873 16.9092L12.8271 15.376L13.6865 14.5508ZM15.3564 11.5283V12.6318C15.3564 13.1429 15.2962 13.5938 15.1758 13.9844C15.0553 14.3717 14.8812 14.6956 14.6533 14.9561C14.4255 15.2132 14.1553 15.4069 13.8428 15.5371C13.5303 15.6673 13.1836 15.7324 12.8027 15.7324C12.4219 15.7324 12.0736 15.6673 11.7578 15.5371C11.4453 15.4069 11.1751 15.2132 10.9473 14.9561C10.7227 14.6956 10.5469 14.3717 10.4199 13.9844C10.2962 13.5938 10.2344 13.1429 10.2344 12.6318V11.5283C10.2344 11.0173 10.2962 10.568 10.4199 10.1807C10.5436 9.79329 10.7178 9.47103 10.9424 9.21387C11.1702 8.95345 11.4404 8.75814 11.7529 8.62793C12.0654 8.49447 12.4121 8.42773 12.793 8.42773C13.1771 8.42773 13.5254 8.49447 13.8379 8.62793C14.1504 8.75814 14.4206 8.95345 14.6484 9.21387C14.8796 9.47103 15.0553 9.79329 15.1758 10.1807C15.2962 10.568 15.3564 11.0173 15.3564 11.5283ZM13.9307 12.6318V11.5186C13.9307 11.1833 13.9062 10.8952 13.8574 10.6543C13.8086 10.4134 13.7354 10.2165 13.6377 10.0635C13.5433 9.91048 13.4245 9.79818 13.2812 9.72656C13.1413 9.65495 12.9785 9.61914 12.793 9.61914C12.6107 9.61914 12.4479 9.65495 12.3047 9.72656C12.1647 9.79818 12.0475 9.91048 11.9531 10.0635C11.8587 10.2165 11.7855 10.4134 11.7334 10.6543C11.6846 10.8952 11.6602 11.1833 11.6602 11.5186V12.6318C11.6602 12.9704 11.6846 13.2601 11.7334 13.501C11.7822 13.7419 11.8538 13.9404 11.9482 14.0967C12.0459 14.2497 12.1647 14.3636 12.3047 14.4385C12.4479 14.5101 12.6139 14.5459 12.8027 14.5459C12.985 14.5459 13.1462 14.5101 13.2861 14.4385C13.4294 14.3636 13.5482 14.2497 13.6426 14.0967C13.7402 13.9437 13.8118 13.7467 13.8574 13.5059C13.9062 13.2617 13.9307 12.9704 13.9307 12.6318Z" />
308 <path d="M7.47266 13.7646C7.47266 13.6377 7.46126 13.5221 7.43848 13.418C7.41569 13.3138 7.36686 13.2178 7.29199 13.1299C7.22038 13.042 7.11458 12.9541 6.97461 12.8662C6.83789 12.7783 6.65885 12.6872 6.4375 12.5928C6.17383 12.4821 5.91829 12.3649 5.6709 12.2412C5.4235 12.1143 5.20052 11.9678 5.00195 11.8018C4.80339 11.6357 4.64551 11.4404 4.52832 11.2158C4.41439 10.988 4.35742 10.721 4.35742 10.415C4.35742 10.1156 4.41113 9.84375 4.51855 9.59961C4.62923 9.35547 4.78548 9.14714 4.9873 8.97461C5.18913 8.79883 5.42513 8.66374 5.69531 8.56934C5.96875 8.47493 6.27311 8.42773 6.6084 8.42773C7.06413 8.42773 7.45801 8.52214 7.79004 8.71094C8.12533 8.89974 8.38411 9.15853 8.56641 9.4873C8.75195 9.81608 8.84473 10.1937 8.84473 10.6201H7.41895C7.41895 10.4183 7.38965 10.2409 7.33105 10.0879C7.27572 9.93164 7.1862 9.80957 7.0625 9.72168C6.9388 9.63379 6.7793 9.58984 6.58398 9.58984C6.40169 9.58984 6.25033 9.62728 6.12988 9.70215C6.0127 9.77376 5.9248 9.87305 5.86621 10C5.81087 10.1237 5.7832 10.2637 5.7832 10.4199C5.7832 10.5371 5.81087 10.6429 5.86621 10.7373C5.9248 10.8285 6.00456 10.9115 6.10547 10.9863C6.20638 11.0579 6.3252 11.1279 6.46191 11.1963C6.60189 11.2646 6.75488 11.3314 6.9209 11.3965C7.24316 11.5234 7.52799 11.6634 7.77539 11.8164C8.02279 11.9661 8.22949 12.1354 8.39551 12.3242C8.56152 12.5098 8.68685 12.7197 8.77148 12.9541C8.85612 13.1885 8.89844 13.4554 8.89844 13.7549C8.89844 14.0511 8.84635 14.3213 8.74219 14.5654C8.64128 14.8063 8.49316 15.0146 8.29785 15.1904C8.10254 15.363 7.86654 15.4964 7.58984 15.5908C7.31641 15.6852 7.01042 15.7324 6.67188 15.7324C6.3431 15.7324 6.03223 15.6868 5.73926 15.5957C5.44629 15.5013 5.1875 15.3597 4.96289 15.1709C4.74154 14.9788 4.56738 14.7363 4.44043 14.4434C4.31348 14.1471 4.25 13.7972 4.25 13.3936H5.68066C5.68066 13.6084 5.70182 13.7923 5.74414 13.9453C5.78646 14.0951 5.85156 14.2155 5.93945 14.3066C6.02734 14.3945 6.13477 14.4613 6.26172 14.5068C6.39193 14.5492 6.54004 14.5703 6.70605 14.5703C6.89486 14.5703 7.0446 14.5345 7.15527 14.4629C7.26921 14.3913 7.35059 14.2952 7.39941 14.1748C7.44824 14.0544 7.47266 13.9176 7.47266 13.7646Z" />
309 <path
310 fillRule="evenodd"
311 clipRule="evenodd"
312 d="M20.5 5.73438H4.5C3.11929 5.73438 2 6.85366 2 8.23438V16.5039C2 17.8846 3.11929 19.0039 4.5 19.0039H20.5C21.8807 19.0039 23 17.8846 23 16.5039V8.23438C23 6.85366 21.8807 5.73438 20.5 5.73438ZM4.5 4.23438C2.29086 4.23438 0.5 6.02524 0.5 8.23438V16.5039C0.5 18.713 2.29086 20.5039 4.5 20.5039H20.5C22.7091 20.5039 24.5 18.713 24.5 16.5039V8.23438C24.5 6.02524 22.7091 4.23438 20.5 4.23438H4.5Z"
313 />
314 </g>
315 <defs>
316 <clipPath id="clip0_1018_49117">
317 <rect width="24" height="24" transform="translate(0.5 0.269531)" />
318 </clipPath>
319 </defs>
320 </svg>
321))
322
323// Define a custom type for SVG components including the size prop
324interface LucideSVGProps extends Omit<React.SVGProps<SVGSVGElement>, 'ref'> {
325 size?: number
326}
327
328const TreeViewFolderIcon = forwardRef<SVGSVGElement, LucideSVGProps & { isOpen?: boolean }>(
329 ({ isOpen, ...props }, ref) => {
330 const Icon = isOpen ? FolderOpen : FolderClosed
331
332 return <Icon ref={ref} {...props} />
333 }
334)
335
336export {
337 flattenTree,
338 SQL_ICON,
339 TreeView,
340 TreeViewFolderIcon,
341 TreeViewItem,
342 type INodeRendererProps,
343}