uibee 3.1.2 → 3.1.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uibee",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
4
4
  "description": "Shared components, functions and hooks for reuse across Login projects",
5
5
  "homepage": "https://github.com/Login-Linjeforening-for-IT/uibee#readme",
6
6
  "bugs": {
@@ -1,5 +1,4 @@
1
1
  'use client'
2
- // Input components
3
2
  export { default as Input } from './inputs/input'
4
3
  export { default as SearchInput } from './inputs/searchInput'
5
4
  export { default as Textarea } from './inputs/textarea'
@@ -11,26 +10,21 @@ export { default as Switch } from './inputs/switch'
11
10
  export { default as Radio } from './inputs/radio'
12
11
  export { default as Range } from './inputs/range'
13
12
 
14
- // Logos
15
13
  export { default as Logo } from './logo/logo'
16
14
  export { default as LogoSmall } from './logo/logoSmall'
17
15
 
18
- // Toggles
19
16
  export { default as ThemeToggle } from './toggle/theme'
20
17
  export { default as LanguageToggle } from './toggle/language'
21
18
 
22
- // Navbar
23
19
  export { default as Navbar } from './navbar/navbar'
24
20
  export { default as NavItem } from './navbar/navbarItem'
25
21
  export { default as NavDropdown } from './navbar/navbarDropdown'
26
22
 
27
- // Footer
28
23
  export { default as Footer } from './footer/footer'
29
24
  export type { FooterColumn, FooterSocialLink, FooterProps, Lang, BilingualString } from './footer/footer'
30
25
  export { loginAddress, loginEmail, loginCopyright, loginSponsor, loginSocialLinks } from './footer/loginContent'
31
26
  export type { LoginSocialLinkData } from './footer/loginContent'
32
27
 
33
- // Containers
34
28
  export { default as PageContainer} from './container/page'
35
29
  export { default as Highlight } from './container/highlight'
36
30
  export { default as LeftBarPanel } from './container/leftBarPanel'
@@ -41,32 +35,23 @@ export { Tabs, TabPanel } from './container/tabs'
41
35
  export { default as Accordion, AccordionGroup } from './container/accordion'
42
36
  export { default as PulseDot } from './container/pulseDot'
43
37
 
44
- // Login
45
38
  export { default as VersionTag } from './version/version'
46
39
  export { default as LoginPage } from './login/loginPage'
47
40
 
48
- // Toasts
49
41
  export { default as Toaster, toast } from './toast/toaster'
50
42
 
51
- // Buttons
52
43
  export { default as Button } from './buttons/button'
53
44
 
54
- // Alert
55
45
  export { default as Alert } from './alert/alert'
56
46
 
57
- // Table
58
- export { MenuButton } from './table/menu'
59
- export { default as Table } from './table/table'
47
+ export { Table, MenuButton } from './table/table'
60
48
  export { default as Pagination } from './table/pagination'
49
+ export type { TableProps, Column, SortState, TableVariant, Density, TableColor } from './table/types'
61
50
 
62
- // Markdown
63
51
  export { default as MarkdownRender } from './markdownrender/markdownRender'
64
52
 
65
- // Confirm
66
53
  export { default as ConfirmPopup } from './confirm/confirmPopup'
67
54
 
68
- // Vulnerability
69
55
  export { default as SeverityPill } from './vulnerability/severityPill'
70
56
 
71
- // Layout
72
57
  export { default as Toggle } from './inputs/toggle'
@@ -69,7 +69,6 @@ export default function Navbar({
69
69
  <div className={`flex w-full max-w-6xl m-auto p-2 transition duration-500 800px:justify-between 800px:p-4 ${
70
70
  isMobileMenuOpen ? 'h-screen bg-login-900/20 800px:h-20' : ''} ${innerClassName}
71
71
  `}>
72
- {/* Logo */}
73
72
  <div className='block h-12 p-1 800px:p-0'>
74
73
  <Link
75
74
  href='/'
@@ -81,12 +80,10 @@ export default function Navbar({
81
80
 
82
81
  {onlyLogo ? null : (
83
82
  <>
84
- {/* Desktop Navigation */}
85
83
  <nav className='hidden 800px:flex 800px:justify-between 800px:items-center 800px:w-fill max-w-200'>
86
84
  {children}
87
85
  </nav>
88
86
 
89
- {/* Controls */}
90
87
  <nav className='flex w-[calc(100vw-8rem)] justify-end h-12 800px:w-fit'>
91
88
  <div className='relative grid gap-2'>
92
89
  {!disableThemeToggle &&
@@ -115,7 +112,6 @@ export default function Navbar({
115
112
  </div>
116
113
  </nav>
117
114
 
118
- {/* Mobile Menu Button */}
119
115
  <button
120
116
  className='w-12 h-12 relative cursor-pointer bg-none border-none 800px:hidden'
121
117
  onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
@@ -124,7 +120,6 @@ export default function Navbar({
124
120
  <div className={hamburgerStyle(isMobileMenuOpen, true)} />
125
121
  </button>
126
122
 
127
- {/* Mobile Navigation */}
128
123
  <nav className={`fixed top-16 w-[calc(100%-2rem)] max-w-140 mx-auto left-0 right-0 800px:hidden
129
124
  transition-all duration-500 ease-in-out overflow-hidden
130
125
  ${isMobileMenuOpen ? 'max-h-[calc(100vh-4rem)] opacity-100' : 'max-h-0 opacity-0'}`}
@@ -16,7 +16,6 @@ export default function NavDropdown({ children, title, className }: NavDropdownP
16
16
 
17
17
  return (
18
18
  <>
19
- {/* Desktop Dropdown */}
20
19
  <div className={'relative hidden 800px:block'}>
21
20
  <div className='outline-none' tabIndex={0} ref={navItemRef}
22
21
  onMouseEnter={() => setIsDesktopOpen(true)}
@@ -53,7 +52,6 @@ export default function NavDropdown({ children, title, className }: NavDropdownP
53
52
  </div>
54
53
  </div>
55
54
 
56
- {/* Mobile Dropdown */}
57
55
  <div className={'block 800px:hidden!'}>
58
56
  <button
59
57
  className={'bg-none border-none cursor-pointer w-full text-left'}
@@ -21,7 +21,6 @@ export default function NavItem({ href, children, external = false, target, rel,
21
21
 
22
22
  return (
23
23
  <>
24
- {/* Desktop version */}
25
24
  <Link {...linkProps} className='hidden 800px:flex'>
26
25
  <li className={`${commonStyling} text-base leading-4 p-3 font-bold transition-colors link-corner-hover
27
26
  group-[.dropdown]:p-2.5 group-[.dropdown]:pr-3 group-[.dropdown]:pl-1`
@@ -32,7 +31,6 @@ export default function NavItem({ href, children, external = false, target, rel,
32
31
  </li>
33
32
  </Link>
34
33
 
35
- {/* Mobile version */}
36
34
  <Link {...linkProps} className='800px:hidden'>
37
35
  <li className={`${commonStyling} text-2xl leading-6 overflow-hidden w-auto pl-4 rounded-[0.3rem] transition-all
38
36
  duration-600 opacity-100 h-16 py-5 group-[.dropdown]:p-0 group-[.dropdown]:text-lg group-[.dropdown]:h-auto
@@ -1,186 +1,293 @@
1
- import { EllipsisVertical } from 'lucide-react'
1
+ 'use client'
2
+
3
+ import React, { useState, useRef, useEffect, useCallback, useMemo, type RefObject } from 'react'
2
4
  import { useRouter } from 'next/navigation'
3
- import { useState, useRef, RefObject, useEffect } from 'react'
4
- import React from 'react'
5
- import useClickOutside from '@hooks/useClickOutside'
6
- import Menu from './menu'
7
- import type { Column } from 'uibee/components'
5
+ import { EllipsisVertical, ChevronDown } from 'lucide-react'
6
+ import type { Column, Density, TableVariant, MenuAnchor, TableShellProps } from './types'
7
+ import { HIGHLIGHT, DENSITY_TD, VARIANT_TBODY, VARIANT_ROW_HOVER, VARIANT_ROW_STRIPED } from './constants'
8
8
  import { formatValue } from './format'
9
+ import { resolveId } from './utils'
10
+ import Menu from './menu'
9
11
 
10
- type BodyProps = {
11
- list: object[]
12
- columns: Column[]
13
- menuItems?: (data: object, id: string) => React.ReactNode
14
- redirectPath?: string | { path: string, key?: string }
15
- variant?: 'default' | 'minimal'
16
- idKey?: string
12
+ function useClickOutside(ref: RefObject<HTMLElement | null>, cb: () => void, enabled: boolean) {
13
+ useEffect(() => {
14
+ if (!enabled) return
15
+ function handler(e: MouseEvent) {
16
+ if (ref.current && !ref.current.contains(e.target as Node)) cb()
17
+ }
18
+ document.addEventListener('mousedown', handler)
19
+ return () => document.removeEventListener('mousedown', handler)
20
+ }, [ref, cb, enabled])
17
21
  }
18
22
 
19
- export default function Body({ list, columns, menuItems, redirectPath, variant = 'default', idKey }: BodyProps) {
20
- const [openMenuId, setOpenMenuId] = useState<string | null>(null)
21
- const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null)
23
+ function resolveRedirectUrl(
24
+ redirectPath: TableShellProps<Record<string, unknown>>['redirectPath'],
25
+ row: Record<string, unknown>,
26
+ id: string
27
+ ): string | null {
28
+ if (!redirectPath) return null
29
+ const cfg = typeof redirectPath === 'string'
30
+ ? { path: redirectPath, key: undefined }
31
+ : redirectPath
32
+ if (!cfg.path) return null
33
+ const rid = cfg.key ? String(row[cfg.key] ?? id) : id
34
+ return cfg.path.includes('?') ? `${cfg.path}${rid}` : `${cfg.path}/${rid}`
35
+ }
36
+
37
+ function Cell<T extends Record<string, unknown>>({
38
+ col,
39
+ row,
40
+ density,
41
+ }: {
42
+ col: Column<T>
43
+ row: T
44
+ density: Density
45
+ }) {
46
+ const value = row[col.key]
47
+ // Truncate: opt-in, only applied when col.truncate === true
48
+ const shouldTruncate = col.truncate === true
49
+ const align = col.align ?? 'left'
50
+
51
+ const wrapperAlign =
52
+ align === 'right' ? 'text-right' :
53
+ align === 'center' ? 'text-center' : ''
54
+
55
+ let content: React.ReactNode
56
+
57
+ if (col.render) {
58
+ // Custom renderer: caller is responsible for alignment
59
+ content = col.render(value, row)
60
+ } else if (col.highlight) {
61
+ const colorName = col.highlight[String(value)] ?? col.highlight['default']
62
+ const formatted = String(formatValue(col.key, value))
63
+ if (colorName) {
64
+ content = (
65
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${HIGHLIGHT[colorName]}`}>
66
+ {formatted}
67
+ </span>
68
+ )
69
+ } else {
70
+ content = (
71
+ <span className={shouldTruncate ? 'block truncate' : 'whitespace-nowrap'}>
72
+ {formatted}
73
+ </span>
74
+ )
75
+ }
76
+ } else {
77
+ const formatted = formatValue(col.key, value)
78
+ content = (
79
+ <span className={shouldTruncate ? 'block truncate' : 'whitespace-nowrap'}>
80
+ {formatted === null || formatted === undefined ? '-' : String(formatted)}
81
+ </span>
82
+ )
83
+ }
84
+
85
+ return (
86
+ <td
87
+ style={col.width ? { width: col.width, flexShrink: 0 } : undefined}
88
+ className={`
89
+ ${col.width ? '' : 'flex-1'} min-w-0 flex items-center text-sm text-login-75
90
+ ${DENSITY_TD[density]}
91
+ `}
92
+ >
93
+ {/* w-full fills the column; text-align handles alignment */}
94
+ <div className={`min-w-0 w-full ${wrapperAlign}`}>
95
+ {content}
96
+ </div>
97
+ </td>
98
+ )
99
+ }
100
+
101
+ type BodyProps<T extends Record<string, unknown>> = Pick<
102
+ TableShellProps<T>,
103
+ 'data' | 'columns' | 'idKey' | 'variant' | 'density' | 'striped' |
104
+ 'redirectPath' | 'onRowClick' | 'renderExpandedRow' |
105
+ 'selectable' | 'selectedIds' | 'onSelectionChange' |
106
+ 'menuItems'
107
+ >
108
+
109
+ export default function Body<T extends Record<string, unknown>>({
110
+ data, columns, idKey, variant, density, striped,
111
+ redirectPath, onRowClick, renderExpandedRow,
112
+ selectable, selectedIds, onSelectionChange,
113
+ menuItems,
114
+ }: BodyProps<T>) {
22
115
  const router = useRouter()
116
+ const [openMenuId, setOpenMenuId] = useState<string | null>(null)
117
+ const [anchor, setAnchor] = useState<MenuAnchor | null>(null)
118
+ const [expandedId, setExpandedId] = useState<string | null>(null)
119
+
23
120
  const menuRef = useRef<HTMLDivElement>(null)
24
121
  const tbodyRef = useRef<HTMLTableSectionElement>(null)
25
122
  const menuWasOpenOnMouseDown = useRef(false)
26
123
 
27
- useClickOutside(menuRef as RefObject<HTMLElement>, () => setOpenMenuId(null))
124
+ const closeMenu = useCallback(() => setOpenMenuId(null), [])
125
+ useClickOutside(menuRef as RefObject<HTMLElement>, closeMenu, openMenuId !== null)
28
126
 
29
127
  useEffect(() => {
30
128
  const el = tbodyRef.current
31
129
  if (!el) return
32
- const close = () => setOpenMenuId(null)
33
- el.addEventListener('scroll', close)
34
- return () => el.removeEventListener('scroll', close)
35
- }, [])
130
+ el.addEventListener('scroll', closeMenu)
131
+ return () => el.removeEventListener('scroll', closeMenu)
132
+ }, [closeMenu])
133
+
134
+ function openMenu(id: string, coords: MenuAnchor) {
135
+ setAnchor(coords)
136
+ setOpenMenuId(id)
137
+ }
138
+
139
+ const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
140
+
141
+ function toggleSelect(id: string) {
142
+ const next = selectedSet.has(id)
143
+ ? selectedIds.filter(s => s !== id)
144
+ : [...selectedIds, id]
145
+ onSelectionChange(next)
146
+ }
147
+
148
+ const hasMenu = Boolean(menuItems)
149
+ const hasExpand = Boolean(renderExpandedRow)
36
150
 
37
151
  return (
38
152
  <tbody
39
153
  ref={tbodyRef}
40
- className={`
41
- block overflow-y-auto flex-1 min-h-0 divide-y
42
- ${variant === 'default' ? 'bg-login-800 divide-login-600/25' : 'bg-transparent divide-login-600/20'}
43
- `}
154
+ className={`block overflow-y-auto flex-1 min-h-0 divide-y ${VARIANT_TBODY[variant]}`}
44
155
  >
45
- {list.map((item, index) => {
46
- const itemRecord = item as Record<string, unknown>
47
- let id: string = ''
48
-
49
- if (idKey && itemRecord[idKey] !== undefined) {
50
- id = String(itemRecord[idKey])
51
- } else if (itemRecord['id'] !== undefined) {
52
- id = String(itemRecord['id'])
53
- } else {
54
- const firstKey = columns[0]?.key || Object.keys(itemRecord)[0]
55
- id = String(itemRecord[firstKey])
56
- }
57
-
58
- const redirectConfig = (typeof redirectPath === 'object' && redirectPath) ? redirectPath : { path: redirectPath as string }
59
- const redirectId = redirectConfig.key ? String(itemRecord[redirectConfig.key]) : id
60
-
61
- const menuButtonColors = variant === 'minimal'
62
- ? {
63
- active: 'bg-login-600 text-login-100',
64
- inactive: 'hover:bg-login-600 text-login-200 hover:text-login-100'
65
- }
66
- : {
67
- active: 'bg-login-400 text-login-100',
68
- inactive: 'hover:bg-login-400 text-login-200 hover:text-login-100'
69
- }
70
-
71
- const buttonClass = openMenuId === id ? menuButtonColors.active : menuButtonColors.inactive
156
+ {data.map((row, rowIdx) => {
157
+ const id = resolveId(row as Record<string, unknown>, idKey as string | undefined, columns as Column<Record<string, unknown>>[])
158
+ const url = resolveRedirectUrl(redirectPath, row as Record<string, unknown>, id)
159
+ const isClickable = Boolean(url || onRowClick || hasExpand)
160
+ const isMenuOpen = openMenuId === id
161
+ const isExpanded = expandedId === id
162
+ const isSelected = selectable && selectedSet.has(id)
163
+ const expandedContent = renderExpandedRow?.(row)
164
+
165
+ const rowClass = [
166
+ 'flex w-full transition-colors duration-100',
167
+ isClickable ? 'cursor-pointer' : '',
168
+ VARIANT_ROW_HOVER[variant],
169
+ striped ? VARIANT_ROW_STRIPED[variant] : '',
170
+ isSelected ? 'bg-login/5' : '',
171
+ ].filter(Boolean).join(' ')
72
172
 
73
173
  return (
74
- <tr
75
- key={index}
76
- className={`
77
- flex w-full group/row transition-colors
78
- ${redirectConfig.path ? 'cursor-pointer' : ''}
79
- ${variant === 'default' && redirectConfig.path ? 'hover:bg-login-600/80' : ''}
80
- ${variant === 'minimal' ? 'hover:bg-login-600/70 border-b border-login-600/20 last:border-0' : ''}
81
- `}
82
- onMouseDown={() => {
83
- menuWasOpenOnMouseDown.current = openMenuId !== null
84
- }}
85
- onClick={() => {
86
- if (menuWasOpenOnMouseDown.current) {
87
- menuWasOpenOnMouseDown.current = false
88
- return
89
- }
90
- if (redirectConfig.path) {
91
- if (redirectConfig.path.includes('?')) {
92
- router.push(`${redirectConfig.path}${redirectId}`)
93
- } else {
94
- router.push(`${redirectConfig.path}/${redirectId}`)
174
+ <React.Fragment key={id + rowIdx}>
175
+ <tr
176
+ className={rowClass}
177
+ onMouseEnter={() => { if (url) router.prefetch(url) }}
178
+ onMouseDown={() => {
179
+ menuWasOpenOnMouseDown.current = openMenuId !== null
180
+ }}
181
+ onClick={() => {
182
+ if (menuWasOpenOnMouseDown.current) {
183
+ menuWasOpenOnMouseDown.current = false
184
+ return
95
185
  }
96
- }
97
- }}
98
- onContextMenu={(e) => {
99
- e.preventDefault()
100
- setAnchor({ top: e.clientY, right: window.innerWidth - e.clientX })
101
- setOpenMenuId(id)
102
- }}
103
- >
104
- {columns.map((col) => {
105
- const val = itemRecord[col.key] as string | number | undefined
106
- const value = val
107
-
108
- let badgeClass = ''
109
- if (col.highlight) {
110
- const highlightKey = String(value)
111
- const colorName = col.highlight[highlightKey] || col.highlight.default
112
- if (colorName) {
113
- switch (colorName) {
114
- case 'green':
115
- badgeClass = 'bg-green-500/20 text-green-400'; break
116
- case 'yellow':
117
- badgeClass = 'bg-yellow-500/20 text-yellow-400'; break
118
- case 'red':
119
- badgeClass = 'bg-red-500/20 text-red-400'; break
120
- case 'blue':
121
- badgeClass = 'bg-blue-500/20 text-blue-400'; break
122
- case 'gray':
123
- badgeClass = 'bg-gray-500/20 text-gray-400'; break
124
- }
125
- badgeClass += ' px-2 py-1 rounded'
186
+ if (hasExpand) {
187
+ setExpandedId(isExpanded ? null : id)
188
+ return
126
189
  }
127
- }
128
-
129
- return (
130
- <td
131
- key={col.key}
132
- className={`
133
- flex-1 min-w-0 px-6 py-4 whitespace-nowrap text-sm flex items-center text-login-100
134
- ${variant === 'minimal' ? 'px-4! py-2!' : ''}
135
- `}
136
- >
137
- <div className='relative w-full min-w-0'>
138
- <span className={`block max-w-full truncate ${badgeClass}`}>
139
- {formatValue(col.key, value as string | number)}
140
- </span>
141
- </div>
142
- </td>
143
- )
144
- })}
145
- {menuItems && (
146
- <td
147
- className='shrink-0 w-16 flex flex-row items-center justify-end p-2 px-4
148
- whitespace-nowrap text-right text-sm font-medium'
149
- >
150
- <div className='relative'>
190
+ if (onRowClick) { onRowClick(row, id); return }
191
+ if (url) router.push(url)
192
+ }}
193
+ onContextMenu={(e) => {
194
+ if (!hasMenu) return
195
+ e.preventDefault()
196
+ openMenu(id, { top: e.clientY, right: window.innerWidth - e.clientX })
197
+ }}
198
+ >
199
+ {selectable && (
200
+ <td className='flex items-center justify-center'
201
+ style={{ width: '3rem', minWidth: '3rem', flexShrink: 0 }}>
151
202
  <button
152
203
  type='button'
153
- className={`p-1.5 rounded flex items-center justify-center transition-colors ${buttonClass}`}
154
- onMouseDown={(e) => e.nativeEvent.stopImmediatePropagation()}
155
- onClick={(e) => {
156
- e.stopPropagation()
157
- const rect = (e.currentTarget as HTMLButtonElement).getBoundingClientRect()
158
- const coords = { top: rect.bottom + 4, right: window.innerWidth - rect.right }
159
- setAnchor(openMenuId === id ? null : coords)
160
- setOpenMenuId(openMenuId === id ? null : id)
161
- }}
204
+ aria-label={isSelected ? 'Deselect row' : 'Select row'}
205
+ aria-checked={isSelected}
206
+ role='checkbox'
207
+ onClick={(e) => { e.stopPropagation(); toggleSelect(id) }}
208
+ className={`
209
+ h-4 w-4 rounded border flex items-center justify-center transition-colors cursor-pointer
210
+ ${isSelected
211
+ ? 'bg-login border-login text-white'
212
+ : 'border-login-400 bg-transparent hover:border-login-100'
213
+ }
214
+ `}
162
215
  >
163
- <span
164
- className='text-xl leading-none select-none'
165
- >
166
- <EllipsisVertical className='h-5 w-5' />
167
- </span>
216
+ {isSelected && (
217
+ <svg className='h-3 w-3' viewBox='0 0 12 12' fill='none'>
218
+ <path d='M2 6l3 3 5-5' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
219
+ </svg>
220
+ )}
168
221
  </button>
169
- {openMenuId === id && anchor && (
170
- <Menu
171
- ref={menuRef}
172
- anchor={anchor}
173
- onClose={() => setOpenMenuId(null)}
222
+ </td>
223
+ )}
224
+
225
+ {columns.map((col) => (
226
+ <Cell key={col.key} col={col} row={row} density={density} />
227
+ ))}
228
+
229
+ {hasExpand && (
230
+ <td className='flex items-center justify-center'
231
+ style={{ width: '2.5rem', minWidth: '2.5rem', flexShrink: 0 }}>
232
+ <ChevronDown className={`
233
+ h-4 w-4 transition-transform duration-200
234
+ ${isExpanded ? 'rotate-180 text-login' : 'text-login-400'}
235
+ `} />
236
+ </td>
237
+ )}
238
+
239
+ {hasMenu && (
240
+ <td className='flex items-center justify-end pr-3'
241
+ style={{ width: '3.5rem', minWidth: '3.5rem', flexShrink: 0 }}>
242
+ <div className='relative'>
243
+ <button
244
+ type='button'
245
+ aria-label='Row actions'
246
+ aria-expanded={isMenuOpen}
247
+ aria-haspopup='menu'
248
+ onMouseDown={(e) => e.nativeEvent.stopImmediatePropagation()}
249
+ onClick={(e) => {
250
+ e.stopPropagation()
251
+ if (isMenuOpen) {
252
+ setOpenMenuId(null)
253
+ } else {
254
+ const rect = e.currentTarget.getBoundingClientRect()
255
+ openMenu(id, { top: rect.bottom + 4, right: window.innerWidth - rect.right })
256
+ }
257
+ }}
258
+ className={`
259
+ p-1.5 rounded flex items-center justify-center transition-colors
260
+ ${isMenuOpen
261
+ ? 'bg-login-500 text-login-75'
262
+ : 'text-login-300 hover:bg-login-500/60 hover:text-login-75'
263
+ }
264
+ `}
174
265
  >
175
- {menuItems?.(item, id)}
176
- </Menu>
177
- )}
178
- </div>
179
- </td>
266
+ <EllipsisVertical className='h-4 w-4' />
267
+ </button>
268
+ {isMenuOpen && anchor && (
269
+ <Menu ref={menuRef} anchor={anchor} onClose={closeMenu}>
270
+ {menuItems!(row, id)}
271
+ </Menu>
272
+ )}
273
+ </div>
274
+ </td>
275
+ )}
276
+ </tr>
277
+
278
+ {hasExpand && isExpanded && (
279
+ <tr className='flex w-full bg-login-700/25 border-b border-login-600/20'>
280
+ <td
281
+ className='w-full px-6 py-4'
282
+ onClick={(e) => e.stopPropagation()}
283
+ >
284
+ {expandedContent}
285
+ </td>
286
+ </tr>
180
287
  )}
181
- </tr>
288
+ </React.Fragment>
182
289
  )
183
290
  })}
184
291
  </tbody>
185
292
  )
186
- }
293
+ }
@@ -0,0 +1,53 @@
1
+ import type { TableColor, Density, TableVariant } from './types'
2
+
3
+ export const HIGHLIGHT: Record<TableColor, string> = {
4
+ green: 'bg-green-500/15 text-green-400 ring-1 ring-green-500/25',
5
+ yellow: 'bg-yellow-500/15 text-yellow-400 ring-1 ring-yellow-500/25',
6
+ red: 'bg-red-500/15 text-red-400 ring-1 ring-red-500/25',
7
+ blue: 'bg-blue-500/15 text-blue-400 ring-1 ring-blue-500/25',
8
+ gray: 'bg-gray-500/15 text-gray-400 ring-1 ring-gray-500/25',
9
+ orange: 'bg-orange-500/15 text-orange-400 ring-1 ring-orange-500/25',
10
+ purple: 'bg-purple-500/15 text-purple-400 ring-1 ring-purple-500/25',
11
+ }
12
+
13
+ export const DENSITY_TH: Record<Density, string> = {
14
+ compact: 'px-4 py-2',
15
+ comfortable: 'px-6 py-3',
16
+ spacious: 'px-8 py-4',
17
+ }
18
+
19
+ export const DENSITY_TD: Record<Density, string> = {
20
+ compact: 'px-4 py-1.5',
21
+ comfortable: 'px-6 py-3.5',
22
+ spacious: 'px-8 py-5',
23
+ }
24
+
25
+ export const VARIANT_CONTAINER: Record<TableVariant, string> = {
26
+ original: 'bg-login-500/50 rounded-lg border border-login-600 shadow',
27
+ modern: 'bg-transparent',
28
+ }
29
+
30
+ export const VARIANT_THEAD: Record<TableVariant, string> = {
31
+ original: 'bg-login-700',
32
+ modern: 'bg-transparent border-b border-login-500/40',
33
+ }
34
+
35
+ export const VARIANT_THEAD_TH: Record<TableVariant, string> = {
36
+ original: 'text-login-200',
37
+ modern: 'text-login-300',
38
+ }
39
+
40
+ export const VARIANT_TBODY: Record<TableVariant, string> = {
41
+ original: 'bg-login-500/50 divide-login-600',
42
+ modern: 'divide-login-600/15',
43
+ }
44
+
45
+ export const VARIANT_ROW_HOVER: Record<TableVariant, string> = {
46
+ original: 'hover:bg-login-600/30',
47
+ modern: 'hover:bg-login-700/50',
48
+ }
49
+
50
+ export const VARIANT_ROW_STRIPED: Record<TableVariant, string> = {
51
+ original: 'even:bg-login-600/40',
52
+ modern: 'even:bg-login-800/40',
53
+ }