uibee 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useState, type ReactNode } from 'react'
4
+ import { Copy, Check } from 'lucide-react'
5
+
6
+ type CodeProps = {
7
+ children: ReactNode
8
+ className?: string
9
+ }
10
+
11
+ export function Code({ children, className = '' }: CodeProps) {
12
+ return (
13
+ <code className={`font-mono text-xs bg-login-700 text-login px-1.5 py-0.5 rounded ${className}`}>
14
+ {children}
15
+ </code>
16
+ )
17
+ }
18
+
19
+ type CodeBlockProps = {
20
+ code: string
21
+ language?: string
22
+ className?: string
23
+ }
24
+
25
+ export function CodeBlock({ code, language, className = '' }: CodeBlockProps) {
26
+ const [copied, setCopied] = useState(false)
27
+
28
+ function copy() {
29
+ navigator.clipboard.writeText(code)
30
+ setCopied(true)
31
+ setTimeout(() => setCopied(false), 2000)
32
+ }
33
+
34
+ return (
35
+ <div className={`relative group rounded-xl border border-login-500/30 bg-login-900 overflow-hidden ${className}`}>
36
+ <div className='flex items-center justify-between px-4 py-2 border-b border-login-500/20 bg-login-800'>
37
+ <span className='text-xs font-mono text-login-400'>{language ?? ''}</span>
38
+ <button
39
+ type='button'
40
+ onClick={copy}
41
+ className='flex cursor-pointer items-center gap-1.5 text-xs text-login-400 hover:text-login-100 transition-colors'
42
+ >
43
+ {copied
44
+ ? <Check className='w-3.5 h-3.5 stroke-emerald-400' />
45
+ : <Copy className='w-3.5 h-3.5' />
46
+ }
47
+ {copied ? 'Copied' : 'Copy'}
48
+ </button>
49
+ </div>
50
+ <pre className='overflow-x-auto p-4 text-xs font-mono leading-relaxed text-login-100'>
51
+ <code>{code}</code>
52
+ </pre>
53
+ </div>
54
+ )
55
+ }
@@ -36,7 +36,11 @@ export default function Accordion({ title, children, defaultOpen = false, classN
36
36
  <button
37
37
  type='button'
38
38
  onClick={() => setOpen((o) => !o)}
39
- className='flex w-full cursor-pointer items-center justify-between bg-login-800 px-4 py-3 text-left transition-colors duration-150 hover:bg-login-700 select-none'
39
+ className='
40
+ flex w-full cursor-pointer items-center justify-between
41
+ bg-login-800 px-4 py-3 text-left select-none
42
+ transition-colors duration-150 hover:bg-login-700
43
+ '
40
44
  >
41
45
  <div className='text-sm font-medium text-login-50'>{title}</div>
42
46
  <ChevronDown
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import type { ElementType, ReactNode } from 'react'
4
+ import { ChevronDown } from 'lucide-react'
5
+ import Card from './card'
6
+ import IconBubble, { type IconBubbleTone } from './iconBubble'
7
+ import PulseDot from './pulseDot'
8
+ import Button from '@components/buttons/button'
9
+
10
+ type PulseVariant = 'online' | 'offline' | 'warning' | 'unknown'
11
+
12
+ type ExpandableCardProps = {
13
+ icon: ElementType
14
+ iconTone?: IconBubbleTone
15
+ title: string
16
+ subtitle?: ReactNode
17
+ pulse?: { variant: PulseVariant; label: string }
18
+ trailing?: ReactNode
19
+ isExpanded: boolean
20
+ onToggle: () => void
21
+ children?: ReactNode
22
+ }
23
+
24
+ export default function ExpandableCard({
25
+ icon,
26
+ iconTone = 'orange',
27
+ title,
28
+ subtitle,
29
+ pulse,
30
+ trailing,
31
+ isExpanded,
32
+ onToggle,
33
+ children,
34
+ }: ExpandableCardProps) {
35
+ return (
36
+ <Card>
37
+ <div
38
+ role='button'
39
+ tabIndex={0}
40
+ onClick={onToggle}
41
+ onKeyDown={(e) => {
42
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() }
43
+ }}
44
+ aria-expanded={isExpanded}
45
+ className='group flex cursor-pointer select-none items-center gap-4 px-5 py-4'
46
+ >
47
+ <IconBubble icon={icon} tone={iconTone} size='sm' />
48
+ <div className='min-w-0 flex-1'>
49
+ <div className='flex flex-wrap items-center gap-3'>
50
+ <span className='font-semibold text-login-50 transition group-hover:text-login'>{title}</span>
51
+ {pulse && (
52
+ <span className='flex items-center gap-2 text-xs text-login-300'>
53
+ <span className='flex h-3 w-3 shrink-0 items-center justify-center'>
54
+ <PulseDot variant={pulse.variant} size='sm' />
55
+ </span>
56
+ {pulse.label}
57
+ </span>
58
+ )}
59
+ </div>
60
+ {subtitle && <div className='mt-0.5 text-xs text-login-300'>{subtitle}</div>}
61
+ </div>
62
+ {trailing && (
63
+ <div className='flex shrink-0 items-center gap-3' onClick={(e) => e.stopPropagation()}>
64
+ {trailing}
65
+ </div>
66
+ )}
67
+ <Button
68
+ variant='secondary'
69
+ icon={<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />}
70
+ onClick={(e) => { e.stopPropagation(); onToggle() }}
71
+ />
72
+ </div>
73
+ {isExpanded && children && (
74
+ <div className='border-t border-white/5 px-5 pb-5 pt-4'>
75
+ {children}
76
+ </div>
77
+ )}
78
+ </Card>
79
+ )
80
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ type GlassCardProps = {
4
+ children: ReactNode
5
+ className?: string
6
+ }
7
+
8
+ export default function GlassCard({ children, className = '' }: GlassCardProps) {
9
+ return (
10
+ <div className={`rounded-xl border border-login-500/20 bg-login-800/60 backdrop-blur-sm ${className}`}>
11
+ {children}
12
+ </div>
13
+ )
14
+ }
@@ -42,9 +42,9 @@ export function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange,
42
42
  className={`
43
43
  px-3 py-1.5 rounded text-sm font-medium transition-all duration-150 cursor-pointer select-none
44
44
  ${activeTab === tab.id
45
- ? 'bg-login text-white shadow-sm'
46
- : 'text-login-200 hover:text-login-50 hover:bg-login-600'
47
- }
45
+ ? 'bg-login text-white shadow-sm'
46
+ : 'text-login-200 hover:text-login-50 hover:bg-login-600'
47
+ }
48
48
  `}
49
49
  >
50
50
  {tab.label}
@@ -0,0 +1,26 @@
1
+ import type { ElementType, ReactNode } from 'react'
2
+
3
+ type EmptyStateProps = {
4
+ icon?: ElementType
5
+ title: string
6
+ description?: string
7
+ action?: ReactNode
8
+ className?: string
9
+ }
10
+
11
+ export default function EmptyState({ icon: Icon, title, description, action, className = '' }: EmptyStateProps) {
12
+ return (
13
+ <div className={`flex flex-col items-center justify-center gap-3 py-12 text-center ${className}`}>
14
+ {Icon && (
15
+ <div className='mb-1 rounded-xl bg-login-600/30 p-3 text-login-400'>
16
+ <Icon className='h-7 w-7' />
17
+ </div>
18
+ )}
19
+ <p className='text-sm font-semibold text-login-100'>{title}</p>
20
+ {description && (
21
+ <p className='max-w-xs text-xs leading-relaxed text-login-400'>{description}</p>
22
+ )}
23
+ {action && <div className='mt-2'>{action}</div>}
24
+ </div>
25
+ )
26
+ }
@@ -55,3 +55,20 @@ export { default as ConfirmPopup } from './confirm/confirmPopup'
55
55
  export { default as SeverityPill } from './vulnerability/severityPill'
56
56
 
57
57
  export { default as Toggle } from './inputs/toggle'
58
+ export { default as FileInput } from './inputs/fileInput'
59
+
60
+ export { default as GlassCard } from './container/glassCard'
61
+ export { default as ExpandableCard } from './container/expandableCard'
62
+
63
+ export { default as Modal } from './modal/modal'
64
+
65
+ export { default as Sidebar } from './sidebar/sidebar'
66
+ export type { SidebarItem, SidebarSubItem } from './sidebar/sidebar'
67
+
68
+ export { default as Badge } from './badge/badge'
69
+
70
+ export { default as EmptyState } from './empty/emptyState'
71
+
72
+ export { default as Spinner } from './spinner/spinner'
73
+
74
+ export { Code, CodeBlock } from './code/code'
@@ -0,0 +1,100 @@
1
+ 'use client'
2
+
3
+ import { useRef, useState, type DragEvent, type ChangeEvent } from 'react'
4
+ import { UploadCloud, X, FileIcon } from 'lucide-react'
5
+
6
+ type FileInputProps = {
7
+ name: string
8
+ label?: string
9
+ accept?: string
10
+ multiple?: boolean
11
+ onChange: (files: File[]) => void
12
+ className?: string
13
+ }
14
+
15
+ export default function FileInput({ name, label, accept, multiple = false, onChange, className = '' }: FileInputProps) {
16
+ const [files, setFiles] = useState<File[]>([])
17
+ const [dragging, setDragging] = useState(false)
18
+ const inputRef = useRef<HTMLInputElement>(null)
19
+
20
+ function handleFiles(incoming: FileList | null) {
21
+ if (!incoming) return
22
+ const arr = multiple ? Array.from(incoming) : [incoming[0]]
23
+ setFiles(arr)
24
+ onChange(arr)
25
+ }
26
+
27
+ function removeFile(index: number) {
28
+ const next = files.filter((_, i) => i !== index)
29
+ setFiles(next)
30
+ onChange(next)
31
+ if (inputRef.current) inputRef.current.value = ''
32
+ }
33
+
34
+ function onDrop(e: DragEvent) {
35
+ e.preventDefault()
36
+ setDragging(false)
37
+ handleFiles(e.dataTransfer.files)
38
+ }
39
+
40
+ return (
41
+ <div className={`flex flex-col gap-2 ${className}`}>
42
+ {label && <label className='text-sm font-medium text-login-200'>{label}</label>}
43
+ <div
44
+ role='button'
45
+ tabIndex={0}
46
+ onClick={() => inputRef.current?.click()}
47
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click() }}
48
+ onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
49
+ onDragLeave={() => setDragging(false)}
50
+ onDrop={onDrop}
51
+ className={`
52
+ flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6
53
+ transition-all duration-150
54
+ ${dragging
55
+ ? 'scale-[1.01] border-login bg-login/5'
56
+ : 'border-login-500/50 bg-login-800/30 hover:border-login/50 hover:bg-login/5'
57
+ }
58
+ `}
59
+ >
60
+ <input
61
+ ref={inputRef}
62
+ type='file'
63
+ name={name}
64
+ accept={accept}
65
+ multiple={multiple}
66
+ className='hidden'
67
+ onChange={(e: ChangeEvent<HTMLInputElement>) => handleFiles(e.target.files)}
68
+ />
69
+ <UploadCloud className='h-7 w-7 text-login-400' />
70
+ <div className='text-center'>
71
+ <p className='text-sm text-login-200'>
72
+ Drop files here or <span className='text-login'>browse</span>
73
+ </p>
74
+ {accept && <p className='mt-0.5 text-xs text-login-400'>{accept}</p>}
75
+ </div>
76
+ </div>
77
+ {files.length > 0 && (
78
+ <ul className='flex flex-col gap-1.5'>
79
+ {files.map((file, i) => (
80
+ <li key={i} className='
81
+ flex items-center gap-2 rounded-lg
82
+ border border-login-500/25 bg-login-800/50 px-3 py-2 text-sm
83
+ '>
84
+ <FileIcon className='h-4 w-4 shrink-0 text-login-400' />
85
+ <span className='flex-1 truncate text-login-200'>{file.name}</span>
86
+ <span className='shrink-0 text-xs text-login-400'>{(file.size / 1024).toFixed(1)} KB</span>
87
+ <button
88
+ type='button'
89
+ onClick={(e) => { e.stopPropagation(); removeFile(i) }}
90
+ className='cursor-pointer text-login-400 transition-colors hover:text-red-400'
91
+ >
92
+ <X className='h-3.5 w-3.5' />
93
+ </button>
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ )}
98
+ </div>
99
+ )
100
+ }
@@ -1,4 +1,4 @@
1
- import { type ChangeEvent, type JSX, useRef, useState, useId } from 'react'
1
+ import React, { type ChangeEvent, type JSX, useRef, useState, useId } from 'react'
2
2
  import { Calendar, Clock } from 'lucide-react'
3
3
  import { FieldWrapper } from './shared'
4
4
  import DateTimePickerPopup from './shared/dateTimePickerPopup'
@@ -158,7 +158,7 @@ export default function Input(props: InputProps) {
158
158
  readOnly={isClickableType}
159
159
  onClick={() => isClickableType && !inputProps.disabled && setIsOpen(true)}
160
160
  aria-describedby={error ? `${name}-error` : undefined}
161
- style={{ anchorName } as any}
161
+ style={{ anchorName } as React.CSSProperties & { anchorName?: string }}
162
162
  className={`
163
163
  w-full rounded-md bg-login-500/50 border border-login-500
164
164
  text-login-text placeholder-login-200 text-sm
@@ -93,7 +93,10 @@ export default function MultiSelect({
93
93
  return (
94
94
  <span
95
95
  key={val}
96
- className='flex items-center gap-1 px-2 py-0.5 bg-login-600 text-login-100 border border-login-500 rounded text-sm'
96
+ className='
97
+ flex items-center gap-1 rounded px-2 py-0.5 text-sm
98
+ border border-login-500 bg-login-600 text-login-100
99
+ '
97
100
  >
98
101
  {option.label}
99
102
  {!disabled && (
@@ -116,7 +119,10 @@ export default function MultiSelect({
116
119
  </div>
117
120
 
118
121
  {open && options.length > 0 && (
119
- <div className='absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md bg-login-800 border border-login-500/50 shadow-lg'>
122
+ <div className='
123
+ absolute z-50 mt-1 w-full max-h-60 overflow-auto
124
+ rounded-md border border-login-500/50 bg-login-800 shadow-lg
125
+ '>
120
126
  {options.map((option) => {
121
127
  const selected = value.includes(option.value)
122
128
  return (
@@ -192,7 +192,7 @@ export default function ColorPickerPopup({ value, onChange, onClose, anchorName
192
192
  }
193
193
 
194
194
  return (
195
- <div
195
+ <div
196
196
  className='fixed z-50 bg-login-700 border border-login-500/50 rounded-md shadow-xl p-3 w-64 select-none anchor-popup'
197
197
  style={{
198
198
  positionAnchor: anchorName,
@@ -220,7 +220,7 @@ export default function DateTimePickerPopup({
220
220
  }
221
221
 
222
222
  return (
223
- <div
223
+ <div
224
224
  className='fixed z-50 bg-login-700 border border-login-500/50 rounded-md shadow-xl p-1 min-w-70 anchor-popup'
225
225
  style={{
226
226
  positionAnchor: anchorName,
@@ -39,7 +39,11 @@ export default function Switch(props: SwitchProps) {
39
39
  ${inputProps.disabled ? 'opacity-40 cursor-not-allowed' : ''}
40
40
  ${error ? 'ring-1 ring-red-500/60' : ''}
41
41
  `}>
42
- <span className='absolute inset-y-0 my-auto left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-all group-has-[input:checked]/sw:translate-x-full' />
42
+ <span className='
43
+ absolute inset-y-0 my-auto left-0.5 h-5 w-5
44
+ rounded-full bg-white shadow-sm transition-all
45
+ group-has-[input:checked]/sw:translate-x-full
46
+ ' />
43
47
  </div>
44
48
  </label>
45
49
  </FieldWrapper>
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { useEffect, type ReactNode } from 'react'
4
+ import { X } from 'lucide-react'
5
+
6
+ type ModalSize = 'sm' | 'md' | 'lg'
7
+
8
+ type ModalProps = {
9
+ isOpen: boolean
10
+ onClose: () => void
11
+ title?: string
12
+ children: ReactNode
13
+ footer?: ReactNode
14
+ size?: ModalSize
15
+ }
16
+
17
+ const sizes: Record<ModalSize, string> = {
18
+ sm: 'max-w-sm',
19
+ md: 'max-w-md',
20
+ lg: 'max-w-lg',
21
+ }
22
+
23
+ export default function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) {
24
+ useEffect(() => {
25
+ if (!isOpen) return
26
+ const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
27
+ document.addEventListener('keydown', handler)
28
+ return () => document.removeEventListener('keydown', handler)
29
+ }, [isOpen, onClose])
30
+
31
+ if (!isOpen) return null
32
+
33
+ return (
34
+ <div
35
+ role='dialog'
36
+ aria-modal='true'
37
+ className='fixed inset-0 z-50 flex items-center justify-center'
38
+ onClick={onClose}
39
+ >
40
+ <div className='absolute inset-0 bg-black/50 backdrop-blur-sm' />
41
+ <div
42
+ className={`
43
+ relative z-10 mx-4 w-full ${sizes[size]}
44
+ flex flex-col rounded-xl border border-login-500/50
45
+ bg-login-800 shadow-2xl
46
+ `}
47
+ onClick={(e) => e.stopPropagation()}
48
+ >
49
+ {title && (
50
+ <div className='flex items-center justify-between px-6 pt-5 pb-4 border-b border-login-500/25'>
51
+ <h2 className='text-login-50 text-base font-semibold leading-snug'>{title}</h2>
52
+ <button
53
+ type='button'
54
+ onClick={onClose}
55
+ className='
56
+ cursor-pointer rounded-md p-1.5
57
+ text-login-400 hover:text-login-100 hover:bg-login-600
58
+ transition-colors
59
+ '
60
+ >
61
+ <X className='w-4 h-4' />
62
+ </button>
63
+ </div>
64
+ )}
65
+ <div className='p-6'>{children}</div>
66
+ {footer && (
67
+ <div className='px-6 pb-5 pt-4 border-t border-login-500/25'>
68
+ {footer}
69
+ </div>
70
+ )}
71
+ </div>
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,177 @@
1
+ 'use client'
2
+
3
+ import { useState, type ElementType, type ReactNode } from 'react'
4
+ import { usePathname } from 'next/navigation'
5
+ import Link from 'next/link'
6
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
7
+
8
+ export type SidebarSubItem = {
9
+ name: string
10
+ path: string
11
+ }
12
+
13
+ export type SidebarItem = {
14
+ name: string
15
+ path: string
16
+ icon: ElementType
17
+ status?: ReactNode
18
+ items?: SidebarSubItem[]
19
+ }
20
+
21
+ type SidebarProps = {
22
+ items: SidebarItem[]
23
+ header?: ReactNode
24
+ bottomAction?: (expanded: boolean) => ReactNode
25
+ mobile?: boolean
26
+ initialExpanded?: boolean
27
+ }
28
+
29
+ const ITEM_HEIGHT = 48
30
+ const GAP = 4
31
+ const SUB_STRIDE = 40
32
+
33
+ export default function Sidebar({ items, header, bottomAction, mobile = false, initialExpanded = true }: SidebarProps) {
34
+ const [expanded, setExpanded] = useState(initialExpanded)
35
+ const pathname = usePathname()
36
+
37
+ function isItemActive(item: SidebarItem) {
38
+ return pathname === item.path ||
39
+ !!(item.items?.some(sub => pathname === sub.path || pathname.startsWith(sub.path + '/')))
40
+ }
41
+
42
+ const activeIndex = items.findIndex(isItemActive)
43
+ const activeOffset = items
44
+ .slice(0, Math.max(activeIndex, 0))
45
+ .reduce((acc, item) => acc + ITEM_HEIGHT + (item.items ? GAP : 0) + GAP, 0)
46
+
47
+ return (
48
+ <aside className={`
49
+ flex flex-col border-r border-login-100/10 bg-login-900
50
+ transition-all duration-300 ease-in-out
51
+ ${mobile ? 'w-full' : `h-full ${expanded ? 'w-64' : 'w-20'}`}
52
+ `}>
53
+ {!mobile && (
54
+ <div className={`relative mb-2 p-4 transition-all duration-300 ${expanded ? 'h-16' : 'h-20'}`}>
55
+ {header && (
56
+ <div className={`
57
+ absolute top-4 flex items-center transition-all duration-300
58
+ ${expanded ? 'left-4 gap-3' : 'left-1/2 -translate-x-1/2 gap-0'}
59
+ `}>
60
+ {header}
61
+ </div>
62
+ )}
63
+ <button
64
+ onClick={() => setExpanded(!expanded)}
65
+ className={`
66
+ absolute cursor-pointer rounded-lg p-1.5
67
+ text-login-200 transition-all duration-300 hover:bg-login-800
68
+ ${expanded ? 'right-4 top-4' : 'left-1/2 top-12 -translate-x-1/2'}
69
+ `}
70
+ >
71
+ {expanded ? <ChevronLeft size={20} /> : <ChevronRight size={20} />}
72
+ </button>
73
+ </div>
74
+ )}
75
+
76
+ {mobile && <div className='h-4' />}
77
+
78
+ <div className='relative flex flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto px-3'>
79
+ {activeIndex >= 0 && (
80
+ <span
81
+ aria-hidden
82
+ className='absolute left-3 right-3 top-0 h-12 rounded-lg bg-login-800 transition-transform duration-300 ease-in-out'
83
+ style={{ transform: `translateY(${activeOffset}px)` }}
84
+ />
85
+ )}
86
+ {items.map((item, index) => {
87
+ const isActive = isItemActive(item)
88
+ const Icon = item.icon
89
+ const activeSubIndex = item.items
90
+ ? item.items.findIndex(sub => pathname === sub.path || pathname.startsWith(sub.path + '/'))
91
+ : -1
92
+
93
+ return (
94
+ <div key={index} className='flex flex-col gap-1'>
95
+ <Link
96
+ href={item.path}
97
+ title={!expanded ? item.name : undefined}
98
+ className={`
99
+ group relative z-10 flex items-center
100
+ overflow-hidden rounded-lg p-3 transition-all duration-200
101
+ ${isActive ? 'text-login' : 'text-login-200 hover:bg-login-800/50 hover:text-login-100'}
102
+ `}
103
+ >
104
+ <div className={`
105
+ flex min-w-6 w-6 items-center justify-center transition-all duration-300
106
+ ${expanded ? '' : 'translate-x-1'}
107
+ ${isActive ? '[&>svg]:stroke-login' : 'group-hover:[&>svg]:stroke-login-100'}
108
+ `}>
109
+ <Icon className='h-5 w-5' />
110
+ </div>
111
+ <span className={`
112
+ whitespace-nowrap overflow-hidden transition-all duration-300
113
+ ${expanded ? 'ml-3 max-w-48 opacity-100' : 'ml-0 max-w-0 opacity-0'}
114
+ `}>
115
+ {item.name}
116
+ </span>
117
+ {item.status && (
118
+ <div className={`
119
+ flex items-center justify-center
120
+ ${expanded ? 'ml-auto' : 'absolute right-1 top-1 scale-75'}
121
+ `}>
122
+ {item.status}
123
+ </div>
124
+ )}
125
+ </Link>
126
+
127
+ {item.items && (
128
+ <div className={`
129
+ relative ml-6 flex flex-col gap-1 overflow-hidden
130
+ border-l border-login-800 pl-2
131
+ transition-all duration-300 ease-in-out
132
+ ${expanded && isActive ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}
133
+ `}>
134
+ {activeSubIndex >= 0 && (
135
+ <span
136
+ aria-hidden
137
+ className='
138
+ absolute left-2 right-0 top-0 h-9 rounded-lg
139
+ bg-login-800/50 transition-transform duration-300 ease-in-out
140
+ '
141
+ style={{ transform: `translateY(${activeSubIndex * SUB_STRIDE}px)` }}
142
+ />
143
+ )}
144
+ {item.items.map((sub, subIndex) => {
145
+ const isSubActive = pathname === sub.path || pathname.startsWith(sub.path + '/')
146
+ return (
147
+ <Link
148
+ key={`${index}-${subIndex}`}
149
+ href={sub.path}
150
+ className={`
151
+ relative z-10 rounded-lg p-2 text-sm
152
+ transition-all duration-200
153
+ ${isSubActive
154
+ ? 'text-login'
155
+ : 'text-login-300 hover:bg-login-800/30 hover:text-login-100'
156
+ }
157
+ `}
158
+ >
159
+ {sub.name}
160
+ </Link>
161
+ )
162
+ })}
163
+ </div>
164
+ )}
165
+ </div>
166
+ )
167
+ })}
168
+ </div>
169
+
170
+ {bottomAction && (
171
+ <div className='border-t border-login-100/10 p-3'>
172
+ {bottomAction(expanded)}
173
+ </div>
174
+ )}
175
+ </aside>
176
+ )
177
+ }